blastoise-archive/library.ts

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