330 lines
10 KiB
TypeScript
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"]);
|
|
|
|
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);
|
|
}
|
|
}
|