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 { type Track } from "./db"; const HASH_CHUNK_SIZE = 64 * 1024; // 64KB const AUDIO_EXTENSIONS = new Set([".mp3", ".ogg", ".flac", ".wav", ".m4a", ".aac", ".opus", ".wma", ".mp4"]); 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 // Scan progress tracking private _scanProgress = { scanning: false, processed: 0, total: 0 }; private scanCompleteCallbacks: Set<() => void> = new Set(); get scanProgress() { return { ...this._scanProgress }; } constructor(musicDir: string, cacheDbPath: string = "./library_cache.db") { this.musicDir = musicDir; this.cacheDb = new Database(cacheDbPath); this.cacheDb.run("PRAGMA journal_mode = WAL"); 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)`); // Tracks table - stores metadata for each unique track this.cacheDb.run(` CREATE TABLE IF NOT EXISTS tracks ( id TEXT PRIMARY KEY, title TEXT, artist TEXT, album TEXT, duration REAL NOT NULL, size INTEGER NOT NULL, created_at INTEGER DEFAULT (unixepoch()) ) `); // Library activity log this.cacheDb.run(` CREATE TABLE IF NOT EXISTS library_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp INTEGER DEFAULT (unixepoch()), action TEXT NOT NULL, track_id TEXT, filename TEXT, title TEXT, user_id INTEGER, username TEXT ) `); this.cacheDb.run(`CREATE INDEX IF NOT EXISTS idx_library_log_time ON library_log(timestamp DESC)`); } logActivity(action: string, track: { id?: string; filename?: string; title?: string | null }, user?: { id: number; username: string } | null): void { this.cacheDb.query(` INSERT INTO library_log (action, track_id, filename, title, user_id, username) VALUES (?, ?, ?, ?, ?, ?) `).run(action, track.id || null, track.filename || null, track.title || null, user?.id || null, user?.username || null); } getActivityLog(limit: number = 100): Array<{ id: number; timestamp: number; action: string; track_id: string | null; filename: string | null; title: string | null; user_id: number | null; username: string | null }> { return this.cacheDb.query(`SELECT * FROM library_log ORDER BY timestamp DESC LIMIT ?`).all(limit) as any; } private upsertTrack(track: { id: string; title: string | null; artist: string | null; album: string | null; duration: number; size: number }): void { this.cacheDb.query(` INSERT INTO tracks (id, title, artist, album, duration, size) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET title = COALESCE(excluded.title, title), artist = COALESCE(excluded.artist, artist), album = COALESCE(excluded.album, album) `).run(track.id, track.title, track.artist, track.album, track.duration, track.size); } 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 cache database this.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] Quick loading from cache...`); // Single query joining file_cache and tracks - all in cacheDb now const cachedTracks = this.cacheDb.query(` SELECT fc.path, fc.track_id, t.title, t.artist, t.album, t.duration FROM file_cache fc LEFT JOIN tracks t ON fc.track_id = t.id `).all() as Array<{ path: string; track_id: string; title: string | null; artist: string | null; album: string | null; duration: number | null; }>; for (const row of cachedTracks) { const fullPath = join(this.musicDir, row.path); const filename = row.path.split(/[/\\]/).pop() || row.path; const track: LibraryTrack = { id: row.track_id, filename, filepath: fullPath, title: row.title || filename.replace(/\.[^.]+$/, ""), artist: row.artist || null, album: row.album || null, duration: row.duration || 0, available: true, }; this.trackMap.set(track.id, fullPath); this.trackInfo.set(track.id, track); } console.log(`[Library] Quick loaded ${cachedTracks.length} tracks from cache`); // Start background scan for new/changed files this.startBackgroundScan(); } private async startBackgroundScan(): Promise { console.log(`[Library] Starting background scan...`); const startTime = Date.now(); let processed = 0; let skipped = 0; const BATCH_SIZE = 10; const BATCH_DELAY_MS = 50; // Pause between batches to not block const filesToProcess: string[] = []; // Collect all files first (fast operation) const collectFiles = async (dir: string) => { try { const entries = await readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = join(dir, entry.name); if (entry.isDirectory()) { await collectFiles(fullPath); } else if (entry.isFile() && this.isAudioFile(entry.name)) { filesToProcess.push(fullPath); } } } catch (e) { console.error(`[Library] Error reading directory ${dir}:`, e); } }; await collectFiles(this.musicDir); console.log(`[Library] Found ${filesToProcess.length} audio files to check`); // Initialize scan progress this._scanProgress = { scanning: true, processed: 0, total: filesToProcess.length }; // Process in batches with delays for (let i = 0; i < filesToProcess.length; i += BATCH_SIZE) { const batch = filesToProcess.slice(i, i + BATCH_SIZE); for (const fullPath of batch) { const relativePath = relative(this.musicDir, fullPath); const cacheEntry = this.getCacheEntry(relativePath); // Check if already loaded and cache is valid if (cacheEntry && this.trackInfo.has(cacheEntry.track_id)) { try { const fileStat = await stat(fullPath); if (cacheEntry.size === fileStat.size && cacheEntry.mtime_ms === Math.floor(fileStat.mtimeMs)) { skipped++; this._scanProgress.processed++; continue; } } catch {} } // Need to process this file const track = await this.processFile(fullPath); if (track) { const isNew = !this.trackInfo.has(track.id); this.trackMap.set(track.id, fullPath); this.trackInfo.set(track.id, track); processed++; if (isNew) { this.emit("added", track); } } this._scanProgress.processed++; } // Yield to other operations if (i + BATCH_SIZE < filesToProcess.length) { await new Promise(resolve => setTimeout(resolve, BATCH_DELAY_MS)); } // Progress log every 100 files if ((i + BATCH_SIZE) % 100 === 0) { console.log(`[Library] Background scan progress: ${Math.min(i + BATCH_SIZE, filesToProcess.length)}/${filesToProcess.length}`); } } this._scanProgress = { scanning: false, processed: filesToProcess.length, total: filesToProcess.length }; const elapsed = Date.now() - startTime; console.log(`[Library] Background scan complete: ${processed} new/updated, ${skipped} unchanged in ${elapsed}ms`); // Notify listeners that scan is complete this.scanCompleteCallbacks.forEach(cb => cb()); } onScanComplete(callback: () => void): void { this.scanCompleteCallbacks.add(callback); } 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); } }