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
This commit is contained in:
@@ -0,0 +1,523 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user