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
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import type { GuideEntry } from "./station-guide.ts";
|
|
3
|
+
/** Why a room may be broadcast, the same five reasons a station gives. */
|
|
4
|
+
export declare const ROOM_BASES: readonly ["owned", "permission", "licensed", "creative-commons", "public-domain"];
|
|
5
|
+
export type RoomBasis = typeof ROOM_BASES[number];
|
|
6
|
+
export interface GuideRoom {
|
|
7
|
+
id: string;
|
|
8
|
+
/** The room's stream address, with its listen key. */
|
|
9
|
+
play: string;
|
|
10
|
+
name: string;
|
|
11
|
+
kind: "audio" | "video";
|
|
12
|
+
basis: RoomBasis;
|
|
13
|
+
note: string;
|
|
14
|
+
/** The nixamp.com account that published it ("" for this server's owner). */
|
|
15
|
+
by: string;
|
|
16
|
+
publishedAt: number;
|
|
17
|
+
}
|
|
18
|
+
/** Rooms one member may have on the guide at once, and rooms on it in all. */
|
|
19
|
+
export declare const ROOMS_PER_MEMBER = 5;
|
|
20
|
+
export declare const ROOMS_MAX = 200;
|
|
21
|
+
/**
|
|
22
|
+
* A room's address taken apart, or null when it is not one: https (http only
|
|
23
|
+
* for a loopback address, for tests), a channel or the live stream, a key,
|
|
24
|
+
* and nothing else in the query.
|
|
25
|
+
*/
|
|
26
|
+
export declare function roomAddress(input: string): {
|
|
27
|
+
origin: string;
|
|
28
|
+
channel: string | null;
|
|
29
|
+
key: string;
|
|
30
|
+
play: string;
|
|
31
|
+
} | null;
|
|
32
|
+
/** Where the guide links a room's page: config `watchBase`, NIXAMP_WATCH_BASE, else this server. */
|
|
33
|
+
export declare function watchBase(fallback: string, env?: Record<string, string | undefined>): string;
|
|
34
|
+
export declare const watchPage: (base: string, play: string) => string;
|
|
35
|
+
export declare class RoomStore {
|
|
36
|
+
private readonly file;
|
|
37
|
+
private rooms;
|
|
38
|
+
constructor(file: string);
|
|
39
|
+
static fileFor(stateDir: string, port: number): string;
|
|
40
|
+
list(): GuideRoom[];
|
|
41
|
+
get(id: string): GuideRoom | undefined;
|
|
42
|
+
/** Publishes a room, or refreshes the one already published at that address. */
|
|
43
|
+
put(room: Omit<GuideRoom, "id" | "publishedAt">): GuideRoom;
|
|
44
|
+
remove(id: string): boolean;
|
|
45
|
+
private save;
|
|
46
|
+
}
|
|
47
|
+
export interface RoomState {
|
|
48
|
+
/** Whether the room's server says it is on the air. */
|
|
49
|
+
live: boolean;
|
|
50
|
+
name: string;
|
|
51
|
+
kind: "audio" | "video";
|
|
52
|
+
nowPlaying: {
|
|
53
|
+
title: string;
|
|
54
|
+
artist: string;
|
|
55
|
+
} | null;
|
|
56
|
+
}
|
|
57
|
+
/** Asks a room's own server what is on it: its channel list for a channel, the stream's headers for the live stream. */
|
|
58
|
+
export declare class RoomProbe {
|
|
59
|
+
private readonly fetcher;
|
|
60
|
+
private readonly refuse;
|
|
61
|
+
private cache;
|
|
62
|
+
/**
|
|
63
|
+
* `refuse` names why an address may not be fetched from this machine (a
|
|
64
|
+
* private or loopback network), or "": a member chooses the address, and
|
|
65
|
+
* this server must not become their way into its own network.
|
|
66
|
+
*/
|
|
67
|
+
constructor(fetcher?: typeof fetch, refuse?: (link: string) => Promise<string>);
|
|
68
|
+
state(play: string, fresh?: boolean): Promise<RoomState | null>;
|
|
69
|
+
private ask;
|
|
70
|
+
}
|
|
71
|
+
/** The rooms as guide cards: the page to open, on the air or not, and what is on. */
|
|
72
|
+
export declare function roomEntries(store: RoomStore, probe: RoomProbe, base: string): Promise<GuideEntry[]>;
|
|
73
|
+
export interface RoomContext {
|
|
74
|
+
store: RoomStore;
|
|
75
|
+
probe: RoomProbe;
|
|
76
|
+
/** The page base for a room's card. */
|
|
77
|
+
base: string;
|
|
78
|
+
/** Whether the request holds this server's control key (or is its signed-in owner). */
|
|
79
|
+
canControl: boolean;
|
|
80
|
+
/** The nixamp.com account behind the request's session, or "". */
|
|
81
|
+
member: () => Promise<string>;
|
|
82
|
+
json: (response: ServerResponse, status: number, body: unknown) => void;
|
|
83
|
+
/** The request body: server.ts's raw text, which handleRooms parses as JSON. */
|
|
84
|
+
readBody: (request: IncomingMessage) => Promise<unknown>;
|
|
85
|
+
/** Refused by the caller's rate limit, or null. */
|
|
86
|
+
throttle?: () => {
|
|
87
|
+
retryAfter: number;
|
|
88
|
+
} | null;
|
|
89
|
+
}
|
|
90
|
+
/** Answers /api/v1/guide/rooms; false when the path is not one. */
|
|
91
|
+
export declare function handleRooms(request: IncomingMessage, response: ServerResponse, path: string, ctx: RoomContext): Promise<boolean>;
|
|
@@ -0,0 +1,264 @@
|
|
|
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 { readConfig } from "./library.js";
|
|
18
|
+
/** Why a room may be broadcast, the same five reasons a station gives. */
|
|
19
|
+
export const ROOM_BASES = ["owned", "permission", "licensed", "creative-commons", "public-domain"];
|
|
20
|
+
/** Rooms one member may have on the guide at once, and rooms on it in all. */
|
|
21
|
+
export const ROOMS_PER_MEMBER = 5;
|
|
22
|
+
export const ROOMS_MAX = 200;
|
|
23
|
+
const STATE_TTL_MS = 15_000;
|
|
24
|
+
const PROBE_TIMEOUT_MS = 4_000;
|
|
25
|
+
/**
|
|
26
|
+
* A room's address taken apart, or null when it is not one: https (http only
|
|
27
|
+
* for a loopback address, for tests), a channel or the live stream, a key,
|
|
28
|
+
* and nothing else in the query.
|
|
29
|
+
*/
|
|
30
|
+
export function roomAddress(input) {
|
|
31
|
+
let url;
|
|
32
|
+
try {
|
|
33
|
+
url = new URL(input.trim());
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const loopback = url.hostname === "127.0.0.1" || url.hostname === "localhost";
|
|
39
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback))
|
|
40
|
+
return null;
|
|
41
|
+
if (url.username || url.password || url.hash)
|
|
42
|
+
return null;
|
|
43
|
+
const channel = /^\/api\/channels\/([A-Za-z0-9._~-]{1,120})\/?$/.exec(url.pathname);
|
|
44
|
+
const live = /^\/api\/live\/?$/.test(url.pathname);
|
|
45
|
+
if (!channel && !live)
|
|
46
|
+
return null;
|
|
47
|
+
const key = url.searchParams.get("k") ?? "";
|
|
48
|
+
if (!/^[A-Za-z0-9_-]{8,128}$/.test(key) || [...url.searchParams.keys()].some((name) => name !== "k"))
|
|
49
|
+
return null;
|
|
50
|
+
const path = channel ? `/api/channels/${channel[1]}` : "/api/live";
|
|
51
|
+
return { origin: url.origin, channel: channel ? channel[1] : null, key, play: `${url.origin}${path}?k=${key}` };
|
|
52
|
+
}
|
|
53
|
+
/** Where the guide links a room's page: config `watchBase`, NIXAMP_WATCH_BASE, else this server. */
|
|
54
|
+
export function watchBase(fallback, env = process.env) {
|
|
55
|
+
const saved = (() => { try {
|
|
56
|
+
return readConfig().watchBase ?? "";
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return "";
|
|
60
|
+
} })();
|
|
61
|
+
const chosen = (env["NIXAMP_WATCH_BASE"] || saved || fallback).trim().replace(/\/+$/, "");
|
|
62
|
+
return /^https?:\/\/[^\s"<>]+$/.test(chosen) ? chosen : fallback;
|
|
63
|
+
}
|
|
64
|
+
export const watchPage = (base, play) => `${base}/watch?play=${encodeURIComponent(play)}`;
|
|
65
|
+
export class RoomStore {
|
|
66
|
+
file;
|
|
67
|
+
rooms = [];
|
|
68
|
+
constructor(file) {
|
|
69
|
+
this.file = file;
|
|
70
|
+
try {
|
|
71
|
+
if (existsSync(file)) {
|
|
72
|
+
const saved = JSON.parse(readFileSync(file, "utf8"));
|
|
73
|
+
this.rooms = (saved.rooms ?? []).filter((one) => roomAddress(one.play) !== null);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// Unreadable is empty; the next write replaces it.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
static fileFor(stateDir, port) {
|
|
81
|
+
return join(stateDir, `guide-rooms-${port}.json`);
|
|
82
|
+
}
|
|
83
|
+
list() {
|
|
84
|
+
return [...this.rooms];
|
|
85
|
+
}
|
|
86
|
+
get(id) {
|
|
87
|
+
return this.rooms.find((one) => one.id === id);
|
|
88
|
+
}
|
|
89
|
+
/** Publishes a room, or refreshes the one already published at that address. */
|
|
90
|
+
put(room) {
|
|
91
|
+
const existing = this.rooms.find((one) => one.play === room.play);
|
|
92
|
+
const saved = { ...room, id: existing?.id ?? randomBytes(6).toString("base64url"), publishedAt: Date.now() };
|
|
93
|
+
this.rooms = [...this.rooms.filter((one) => one.play !== room.play), saved];
|
|
94
|
+
this.save();
|
|
95
|
+
return saved;
|
|
96
|
+
}
|
|
97
|
+
remove(id) {
|
|
98
|
+
const before = this.rooms.length;
|
|
99
|
+
this.rooms = this.rooms.filter((one) => one.id !== id);
|
|
100
|
+
if (this.rooms.length === before)
|
|
101
|
+
return false;
|
|
102
|
+
this.save();
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
save() {
|
|
106
|
+
writeFileSync(`${this.file}.part`, `${JSON.stringify({ version: 1, rooms: this.rooms }, null, 2)}\n`);
|
|
107
|
+
renameSync(`${this.file}.part`, this.file);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/** Asks a room's own server what is on it: its channel list for a channel, the stream's headers for the live stream. */
|
|
111
|
+
export class RoomProbe {
|
|
112
|
+
fetcher;
|
|
113
|
+
refuse;
|
|
114
|
+
cache = new Map();
|
|
115
|
+
/**
|
|
116
|
+
* `refuse` names why an address may not be fetched from this machine (a
|
|
117
|
+
* private or loopback network), or "": a member chooses the address, and
|
|
118
|
+
* this server must not become their way into its own network.
|
|
119
|
+
*/
|
|
120
|
+
constructor(fetcher = fetch, refuse = async () => "") {
|
|
121
|
+
this.fetcher = fetcher;
|
|
122
|
+
this.refuse = refuse;
|
|
123
|
+
}
|
|
124
|
+
async state(play, fresh = false) {
|
|
125
|
+
const cached = this.cache.get(play);
|
|
126
|
+
if (!fresh && cached && Date.now() - cached.at < STATE_TTL_MS)
|
|
127
|
+
return cached.state;
|
|
128
|
+
const state = await this.ask(play).catch(() => null);
|
|
129
|
+
this.cache.set(play, { at: Date.now(), state });
|
|
130
|
+
if (this.cache.size > 500)
|
|
131
|
+
this.cache.delete(this.cache.keys().next().value);
|
|
132
|
+
return state;
|
|
133
|
+
}
|
|
134
|
+
async ask(play) {
|
|
135
|
+
const address = roomAddress(play);
|
|
136
|
+
if (!address || (await this.refuse(address.play)) !== "")
|
|
137
|
+
return null;
|
|
138
|
+
if (address.channel !== null) {
|
|
139
|
+
const answer = await this.fetcher(`${address.origin}/api/channels?k=${address.key}`, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), redirect: "error" });
|
|
140
|
+
if (!answer.ok)
|
|
141
|
+
return null;
|
|
142
|
+
const body = (await answer.json());
|
|
143
|
+
const row = (body.channels ?? []).find((one) => one.id === address.channel);
|
|
144
|
+
if (!row)
|
|
145
|
+
return null;
|
|
146
|
+
const tags = row.codecs?.tags;
|
|
147
|
+
return {
|
|
148
|
+
live: row.live !== false,
|
|
149
|
+
name: String(row.name ?? address.channel).replace(/\/+$/, "").slice(0, 120),
|
|
150
|
+
kind: row.kind === "video" ? "video" : "audio",
|
|
151
|
+
nowPlaying: tags && (tags.title || tags.artist) ? { title: String(tags.title ?? ""), artist: String(tags.artist ?? "") } : null,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
// The live stream: on the air when it answers with media.
|
|
155
|
+
const controller = new AbortController();
|
|
156
|
+
const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
|
|
157
|
+
try {
|
|
158
|
+
const answer = await this.fetcher(address.play, { signal: controller.signal, redirect: "error" });
|
|
159
|
+
const type = answer.headers.get("content-type") ?? "";
|
|
160
|
+
controller.abort();
|
|
161
|
+
if (!answer.ok)
|
|
162
|
+
return null;
|
|
163
|
+
return { live: true, name: "Live", kind: type.startsWith("video/") ? "video" : "audio", nowPlaying: null };
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
clearTimeout(timer);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/** The rooms as guide cards: the page to open, on the air or not, and what is on. */
|
|
171
|
+
export async function roomEntries(store, probe, base) {
|
|
172
|
+
return Promise.all(store.list().map(async (room) => {
|
|
173
|
+
const state = await probe.state(room.play);
|
|
174
|
+
return {
|
|
175
|
+
id: room.id,
|
|
176
|
+
name: room.name,
|
|
177
|
+
kind: state?.kind ?? room.kind,
|
|
178
|
+
group: "rooms",
|
|
179
|
+
state: state?.live ? "live" : "offline",
|
|
180
|
+
page: watchPage(base, room.play),
|
|
181
|
+
nowPlaying: state?.nowPlaying ?? null,
|
|
182
|
+
};
|
|
183
|
+
}));
|
|
184
|
+
}
|
|
185
|
+
/** Answers /api/v1/guide/rooms; false when the path is not one. */
|
|
186
|
+
export async function handleRooms(request, response, path, ctx) {
|
|
187
|
+
if (path !== "/api/v1/guide/rooms" && !path.startsWith("/api/v1/guide/rooms/"))
|
|
188
|
+
return false;
|
|
189
|
+
const method = request.method ?? "GET";
|
|
190
|
+
if (path === "/api/v1/guide/rooms" && (method === "GET" || method === "HEAD")) {
|
|
191
|
+
ctx.json(response, 200, { rooms: await roomEntries(ctx.store, ctx.probe, ctx.base) });
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
const by = ctx.canControl ? "" : await ctx.member();
|
|
195
|
+
if (!ctx.canControl && by === "") {
|
|
196
|
+
ctx.json(response, 401, { error: "sign in to nixamp.com to publish a live room", code: "sign_in" });
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
const refused = ctx.throttle?.();
|
|
200
|
+
if (refused) {
|
|
201
|
+
response.setHeader("retry-after", String(refused.retryAfter));
|
|
202
|
+
ctx.json(response, 429, { error: "too many changes; wait a moment", code: "throttled" });
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
if (path === "/api/v1/guide/rooms" && method === "POST") {
|
|
206
|
+
// server.ts hands over the raw text; a body that was never parsed has no
|
|
207
|
+
// `play`, and every publish read as a bad address.
|
|
208
|
+
const raw = await ctx.readBody(request).catch(() => null);
|
|
209
|
+
const body = (typeof raw === "string" ? (() => { try {
|
|
210
|
+
return JSON.parse(raw);
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
return null;
|
|
214
|
+
} })() : raw);
|
|
215
|
+
const address = roomAddress(typeof body?.play === "string" ? body.play : "");
|
|
216
|
+
if (!address) {
|
|
217
|
+
ctx.json(response, 400, { error: "play must be a nixamp room's stream address: https://host/api/channels/<id>?k=KEY", code: "bad_address" });
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
const basis = ROOM_BASES.find((one) => one === body?.basis);
|
|
221
|
+
if (!basis || body?.agree !== true) {
|
|
222
|
+
ctx.json(response, 400, { error: "say why it may be broadcast (owned, permission, licensed, creative-commons or public-domain) and agree", code: "basis_required" });
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
const state = await ctx.probe.state(address.play, true);
|
|
226
|
+
if (!state) {
|
|
227
|
+
ctx.json(response, 404, { error: "that room is not on its server, or the key does not open it", code: "not_found" });
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
const all = ctx.store.list();
|
|
231
|
+
const mine = all.filter((one) => one.by === by && one.play !== address.play);
|
|
232
|
+
if (by !== "" && mine.length >= ROOMS_PER_MEMBER) {
|
|
233
|
+
ctx.json(response, 409, { error: `a member may have ${ROOMS_PER_MEMBER} rooms on the guide; take one off first`, code: "too_many" });
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
if (!all.some((one) => one.play === address.play) && all.length >= ROOMS_MAX) {
|
|
237
|
+
ctx.json(response, 409, { error: "the guide is full", code: "full" });
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
const taken = all.find((one) => one.play === address.play);
|
|
241
|
+
if (taken && taken.by !== by && !ctx.canControl) {
|
|
242
|
+
ctx.json(response, 409, { error: "somebody else published that room", code: "taken" });
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
const title = typeof body?.title === "string" ? body.title.trim().slice(0, 120) : "";
|
|
246
|
+
const note = typeof body?.note === "string" ? body.note.trim().slice(0, 300) : "";
|
|
247
|
+
const room = ctx.store.put({ play: address.play, name: title || state.name, kind: state.kind, basis, note, by: taken?.by ?? by });
|
|
248
|
+
ctx.json(response, 200, { room: { id: room.id, name: room.name, kind: room.kind, basis: room.basis }, page: watchPage(ctx.base, room.play) });
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
const one = /^\/api\/v1\/guide\/rooms\/([A-Za-z0-9_-]{1,32})$/.exec(path);
|
|
252
|
+
if (one && method === "DELETE") {
|
|
253
|
+
const room = ctx.store.get(one[1]);
|
|
254
|
+
if (!room || (!ctx.canControl && room.by !== by)) {
|
|
255
|
+
ctx.json(response, 404, { error: "no such room of yours on the guide", code: "not_found" });
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
ctx.store.remove(room.id);
|
|
259
|
+
ctx.json(response, 200, { removed: room.id });
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
ctx.json(response, 405, { error: "method not allowed", code: "method_not_allowed" });
|
|
263
|
+
return true;
|
|
264
|
+
}
|
package/dist/library.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export interface Config {
|
|
|
9
9
|
guideHosts?: string[];
|
|
10
10
|
/** Other servers (https base addresses) whose public, listed stations this guide shows too. */
|
|
11
11
|
guidePeers?: string[];
|
|
12
|
+
/** Where the guide links a published live room's page (https://live.streaming.pit.world); this server's own address when unset. */
|
|
13
|
+
watchBase?: string;
|
|
12
14
|
/** crawlproof on the guide: tracker project id (site), ad slot and format (`nixamp station guide-ads`). */
|
|
13
15
|
guideAds?: {
|
|
14
16
|
site?: string;
|
|
@@ -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
|
+
let cached = "";
|
|
13
|
+
let fetchedAt = 0;
|
|
14
|
+
let inflight = null;
|
|
15
|
+
export function refreshPitFooter() {
|
|
16
|
+
inflight ??= fetch(PIT_FOOTER_URL, { signal: AbortSignal.timeout(3000) })
|
|
17
|
+
.then(async (res) => {
|
|
18
|
+
const text = res.ok ? await res.text() : "";
|
|
19
|
+
if (!text.includes("data-pit-footer"))
|
|
20
|
+
throw new Error(`pit footer: HTTP ${res.status}`);
|
|
21
|
+
cached = text;
|
|
22
|
+
fetchedAt = Date.now();
|
|
23
|
+
})
|
|
24
|
+
.catch(() => {
|
|
25
|
+
fetchedAt = Date.now() - MAX_AGE_MS + 60_000; // try again in a minute
|
|
26
|
+
})
|
|
27
|
+
.finally(() => {
|
|
28
|
+
inflight = null;
|
|
29
|
+
});
|
|
30
|
+
return inflight;
|
|
31
|
+
}
|
|
32
|
+
/** The page with the footer before its last </body>, or the page unchanged. */
|
|
33
|
+
export function withPitFooter(page) {
|
|
34
|
+
if (Date.now() - fetchedAt > MAX_AGE_MS)
|
|
35
|
+
void refreshPitFooter();
|
|
36
|
+
if (!cached || page.includes("data-pit-footer"))
|
|
37
|
+
return page;
|
|
38
|
+
const at = page.lastIndexOf("</body>");
|
|
39
|
+
return at < 0 ? page : page.slice(0, at) + cached + page.slice(at);
|
|
40
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { Importer } from "./handoff.ts";
|
|
|
27
27
|
import { AiRadio } from "./ai-radio.ts";
|
|
28
28
|
import { ListenerShares } from "./listener-shares.ts";
|
|
29
29
|
import { Support, SupportSubscriptions } from "./support.ts";
|
|
30
|
+
import { RoomProbe, RoomStore } from "./guide-rooms.ts";
|
|
30
31
|
import { PeerGuides } from "./station-guide.ts";
|
|
31
32
|
import { StationRunner } from "./station-runner.ts";
|
|
32
33
|
import { StationStore, type Station } from "./stations.ts";
|
|
@@ -616,6 +617,11 @@ export interface HandlerOptions {
|
|
|
616
617
|
trustedProxies?: () => Set<string>;
|
|
617
618
|
/** Other servers' public stations, for this guide to list beside its own. */
|
|
618
619
|
peers?: PeerGuides;
|
|
620
|
+
/** Live rooms members published to this guide, and how to ask a room's server what is on it. */
|
|
621
|
+
rooms?: {
|
|
622
|
+
store: RoomStore;
|
|
623
|
+
probe: RoomProbe;
|
|
624
|
+
};
|
|
619
625
|
};
|
|
620
626
|
web: string | null;
|
|
621
627
|
/** Branded clients on this same backend, keyed by an explicitly configured host. */
|
package/dist/server.js
CHANGED
|
@@ -68,7 +68,9 @@ import { SUPPORT_CSP, SUPPORT_PAGE, Support, SupportError, SupportSubscriptions,
|
|
|
68
68
|
import { LyricsFinder } from "./lyrics.js";
|
|
69
69
|
import { LyricsPublisher } from "./lyrics-publish.js";
|
|
70
70
|
import { aiKey } from "./ai-keys.js";
|
|
71
|
-
import { STATION_APP_CSP, STATION_PAGE, STATION_PAGE_CSP, STATION_PAGE_HTML } from "./station-page.js";
|
|
71
|
+
import { STATION_APP_CSP, STATION_PAGE, STATION_PAGE_CSP, STATION_PAGE_HTML, WATCH_APP_CSP } from "./station-page.js";
|
|
72
|
+
import { handleRooms, RoomProbe, roomEntries, RoomStore, watchBase } from "./guide-rooms.js";
|
|
73
|
+
import { withPitFooter } from "./pit-footer.js";
|
|
72
74
|
import { guideAds, guideCsp, guideHosts, guidePeers, peerBase, PeerGuides, renderGuide } from "./station-guide.js";
|
|
73
75
|
import { StationRunner } from "./station-runner.js";
|
|
74
76
|
import { StationError, StationStore, channelOf, stationOfChannel } from "./stations.js";
|
|
@@ -2170,6 +2172,10 @@ export function createHandler(engine, options) {
|
|
|
2170
2172
|
!/^\/support\/[a-z0-9-]{1,40}$/.test(path) && path !== "/support/cancel" && !/^\/api\/v1\/support\/[a-z0-9-]{1,40}$/.test(path) &&
|
|
2171
2173
|
// A station's short link, which sends the browser to wherever it plays.
|
|
2172
2174
|
!/^\/watch\/[a-z0-9]{10}$/.test(path) &&
|
|
2175
|
+
// A live room's page: the room's own key travels in ?play=, checked by its server.
|
|
2176
|
+
!(path === "/watch" && Boolean(options.stations?.rooms)) &&
|
|
2177
|
+
// Live rooms on the guide: listed for anybody, published by a member (guide-rooms.ts).
|
|
2178
|
+
!((path === "/api/v1/guide/rooms" || path.startsWith("/api/v1/guide/rooms/")) && Boolean(options.stations?.rooms)) &&
|
|
2173
2179
|
!STATION_PAGE.test(path) &&
|
|
2174
2180
|
!isPublicAsset &&
|
|
2175
2181
|
!isGuidePage &&
|
|
@@ -2345,8 +2351,11 @@ export function createHandler(engine, options) {
|
|
|
2345
2351
|
});
|
|
2346
2352
|
const own = new Set(entries.map((one) => one.page));
|
|
2347
2353
|
const others = options.stations.peers ? (await options.stations.peers.entries()).filter((one) => !own.has(one.page)) : [];
|
|
2354
|
+
const rooms = options.stations.rooms
|
|
2355
|
+
? await roomEntries(options.stations.rooms.store, options.stations.rooms.probe, watchBase(options.stations.publicBase?.() || `${encrypted ? "https" : "http"}://${request.headers.host ?? "localhost"}`))
|
|
2356
|
+
: [];
|
|
2348
2357
|
const ads = guideAds();
|
|
2349
|
-
const page = renderGuide([...entries, ...others], options.stations.publicBase?.() ?? "", ads);
|
|
2358
|
+
const page = renderGuide([...rooms, ...entries, ...others], options.stations.publicBase?.() ?? "", ads);
|
|
2350
2359
|
response.writeHead(200, {
|
|
2351
2360
|
"content-type": "text/html; charset=utf-8",
|
|
2352
2361
|
"cache-control": "no-cache",
|
|
@@ -2359,6 +2368,44 @@ export function createHandler(engine, options) {
|
|
|
2359
2368
|
}
|
|
2360
2369
|
// The page a station's link opens. The same bytes for every station: it
|
|
2361
2370
|
// reads its id and token from its own address.
|
|
2371
|
+
// A live room's page: /watch?play=<a nixamp server's channel or live
|
|
2372
|
+
// stream address>. The built page plays it in @profullstack/player, as a
|
|
2373
|
+
// film or as radio; nothing about the room passes through here.
|
|
2374
|
+
if (options.stations?.rooms && path === "/watch" && (request.method === "GET" || request.method === "HEAD")) {
|
|
2375
|
+
const built = web !== null && isFile(join(web, "watch.html")) ? readFileSync(join(web, "watch.html")) : null;
|
|
2376
|
+
if (!built) {
|
|
2377
|
+
json(response, 404, { error: "this server was started without its web pages" });
|
|
2378
|
+
return;
|
|
2379
|
+
}
|
|
2380
|
+
response.writeHead(200, {
|
|
2381
|
+
"content-type": "text/html; charset=utf-8",
|
|
2382
|
+
"cache-control": "no-cache",
|
|
2383
|
+
"content-security-policy": WATCH_APP_CSP,
|
|
2384
|
+
"referrer-policy": "no-referrer",
|
|
2385
|
+
"x-content-type-options": "nosniff",
|
|
2386
|
+
});
|
|
2387
|
+
response.end(request.method === "HEAD" ? undefined : built);
|
|
2388
|
+
return;
|
|
2389
|
+
}
|
|
2390
|
+
if (options.stations?.rooms && (path === "/api/v1/guide/rooms" || path.startsWith("/api/v1/guide/rooms/"))) {
|
|
2391
|
+
const control = await controlOf();
|
|
2392
|
+
const session = tokenFrom(request.headers) || url.searchParams.get("session") || "";
|
|
2393
|
+
const rooms = options.stations.rooms;
|
|
2394
|
+
await handleRooms(request, response, path, {
|
|
2395
|
+
store: rooms.store,
|
|
2396
|
+
probe: rooms.probe,
|
|
2397
|
+
base: watchBase(options.stations.publicBase?.() || `https://${request.headers.host ?? "localhost"}`),
|
|
2398
|
+
canControl: control.allowed,
|
|
2399
|
+
member: async () => (session && options.owner ? (await options.owner.accountFor(session)) || "" : ""),
|
|
2400
|
+
json,
|
|
2401
|
+
readBody,
|
|
2402
|
+
throttle: () => {
|
|
2403
|
+
const verdict = guard.check(`rooms:${callerOf(request.headers, request.socket.remoteAddress, behindProxy)}`, STATION_CONTROL_LIMIT);
|
|
2404
|
+
return verdict.ok ? null : { retryAfter: verdict.retryAfter };
|
|
2405
|
+
},
|
|
2406
|
+
});
|
|
2407
|
+
return;
|
|
2408
|
+
}
|
|
2362
2409
|
if (options.stations && STATION_PAGE.test(path) && (request.method === "GET" || request.method === "HEAD")) {
|
|
2363
2410
|
// The built page plays in @profullstack/player; the inline one is for a
|
|
2364
2411
|
// server started without the web build.
|
|
@@ -2475,6 +2522,9 @@ export function createHandler(engine, options) {
|
|
|
2475
2522
|
...(options.stations.lyrics ? { lyrics: options.stations.lyrics } : {}),
|
|
2476
2523
|
...(options.stations.lyricsPublisher ? { lyricsPublisher: options.stations.lyricsPublisher } : {}),
|
|
2477
2524
|
...(options.stations.peers ? { peers: () => options.stations.peers.entries() } : {}),
|
|
2525
|
+
...(options.stations.rooms ? {
|
|
2526
|
+
rooms: () => roomEntries(options.stations.rooms.store, options.stations.rooms.probe, watchBase(options.stations.publicBase?.() || `${scheme}://${request.headers.host ?? "localhost"}`)),
|
|
2527
|
+
} : {}),
|
|
2478
2528
|
throttle: () => {
|
|
2479
2529
|
const verdict = guard.check(`stations:${control.account || callerOf(request.headers, request.socket.remoteAddress, behindProxy)}`, STATION_CONTROL_LIMIT);
|
|
2480
2530
|
return verdict.ok ? null : { retryAfter: verdict.retryAfter };
|
|
@@ -5959,7 +6009,24 @@ export function createHandler(engine, options) {
|
|
|
5959
6009
|
}
|
|
5960
6010
|
const shell = subject ? readIfPossible(file) : null;
|
|
5961
6011
|
if (subject && shell !== null) {
|
|
5962
|
-
html(response, 200, joinDocument(shell, subject, eventSite ?? "https://nixamp.com"));
|
|
6012
|
+
html(response, 200, requestHost === "nixamp.com" ? withPitFooter(joinDocument(shell, subject, eventSite ?? "https://nixamp.com")) : joinDocument(shell, subject, eventSite ?? "https://nixamp.com"));
|
|
6013
|
+
return;
|
|
6014
|
+
}
|
|
6015
|
+
}
|
|
6016
|
+
// nixamp.com's own pages (the app shell, the radio console) carry the
|
|
6017
|
+
// pit.world global footer, appended on the way out. A local install
|
|
6018
|
+
// serving the same files on its own host is left alone.
|
|
6019
|
+
if (requestHost === "nixamp.com" && file.endsWith(".html") && (request.method === "GET" || request.method === "HEAD")) {
|
|
6020
|
+
const shell = readIfPossible(file);
|
|
6021
|
+
if (shell !== null) {
|
|
6022
|
+
const page = withPitFooter(shell);
|
|
6023
|
+
response.writeHead(200, {
|
|
6024
|
+
...CORS,
|
|
6025
|
+
"content-type": "text/html; charset=utf-8",
|
|
6026
|
+
"content-length": Buffer.byteLength(page),
|
|
6027
|
+
"cache-control": "no-cache",
|
|
6028
|
+
});
|
|
6029
|
+
response.end(request.method === "HEAD" ? undefined : page);
|
|
5963
6030
|
return;
|
|
5964
6031
|
}
|
|
5965
6032
|
}
|
|
@@ -6561,6 +6628,8 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
6561
6628
|
const listed = directory ? directory.list().map((one) => peerBase(one.url)).filter(Boolean).slice(0, 100) : [];
|
|
6562
6629
|
return [...new Set([...guidePeers(), ...listed])];
|
|
6563
6630
|
}),
|
|
6631
|
+
// Live rooms published to this guide; the probe never reaches this machine's own network.
|
|
6632
|
+
rooms: { store: new RoomStore(RoomStore.fileFor(stateDir(), options.port)), probe: new RoomProbe(fetch, (link) => refusedDestination(link)) },
|
|
6564
6633
|
};
|
|
6565
6634
|
// Music handed over from BitTorrented, copied into a folder of our own.
|
|
6566
6635
|
const importer = new Importer(join(stateDir(), `handoffs-${options.port}.json`), {
|
package/dist/station-guide.d.ts
CHANGED
|
@@ -11,8 +11,8 @@ export interface GuideEntry {
|
|
|
11
11
|
id?: string;
|
|
12
12
|
name: string;
|
|
13
13
|
kind: "audio" | "video";
|
|
14
|
-
/** ai, shared, radio or tv; from kind when absent. */
|
|
15
|
-
group?: "ai" | "shared" | "radio" | "tv";
|
|
14
|
+
/** rooms (live rooms, src/guide-rooms.ts), ai, shared, radio or tv; from kind when absent. */
|
|
15
|
+
group?: "rooms" | "ai" | "shared" | "radio" | "tv";
|
|
16
16
|
state: string;
|
|
17
17
|
page: string;
|
|
18
18
|
nowPlaying: {
|
package/dist/station-guide.js
CHANGED
|
@@ -65,7 +65,7 @@ const SCRIPT = `
|
|
|
65
65
|
} catch { button.textContent = "Could not report; try again"; button.disabled = false; }
|
|
66
66
|
};
|
|
67
67
|
const draw = (stations) => {
|
|
68
|
-
for (const group of ["ai", "shared", "radio", "tv"]) {
|
|
68
|
+
for (const group of ["rooms", "ai", "shared", "radio", "tv"]) {
|
|
69
69
|
const grid = document.querySelector("#" + group);
|
|
70
70
|
grid.replaceChildren();
|
|
71
71
|
const list = stations.filter((s) => (s.group || (s.kind === "video" ? "tv" : "radio")) === group);
|
|
@@ -144,7 +144,7 @@ const hash = (text) => `'sha256-${createHash("sha256").update(text).digest("base
|
|
|
144
144
|
function escape(text) {
|
|
145
145
|
return text.replace(/[&<>"']/g, (one) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[one]);
|
|
146
146
|
}
|
|
147
|
-
const GROUPS = ["ai", "shared", "radio", "tv"];
|
|
147
|
+
const GROUPS = ["rooms", "ai", "shared", "radio", "tv"];
|
|
148
148
|
/**
|
|
149
149
|
* The guide with its stations already in it, so it reads the same through a
|
|
150
150
|
* gateway that cannot run its script against this server (the Moshpit's
|
|
@@ -167,7 +167,9 @@ export function renderGuide(entries, apiBase = "", ads = {}) {
|
|
|
167
167
|
page = page.replace("</head>", `<script data-site="${escape(ads.site)}" src="${CRAWLPROOF}/stats.js" async></script>\n</head>`);
|
|
168
168
|
if (ads.slot) {
|
|
169
169
|
const format = ads.format ?? "banner_300x250";
|
|
170
|
-
|
|
170
|
+
const slot = ads.slot;
|
|
171
|
+
// The one ad goes at the top of the content, never against the footer.
|
|
172
|
+
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`);
|
|
171
173
|
}
|
|
172
174
|
const api = /^https:\/\/[^\s"<>]+$/.test(apiBase) ? apiBase.replace(/\/+$/, "") : "";
|
|
173
175
|
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>");
|
|
@@ -186,6 +188,8 @@ export const GUIDE_HTML = `<!doctype html>
|
|
|
186
188
|
<main>
|
|
187
189
|
<h1>Live</h1>
|
|
188
190
|
<p class="lead">Radio and TV, on the air now. Pick one and press play.</p>
|
|
191
|
+
<h2 id="rooms-title" hidden>Live rooms</h2>
|
|
192
|
+
<div id="rooms" class="grid" aria-live="polite"></div>
|
|
189
193
|
<h2 id="ai-title" hidden>AI Radio</h2>
|
|
190
194
|
<div id="ai" class="grid" aria-live="polite"></div>
|
|
191
195
|
<h2 id="shared-title" hidden>Shared by listeners</h2>
|
|
@@ -217,7 +221,7 @@ export const GUIDE_HTML = `<!doctype html>
|
|
|
217
221
|
<button type="submit">Share it</button>
|
|
218
222
|
<div id="status" role="status" aria-live="polite"></div>
|
|
219
223
|
</form>
|
|
220
|
-
<footer>Played by <a href="https://nixamp.com">nixamp</a
|
|
224
|
+
<footer>Played by <a href="https://nixamp.com">nixamp</a>.</footer>
|
|
221
225
|
</main>
|
|
222
226
|
<script>${SCRIPT}</script>
|
|
223
227
|
</body>
|
package/dist/station-page.d.ts
CHANGED
|
@@ -4,4 +4,9 @@ export declare const STATION_PAGE_HTML = "<!doctype html>\n<html lang=\"en\">\n<
|
|
|
4
4
|
/** Only its own script and style, only its own server, never framed. */
|
|
5
5
|
export declare const STATION_PAGE_CSP: string;
|
|
6
6
|
/** The built page: its own bundle, styles and player engines, only this server, never framed. */
|
|
7
|
+
/**
|
|
8
|
+
* A live room's page (/watch): the same built page shape, but what it plays and
|
|
9
|
+
* asks about is on the room's own server, any https nixamp server.
|
|
10
|
+
*/
|
|
11
|
+
export declare const WATCH_APP_CSP: string;
|
|
7
12
|
export declare const STATION_APP_CSP: string;
|
package/dist/station-page.js
CHANGED
|
@@ -209,6 +209,21 @@ export const STATION_PAGE_CSP = [
|
|
|
209
209
|
"frame-ancestors 'none'",
|
|
210
210
|
].join("; ");
|
|
211
211
|
/** The built page: its own bundle, styles and player engines, only this server, never framed. */
|
|
212
|
+
/**
|
|
213
|
+
* A live room's page (/watch): the same built page shape, but what it plays and
|
|
214
|
+
* asks about is on the room's own server, any https nixamp server.
|
|
215
|
+
*/
|
|
216
|
+
export const WATCH_APP_CSP = [
|
|
217
|
+
"default-src 'self'",
|
|
218
|
+
"style-src 'self' 'unsafe-inline'",
|
|
219
|
+
"media-src 'self' blob: https:",
|
|
220
|
+
"worker-src 'self' blob:",
|
|
221
|
+
"img-src 'self' data: blob: https:",
|
|
222
|
+
"connect-src 'self' https:",
|
|
223
|
+
"base-uri 'none'",
|
|
224
|
+
"form-action 'none'",
|
|
225
|
+
"frame-ancestors 'none'",
|
|
226
|
+
].join("; ");
|
|
212
227
|
export const STATION_APP_CSP = [
|
|
213
228
|
"default-src 'self'",
|
|
214
229
|
"style-src 'self' 'unsafe-inline'",
|