Aurora SpotiRip desktop and web app
- Spotify playlist, library, album and artist browsing with in-app playback - Song, playlist and album downloads via spotdl with YouTube and Bandcamp fallbacks - Electron desktop app (castLabs Widevine) with a permanent sidebar layout - Linux and Windows packaging scripts
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Copy,
|
||||
FolderOpen,
|
||||
Library,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import { Button } from "../components/Button";
|
||||
import { Input } from "../components/Input";
|
||||
import {
|
||||
scanAudioLibrary,
|
||||
type ScannedLibraryTrack,
|
||||
} from "../helpers/libraryScanner";
|
||||
import { copyOrganizedTrack, suggestedLibraryPath } from "../helpers/libraryOrganizer";
|
||||
import { loadSpotiRipSettings } from "../helpers/spotiripSettings";
|
||||
import { rememberLibraryTrack } from "../helpers/libraryIndex";
|
||||
import styles from "./library.module.css";
|
||||
import { MobileNav } from "../components/MobileNav";
|
||||
|
||||
export default function LibraryPage() {
|
||||
const [settings] = useState(() => loadSpotiRipSettings());
|
||||
const [rootName, setRootName] = useState<string>();
|
||||
const [tracks, setTracks] = useState<ScannedLibraryTrack[]>([]);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [progress, setProgress] = useState({ completed: 0, total: 0 });
|
||||
const [organizing, setOrganizing] = useState(false);
|
||||
const [message, setMessage] = useState<string>();
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const q = filter.trim().toLowerCase();
|
||||
if (!q) return tracks;
|
||||
return tracks.filter((track) =>
|
||||
[track.title, track.artist, track.album, track.relativePath]
|
||||
.filter(Boolean)
|
||||
.some((value) => value!.toLowerCase().includes(q)),
|
||||
);
|
||||
}, [tracks, filter]);
|
||||
|
||||
const stats = useMemo(() => ({
|
||||
missing: tracks.filter((track) => track.missing.length).length,
|
||||
duplicates: tracks.filter((track) => track.duplicateOf).length,
|
||||
artwork: tracks.filter((track) => track.artworkPresent).length,
|
||||
}), [tracks]);
|
||||
|
||||
async function scanFolder() {
|
||||
const picker = (window as typeof window & {
|
||||
showDirectoryPicker?: () => Promise<FileSystemDirectoryHandle>;
|
||||
}).showDirectoryPicker;
|
||||
if (!picker) {
|
||||
setError("Folder scanning requires a browser with the File System Access API.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const directory = await picker();
|
||||
setRootName(directory.name);
|
||||
setTracks([]);
|
||||
setScanning(true);
|
||||
setError(undefined);
|
||||
setMessage(undefined);
|
||||
setProgress({ completed: 0, total: 0 });
|
||||
const result = await scanAudioLibrary(directory, (completed, total) =>
|
||||
setProgress({ completed, total }),
|
||||
);
|
||||
setTracks(result);
|
||||
setMessage(
|
||||
"Scanned " + result.length + " audio file" + (result.length === 1 ? "" : "s") +
|
||||
" in “" + directory.name + "”.",
|
||||
);
|
||||
} catch (reason) {
|
||||
if (reason instanceof DOMException && reason.name === "AbortError") return;
|
||||
setError(reason instanceof Error ? reason.message : "Library scan failed.");
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function organizeCopies() {
|
||||
if (!tracks.length || organizing) return;
|
||||
const picker = (window as typeof window & {
|
||||
showDirectoryPicker?: () => Promise<FileSystemDirectoryHandle>;
|
||||
}).showDirectoryPicker;
|
||||
if (!picker) {
|
||||
setError("Folder organization requires a browser with the File System Access API.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const destination = await picker();
|
||||
setOrganizing(true);
|
||||
setError(undefined);
|
||||
let completed = 0;
|
||||
const source = tracks.filter((track) => !track.duplicateOf);
|
||||
for (const track of source) {
|
||||
const savedPath = await copyOrganizedTrack(destination, track, settings);
|
||||
await rememberLibraryTrack({
|
||||
isrc: track.isrc,
|
||||
title: track.title,
|
||||
artist: track.artist || "Unknown Artist",
|
||||
album: track.album,
|
||||
albumArtist: track.albumArtist,
|
||||
savedPath,
|
||||
source: "library-organizer",
|
||||
}).catch(() => undefined);
|
||||
completed += 1;
|
||||
setProgress({ completed, total: source.length });
|
||||
}
|
||||
setMessage(
|
||||
"Copied " + completed + " organized file" + (completed === 1 ? "" : "s") +
|
||||
" into “" + destination.name + "”. Originals were not changed.",
|
||||
);
|
||||
} catch (reason) {
|
||||
if (reason instanceof DOMException && reason.name === "AbortError") return;
|
||||
setError(reason instanceof Error ? reason.message : "Library organization failed.");
|
||||
} finally {
|
||||
setOrganizing(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<header className={styles.topbar}>
|
||||
<span>Local Library</span>
|
||||
</header>
|
||||
|
||||
<section className={styles.heading}>
|
||||
<div>
|
||||
<span>LOCAL LIBRARY</span>
|
||||
<h1>Library Scanner</h1>
|
||||
<p>Inventory your music folder, inspect existing tags, find duplicates and missing metadata, then organize clean copies without modifying the source library.</p>
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<Button variant="outline" disabled={scanning} onClick={scanFolder}>
|
||||
<FolderOpen size={16} /> {scanning ? "Scanning…" : "Scan folder"}
|
||||
</Button>
|
||||
<Button disabled={!tracks.length || organizing} onClick={organizeCopies}>
|
||||
<Copy size={16} /> {organizing ? "Organizing…" : "Organize copies"}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{(message || error) ? <div className={error ? styles.noticeError : styles.notice}>{error || message}</div> : null}
|
||||
|
||||
<section className={styles.stats}>
|
||||
<div><strong>{tracks.length}</strong><span>Audio files</span></div>
|
||||
<div><strong>{stats.missing}</strong><span>Need metadata</span></div>
|
||||
<div><strong>{stats.duplicates}</strong><span>Duplicates</span></div>
|
||||
<div><strong>{stats.artwork}</strong><span>With artwork</span></div>
|
||||
</section>
|
||||
|
||||
<section className={styles.panel}>
|
||||
<div className={styles.toolbar}>
|
||||
<div className={styles.libraryName}>
|
||||
<Library size={17} />
|
||||
<div><span>SCANNED ROOT</span><strong>{rootName || "No folder selected"}</strong></div>
|
||||
</div>
|
||||
<div className={styles.search}>
|
||||
<Search size={15} />
|
||||
<Input value={filter} onChange={(event) => setFilter(event.target.value)} placeholder="Filter library" />
|
||||
</div>
|
||||
{(scanning || organizing) && progress.total ? (
|
||||
<span className={styles.progress}>{progress.completed}/{progress.total}</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={styles.headerRow}>
|
||||
<span>File</span><span>Metadata</span><span>Audio</span><span>Issues</span>
|
||||
</div>
|
||||
|
||||
{visible.map((track) => {
|
||||
const target = suggestedLibraryPath(track, settings);
|
||||
return (
|
||||
<article className={styles.row} key={track.id}>
|
||||
<div className={styles.file}>
|
||||
<strong>{track.relativePath}</strong>
|
||||
<span>{(track.file.size / 1024 / 1024).toFixed(1)} MB</span>
|
||||
</div>
|
||||
<div className={styles.meta}>
|
||||
<strong>{track.title}</strong>
|
||||
<span>{track.artist || "Unknown artist"} · {track.album || "Unknown album"}</span>
|
||||
<small>{target.folders.join(" / ")} / {target.filename}</small>
|
||||
</div>
|
||||
<div className={styles.audio}>
|
||||
<strong>{track.codec?.toUpperCase() || "—"}</strong>
|
||||
<span>
|
||||
{track.bitrateKbps ? track.bitrateKbps + " kbps" : "bitrate —"} ·{" "}
|
||||
{track.sampleRate ? Math.round(track.sampleRate / 100) / 10 + " kHz" : "rate —"}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.issues}>
|
||||
{track.duplicateOf ? (
|
||||
<span className={styles.duplicate}><AlertTriangle size={13} /> duplicate</span>
|
||||
) : null}
|
||||
{track.missing.length ? (
|
||||
<span>{track.missing.join(", ")}</span>
|
||||
) : (
|
||||
<span className={styles.clean}><CheckCircle2 size={13} /> clean</span>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
|
||||
{!tracks.length ? <div className={styles.empty}>Choose a music folder to build a local library inventory.</div> : null}
|
||||
{tracks.length > 0 && visible.length === 0 ? (
|
||||
<div className={styles.empty}>No library items match “{filter}”.</div>
|
||||
) : null}
|
||||
</section>
|
||||
<MobileNav />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user