- 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
162 lines
5.2 KiB
TypeScript
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;
|
|
}
|