import { Database } from "bun:sqlite"; import { createHash } from "crypto"; import { watch, type FSWatcher } from "fs"; import { readdir, stat } from "fs/promises"; import { join, relative } from "path"; import { parseFile } from "music-metadata"; import { upsertTrack, type Track } from "./db"; const HASH_CHUNK_SIZE = 64 * 1024; // 64KB const AUDIO_EXTENSIONS = new Set([".mp3", ".ogg", ".flac", ".wav", ".m4a", ".aac", ".opus", ".wma"]); export interface LibraryTrack extends Track { filename: string; filepath: string; available: boolean; } interface CacheEntry { path: string; track_id: string; size: number; mtime_ms: number; cached_at: number; } type LibraryEventType = "added" | "removed" | "changed"; type LibraryEventCallback = (track: LibraryTrack) => void; export class Library { private cacheDb: Database; private musicDir: string; private trackMap = new Map(); // trackId -> filepath private trackInfo = new Map(); // trackId -> full info private watcher: FSWatcher | null = null; private eventListeners = new Map>(); private pendingFiles = new Map(); // filepath -> debounce timer constructor(musicDir: string, cacheDbPath: string = "./library_cache.db") { this.musicDir = musicDir; this.cacheDb = new Database(cacheDbPath); this.initCacheDb(); } private initCacheDb(): void { this.cacheDb.run(` CREATE TABLE IF NOT EXISTS file_cache ( path TEXT PRIMARY KEY, track_id TEXT NOT NULL, size INTEGER NOT NULL, mtime_ms INTEGER NOT NULL, cached_at INTEGER DEFAULT (unixepoch()) ) `); this.cacheDb.run(`CREATE INDEX IF NOT EXISTS idx_file_cache_track ON file_cache(track_id)`); } async computeTrackId(filePath: string): Promise { const file = Bun.file(filePath); const size = file.size; // Read first 64KB const chunk = await file.slice(0, HASH_CHUNK_SIZE).arrayBuffer(); // Get duration from metadata let duration = 0; try { const metadata = await parseFile(filePath, { duration: true }); duration = metadata.format.duration || 0; } catch { // If metadata parsing fails, use size only } // Hash: size + duration + first 64KB const hash = createHash("sha256"); hash.update(`${size}:${duration.toFixed(3)}:`); hash.update(new Uint8Array(chunk)); return "sha256:" + hash.digest("hex").substring(0, 32); } private getCacheEntry(relativePath: string): CacheEntry | null { return this.cacheDb.query("SELECT * FROM file_cache WHERE path = ?").get(relativePath) as CacheEntry | null; } private setCacheEntry(relativePath: string, trackId: string, size: number, mtimeMs: number): void { this.cacheDb.query(` INSERT OR REPLACE INTO file_cache (path, track_id, size, mtime_ms, cached_at) VALUES (?, ?, ?, ?, unixepoch()) `).run(relativePath, trackId, size, mtimeMs); } private removeCacheEntry(relativePath: string): void { this.cacheDb.query("DELETE FROM file_cache WHERE path = ?").run(relativePath); } private isAudioFile(filename: string): boolean { const ext = filename.substring(filename.lastIndexOf(".")).toLowerCase(); return AUDIO_EXTENSIONS.has(ext); } private async processFile(filePath: string): Promise { const relativePath = relative(this.musicDir, filePath); const filename = filePath.substring(filePath.lastIndexOf("/") + 1).replace(/\\/g, "/").split("/").pop() || filePath; try { const fileStat = await stat(filePath); const size = fileStat.size; const mtimeMs = fileStat.mtimeMs; // Check cache const cached = this.getCacheEntry(relativePath); let trackId: string; if (cached && cached.size === size && cached.mtime_ms === Math.floor(mtimeMs)) { trackId = cached.track_id; } else { // Compute new hash trackId = await this.computeTrackId(filePath); this.setCacheEntry(relativePath, trackId, size, Math.floor(mtimeMs)); } // Get metadata let title = filename.replace(/\.[^.]+$/, ""); let artist: string | null = null; let album: string | null = null; let duration = 0; try { const metadata = await parseFile(filePath, { duration: true }); title = metadata.common.title || title; artist = metadata.common.artist || null; album = metadata.common.album || null; duration = metadata.format.duration || 0; } catch { // Use defaults } const track: LibraryTrack = { id: trackId, filename, filepath: filePath, title, artist, album, duration, size, available: true, created_at: Math.floor(Date.now() / 1000), }; // Upsert to main database upsertTrack({ id: trackId, title, artist, album, duration, size, }); return track; } catch (e) { console.error(`[Library] Failed to process ${filePath}:`, e); return null; } } async scan(): Promise { console.log(`[Library] Scanning ${this.musicDir}...`); const startTime = Date.now(); let processed = 0; let cached = 0; const scanDir = async (dir: string) => { const entries = await readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = join(dir, entry.name); if (entry.isDirectory()) { await scanDir(fullPath); } else if (entry.isFile() && this.isAudioFile(entry.name)) { const relativePath = relative(this.musicDir, fullPath); const cacheEntry = this.getCacheEntry(relativePath); // Quick check if cache is valid if (cacheEntry) { try { const fileStat = await stat(fullPath); if (cacheEntry.size === fileStat.size && cacheEntry.mtime_ms === Math.floor(fileStat.mtimeMs)) { // Cache hit - reuse existing data const existingTrack = this.trackInfo.get(cacheEntry.track_id); if (!existingTrack) { // Need metadata but skip hashing const track = await this.processFile(fullPath); if (track) { this.trackMap.set(track.id, fullPath); this.trackInfo.set(track.id, track); } } else { this.trackMap.set(cacheEntry.track_id, fullPath); } cached++; continue; } } catch {} } const track = await this.processFile(fullPath); if (track) { this.trackMap.set(track.id, fullPath); this.trackInfo.set(track.id, track); processed++; } } } }; await scanDir(this.musicDir); const elapsed = Date.now() - startTime; console.log(`[Library] Scan complete: ${this.trackMap.size} tracks (${processed} processed, ${cached} cached) in ${elapsed}ms`); } startWatching(): void { if (this.watcher) return; console.log(`[Library] Watching ${this.musicDir} for changes...`); this.watcher = watch(this.musicDir, { recursive: true }, async (event, filename) => { if (!filename) return; // Normalize path separators const normalizedFilename = filename.replace(/\\/g, "/"); if (!this.isAudioFile(normalizedFilename)) return; const fullPath = join(this.musicDir, filename); // Debounce: wait 5 seconds after last change before processing const existing = this.pendingFiles.get(fullPath); if (existing) clearTimeout(existing); this.pendingFiles.set(fullPath, setTimeout(async () => { this.pendingFiles.delete(fullPath); await this.processFileChange(fullPath); }, 5000)); }); } private async processFileChange(fullPath: string): Promise { try { const exists = await Bun.file(fullPath).exists(); if (exists) { // File added or modified const track = await this.processFile(fullPath); if (track) { const wasNew = !this.trackMap.has(track.id); this.trackMap.set(track.id, fullPath); this.trackInfo.set(track.id, track); this.emit(wasNew ? "added" : "changed", track); console.log(`[Library] ${wasNew ? "Added" : "Updated"}: ${track.title}`); } } else { // File deleted const relativePath = relative(this.musicDir, fullPath); const cacheEntry = this.getCacheEntry(relativePath); if (cacheEntry) { const track = this.trackInfo.get(cacheEntry.track_id); if (track) { track.available = false; this.trackMap.delete(cacheEntry.track_id); this.emit("removed", track); console.log(`[Library] Removed: ${track.title}`); } this.removeCacheEntry(relativePath); } } } catch (e) { console.error(`[Library] Watch error for ${fullPath}:`, e); } } stopWatching(): void { if (this.watcher) { this.watcher.close(); this.watcher = null; } } // Event handling on(event: LibraryEventType, callback: LibraryEventCallback): void { if (!this.eventListeners.has(event)) { this.eventListeners.set(event, new Set()); } this.eventListeners.get(event)!.add(callback); } off(event: LibraryEventType, callback: LibraryEventCallback): void { this.eventListeners.get(event)?.delete(callback); } private emit(event: LibraryEventType, track: LibraryTrack): void { this.eventListeners.get(event)?.forEach((cb) => cb(track)); } // Accessors getTrack(id: string): LibraryTrack | null { return this.trackInfo.get(id) || null; } getFilePath(id: string): string | null { return this.trackMap.get(id) || null; } getAllTracks(): LibraryTrack[] { return Array.from(this.trackInfo.values()).filter((t) => t.available); } getTrackCount(): number { return this.trackMap.size; } // Check if a track ID is available (file exists) isAvailable(id: string): boolean { return this.trackMap.has(id); } }