nixamp 0.44.0 → 0.45.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/guide-rooms.d.ts +91 -0
- package/dist/guide-rooms.js +264 -0
- package/dist/library.d.ts +2 -0
- package/dist/pit-footer.d.ts +3 -0
- package/dist/pit-footer.js +40 -0
- package/dist/server.d.ts +6 -0
- package/dist/server.js +72 -3
- package/dist/station-guide.d.ts +2 -2
- package/dist/station-guide.js +8 -4
- package/dist/station-page.d.ts +5 -0
- package/dist/station-page.js +15 -0
- package/dist/station-routes.d.ts +2 -0
- package/dist/station-routes.js +3 -1
- package/package.json +1 -1
- package/src/guide-rooms.ts +300 -0
- package/src/library.ts +2 -0
- package/src/pit-footer.ts +40 -0
- package/src/server.ts +76 -3
- package/src/station-guide.ts +10 -6
- package/src/station-page.ts +16 -0
- package/src/station-routes.ts +5 -1
- package/web/dist/assets/{ar-CFKc4sST.js → ar-h3oK-9o8.js} +1 -1
- package/web/dist/assets/{cs-hYUvrLOA.js → cs-C-lbAed-.js} +1 -1
- package/web/dist/assets/{da-CDVRP4eF.js → da-C9e_YO_c.js} +1 -1
- package/web/dist/assets/{de-Cnsv6ovL.js → de-exPthSE9.js} +1 -1
- package/web/dist/assets/{es-BCZf7hTI.js → es-BJxLCiJh.js} +1 -1
- package/web/dist/assets/{fi-CJFGk4B4.js → fi-DABaBL4W.js} +1 -1
- package/web/dist/assets/{fr-L6t6E_D0.js → fr-H3FYq6aP.js} +1 -1
- package/web/dist/assets/{hi-CYNtSMhg.js → hi-BIABiE3i.js} +1 -1
- package/web/dist/assets/{hu-Cy2Vq3jY.js → hu-C02GlLv_.js} +1 -1
- package/web/dist/assets/{id-DQOdpqH0.js → id-CL1mVpLr.js} +1 -1
- package/web/dist/assets/{it-BAB9lSHI.js → it-D_mnd_IU.js} +1 -1
- package/web/dist/assets/main-DnwdecQp.js +22 -0
- package/web/dist/assets/{nl-CC8ycc0R.js → nl-CwCdRELu.js} +1 -1
- package/web/dist/assets/{radio-Cvjck2GD.js → radio-CwO-W8JC.js} +1 -1
- package/web/dist/assets/{ru-acMGqMTM.js → ru-BFWS4hkB.js} +1 -1
- package/web/dist/assets/{station-B9ORrH8G.js → station-PbhmEmAJ.js} +1 -1
- package/web/dist/assets/styles-BBzoLLVb.css +1 -0
- package/web/dist/assets/{sv-D4aGnzyX.js → sv-BuQ10xBo.js} +1 -1
- package/web/dist/assets/{uk-B7NCqrVY.js → uk-4nNmXzfQ.js} +1 -1
- package/web/dist/assets/{vi-C0pXT2cL.js → vi-MYoKiHQ4.js} +1 -1
- package/web/dist/assets/watch-DPIQsdu7.js +1 -0
- package/web/dist/assets/{zh-kT9LPD9I.js → zh-D8-H7gq2.js} +1 -1
- package/web/dist/index.html +40 -5
- package/web/dist/radio.html +3 -3
- package/web/dist/station.html +3 -3
- package/web/dist/sw.js +25 -23
- package/web/dist/watch.html +30 -0
- package/web/dist/assets/main-CnCKUBjG.js +0 -22
- package/web/dist/assets/styles-qC62Gona.css +0 -1
- /package/web/dist/assets/{styles-D70tSrIe.js → styles-DUuj7O7h.js} +0 -0
package/dist/station-routes.d.ts
CHANGED
|
@@ -83,6 +83,8 @@ export interface StationContext {
|
|
|
83
83
|
};
|
|
84
84
|
/** The public stations other servers list (guide peers); absent when there are none. */
|
|
85
85
|
peers?: () => Promise<unknown[]>;
|
|
86
|
+
/** Live rooms members published to this guide (guide-rooms.ts); absent when this server keeps none. */
|
|
87
|
+
rooms?: () => Promise<unknown[]>;
|
|
86
88
|
}
|
|
87
89
|
declare function links(ctx: StationContext, station: Station, base?: string): {
|
|
88
90
|
page: string;
|
package/dist/station-routes.js
CHANGED
|
@@ -100,7 +100,9 @@ export async function handleStations(request, response, path, url, ctx) {
|
|
|
100
100
|
const shown = new Set(own.map((one) => one.page));
|
|
101
101
|
const others = url.searchParams.has("local") || !ctx.peers ? [] : (await ctx.peers())
|
|
102
102
|
.filter((one) => !shown.has(one.page));
|
|
103
|
-
|
|
103
|
+
// Rooms are this guide's own; a peer asking for its stations does not list them twice.
|
|
104
|
+
const rooms = url.searchParams.has("local") || !ctx.rooms ? [] : await ctx.rooms();
|
|
105
|
+
ctx.json(response, 200, { stations: [...rooms, ...own, ...others] });
|
|
104
106
|
return true;
|
|
105
107
|
}
|
|
106
108
|
const listen = LISTEN.exec(path);
|
package/package.json
CHANGED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live rooms on the station guide.
|
|
3
|
+
*
|
|
4
|
+
* A room is a channel, or the live stream, of any nixamp server: the address
|
|
5
|
+
* a Share or a "?play=" link carries, `https://host:port/api/channels/<id>?k=KEY`
|
|
6
|
+
* or `https://host:port/api/live?k=KEY`. A nixamp.com member publishes one with
|
|
7
|
+
* a rights basis, as a station is published; the guide lists it beside the
|
|
8
|
+
* stations, and its card opens /watch?play=<that address>, which plays it as
|
|
9
|
+
* a film or as radio, whichever it carries.
|
|
10
|
+
*
|
|
11
|
+
* Nothing here plays anything: the room's own server does, and this only asks
|
|
12
|
+
* it, now and then, whether the room is on the air and what is on it.
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { randomBytes } from "node:crypto";
|
|
17
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
18
|
+
import { readConfig } from "./library.ts";
|
|
19
|
+
import type { GuideEntry } from "./station-guide.ts";
|
|
20
|
+
|
|
21
|
+
/** Why a room may be broadcast, the same five reasons a station gives. */
|
|
22
|
+
export const ROOM_BASES = ["owned", "permission", "licensed", "creative-commons", "public-domain"] as const;
|
|
23
|
+
export type RoomBasis = typeof ROOM_BASES[number];
|
|
24
|
+
|
|
25
|
+
export interface GuideRoom {
|
|
26
|
+
id: string;
|
|
27
|
+
/** The room's stream address, with its listen key. */
|
|
28
|
+
play: string;
|
|
29
|
+
name: string;
|
|
30
|
+
kind: "audio" | "video";
|
|
31
|
+
basis: RoomBasis;
|
|
32
|
+
note: string;
|
|
33
|
+
/** The nixamp.com account that published it ("" for this server's owner). */
|
|
34
|
+
by: string;
|
|
35
|
+
publishedAt: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Rooms one member may have on the guide at once, and rooms on it in all. */
|
|
39
|
+
export const ROOMS_PER_MEMBER = 5;
|
|
40
|
+
export const ROOMS_MAX = 200;
|
|
41
|
+
const STATE_TTL_MS = 15_000;
|
|
42
|
+
const PROBE_TIMEOUT_MS = 4_000;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A room's address taken apart, or null when it is not one: https (http only
|
|
46
|
+
* for a loopback address, for tests), a channel or the live stream, a key,
|
|
47
|
+
* and nothing else in the query.
|
|
48
|
+
*/
|
|
49
|
+
export function roomAddress(input: string): { origin: string; channel: string | null; key: string; play: string } | null {
|
|
50
|
+
let url: URL;
|
|
51
|
+
try {
|
|
52
|
+
url = new URL(input.trim());
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
const loopback = url.hostname === "127.0.0.1" || url.hostname === "localhost";
|
|
57
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) return null;
|
|
58
|
+
if (url.username || url.password || url.hash) return null;
|
|
59
|
+
const channel = /^\/api\/channels\/([A-Za-z0-9._~-]{1,120})\/?$/.exec(url.pathname);
|
|
60
|
+
const live = /^\/api\/live\/?$/.test(url.pathname);
|
|
61
|
+
if (!channel && !live) return null;
|
|
62
|
+
const key = url.searchParams.get("k") ?? "";
|
|
63
|
+
if (!/^[A-Za-z0-9_-]{8,128}$/.test(key) || [...url.searchParams.keys()].some((name) => name !== "k")) return null;
|
|
64
|
+
const path = channel ? `/api/channels/${channel[1]}` : "/api/live";
|
|
65
|
+
return { origin: url.origin, channel: channel ? channel[1]! : null, key, play: `${url.origin}${path}?k=${key}` };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Where the guide links a room's page: config `watchBase`, NIXAMP_WATCH_BASE, else this server. */
|
|
69
|
+
export function watchBase(fallback: string, env: Record<string, string | undefined> = process.env): string {
|
|
70
|
+
const saved = (() => { try { return readConfig().watchBase ?? ""; } catch { return ""; } })();
|
|
71
|
+
const chosen = (env["NIXAMP_WATCH_BASE"] || saved || fallback).trim().replace(/\/+$/, "");
|
|
72
|
+
return /^https?:\/\/[^\s"<>]+$/.test(chosen) ? chosen : fallback;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const watchPage = (base: string, play: string): string => `${base}/watch?play=${encodeURIComponent(play)}`;
|
|
76
|
+
|
|
77
|
+
export class RoomStore {
|
|
78
|
+
private rooms: GuideRoom[] = [];
|
|
79
|
+
constructor(private readonly file: string) {
|
|
80
|
+
try {
|
|
81
|
+
if (existsSync(file)) {
|
|
82
|
+
const saved = JSON.parse(readFileSync(file, "utf8")) as { rooms?: GuideRoom[] };
|
|
83
|
+
this.rooms = (saved.rooms ?? []).filter((one) => roomAddress(one.play) !== null);
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
// Unreadable is empty; the next write replaces it.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
static fileFor(stateDir: string, port: number): string {
|
|
91
|
+
return join(stateDir, `guide-rooms-${port}.json`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
list(): GuideRoom[] {
|
|
95
|
+
return [...this.rooms];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
get(id: string): GuideRoom | undefined {
|
|
99
|
+
return this.rooms.find((one) => one.id === id);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Publishes a room, or refreshes the one already published at that address. */
|
|
103
|
+
put(room: Omit<GuideRoom, "id" | "publishedAt">): GuideRoom {
|
|
104
|
+
const existing = this.rooms.find((one) => one.play === room.play);
|
|
105
|
+
const saved: GuideRoom = { ...room, id: existing?.id ?? randomBytes(6).toString("base64url"), publishedAt: Date.now() };
|
|
106
|
+
this.rooms = [...this.rooms.filter((one) => one.play !== room.play), saved];
|
|
107
|
+
this.save();
|
|
108
|
+
return saved;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
remove(id: string): boolean {
|
|
112
|
+
const before = this.rooms.length;
|
|
113
|
+
this.rooms = this.rooms.filter((one) => one.id !== id);
|
|
114
|
+
if (this.rooms.length === before) return false;
|
|
115
|
+
this.save();
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private save(): void {
|
|
120
|
+
writeFileSync(`${this.file}.part`, `${JSON.stringify({ version: 1, rooms: this.rooms }, null, 2)}\n`);
|
|
121
|
+
renameSync(`${this.file}.part`, this.file);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface RoomState {
|
|
126
|
+
/** Whether the room's server says it is on the air. */
|
|
127
|
+
live: boolean;
|
|
128
|
+
name: string;
|
|
129
|
+
kind: "audio" | "video";
|
|
130
|
+
nowPlaying: { title: string; artist: string } | null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
interface ChannelRow {
|
|
134
|
+
id: string;
|
|
135
|
+
name?: string;
|
|
136
|
+
kind?: string;
|
|
137
|
+
live?: boolean;
|
|
138
|
+
codecs?: { tags?: { title?: string; artist?: string } };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Asks a room's own server what is on it: its channel list for a channel, the stream's headers for the live stream. */
|
|
142
|
+
export class RoomProbe {
|
|
143
|
+
private cache = new Map<string, { at: number; state: RoomState | null }>();
|
|
144
|
+
/**
|
|
145
|
+
* `refuse` names why an address may not be fetched from this machine (a
|
|
146
|
+
* private or loopback network), or "": a member chooses the address, and
|
|
147
|
+
* this server must not become their way into its own network.
|
|
148
|
+
*/
|
|
149
|
+
constructor(
|
|
150
|
+
private readonly fetcher: typeof fetch = fetch,
|
|
151
|
+
private readonly refuse: (link: string) => Promise<string> = async () => "",
|
|
152
|
+
) {}
|
|
153
|
+
|
|
154
|
+
async state(play: string, fresh = false): Promise<RoomState | null> {
|
|
155
|
+
const cached = this.cache.get(play);
|
|
156
|
+
if (!fresh && cached && Date.now() - cached.at < STATE_TTL_MS) return cached.state;
|
|
157
|
+
const state = await this.ask(play).catch(() => null);
|
|
158
|
+
this.cache.set(play, { at: Date.now(), state });
|
|
159
|
+
if (this.cache.size > 500) this.cache.delete(this.cache.keys().next().value!);
|
|
160
|
+
return state;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private async ask(play: string): Promise<RoomState | null> {
|
|
164
|
+
const address = roomAddress(play);
|
|
165
|
+
if (!address || (await this.refuse(address.play)) !== "") return null;
|
|
166
|
+
if (address.channel !== null) {
|
|
167
|
+
const answer = await this.fetcher(`${address.origin}/api/channels?k=${address.key}`, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), redirect: "error" });
|
|
168
|
+
if (!answer.ok) return null;
|
|
169
|
+
const body = (await answer.json()) as { channels?: ChannelRow[] };
|
|
170
|
+
const row = (body.channels ?? []).find((one) => one.id === address.channel);
|
|
171
|
+
if (!row) return null;
|
|
172
|
+
const tags = row.codecs?.tags;
|
|
173
|
+
return {
|
|
174
|
+
live: row.live !== false,
|
|
175
|
+
name: String(row.name ?? address.channel).replace(/\/+$/, "").slice(0, 120),
|
|
176
|
+
kind: row.kind === "video" ? "video" : "audio",
|
|
177
|
+
nowPlaying: tags && (tags.title || tags.artist) ? { title: String(tags.title ?? ""), artist: String(tags.artist ?? "") } : null,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
// The live stream: on the air when it answers with media.
|
|
181
|
+
const controller = new AbortController();
|
|
182
|
+
const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
|
|
183
|
+
try {
|
|
184
|
+
const answer = await this.fetcher(address.play, { signal: controller.signal, redirect: "error" });
|
|
185
|
+
const type = answer.headers.get("content-type") ?? "";
|
|
186
|
+
controller.abort();
|
|
187
|
+
if (!answer.ok) return null;
|
|
188
|
+
return { live: true, name: "Live", kind: type.startsWith("video/") ? "video" : "audio", nowPlaying: null };
|
|
189
|
+
} finally {
|
|
190
|
+
clearTimeout(timer);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The rooms as guide cards: the page to open, on the air or not, and what is on. */
|
|
196
|
+
export async function roomEntries(store: RoomStore, probe: RoomProbe, base: string): Promise<GuideEntry[]> {
|
|
197
|
+
return Promise.all(store.list().map(async (room) => {
|
|
198
|
+
const state = await probe.state(room.play);
|
|
199
|
+
return {
|
|
200
|
+
id: room.id,
|
|
201
|
+
name: room.name,
|
|
202
|
+
kind: state?.kind ?? room.kind,
|
|
203
|
+
group: "rooms" as const,
|
|
204
|
+
state: state?.live ? "live" : "offline",
|
|
205
|
+
page: watchPage(base, room.play),
|
|
206
|
+
nowPlaying: state?.nowPlaying ?? null,
|
|
207
|
+
};
|
|
208
|
+
}));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export interface RoomContext {
|
|
212
|
+
store: RoomStore;
|
|
213
|
+
probe: RoomProbe;
|
|
214
|
+
/** The page base for a room's card. */
|
|
215
|
+
base: string;
|
|
216
|
+
/** Whether the request holds this server's control key (or is its signed-in owner). */
|
|
217
|
+
canControl: boolean;
|
|
218
|
+
/** The nixamp.com account behind the request's session, or "". */
|
|
219
|
+
member: () => Promise<string>;
|
|
220
|
+
json: (response: ServerResponse, status: number, body: unknown) => void;
|
|
221
|
+
/** The request body: server.ts's raw text, which handleRooms parses as JSON. */
|
|
222
|
+
readBody: (request: IncomingMessage) => Promise<unknown>;
|
|
223
|
+
/** Refused by the caller's rate limit, or null. */
|
|
224
|
+
throttle?: () => { retryAfter: number } | null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Answers /api/v1/guide/rooms; false when the path is not one. */
|
|
228
|
+
export async function handleRooms(request: IncomingMessage, response: ServerResponse, path: string, ctx: RoomContext): Promise<boolean> {
|
|
229
|
+
if (path !== "/api/v1/guide/rooms" && !path.startsWith("/api/v1/guide/rooms/")) return false;
|
|
230
|
+
const method = request.method ?? "GET";
|
|
231
|
+
if (path === "/api/v1/guide/rooms" && (method === "GET" || method === "HEAD")) {
|
|
232
|
+
ctx.json(response, 200, { rooms: await roomEntries(ctx.store, ctx.probe, ctx.base) });
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
const by = ctx.canControl ? "" : await ctx.member();
|
|
236
|
+
if (!ctx.canControl && by === "") {
|
|
237
|
+
ctx.json(response, 401, { error: "sign in to nixamp.com to publish a live room", code: "sign_in" });
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
const refused = ctx.throttle?.();
|
|
241
|
+
if (refused) {
|
|
242
|
+
response.setHeader("retry-after", String(refused.retryAfter));
|
|
243
|
+
ctx.json(response, 429, { error: "too many changes; wait a moment", code: "throttled" });
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
if (path === "/api/v1/guide/rooms" && method === "POST") {
|
|
247
|
+
// server.ts hands over the raw text; a body that was never parsed has no
|
|
248
|
+
// `play`, and every publish read as a bad address.
|
|
249
|
+
const raw = await ctx.readBody(request).catch(() => null);
|
|
250
|
+
const body = (typeof raw === "string" ? (() => { try { return JSON.parse(raw) as unknown; } catch { return null; } })() : raw) as { play?: unknown; basis?: unknown; note?: unknown; title?: unknown; agree?: unknown } | null;
|
|
251
|
+
const address = roomAddress(typeof body?.play === "string" ? body.play : "");
|
|
252
|
+
if (!address) {
|
|
253
|
+
ctx.json(response, 400, { error: "play must be a nixamp room's stream address: https://host/api/channels/<id>?k=KEY", code: "bad_address" });
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
const basis = ROOM_BASES.find((one) => one === body?.basis);
|
|
257
|
+
if (!basis || body?.agree !== true) {
|
|
258
|
+
ctx.json(response, 400, { error: "say why it may be broadcast (owned, permission, licensed, creative-commons or public-domain) and agree", code: "basis_required" });
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
const state = await ctx.probe.state(address.play, true);
|
|
262
|
+
if (!state) {
|
|
263
|
+
ctx.json(response, 404, { error: "that room is not on its server, or the key does not open it", code: "not_found" });
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
const all = ctx.store.list();
|
|
267
|
+
const mine = all.filter((one) => one.by === by && one.play !== address.play);
|
|
268
|
+
if (by !== "" && mine.length >= ROOMS_PER_MEMBER) {
|
|
269
|
+
ctx.json(response, 409, { error: `a member may have ${ROOMS_PER_MEMBER} rooms on the guide; take one off first`, code: "too_many" });
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
if (!all.some((one) => one.play === address.play) && all.length >= ROOMS_MAX) {
|
|
273
|
+
ctx.json(response, 409, { error: "the guide is full", code: "full" });
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
const taken = all.find((one) => one.play === address.play);
|
|
277
|
+
if (taken && taken.by !== by && !ctx.canControl) {
|
|
278
|
+
ctx.json(response, 409, { error: "somebody else published that room", code: "taken" });
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
281
|
+
const title = typeof body?.title === "string" ? body.title.trim().slice(0, 120) : "";
|
|
282
|
+
const note = typeof body?.note === "string" ? body.note.trim().slice(0, 300) : "";
|
|
283
|
+
const room = ctx.store.put({ play: address.play, name: title || state.name, kind: state.kind, basis, note, by: taken?.by ?? by });
|
|
284
|
+
ctx.json(response, 200, { room: { id: room.id, name: room.name, kind: room.kind, basis: room.basis }, page: watchPage(ctx.base, room.play) });
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
const one = /^\/api\/v1\/guide\/rooms\/([A-Za-z0-9_-]{1,32})$/.exec(path);
|
|
288
|
+
if (one && method === "DELETE") {
|
|
289
|
+
const room = ctx.store.get(one[1]!);
|
|
290
|
+
if (!room || (!ctx.canControl && room.by !== by)) {
|
|
291
|
+
ctx.json(response, 404, { error: "no such room of yours on the guide", code: "not_found" });
|
|
292
|
+
return true;
|
|
293
|
+
}
|
|
294
|
+
ctx.store.remove(room.id);
|
|
295
|
+
ctx.json(response, 200, { removed: room.id });
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
ctx.json(response, 405, { error: "method not allowed", code: "method_not_allowed" });
|
|
299
|
+
return true;
|
|
300
|
+
}
|
package/src/library.ts
CHANGED
|
@@ -31,6 +31,8 @@ export interface Config {
|
|
|
31
31
|
guideHosts?: string[];
|
|
32
32
|
/** Other servers (https base addresses) whose public, listed stations this guide shows too. */
|
|
33
33
|
guidePeers?: string[];
|
|
34
|
+
/** Where the guide links a published live room's page (https://live.streaming.pit.world); this server's own address when unset. */
|
|
35
|
+
watchBase?: string;
|
|
34
36
|
/** crawlproof on the guide: tracker project id (site), ad slot and format (`nixamp station guide-ads`). */
|
|
35
37
|
guideAds?: { site?: string; slot?: string; format?: "banner_300x250" | "text_link" };
|
|
36
38
|
/** Station limits kept across restarts (station-metering.ts). */
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pit.world global footer (site name, search, ring, publish), fetched on
|
|
3
|
+
* the server and appended as the last thing in <body> of the nixamp.com pages.
|
|
4
|
+
*
|
|
5
|
+
* A module-level cache: the last good copy is served, and one older than an
|
|
6
|
+
* hour is refreshed in the background (3s timeout). A failed refresh keeps the
|
|
7
|
+
* old copy; with no copy at all the page goes out unchanged, so pit.world being
|
|
8
|
+
* down never breaks a page.
|
|
9
|
+
*/
|
|
10
|
+
const PIT_FOOTER_URL = "https://pit.world/snippets/footer.html?site=nixamp.com";
|
|
11
|
+
const MAX_AGE_MS = 60 * 60 * 1000;
|
|
12
|
+
|
|
13
|
+
let cached = "";
|
|
14
|
+
let fetchedAt = 0;
|
|
15
|
+
let inflight: Promise<void> | null = null;
|
|
16
|
+
|
|
17
|
+
export function refreshPitFooter(): Promise<void> {
|
|
18
|
+
inflight ??= fetch(PIT_FOOTER_URL, { signal: AbortSignal.timeout(3000) })
|
|
19
|
+
.then(async (res) => {
|
|
20
|
+
const text = res.ok ? await res.text() : "";
|
|
21
|
+
if (!text.includes("data-pit-footer")) throw new Error(`pit footer: HTTP ${res.status}`);
|
|
22
|
+
cached = text;
|
|
23
|
+
fetchedAt = Date.now();
|
|
24
|
+
})
|
|
25
|
+
.catch(() => {
|
|
26
|
+
fetchedAt = Date.now() - MAX_AGE_MS + 60_000; // try again in a minute
|
|
27
|
+
})
|
|
28
|
+
.finally(() => {
|
|
29
|
+
inflight = null;
|
|
30
|
+
});
|
|
31
|
+
return inflight;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The page with the footer before its last </body>, or the page unchanged. */
|
|
35
|
+
export function withPitFooter(page: string): string {
|
|
36
|
+
if (Date.now() - fetchedAt > MAX_AGE_MS) void refreshPitFooter();
|
|
37
|
+
if (!cached || page.includes("data-pit-footer")) return page;
|
|
38
|
+
const at = page.lastIndexOf("</body>");
|
|
39
|
+
return at < 0 ? page : page.slice(0, at) + cached + page.slice(at);
|
|
40
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -85,7 +85,9 @@ import { SUPPORT_CSP, SUPPORT_PAGE, Support, SupportError, SupportSubscriptions,
|
|
|
85
85
|
import { LyricsFinder } from "./lyrics.ts";
|
|
86
86
|
import { LyricsPublisher } from "./lyrics-publish.ts";
|
|
87
87
|
import { aiKey } from "./ai-keys.ts";
|
|
88
|
-
import { STATION_APP_CSP, STATION_PAGE, STATION_PAGE_CSP, STATION_PAGE_HTML } from "./station-page.ts";
|
|
88
|
+
import { STATION_APP_CSP, STATION_PAGE, STATION_PAGE_CSP, STATION_PAGE_HTML, WATCH_APP_CSP } from "./station-page.ts";
|
|
89
|
+
import { handleRooms, RoomProbe, roomEntries, RoomStore, watchBase } from "./guide-rooms.ts";
|
|
90
|
+
import { withPitFooter } from "./pit-footer.ts";
|
|
89
91
|
import { guideAds, guideCsp, guideHosts, guidePeers, peerBase, PeerGuides, renderGuide, type GuideEntry } from "./station-guide.ts";
|
|
90
92
|
import { StationRunner } from "./station-runner.ts";
|
|
91
93
|
import { StationError, StationStore, channelOf, stationOfChannel, type Station } from "./stations.ts";
|
|
@@ -1755,6 +1757,8 @@ export interface HandlerOptions {
|
|
|
1755
1757
|
trustedProxies?: () => Set<string>;
|
|
1756
1758
|
/** Other servers' public stations, for this guide to list beside its own. */
|
|
1757
1759
|
peers?: PeerGuides;
|
|
1760
|
+
/** Live rooms members published to this guide, and how to ask a room's server what is on it. */
|
|
1761
|
+
rooms?: { store: RoomStore; probe: RoomProbe };
|
|
1758
1762
|
};
|
|
1759
1763
|
web: string | null;
|
|
1760
1764
|
/** Branded clients on this same backend, keyed by an explicitly configured host. */
|
|
@@ -2710,6 +2714,10 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2710
2714
|
!/^\/support\/[a-z0-9-]{1,40}$/.test(path) && path !== "/support/cancel" && !/^\/api\/v1\/support\/[a-z0-9-]{1,40}$/.test(path) &&
|
|
2711
2715
|
// A station's short link, which sends the browser to wherever it plays.
|
|
2712
2716
|
!/^\/watch\/[a-z0-9]{10}$/.test(path) &&
|
|
2717
|
+
// A live room's page: the room's own key travels in ?play=, checked by its server.
|
|
2718
|
+
!(path === "/watch" && Boolean(options.stations?.rooms)) &&
|
|
2719
|
+
// Live rooms on the guide: listed for anybody, published by a member (guide-rooms.ts).
|
|
2720
|
+
!((path === "/api/v1/guide/rooms" || path.startsWith("/api/v1/guide/rooms/")) && Boolean(options.stations?.rooms)) &&
|
|
2713
2721
|
!STATION_PAGE.test(path) &&
|
|
2714
2722
|
!isPublicAsset &&
|
|
2715
2723
|
!isGuidePage &&
|
|
@@ -2877,8 +2885,11 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2877
2885
|
});
|
|
2878
2886
|
const own = new Set(entries.map((one) => one.page));
|
|
2879
2887
|
const others = options.stations!.peers ? (await options.stations!.peers.entries()).filter((one) => !own.has(one.page)) : [];
|
|
2888
|
+
const rooms = options.stations!.rooms
|
|
2889
|
+
? await roomEntries(options.stations!.rooms.store, options.stations!.rooms.probe, watchBase(options.stations!.publicBase?.() || `${encrypted ? "https" : "http"}://${request.headers.host ?? "localhost"}`))
|
|
2890
|
+
: [];
|
|
2880
2891
|
const ads = guideAds();
|
|
2881
|
-
const page = renderGuide([...entries, ...others] as GuideEntry[], options.stations!.publicBase?.() ?? "", ads);
|
|
2892
|
+
const page = renderGuide([...rooms, ...entries, ...others] as GuideEntry[], options.stations!.publicBase?.() ?? "", ads);
|
|
2882
2893
|
response.writeHead(200, {
|
|
2883
2894
|
"content-type": "text/html; charset=utf-8",
|
|
2884
2895
|
"cache-control": "no-cache",
|
|
@@ -2892,6 +2903,46 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2892
2903
|
|
|
2893
2904
|
// The page a station's link opens. The same bytes for every station: it
|
|
2894
2905
|
// reads its id and token from its own address.
|
|
2906
|
+
// A live room's page: /watch?play=<a nixamp server's channel or live
|
|
2907
|
+
// stream address>. The built page plays it in @profullstack/player, as a
|
|
2908
|
+
// film or as radio; nothing about the room passes through here.
|
|
2909
|
+
if (options.stations?.rooms && path === "/watch" && (request.method === "GET" || request.method === "HEAD")) {
|
|
2910
|
+
const built = web !== null && isFile(join(web, "watch.html")) ? readFileSync(join(web, "watch.html")) : null;
|
|
2911
|
+
if (!built) {
|
|
2912
|
+
json(response, 404, { error: "this server was started without its web pages" });
|
|
2913
|
+
return;
|
|
2914
|
+
}
|
|
2915
|
+
response.writeHead(200, {
|
|
2916
|
+
"content-type": "text/html; charset=utf-8",
|
|
2917
|
+
"cache-control": "no-cache",
|
|
2918
|
+
"content-security-policy": WATCH_APP_CSP,
|
|
2919
|
+
"referrer-policy": "no-referrer",
|
|
2920
|
+
"x-content-type-options": "nosniff",
|
|
2921
|
+
});
|
|
2922
|
+
response.end(request.method === "HEAD" ? undefined : built);
|
|
2923
|
+
return;
|
|
2924
|
+
}
|
|
2925
|
+
|
|
2926
|
+
if (options.stations?.rooms && (path === "/api/v1/guide/rooms" || path.startsWith("/api/v1/guide/rooms/"))) {
|
|
2927
|
+
const control = await controlOf();
|
|
2928
|
+
const session = tokenFrom(request.headers) || url.searchParams.get("session") || "";
|
|
2929
|
+
const rooms = options.stations.rooms;
|
|
2930
|
+
await handleRooms(request, response, path, {
|
|
2931
|
+
store: rooms.store,
|
|
2932
|
+
probe: rooms.probe,
|
|
2933
|
+
base: watchBase(options.stations.publicBase?.() || `https://${request.headers.host ?? "localhost"}`),
|
|
2934
|
+
canControl: control.allowed,
|
|
2935
|
+
member: async () => (session && options.owner ? (await options.owner.accountFor(session)) || "" : ""),
|
|
2936
|
+
json,
|
|
2937
|
+
readBody,
|
|
2938
|
+
throttle: () => {
|
|
2939
|
+
const verdict = guard.check(`rooms:${callerOf(request.headers, request.socket.remoteAddress, behindProxy)}`, STATION_CONTROL_LIMIT);
|
|
2940
|
+
return verdict.ok ? null : { retryAfter: verdict.retryAfter };
|
|
2941
|
+
},
|
|
2942
|
+
});
|
|
2943
|
+
return;
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2895
2946
|
if (options.stations && STATION_PAGE.test(path) && (request.method === "GET" || request.method === "HEAD")) {
|
|
2896
2947
|
// The built page plays in @profullstack/player; the inline one is for a
|
|
2897
2948
|
// server started without the web build.
|
|
@@ -3009,6 +3060,9 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
3009
3060
|
...(options.stations.lyrics ? { lyrics: options.stations.lyrics } : {}),
|
|
3010
3061
|
...(options.stations.lyricsPublisher ? { lyricsPublisher: options.stations.lyricsPublisher } : {}),
|
|
3011
3062
|
...(options.stations.peers ? { peers: () => options.stations!.peers!.entries() } : {}),
|
|
3063
|
+
...(options.stations.rooms ? {
|
|
3064
|
+
rooms: () => roomEntries(options.stations!.rooms!.store, options.stations!.rooms!.probe, watchBase(options.stations!.publicBase?.() || `${scheme}://${request.headers.host ?? "localhost"}`)),
|
|
3065
|
+
} : {}),
|
|
3012
3066
|
throttle: () => {
|
|
3013
3067
|
const verdict = guard.check(`stations:${control.account || callerOf(request.headers, request.socket.remoteAddress, behindProxy)}`, STATION_CONTROL_LIMIT);
|
|
3014
3068
|
return verdict.ok ? null : { retryAfter: verdict.retryAfter };
|
|
@@ -6430,7 +6484,24 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
6430
6484
|
}
|
|
6431
6485
|
const shell = subject ? readIfPossible(file) : null;
|
|
6432
6486
|
if (subject && shell !== null) {
|
|
6433
|
-
html(response, 200, joinDocument(shell, subject, eventSite ?? "https://nixamp.com"));
|
|
6487
|
+
html(response, 200, requestHost === "nixamp.com" ? withPitFooter(joinDocument(shell, subject, eventSite ?? "https://nixamp.com")) : joinDocument(shell, subject, eventSite ?? "https://nixamp.com"));
|
|
6488
|
+
return;
|
|
6489
|
+
}
|
|
6490
|
+
}
|
|
6491
|
+
// nixamp.com's own pages (the app shell, the radio console) carry the
|
|
6492
|
+
// pit.world global footer, appended on the way out. A local install
|
|
6493
|
+
// serving the same files on its own host is left alone.
|
|
6494
|
+
if (requestHost === "nixamp.com" && file.endsWith(".html") && (request.method === "GET" || request.method === "HEAD")) {
|
|
6495
|
+
const shell = readIfPossible(file);
|
|
6496
|
+
if (shell !== null) {
|
|
6497
|
+
const page = withPitFooter(shell);
|
|
6498
|
+
response.writeHead(200, {
|
|
6499
|
+
...CORS,
|
|
6500
|
+
"content-type": "text/html; charset=utf-8",
|
|
6501
|
+
"content-length": Buffer.byteLength(page),
|
|
6502
|
+
"cache-control": "no-cache",
|
|
6503
|
+
});
|
|
6504
|
+
response.end(request.method === "HEAD" ? undefined : page);
|
|
6434
6505
|
return;
|
|
6435
6506
|
}
|
|
6436
6507
|
}
|
|
@@ -7054,6 +7125,8 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
7054
7125
|
const listed = directory ? directory.list().map((one) => peerBase(one.url)).filter(Boolean).slice(0, 100) : [];
|
|
7055
7126
|
return [...new Set([...guidePeers(), ...listed])];
|
|
7056
7127
|
}),
|
|
7128
|
+
// Live rooms published to this guide; the probe never reaches this machine's own network.
|
|
7129
|
+
rooms: { store: new RoomStore(RoomStore.fileFor(stateDir(), options.port)), probe: new RoomProbe(fetch, (link) => refusedDestination(link)) },
|
|
7057
7130
|
};
|
|
7058
7131
|
// Music handed over from BitTorrented, copied into a folder of our own.
|
|
7059
7132
|
const importer = new Importer(join(stateDir(), `handoffs-${options.port}.json`), {
|
package/src/station-guide.ts
CHANGED
|
@@ -67,7 +67,7 @@ const SCRIPT = `
|
|
|
67
67
|
} catch { button.textContent = "Could not report; try again"; button.disabled = false; }
|
|
68
68
|
};
|
|
69
69
|
const draw = (stations) => {
|
|
70
|
-
for (const group of ["ai", "shared", "radio", "tv"]) {
|
|
70
|
+
for (const group of ["rooms", "ai", "shared", "radio", "tv"]) {
|
|
71
71
|
const grid = document.querySelector("#" + group);
|
|
72
72
|
grid.replaceChildren();
|
|
73
73
|
const list = stations.filter((s) => (s.group || (s.kind === "video" ? "tv" : "radio")) === group);
|
|
@@ -154,8 +154,8 @@ export interface GuideEntry {
|
|
|
154
154
|
id?: string;
|
|
155
155
|
name: string;
|
|
156
156
|
kind: "audio" | "video";
|
|
157
|
-
/** ai, shared, radio or tv; from kind when absent. */
|
|
158
|
-
group?: "ai" | "shared" | "radio" | "tv";
|
|
157
|
+
/** rooms (live rooms, src/guide-rooms.ts), ai, shared, radio or tv; from kind when absent. */
|
|
158
|
+
group?: "rooms" | "ai" | "shared" | "radio" | "tv";
|
|
159
159
|
state: string;
|
|
160
160
|
page: string;
|
|
161
161
|
nowPlaying: { title: string; artist: string } | null;
|
|
@@ -163,7 +163,7 @@ export interface GuideEntry {
|
|
|
163
163
|
peer?: boolean;
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
-
const GROUPS = ["ai", "shared", "radio", "tv"] as const;
|
|
166
|
+
const GROUPS = ["rooms", "ai", "shared", "radio", "tv"] as const;
|
|
167
167
|
|
|
168
168
|
/**
|
|
169
169
|
* The guide with its stations already in it, so it reads the same through a
|
|
@@ -186,7 +186,9 @@ export function renderGuide(entries: GuideEntry[], apiBase = "", ads: GuideAds =
|
|
|
186
186
|
if (ads.site) page = page.replace("</head>", `<script data-site="${escape(ads.site)}" src="${CRAWLPROOF}/stats.js" async></script>\n</head>`);
|
|
187
187
|
if (ads.slot) {
|
|
188
188
|
const format = ads.format ?? "banner_300x250";
|
|
189
|
-
|
|
189
|
+
const slot = ads.slot;
|
|
190
|
+
// The one ad goes at the top of the content, never against the footer.
|
|
191
|
+
page = page.replace('<p class="lead">Radio and TV, on the air now. Pick one and press play.</p>\n', (lead) => `${lead} <div class="ad-unit ad-${format}"><iframe src="${CRAWLPROOF}/api/ads/frame?slot=${encodeURIComponent(slot)}&format=${format}" title="Advertisement" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation" scrolling="no"></iframe></div>\n`);
|
|
190
192
|
}
|
|
191
193
|
const api = /^https:\/\/[^\s"<>]+$/.test(apiBase) ? apiBase.replace(/\/+$/, "") : "";
|
|
192
194
|
return (page.replace('<p id="empty" class="empty">', entries.length ? '<p id="empty" class="empty" hidden>' : '<p id="empty" class="empty">')).replace("<body>", api ? `<body data-api="${escape(api)}">` : "<body>");
|
|
@@ -206,6 +208,8 @@ export const GUIDE_HTML = `<!doctype html>
|
|
|
206
208
|
<main>
|
|
207
209
|
<h1>Live</h1>
|
|
208
210
|
<p class="lead">Radio and TV, on the air now. Pick one and press play.</p>
|
|
211
|
+
<h2 id="rooms-title" hidden>Live rooms</h2>
|
|
212
|
+
<div id="rooms" class="grid" aria-live="polite"></div>
|
|
209
213
|
<h2 id="ai-title" hidden>AI Radio</h2>
|
|
210
214
|
<div id="ai" class="grid" aria-live="polite"></div>
|
|
211
215
|
<h2 id="shared-title" hidden>Shared by listeners</h2>
|
|
@@ -237,7 +241,7 @@ export const GUIDE_HTML = `<!doctype html>
|
|
|
237
241
|
<button type="submit">Share it</button>
|
|
238
242
|
<div id="status" role="status" aria-live="polite"></div>
|
|
239
243
|
</form>
|
|
240
|
-
<footer>Played by <a href="https://nixamp.com">nixamp</a
|
|
244
|
+
<footer>Played by <a href="https://nixamp.com">nixamp</a>.</footer>
|
|
241
245
|
</main>
|
|
242
246
|
<script>${SCRIPT}</script>
|
|
243
247
|
</body>
|
package/src/station-page.ts
CHANGED
|
@@ -217,6 +217,22 @@ export const STATION_PAGE_CSP = [
|
|
|
217
217
|
].join("; ");
|
|
218
218
|
|
|
219
219
|
/** The built page: its own bundle, styles and player engines, only this server, never framed. */
|
|
220
|
+
/**
|
|
221
|
+
* A live room's page (/watch): the same built page shape, but what it plays and
|
|
222
|
+
* asks about is on the room's own server, any https nixamp server.
|
|
223
|
+
*/
|
|
224
|
+
export const WATCH_APP_CSP = [
|
|
225
|
+
"default-src 'self'",
|
|
226
|
+
"style-src 'self' 'unsafe-inline'",
|
|
227
|
+
"media-src 'self' blob: https:",
|
|
228
|
+
"worker-src 'self' blob:",
|
|
229
|
+
"img-src 'self' data: blob: https:",
|
|
230
|
+
"connect-src 'self' https:",
|
|
231
|
+
"base-uri 'none'",
|
|
232
|
+
"form-action 'none'",
|
|
233
|
+
"frame-ancestors 'none'",
|
|
234
|
+
].join("; ");
|
|
235
|
+
|
|
220
236
|
export const STATION_APP_CSP = [
|
|
221
237
|
"default-src 'self'",
|
|
222
238
|
"style-src 'self' 'unsafe-inline'",
|
package/src/station-routes.ts
CHANGED
|
@@ -74,6 +74,8 @@ export interface StationContext {
|
|
|
74
74
|
auto?: { sync: (input: Record<string, unknown>) => Promise<unknown>; setting: () => unknown };
|
|
75
75
|
/** The public stations other servers list (guide peers); absent when there are none. */
|
|
76
76
|
peers?: () => Promise<unknown[]>;
|
|
77
|
+
/** Live rooms members published to this guide (guide-rooms.ts); absent when this server keeps none. */
|
|
78
|
+
rooms?: () => Promise<unknown[]>;
|
|
77
79
|
}
|
|
78
80
|
|
|
79
81
|
function fail(ctx: StationContext, response: ServerResponse, error: unknown): void {
|
|
@@ -181,7 +183,9 @@ export async function handleStations(
|
|
|
181
183
|
const shown = new Set(own.map((one) => one.page));
|
|
182
184
|
const others = url.searchParams.has("local") || !ctx.peers ? [] : (await ctx.peers())
|
|
183
185
|
.filter((one) => !shown.has((one as { page: string }).page));
|
|
184
|
-
|
|
186
|
+
// Rooms are this guide's own; a peer asking for its stations does not list them twice.
|
|
187
|
+
const rooms = url.searchParams.has("local") || !ctx.rooms ? [] : await ctx.rooms();
|
|
188
|
+
ctx.json(response, 200, { stations: [...rooms, ...own, ...others] });
|
|
185
189
|
return true;
|
|
186
190
|
}
|
|
187
191
|
|