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,161 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user