feat(streamer): now-playing watcher with cover resolver
This commit is contained in:
parent
23d98320df
commit
48955634af
6 changed files with 220 additions and 0 deletions
100
streamer/src/nowplaying.ts
Normal file
100
streamer/src/nowplaying.ts
Normal file
|
|
@ -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<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)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
3
streamer/tests/fixtures/library/minecraft/_meta.yml
vendored
Normal file
3
streamer/tests/fixtures/library/minecraft/_meta.yml
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
name: Minecraft
|
||||||
|
description: blocks
|
||||||
|
color: "#5ef7ff"
|
||||||
BIN
streamer/tests/fixtures/library/minecraft/cover.jpg
vendored
Normal file
BIN
streamer/tests/fixtures/library/minecraft/cover.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 630 B |
BIN
streamer/tests/fixtures/library/minecraft/tracks/Volume Alpha/cover.jpg
vendored
Normal file
BIN
streamer/tests/fixtures/library/minecraft/tracks/Volume Alpha/cover.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 630 B |
14
streamer/tests/fixtures/now-playing/minecraft.json
vendored
Normal file
14
streamer/tests/fixtures/now-playing/minecraft.json
vendored
Normal file
|
|
@ -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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
103
streamer/tests/nowplaying.test.ts
Normal file
103
streamer/tests/nowplaying.test.ts
Normal file
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue