feat(streamer): page http server with sse + cover proxy
This commit is contained in:
parent
40ac86717a
commit
6bcf18c1bf
1 changed files with 127 additions and 0 deletions
127
streamer/src/page-server.ts
Normal file
127
streamer/src/page-server.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { createServer, IncomingMessage, ServerResponse } from "node:http";
|
||||
import { createReadStream, existsSync } from "node:fs";
|
||||
import { extname, join } from "node:path";
|
||||
import { EventEmitter } from "node:events";
|
||||
|
||||
export interface PageServerOpts {
|
||||
port: number;
|
||||
staticDir: string;
|
||||
libraryDir: string;
|
||||
style: "denpa" | "modern";
|
||||
station: string;
|
||||
tz: string;
|
||||
tuneInUrl: string;
|
||||
}
|
||||
|
||||
export class PageServer extends EventEmitter {
|
||||
private nowClients = new Set<ServerResponse>();
|
||||
private specClients = new Set<ServerResponse>();
|
||||
private latestNowJson: string | null = null;
|
||||
private latestListeners: { current: number; peak: number } = { current: 0, peak: 0 };
|
||||
private server = createServer((req, res) => this.handle(req, res));
|
||||
|
||||
constructor(private opts: PageServerOpts) { super(); }
|
||||
|
||||
start(): Promise<void> {
|
||||
return new Promise((resolve) => this.server.listen(this.opts.port, "127.0.0.1", () => resolve()));
|
||||
}
|
||||
|
||||
stop(): Promise<void> {
|
||||
for (const c of this.nowClients) c.end();
|
||||
for (const c of this.specClients) c.end();
|
||||
return new Promise((resolve) => this.server.close(() => resolve()));
|
||||
}
|
||||
|
||||
pushNowPlaying(json: unknown): void {
|
||||
this.latestNowJson = JSON.stringify(json);
|
||||
const payload = `data: ${this.latestNowJson}\n\n`;
|
||||
for (const c of this.nowClients) c.write(payload);
|
||||
}
|
||||
|
||||
pushSpectrum(bars: number[]): void {
|
||||
const payload = `data: ${JSON.stringify(bars)}\n\n`;
|
||||
for (const c of this.specClients) c.write(payload);
|
||||
}
|
||||
|
||||
pushListeners(l: { current: number; peak: number }): void {
|
||||
this.latestListeners = l;
|
||||
}
|
||||
|
||||
private handle(req: IncomingMessage, res: ServerResponse): void {
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
|
||||
if (url.pathname === "/") return this.serveIndex(res);
|
||||
if (url.pathname.startsWith("/assets/")) return this.serveStatic(url.pathname, res);
|
||||
if (url.pathname === "/now-playing") return this.serveSse(res, this.nowClients, this.latestNowJson);
|
||||
if (url.pathname === "/spectrum") return this.serveSse(res, this.specClients, null);
|
||||
if (url.pathname === "/listeners") {
|
||||
res.setHeader("content-type", "application/json");
|
||||
res.end(JSON.stringify(this.latestListeners));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/cover") return this.serveCover(url, res);
|
||||
|
||||
res.statusCode = 404;
|
||||
res.end("not found");
|
||||
}
|
||||
|
||||
private serveIndex(res: ServerResponse): void {
|
||||
const indexPath = join(this.opts.staticDir, "index.html");
|
||||
if (!existsSync(indexPath)) {
|
||||
res.statusCode = 500;
|
||||
res.end("index not built");
|
||||
return;
|
||||
}
|
||||
const inject = `<script>window.__STREAMER_CONFIG__=${JSON.stringify({
|
||||
style: this.opts.style,
|
||||
station: this.opts.station,
|
||||
tz: this.opts.tz,
|
||||
tuneInUrl: this.opts.tuneInUrl,
|
||||
})}</script>`;
|
||||
let html = "";
|
||||
createReadStream(indexPath, "utf8")
|
||||
.on("data", (chunk) => (html += chunk))
|
||||
.on("end", () => {
|
||||
res.setHeader("content-type", "text/html; charset=utf-8");
|
||||
res.end(html.replace("</head>", `${inject}</head>`));
|
||||
});
|
||||
}
|
||||
|
||||
private serveStatic(pathname: string, res: ServerResponse): void {
|
||||
const file = join(this.opts.staticDir, pathname.replace(/^\/assets\//, ""));
|
||||
if (!file.startsWith(this.opts.staticDir) || !existsSync(file)) {
|
||||
res.statusCode = 404; res.end("not found"); return;
|
||||
}
|
||||
const ct = ({
|
||||
".js": "application/javascript",
|
||||
".css": "text/css",
|
||||
".woff2": "font/woff2",
|
||||
} as const)[extname(file) as ".js" | ".css" | ".woff2"] ?? "application/octet-stream";
|
||||
res.setHeader("content-type", ct);
|
||||
res.setHeader("cache-control", "max-age=86400");
|
||||
createReadStream(file).pipe(res);
|
||||
}
|
||||
|
||||
private serveSse(res: ServerResponse, set: Set<ServerResponse>, initial: string | null): void {
|
||||
res.setHeader("content-type", "text/event-stream");
|
||||
res.setHeader("cache-control", "no-cache");
|
||||
res.setHeader("connection", "keep-alive");
|
||||
res.flushHeaders();
|
||||
set.add(res);
|
||||
if (initial) res.write(`data: ${initial}\n\n`);
|
||||
res.on("close", () => set.delete(res));
|
||||
}
|
||||
|
||||
private serveCover(url: URL, res: ServerResponse): void {
|
||||
const requested = url.searchParams.get("path") ?? "";
|
||||
if (!requested.startsWith("/library/")) { res.statusCode = 400; res.end(); return; }
|
||||
const file = join(this.opts.libraryDir, requested.slice("/library/".length));
|
||||
if (!file.startsWith(this.opts.libraryDir) || !existsSync(file)) {
|
||||
res.statusCode = 404; res.end(); return;
|
||||
}
|
||||
const ext = extname(file).toLowerCase();
|
||||
res.setHeader("content-type", ext === ".png" ? "image/png" : ext === ".webp" ? "image/webp" : "image/jpeg");
|
||||
res.setHeader("cache-control", "no-store");
|
||||
createReadStream(file).pipe(res);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue