- 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
215 lines
8.3 KiB
TypeScript
215 lines
8.3 KiB
TypeScript
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>
|
|
);
|
|
}
|