- 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
524 lines
21 KiB
TypeScript
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>
|
|
);
|
|
}
|