From 48955634afdfaa2e897d5cab2370ffe850146dc0 Mon Sep 17 00:00:00 2001 From: devilreef Date: Thu, 30 Apr 2026 15:17:37 +0600 Subject: [PATCH] feat(streamer): now-playing watcher with cover resolver --- streamer/src/nowplaying.ts | 100 +++++++++++++++++ .../fixtures/library/minecraft/_meta.yml | 3 + .../fixtures/library/minecraft/cover.jpg | Bin 0 -> 630 bytes .../minecraft/tracks/Volume Alpha/cover.jpg | Bin 0 -> 630 bytes .../tests/fixtures/now-playing/minecraft.json | 14 +++ streamer/tests/nowplaying.test.ts | 103 ++++++++++++++++++ 6 files changed, 220 insertions(+) create mode 100644 streamer/src/nowplaying.ts create mode 100644 streamer/tests/fixtures/library/minecraft/_meta.yml create mode 100644 streamer/tests/fixtures/library/minecraft/cover.jpg create mode 100644 streamer/tests/fixtures/library/minecraft/tracks/Volume Alpha/cover.jpg create mode 100644 streamer/tests/fixtures/now-playing/minecraft.json create mode 100644 streamer/tests/nowplaying.test.ts diff --git a/streamer/src/nowplaying.ts b/streamer/src/nowplaying.ts new file mode 100644 index 0000000..b0955d0 --- /dev/null +++ b/streamer/src/nowplaying.ts @@ -0,0 +1,100 @@ +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)}`; + } +} diff --git a/streamer/tests/fixtures/library/minecraft/_meta.yml b/streamer/tests/fixtures/library/minecraft/_meta.yml new file mode 100644 index 0000000..766c489 --- /dev/null +++ b/streamer/tests/fixtures/library/minecraft/_meta.yml @@ -0,0 +1,3 @@ +name: Minecraft +description: blocks +color: "#5ef7ff" diff --git a/streamer/tests/fixtures/library/minecraft/cover.jpg b/streamer/tests/fixtures/library/minecraft/cover.jpg new file mode 100644 index 0000000000000000000000000000000000000000..324d210638360ba7ac7b890e1e661fce187d73c6 GIT binary patch literal 630 zcmex=^(PF6}rMnOeST|r4lSw=>~TvNxu(8R<ECr+Na zbot8FYu9hwy!G(W<0ns_J%91?)yGetzkL1n{m0K=Ab&A3Fhjfr_ZgbM1cClyVqsxs zVF&q(k*OSrnFU!`6%E;h90S=C3x$=88aYIqCNA7~kW<+>=!0ld(M2vX6_bamA3L7B$#^0|0SU&M*J~ literal 0 HcmV?d00001 diff --git a/streamer/tests/fixtures/library/minecraft/tracks/Volume Alpha/cover.jpg b/streamer/tests/fixtures/library/minecraft/tracks/Volume Alpha/cover.jpg new file mode 100644 index 0000000000000000000000000000000000000000..324d210638360ba7ac7b890e1e661fce187d73c6 GIT binary patch literal 630 zcmex=^(PF6}rMnOeST|r4lSw=>~TvNxu(8R<ECr+Na zbot8FYu9hwy!G(W<0ns_J%91?)yGetzkL1n{m0K=Ab&A3Fhjfr_ZgbM1cClyVqsxs zVF&q(k*OSrnFU!`6%E;h90S=C3x$=88aYIqCNA7~kW<+>=!0ld(M2vX6_bamA3L7B$#^0|0SU&M*J~ literal 0 HcmV?d00001 diff --git a/streamer/tests/fixtures/now-playing/minecraft.json b/streamer/tests/fixtures/now-playing/minecraft.json new file mode 100644 index 0000000..a979bd5 --- /dev/null +++ b/streamer/tests/fixtures/now-playing/minecraft.json @@ -0,0 +1,14 @@ +{ + "station": "minecraft", + "title": "Sweden", + "artist": "C418", + "album": "Volume Alpha", + "filename": "/library/minecraft/tracks/Volume Alpha/18. Sweden.flac", + "duration": "215", + "started_at": "1730358000", + "cover_path": "/library/minecraft/tracks/Volume Alpha/cover.jpg", + "up_next": [ + { "title": "Wet Hands", "artist": "C418", "album": "Volume Alpha", "duration": "90", + "cover_path": "/library/minecraft/tracks/Volume Alpha/cover.jpg" } + ] +} diff --git a/streamer/tests/nowplaying.test.ts b/streamer/tests/nowplaying.test.ts new file mode 100644 index 0000000..053996e --- /dev/null +++ b/streamer/tests/nowplaying.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, writeFileSync, rmSync, cpSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { NowPlayingWatcher } from "../src/nowplaying.ts"; + +let tmpRoot: string; + +beforeEach(() => { + tmpRoot = mkdtempSync(join(tmpdir(), "denpa-np-")); + cpSync("tests/fixtures/now-playing", join(tmpRoot, "now-playing"), { recursive: true }); + cpSync("tests/fixtures/library", join(tmpRoot, "library"), { recursive: true }); +}); + +afterEach(() => { + rmSync(tmpRoot, { recursive: true, force: true }); +}); + +describe("NowPlayingWatcher", () => { + it("reads initial state from disk", async () => { + const watcher = new NowPlayingWatcher({ + station: "minecraft", + nowPlayingDir: join(tmpRoot, "now-playing"), + libraryDir: join(tmpRoot, "library"), + }); + await watcher.start(); + const state = watcher.current(); + expect(state).not.toBeNull(); + expect(state!.title).toBe("Sweden"); + expect(state!.cover_url).toMatch(/^\/cover\?path=/); + expect(state!.up_next).toHaveLength(1); + watcher.stop(); + }); + + it("rewrites cover_path to /cover?path=… url", async () => { + const watcher = new NowPlayingWatcher({ + station: "minecraft", + nowPlayingDir: join(tmpRoot, "now-playing"), + libraryDir: join(tmpRoot, "library"), + }); + await watcher.start(); + expect(watcher.current()!.cover_url).toContain( + encodeURIComponent("/library/minecraft/tracks/Volume Alpha/cover.jpg"), + ); + watcher.stop(); + }); + + it("emits change events on file rewrite", async () => { + const watcher = new NowPlayingWatcher({ + station: "minecraft", + nowPlayingDir: join(tmpRoot, "now-playing"), + libraryDir: join(tmpRoot, "library"), + }); + await watcher.start(); + + let changes = 0; + watcher.on("change", () => changes++); + + const newPayload = JSON.stringify({ + station: "minecraft", + title: "Pigstep", + artist: "Lena Raine", + album: "Nether Update", + filename: "/library/minecraft/tracks/Nether/01. Pigstep.flac", + duration: "148", + started_at: "1730358500", + cover_path: "", + up_next: [], + }); + writeFileSync(join(tmpRoot, "now-playing", "minecraft.json"), newPayload); + + await new Promise((r) => setTimeout(r, 200)); + expect(changes).toBeGreaterThan(0); + expect(watcher.current()!.title).toBe("Pigstep"); + watcher.stop(); + }); + + it("falls back to station cover when track cover missing", async () => { + const payload = JSON.stringify({ + station: "minecraft", + title: "Untagged Track", + artist: "?", + album: "?", + filename: "/library/minecraft/tracks/UnknownAlbum/track.flac", + duration: "120", + started_at: "1730358000", + cover_path: "", + up_next: [], + }); + writeFileSync(join(tmpRoot, "now-playing", "minecraft.json"), payload); + + const watcher = new NowPlayingWatcher({ + station: "minecraft", + nowPlayingDir: join(tmpRoot, "now-playing"), + libraryDir: join(tmpRoot, "library"), + }); + await watcher.start(); + expect(watcher.current()!.cover_url).toContain( + encodeURIComponent("/library/minecraft/cover.jpg"), + ); + watcher.stop(); + }); +});