blastoise-archive/library.ts

330 lines
10 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 { 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", ".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
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<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 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<void> {
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<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);
}
}