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(); const [tracks, setTracks] = useState([]); 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(); const [error, setError] = useState(); 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; }).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; }).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 (
Local Library
LOCAL LIBRARY

Library Scanner

Inventory your music folder, inspect existing tags, find duplicates and missing metadata, then organize clean copies without modifying the source library.

{(message || error) ?
{error || message}
: null}
{tracks.length}Audio files
{stats.missing}Need metadata
{stats.duplicates}Duplicates
{stats.artwork}With artwork
SCANNED ROOT{rootName || "No folder selected"}
setFilter(event.target.value)} placeholder="Filter library" />
{(scanning || organizing) && progress.total ? ( {progress.completed}/{progress.total} ) : null}
FileMetadataAudioIssues
{visible.map((track) => { const target = suggestedLibraryPath(track, settings); return (
{track.relativePath} {(track.file.size / 1024 / 1024).toFixed(1)} MB
{track.title} {track.artist || "Unknown artist"} · {track.album || "Unknown album"} {target.folders.join(" / ")} / {target.filename}
{track.codec?.toUpperCase() || "—"} {track.bitrateKbps ? track.bitrateKbps + " kbps" : "bitrate —"} ·{" "} {track.sampleRate ? Math.round(track.sampleRate / 100) / 10 + " kHz" : "rate —"}
{track.duplicateOf ? ( duplicate ) : null} {track.missing.length ? ( {track.missing.join(", ")} ) : ( clean )}
); })} {!tracks.length ?
Choose a music folder to build a local library inventory.
: null} {tracks.length > 0 && visible.length === 0 ? (
No library items match “{filter}”.
) : null}
); }