import { useEffect, useMemo, useState } from "react"; import { AudioLines, CheckCircle2, FileAudio, FolderOpen, Save, Search, Sparkles, Tag, WandSparkles, } from "lucide-react"; import { FileDropzone } from "../components/FileDropzone"; import { Button } from "../components/Button"; import { Checkbox } from "../components/Checkbox"; import { Input } from "../components/Input"; import { Textarea } from "../components/Textarea"; import { postIdentify } from "../endpoints/identify_POST.schema"; import { postMetadataEnrich, type OutputType as MetadataEnrichment, } from "../endpoints/metadata-enrich_POST.schema"; import { createRecognitionSnippet } from "../helpers/audioProcessing"; import { downloadBlob, formatFolderPath, formatMp3Filename, saveBlobToOrganizedPath, type WritableDirectoryHandle, writeMp3Tags, } from "../helpers/mp3Tags"; import { loadSpotiRipSettings } from "../helpers/spotiripSettings"; import { ensureDirectoryWritePermission, loadOutputDirectory, saveOutputDirectory, } from "../helpers/directoryStore"; import { addLibraryHistory } from "../helpers/libraryHistory"; import { findLibraryDuplicate, rememberLibraryTrack, } from "../helpers/libraryIndex"; import styles from "./identify.module.css"; import { MobileNav } from "../components/MobileNav"; type LocalTrack = { id: string; file: File; status: "ready" | "identifying" | "identified" | "saved"; selected: boolean; error?: string; savedFilename?: string; proposedTitle?: string; proposedArtist?: string; proposedAlbum?: string; proposedAlbumArtist?: string; proposedReleaseDate?: string; proposedTrackNumber?: number; proposedDiscNumber?: number; proposedGenre?: string; proposedComposer?: string; proposedIsrc?: string; proposedLyrics?: string; metadataSources?: string[]; duplicatePath?: string; artworkUrl?: string; }; function cleanGuess(filename: string) { return filename .replace(/\.[^.]+$/, "") .replace(/[_]+/g, " ") .replace(/\s+/g, " ") .trim(); } export default function IdentifyPage() { const [settings] = useState(() => loadSpotiRipSettings()); const [tracks, setTracks] = useState([]); const [identifying, setIdentifying] = useState(false); const [applying, setApplying] = useState(false); const [outputDirectory, setOutputDirectory] = useState(); const [filenameTemplate, setFilenameTemplate] = useState(settings.filenameTemplate); const [message, setMessage] = useState(); const [error, setError] = useState(); const totalSize = useMemo( () => tracks.reduce((sum, track) => sum + track.file.size, 0), [tracks], ); const matched = useMemo(() => tracks.filter((track) => track.status === "identified" || track.status === "saved").length, [tracks]); const selectedCount = useMemo(() => tracks.filter((track) => track.selected && track.status === "identified").length, [tracks]); useEffect(() => { loadOutputDirectory().then(setOutputDirectory).catch(() => undefined); }, []); function addFiles(files: File[]) { setTracks((current) => [ ...current, ...files .filter((file) => !current.some((item) => item.id === `${file.name}-${file.size}-${file.lastModified}`)) .map((file) => ({ id: `${file.name}-${file.size}-${file.lastModified}`, file, status: "ready" as const, selected: false, })), ]); setMessage(undefined); setError(undefined); } async function chooseOutputFolder() { const picker = (window as typeof window & { showDirectoryPicker?: () => Promise; }).showDirectoryPicker; if (!picker) { setError("This browser does not support direct folder writing. SpotiRip will use normal file downloads instead."); return; } try { const handle = await picker(); setOutputDirectory(handle); await saveOutputDirectory(handle); setMessage(`Fixed files will be saved to “${handle.name}”.`); setError(undefined); } catch { } } async function identifyTracks() { const pending = tracks.filter((track) => track.status === "ready" || Boolean(track.error)); if (!pending.length || identifying) return; setIdentifying(true); setError(undefined); setMessage(undefined); for (const track of pending) { setTracks((current) => current.map((item) => item.id === track.id ? { ...item, status: "identifying", error: undefined } : item), ); try { const snippet = await createRecognitionSnippet(track.file); const result = await postIdentify(snippet); const title = result.title || cleanGuess(track.file.name); const artist = result.artist || "Unknown artist"; const album = result.album || "Unknown album"; const enrichment: MetadataEnrichment = result.matched ? await postMetadataEnrich({ title, artist, album, isrc: result.isrc, }).catch(() => ({ sources: [] } as MetadataEnrichment)) : { sources: [] }; const resolvedIsrc = enrichment.isrc || result.isrc; const duplicate = result.matched && settings.smartDedup ? await findLibraryDuplicate({ isrc: resolvedIsrc, title, artist, }).catch(() => undefined) : undefined; setTracks((current) => current.map((item) => item.id !== track.id ? item : result.matched ? { ...item, status: "identified", selected: settings.autoSelectMatches && !duplicate, error: undefined, proposedTitle: title, proposedArtist: artist, proposedAlbum: album, proposedAlbumArtist: enrichment.albumArtist, proposedReleaseDate: enrichment.releaseDate || result.releaseDate, proposedTrackNumber: result.trackNumber, proposedDiscNumber: result.discNumber, proposedGenre: enrichment.genre, proposedComposer: enrichment.composer, proposedIsrc: resolvedIsrc, proposedLyrics: enrichment.lyrics?.plain || enrichment.lyrics?.synced, metadataSources: enrichment.sources, duplicatePath: duplicate?.savedPath, artworkUrl: result.artworkUrl || enrichment.artworkUrl, } : { ...item, status: "ready", selected: false, error: "No confident audio match found.", }, ), ); } catch (reason) { const detail = reason instanceof Error ? reason.message : "Identification failed."; setTracks((current) => current.map((item) => item.id === track.id ? { ...item, status: "ready", selected: false, error: detail } : item), ); } } setIdentifying(false); setMessage("Identification pass complete. Review the proposed matches before saving."); } function updateTrack(id: string, changes: Partial) { setTracks((current) => current.map((track) => track.id === id ? { ...track, ...changes } : track)); } function editManually(id: string) { setTracks((current) => current.map((track) => track.id === id ? { ...track, status: "identified", selected: true, error: undefined, proposedTitle: track.proposedTitle || cleanGuess(track.file.name), proposedArtist: track.proposedArtist || "", proposedAlbum: track.proposedAlbum || "", } : track, ), ); } async function applySelected() { const selected = tracks.filter((track) => track.selected && track.status === "identified"); if (!selected.length || applying) return; setApplying(true); setError(undefined); setMessage(undefined); let completed = 0; let activeDirectory = outputDirectory; try { if (activeDirectory && !(await ensureDirectoryWritePermission(activeDirectory))) { activeDirectory = undefined; setOutputDirectory(undefined); setMessage("Folder permission was not granted; corrected files will download normally."); } for (const track of selected) { const trackLabel = track.proposedTrackNumber ? settings.multiDiscMode === "offset" && track.proposedDiscNumber ? String(track.proposedDiscNumber * 100 + track.proposedTrackNumber) : settings.multiDiscMode === "disc-prefix" && track.proposedDiscNumber ? `${track.proposedDiscNumber}-${String(track.proposedTrackNumber).padStart(2, "0")}` : String(track.proposedTrackNumber).padStart(2, "0") : undefined; const metadata = { title: track.proposedTitle || cleanGuess(track.file.name), artist: track.proposedArtist || "Unknown Artist", album: track.proposedAlbum, albumArtist: track.proposedAlbumArtist, year: track.proposedReleaseDate?.slice(0, 4), trackNumber: track.proposedTrackNumber, trackLabel, discNumber: track.proposedDiscNumber, genre: track.proposedGenre, composer: track.proposedComposer, isrc: track.proposedIsrc, lyrics: track.proposedLyrics, artworkUrl: settings.embedArtwork ? track.artworkUrl : undefined, }; const blob = await writeMp3Tags(track.file, metadata, settings.mp3Bitrate); const filename = formatMp3Filename(filenameTemplate, metadata); const folders = formatFolderPath(settings.folderTemplate, metadata); const savedFilename = activeDirectory ? await saveBlobToOrganizedPath(activeDirectory, folders, filename, blob) : (downloadBlob(filename, blob), filename); await rememberLibraryTrack({ isrc: metadata.isrc, title: metadata.title, artist: metadata.artist, album: metadata.album, albumArtist: metadata.albumArtist, savedPath: savedFilename, artworkUrl: metadata.artworkUrl, source: "identifier", }).catch(() => undefined); completed += 1; updateTrack(track.id, { status: "saved", selected: false, savedFilename, error: undefined, }); } setMessage( activeDirectory ? `Saved ${completed} repaired MP3 file${completed === 1 ? "" : "s"} to “${activeDirectory.name}”.` : `Prepared ${completed} repaired MP3 download${completed === 1 ? "" : "s"}.`, ); if (completed > 0) { addLibraryHistory({ kind: "identify", title: `Repaired ${completed} audio file${completed === 1 ? "" : "s"}`, trackCount: completed, }); } } catch (reason) { setError(reason instanceof Error ? reason.message : "Unable to save corrected MP3 files."); } finally { setApplying(false); } } return (
{matched}/{tracks.length} matched
LOCAL AUDIO TOOLS

Audio Identifier

Recognize unknown audio files, review the match, then create corrected MP3 copies with clean ID3 tags and filenames.

{tracks.length}Files
{(totalSize / 1024 / 1024).toFixed(1)} MBAudio
{selectedCount}Selected
{(message || error) && (
{error || message}
)}
Add unknown tracks Only a short audio fingerprint sample is sent for recognition.
} title="Drop audio files here" subtitle="MP3, WAV, M4A, AAC or OGG · up to 100 files" />
REVIEW QUEUE {tracks.length ? "Matches and metadata" : "No files loaded"}
{!tracks.length ? (
No audio files queued Add MP3 files above to start identifying them.
) : (
{tracks.map((track, index) => (
updateTrack(track.id, { selected: event.target.checked })} />
{String(index + 1).padStart(2, "0")}
CURRENT FILE {track.file.name} {(track.file.size / 1024 / 1024).toFixed(1)} MB
{track.status === "identified" || track.status === "saved" ? ( <> {track.artworkUrl ? :
}
updateTrack(track.id, { proposedTitle: event.target.value })} aria-label="Track title" />
updateTrack(track.id, { proposedArtist: event.target.value })} aria-label="Artist" /> updateTrack(track.id, { proposedAlbum: event.target.value })} aria-label="Album" />
updateTrack(track.id, { proposedGenre: event.target.value })} placeholder="Genre" aria-label="Genre" /> updateTrack(track.id, { proposedComposer: event.target.value })} placeholder="Composer" aria-label="Composer" /> updateTrack(track.id, { proposedIsrc: event.target.value })} placeholder="ISRC" aria-label="ISRC" /> updateTrack(track.id, { proposedDiscNumber: event.target.value ? Number(event.target.value) : undefined })} placeholder="Disc" aria-label="Disc number" />