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

162 lines
5.2 KiB
TypeScript

import { ALL_FORMATS, BlobSource, Input } from "mediabunny";
export type ScannedLibraryTrack = {
id: string;
file: File;
relativePath: string;
title: string;
artist: string;
album?: string;
albumArtist?: string;
trackNumber?: number;
discNumber?: number;
genre?: string;
date?: string;
lyricsPresent: boolean;
artworkPresent: boolean;
isrc?: string;
codec?: string;
sampleRate?: number;
channels?: number;
bitrateKbps?: number;
missing: string[];
duplicateOf?: string;
};
type DirectoryHandle = FileSystemDirectoryHandle & {
values(): AsyncIterableIterator<FileSystemFileHandle | FileSystemDirectoryHandle>;
};
const AUDIO_EXTENSIONS = new Set(["mp3", "wav", "flac", "m4a", "aac", "ogg", "opus"]);
function extension(name: string) {
const dot = name.lastIndexOf(".");
return dot >= 0 ? name.slice(dot + 1).toLowerCase() : "";
}
function cleanGuess(name: string) {
return name.replace(/\.[^.]+$/, "").replace(/[_]+/g, " ").replace(/\s+/g, " ").trim();
}
function rawText(raw: Record<string, unknown> | undefined, key: string) {
const value = raw?.[key];
if (typeof value === "string") return value;
if (Array.isArray(value) && typeof value[0] === "string") return value[0];
return undefined;
}
async function inspectFile(file: File, relativePath: string): Promise<ScannedLibraryTrack> {
const input = new Input({ source: new BlobSource(file), formats: ALL_FORMATS });
try {
const [tags, audioTrack] = await Promise.all([
input.getMetadataTags(),
input.getPrimaryAudioTrack(),
]);
const [codec, sampleRate, channels, averageBitrate] = audioTrack
? await Promise.all([
audioTrack.getCodec().catch(() => null),
audioTrack.getSampleRate().catch(() => undefined),
audioTrack.getNumberOfChannels().catch(() => undefined),
audioTrack.getAverageBitrate().catch(() => null),
])
: [null, undefined, undefined, null];
const title = tags.title?.trim() || cleanGuess(file.name);
const artist = tags.artist?.trim() || "";
const album = tags.album?.trim() || undefined;
const albumArtist = tags.albumArtist?.trim() || undefined;
const raw = tags.raw as Record<string, unknown> | undefined;
const isrc = rawText(raw, "TSRC") || rawText(raw, "ISRC") || rawText(raw, "isrc");
const missing: string[] = [];
if (!tags.title?.trim()) missing.push("title");
if (!artist) missing.push("artist");
if (!album) missing.push("album");
if (!tags.trackNumber) missing.push("track");
if (!tags.genre) missing.push("genre");
if (!tags.images?.length) missing.push("artwork");
return {
id: relativePath + ":" + file.size + ":" + file.lastModified,
file,
relativePath,
title,
artist,
album,
albumArtist,
trackNumber: tags.trackNumber,
discNumber: tags.discNumber,
genre: tags.genre,
date: tags.date?.toISOString().slice(0, 10),
lyricsPresent: Boolean(tags.lyrics?.trim()),
artworkPresent: Boolean(tags.images?.length),
isrc,
codec: codec || undefined,
sampleRate,
channels,
bitrateKbps: averageBitrate ? Math.round(averageBitrate / 1000) : undefined,
missing,
};
} finally {
input.dispose();
}
}
export async function collectAudioFiles(
directory: FileSystemDirectoryHandle,
prefix = "",
): Promise<Array<{ file: File; relativePath: string }>> {
const files: Array<{ file: File; relativePath: string }> = [];
for await (const entry of (directory as DirectoryHandle).values()) {
const relativePath = prefix ? prefix + "/" + entry.name : entry.name;
if (entry.kind === "directory") {
files.push(...(await collectAudioFiles(entry as FileSystemDirectoryHandle, relativePath)));
continue;
}
if (!AUDIO_EXTENSIONS.has(extension(entry.name))) continue;
files.push({
file: await (entry as FileSystemFileHandle).getFile(),
relativePath,
});
}
return files;
}
export async function scanAudioLibrary(
directory: FileSystemDirectoryHandle,
onProgress?: (completed: number, total: number) => void,
) {
const candidates = await collectAudioFiles(directory);
const tracks: ScannedLibraryTrack[] = [];
for (let index = 0; index < candidates.length; index += 1) {
const candidate = candidates[index];
try {
tracks.push(await inspectFile(candidate.file, candidate.relativePath));
} catch {
tracks.push({
id: candidate.relativePath + ":" + candidate.file.size + ":" + candidate.file.lastModified,
file: candidate.file,
relativePath: candidate.relativePath,
title: cleanGuess(candidate.file.name),
artist: "",
lyricsPresent: false,
artworkPresent: false,
missing: ["unreadable metadata"],
});
}
onProgress?.(index + 1, candidates.length);
}
const identities = new Map<string, ScannedLibraryTrack>();
for (const track of tracks) {
const identity = track.isrc?.trim().toUpperCase() ||
track.artist.toLowerCase().trim() + "::" +
track.title.toLowerCase().trim() + "::" +
(track.album || "").toLowerCase().trim();
const existing = identities.get(identity);
if (existing) track.duplicateOf = existing.relativePath;
else identities.set(identity, track);
}
return tracks;
}