Files
R3ap3r 3178b8f4f7 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
2026-09-25 23:20:57 -05:00

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>
);
}