100 lines
2.7 KiB
TypeScript
100 lines
2.7 KiB
TypeScript
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<void> {
|
|
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)}`;
|
|
}
|
|
}
|