import { EventEmitter } from "node:events"; import { existsSync, readFileSync, watch, FSWatcher } from "node:fs"; import { join } from "node:path"; export interface NowPlayingTrack { title: string; artist: string; album: string; duration: string; cover_path: string; } export interface NowPlayingState { station: string; title: string; artist: string; album: string; filename: string; duration: string; started_at: string; cover_path: string; cover_url: string; up_next: (NowPlayingTrack & { cover_url: string })[]; } export interface NowPlayingOpts { station: string; nowPlayingDir: string; libraryDir: string; } export class NowPlayingWatcher extends EventEmitter { private state: NowPlayingState | null = null; private watcher: FSWatcher | null = null; private debounce: NodeJS.Timeout | null = null; private path: string; constructor(private opts: NowPlayingOpts) { super(); this.path = join(opts.nowPlayingDir, `${opts.station}.json`); } async start(): Promise { this.read(); this.watcher = watch(this.opts.nowPlayingDir, (_event, filename) => { if (filename !== `${this.opts.station}.json`) return; if (this.debounce) clearTimeout(this.debounce); this.debounce = setTimeout(() => this.read(), 50); }); } stop(): void { this.watcher?.close(); this.watcher = null; if (this.debounce) clearTimeout(this.debounce); } current(): NowPlayingState | null { return this.state; } private read(): void { if (!existsSync(this.path)) return; try { const raw = readFileSync(this.path, "utf8"); const parsed = JSON.parse(raw); const cover_path = this.resolveCover(parsed.cover_path, parsed.filename); const up_next = (parsed.up_next ?? []).map((t: NowPlayingTrack) => ({ ...t, cover_url: this.coverUrl(this.resolveCover(t.cover_path, "")), })); this.state = { ...parsed, cover_path, cover_url: this.coverUrl(cover_path), up_next, }; this.emit("change", this.state); } catch (err) { this.emit("warn", err); } } private resolveCover(suggested: string, _filename: string): string { if (suggested && existsSync(this.toAbsolute(suggested))) return suggested; const stationCover = `/library/${this.opts.station}/cover.jpg`; if (existsSync(this.toAbsolute(stationCover))) return stationCover; return ""; } private toAbsolute(libraryPath: string): string { if (!libraryPath.startsWith("/library/")) return libraryPath; return join(this.opts.libraryDir, libraryPath.slice("/library/".length)); } private coverUrl(libraryPath: string): string { if (!libraryPath) return ""; return `/cover?path=${encodeURIComponent(libraryPath)}`; } }