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; }; 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 | 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 { 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 | 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> { 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(); 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; }