465 lines
15 KiB
TypeScript
465 lines
15 KiB
TypeScript
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<string, string>(); // trackId -> filepath
|
|
private trackInfo = new Map<string, LibraryTrack>(); // trackId -> full info
|
|
private watcher: FSWatcher | null = null;
|
|
private eventListeners = new Map<LibraryEventType, Set<LibraryEventCallback>>();
|
|
private pendingFiles = new Map<string, NodeJS.Timeout>(); // 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<string> {
|
|
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<LibraryTrack | null> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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);
|
|
}
|
|
}
|