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

524 lines
21 KiB
TypeScript

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<LocalTrack[]>([]);
const [identifying, setIdentifying] = useState(false);
const [applying, setApplying] = useState(false);
const [outputDirectory, setOutputDirectory] = useState<WritableDirectoryHandle>();
const [filenameTemplate, setFilenameTemplate] = useState(settings.filenameTemplate);
const [message, setMessage] = useState<string>();
const [error, setError] = useState<string>();
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<WritableDirectoryHandle>;
}).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<LocalTrack>) {
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 (
<main className={styles.page}>
<header className={styles.topbar}>
<div className={styles.topStatus}>{matched}/{tracks.length} matched</div>
</header>
<section className={styles.intro}>
<div>
<span className={styles.kicker}>LOCAL AUDIO TOOLS</span>
<h1>Audio Identifier</h1>
<p>Recognize unknown audio files, review the match, then create corrected MP3 copies with clean ID3 tags and filenames.</p>
</div>
<div className={styles.summary}>
<div><strong>{tracks.length}</strong><span>Files</span></div>
<div><strong>{(totalSize / 1024 / 1024).toFixed(1)} MB</strong><span>Audio</span></div>
<div><strong>{selectedCount}</strong><span>Selected</span></div>
</div>
</section>
{(message || error) && (
<div className={error ? styles.noticeError : styles.notice}>{error || message}</div>
)}
<section className={styles.workspace}>
<div className={styles.sourcePanel}>
<div className={styles.panelHeading}>
<AudioLines size={20} />
<div>
<strong>Add unknown tracks</strong>
<span>Only a short audio fingerprint sample is sent for recognition.</span>
</div>
</div>
<FileDropzone
accept=".mp3,.wav,.m4a,.aac,.ogg,audio/mpeg,audio/wav,audio/mp4,audio/aac,audio/ogg"
maxFiles={100}
maxSize={80 * 1024 * 1024}
onFilesSelected={addFiles}
icon={<FileAudio size={36} />}
title="Drop audio files here"
subtitle="MP3, WAV, M4A, AAC or OGG · up to 100 files"
/>
<div className={styles.actionRow}>
<Button disabled={!tracks.length || identifying} onClick={identifyTracks}>
<Search size={16} /> {identifying ? "Listening…" : "Identify tracks"}
</Button>
<Button variant="outline" onClick={chooseOutputFolder}>
<FolderOpen size={16} /> {outputDirectory?.name || "Choose output folder"}
</Button>
</div>
</div>
<aside className={styles.settingsPanel}>
<div className={styles.settingsHeading}><Tag size={18} /><strong>Save settings</strong></div>
<label className={styles.fieldLabel} htmlFor="filename-template">Filename template</label>
<Input
id="filename-template"
value={filenameTemplate}
onChange={(event) => setFilenameTemplate(event.target.value)}
placeholder="{artist} - {title}"
/>
<p>Tokens: {"{artist}"} {"{title}"} {"{album}"} {"{year}"} {"{track}"} {"{disc}"} {"{genre}"} {"{composer}"} {"{isrc}"}</p>
<div className={styles.settingLine}>
<CheckCircle2 size={15} />
<span>Original files are preserved</span>
</div>
<div className={styles.settingLine}>
<Save size={15} />
<span>{outputDirectory ? `Save to ${outputDirectory.name}` : "Download fallback enabled"}</span>
</div>
<div className={styles.settingLine}>
<FileAudio size={15} />
<span>Non-MP3 sources convert at {settings.mp3Bitrate} kbps</span>
</div>
</aside>
</section>
<section className={styles.queuePanel}>
<div className={styles.queueHeader}>
<div>
<span className={styles.kicker}>REVIEW QUEUE</span>
<strong>{tracks.length ? "Matches and metadata" : "No files loaded"}</strong>
</div>
<Button disabled={!selectedCount || applying} onClick={applySelected}>
<Save size={16} /> {applying ? "Saving…" : selectedCount ? `Apply ${selectedCount} selected` : "Apply selected"}
</Button>
</div>
{!tracks.length ? (
<div className={styles.emptyState}>
<Sparkles size={26} />
<strong>No audio files queued</strong>
<span>Add MP3 files above to start identifying them.</span>
</div>
) : (
<div className={styles.trackList}>
{tracks.map((track, index) => (
<article className={styles.trackCard} key={track.id}>
<div className={styles.selectCell}>
<Checkbox
aria-label={`Select ${track.file.name}`}
checked={track.selected}
disabled={track.status !== "identified"}
onChange={(event) => updateTrack(track.id, { selected: event.target.checked })}
/>
</div>
<div className={styles.trackIndex}>{String(index + 1).padStart(2, "0")}</div>
<div className={styles.original}>
<span>CURRENT FILE</span>
<strong>{track.file.name}</strong>
<small>{(track.file.size / 1024 / 1024).toFixed(1)} MB</small>
</div>
<div className={styles.result}>
{track.status === "identified" || track.status === "saved" ? (
<>
{track.artworkUrl ? <img className={styles.artwork} src={track.artworkUrl} alt="" /> : <div className={styles.artworkFallback}><FileAudio size={18} /></div>}
<div className={styles.editFields}>
<Input
value={track.proposedTitle || ""}
onChange={(event) => updateTrack(track.id, { proposedTitle: event.target.value })}
aria-label="Track title"
/>
<div className={styles.twoFields}>
<Input
value={track.proposedArtist || ""}
onChange={(event) => updateTrack(track.id, { proposedArtist: event.target.value })}
aria-label="Artist"
/>
<Input
value={track.proposedAlbum || ""}
onChange={(event) => updateTrack(track.id, { proposedAlbum: event.target.value })}
aria-label="Album"
/>
</div>
<div className={styles.advancedFields}>
<Input
value={track.proposedGenre || ""}
onChange={(event) => updateTrack(track.id, { proposedGenre: event.target.value })}
placeholder="Genre"
aria-label="Genre"
/>
<Input
value={track.proposedComposer || ""}
onChange={(event) => updateTrack(track.id, { proposedComposer: event.target.value })}
placeholder="Composer"
aria-label="Composer"
/>
<Input
value={track.proposedIsrc || ""}
onChange={(event) => updateTrack(track.id, { proposedIsrc: event.target.value })}
placeholder="ISRC"
aria-label="ISRC"
/>
<Input
type="number"
min={1}
value={track.proposedDiscNumber || ""}
onChange={(event) => updateTrack(track.id, { proposedDiscNumber: event.target.value ? Number(event.target.value) : undefined })}
placeholder="Disc"
aria-label="Disc number"
/>
</div>
<Textarea
className={styles.lyricsField}
value={track.proposedLyrics || ""}
onChange={(event) => updateTrack(track.id, { proposedLyrics: event.target.value })}
placeholder="Lyrics (optional)"
aria-label="Lyrics"
rows={2}
/>
<small>
{track.proposedReleaseDate?.slice(0, 4) || "Year unknown"}
{track.proposedTrackNumber ? ` · Track ${track.proposedTrackNumber}` : ""}
{track.metadataSources?.length ? ` · ${track.metadataSources.join(" + ")}` : ""}
{track.duplicatePath ? ` · Duplicate: ${track.duplicatePath}` : ""}
</small>
</div>
<div className={track.status === "saved" ? styles.savedState : styles.matchState}>
<CheckCircle2 size={15} />
<span>{track.status === "saved" ? track.savedFilename || "Saved" : "Match ready"}</span>
</div>
</>
) : track.status === "identifying" ? (
<div className={styles.waiting}><WandSparkles size={17} /><span>Listening…</span></div>
) : track.error ? (
<div className={styles.manualState}>
<span className={styles.errorState}>{track.error}</span>
<Button variant="outline" size="sm" onClick={() => editManually(track.id)}>Edit manually</Button>
</div>
) : (
<div className={styles.manualState}>
<div className={styles.waiting}><WandSparkles size={17} /><span>Ready to analyze</span></div>
<Button variant="ghost" size="sm" onClick={() => editManually(track.id)}>Manual metadata</Button>
</div>
)}
</div>
</article>
))}
</div>
)}
</section>
<MobileNav />
</main>
);
}