nixamp 0.38.1 → 0.39.1

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.
Files changed (32) hide show
  1. package/dist/directory-mcp.d.ts +89 -0
  2. package/dist/directory-mcp.js +103 -0
  3. package/dist/mcp.d.ts +3 -0
  4. package/dist/mcp.js +10 -0
  5. package/dist/server.js +124 -4
  6. package/dist/station-guide.d.ts +2 -2
  7. package/dist/station-guide.js +18 -13
  8. package/package.json +1 -1
  9. package/src/directory-mcp.ts +122 -0
  10. package/src/mcp.ts +12 -0
  11. package/src/server.ts +114 -4
  12. package/src/station-guide.ts +18 -13
  13. package/web/dist/assets/{ar-COUOgLwt.js → ar-CfH8XZbx.js} +1 -1
  14. package/web/dist/assets/{cs-BAbCGcwj.js → cs-CeMJ7kYa.js} +1 -1
  15. package/web/dist/assets/{da-BbuDvjI-.js → da-6KhxLIZ0.js} +1 -1
  16. package/web/dist/assets/{de-CwD_N7qJ.js → de-BVR2Stjg.js} +1 -1
  17. package/web/dist/assets/{es-Ck7wfYxi.js → es-CjMuKD1P.js} +1 -1
  18. package/web/dist/assets/{fi-BdHztPZ5.js → fi-BUyaV3Dl.js} +1 -1
  19. package/web/dist/assets/{fr-DspyIX0H.js → fr-CJAmexWD.js} +1 -1
  20. package/web/dist/assets/{hi-CafGjrd-.js → hi-C14LtUf_.js} +1 -1
  21. package/web/dist/assets/{hu-WPQAw701.js → hu-DcEP4OTS.js} +1 -1
  22. package/web/dist/assets/{id-CpnYKVLb.js → id-CBLpeDO7.js} +1 -1
  23. package/web/dist/assets/{it-pT4i7fpZ.js → it-DJoytB_E.js} +1 -1
  24. package/web/dist/assets/{main-CjbWfOjh.js → main-CDJ3EJVW.js} +2 -2
  25. package/web/dist/assets/{nl-Cv_EX-9Z.js → nl-CO2DuuG-.js} +1 -1
  26. package/web/dist/assets/{ru-DnLA1MM6.js → ru-dGB_71qg.js} +1 -1
  27. package/web/dist/assets/{sv-55BevY51.js → sv-fzYrXr-B.js} +1 -1
  28. package/web/dist/assets/{uk-CSDxbIns.js → uk-ChGhFlXg.js} +1 -1
  29. package/web/dist/assets/{vi-B1Op-uBs.js → vi-C7SpqYEM.js} +1 -1
  30. package/web/dist/assets/{zh-re8w8jaX.js → zh-CcQj0L81.js} +1 -1
  31. package/web/dist/index.html +1 -1
  32. package/web/dist/sw.js +19 -19
@@ -0,0 +1,89 @@
1
+ /**
2
+ * The station directory as MCP tools anybody may use: what is on, anywhere.
3
+ *
4
+ * nixamp.com's guide lists every public, listed station on every server in
5
+ * its directory (each server is asked for its own guide). These tools read
6
+ * that list, so an agent with no account and no nixamp installed can find a
7
+ * station and hand somebody its link, and can share a track to a server that
8
+ * takes listener shares -- the same as the guide's form, with the same
9
+ * declaration. Nothing here changes a station; that is the owner's, with the
10
+ * owner's tools.
11
+ */
12
+ export declare const PUBLIC_TOOLS: readonly [{
13
+ readonly name: "stations_list";
14
+ readonly description: "Every public station on nixamp, on any server: name, radio or TV, group (ai, shared, radio, tv), whether it is live, what is playing, and the link to watch or listen. No sign-in needed.";
15
+ readonly inputSchema: {
16
+ readonly type: "object";
17
+ readonly properties: {
18
+ readonly kind: {
19
+ readonly type: "string";
20
+ readonly enum: readonly ["audio", "video"];
21
+ readonly description: "Only radio (audio) or only TV (video).";
22
+ };
23
+ readonly group: {
24
+ readonly type: "string";
25
+ readonly enum: readonly ["ai", "shared", "radio", "tv"];
26
+ };
27
+ };
28
+ };
29
+ }, {
30
+ readonly name: "station_find";
31
+ readonly description: "Find public nixamp stations whose name, or what is playing, matches the words given (e.g. \"data structures\", \"lofi\", \"jazz\"). Returns each match with its link.";
32
+ readonly inputSchema: {
33
+ readonly type: "object";
34
+ readonly properties: {
35
+ readonly query: {
36
+ readonly type: "string";
37
+ readonly description: "Words to look for.";
38
+ };
39
+ };
40
+ readonly required: readonly ["query"];
41
+ };
42
+ }, {
43
+ readonly name: "station_share";
44
+ readonly description: "Share a music file by its link with the server a station is on, for its listener-share stations. The sharer must have the right to have it broadcast and say on what basis; AI files it under a genre and checks it before it airs. Returns a status link.";
45
+ readonly inputSchema: {
46
+ readonly type: "object";
47
+ readonly properties: {
48
+ readonly station: {
49
+ readonly type: "string";
50
+ readonly description: "A station's link or id from stations_list: the track goes to that station's server.";
51
+ };
52
+ readonly url: {
53
+ readonly type: "string";
54
+ readonly description: "A direct link to the MP3 (or OGG, FLAC, M4A, WAV).";
55
+ };
56
+ readonly basis: {
57
+ readonly type: "string";
58
+ readonly enum: readonly ["owned", "permission", "licensed", "creative-commons", "public-domain"];
59
+ };
60
+ readonly note: {
61
+ readonly type: "string";
62
+ readonly description: "Licence, credit, a link to the artist.";
63
+ };
64
+ readonly by: {
65
+ readonly type: "string";
66
+ readonly description: "A name to credit the sharer with.";
67
+ };
68
+ readonly agree: {
69
+ readonly type: "boolean";
70
+ readonly description: "true: the sharer has the right to share this for broadcast.";
71
+ };
72
+ };
73
+ readonly required: readonly ["station", "url", "basis", "agree"];
74
+ };
75
+ }];
76
+ export declare function isPublicTool(name: string): boolean;
77
+ export interface PublicToolOptions {
78
+ /** The guide's stations; nixamp.com's own list by default. */
79
+ guide?: () => Promise<unknown[]>;
80
+ fetcher?: typeof fetch;
81
+ site?: string;
82
+ }
83
+ export declare function callPublicTool(name: string, args: Record<string, unknown>, options?: PublicToolOptions): Promise<{
84
+ content: {
85
+ type: "text";
86
+ text: string;
87
+ }[];
88
+ isError?: boolean | undefined;
89
+ }>;
@@ -0,0 +1,103 @@
1
+ /**
2
+ * The station directory as MCP tools anybody may use: what is on, anywhere.
3
+ *
4
+ * nixamp.com's guide lists every public, listed station on every server in
5
+ * its directory (each server is asked for its own guide). These tools read
6
+ * that list, so an agent with no account and no nixamp installed can find a
7
+ * station and hand somebody its link, and can share a track to a server that
8
+ * takes listener shares -- the same as the guide's form, with the same
9
+ * declaration. Nothing here changes a station; that is the owner's, with the
10
+ * owner's tools.
11
+ */
12
+ const STRING = { type: "string" };
13
+ export const PUBLIC_TOOLS = [
14
+ {
15
+ name: "stations_list",
16
+ description: "Every public station on nixamp, on any server: name, radio or TV, group (ai, shared, radio, tv), whether it is live, what is playing, and the link to watch or listen. No sign-in needed.",
17
+ inputSchema: {
18
+ type: "object",
19
+ properties: {
20
+ kind: { type: "string", enum: ["audio", "video"], description: "Only radio (audio) or only TV (video)." },
21
+ group: { type: "string", enum: ["ai", "shared", "radio", "tv"] },
22
+ },
23
+ },
24
+ },
25
+ {
26
+ name: "station_find",
27
+ description: "Find public nixamp stations whose name, or what is playing, matches the words given (e.g. \"data structures\", \"lofi\", \"jazz\"). Returns each match with its link.",
28
+ inputSchema: { type: "object", properties: { query: { ...STRING, description: "Words to look for." } }, required: ["query"] },
29
+ },
30
+ {
31
+ name: "station_share",
32
+ description: "Share a music file by its link with the server a station is on, for its listener-share stations. The sharer must have the right to have it broadcast and say on what basis; AI files it under a genre and checks it before it airs. Returns a status link.",
33
+ inputSchema: {
34
+ type: "object",
35
+ properties: {
36
+ station: { ...STRING, description: "A station's link or id from stations_list: the track goes to that station's server." },
37
+ url: { ...STRING, description: "A direct link to the MP3 (or OGG, FLAC, M4A, WAV)." },
38
+ basis: { type: "string", enum: ["owned", "permission", "licensed", "creative-commons", "public-domain"] },
39
+ note: { ...STRING, description: "Licence, credit, a link to the artist." },
40
+ by: { ...STRING, description: "A name to credit the sharer with." },
41
+ agree: { type: "boolean", description: "true: the sharer has the right to share this for broadcast." },
42
+ },
43
+ required: ["station", "url", "basis", "agree"],
44
+ },
45
+ },
46
+ ];
47
+ export function isPublicTool(name) {
48
+ return PUBLIC_TOOLS.some((tool) => tool.name === name);
49
+ }
50
+ const say = (text, isError = false) => ({ content: [{ type: "text", text }], ...(isError ? { isError: true } : {}) });
51
+ async function stations(options) {
52
+ const raw = options.guide
53
+ ? await options.guide()
54
+ : await (async () => {
55
+ const answer = await (options.fetcher ?? fetch)(`${(options.site ?? "https://nixamp.com").replace(/\/+$/, "")}/api/v1/stations/guide`, { signal: AbortSignal.timeout(15_000) });
56
+ if (!answer.ok)
57
+ throw new Error(`the guide answered HTTP ${answer.status}`);
58
+ return (await answer.json()).stations ?? [];
59
+ })();
60
+ return raw.filter((one) => typeof one === "object" && one !== null && typeof one.name === "string" && typeof one.page === "string");
61
+ }
62
+ function line(one) {
63
+ const now = one.nowPlaying && (one.nowPlaying.title || one.nowPlaying.artist) ? ` — now: ${[one.nowPlaying.title, one.nowPlaying.artist].filter(Boolean).join(" — ")}` : "";
64
+ return `${one.name} [${one.kind === "video" ? "TV" : "radio"}${one.group ? `, ${one.group}` : ""}] ${one.state === "live" ? "LIVE" : one.state}${now}\n ${one.page}`;
65
+ }
66
+ export async function callPublicTool(name, args, options = {}) {
67
+ try {
68
+ const all = await stations(options);
69
+ if (name === "stations_list") {
70
+ const shown = all.filter((one) => (!args["kind"] || one.kind === args["kind"]) && (!args["group"] || one.group === args["group"]));
71
+ return say(shown.length ? shown.map(line).join("\n") : "No public stations match.");
72
+ }
73
+ if (name === "station_find") {
74
+ const words = String(args["query"] ?? "").toLowerCase().split(/\s+/).filter(Boolean);
75
+ if (words.length === 0)
76
+ return say("Say what to look for.", true);
77
+ const hay = (one) => [one.name, one.nowPlaying?.title, one.nowPlaying?.artist, one.group, one.kind === "video" ? "tv video" : "radio audio"].join(" ").toLowerCase();
78
+ const found = all.filter((one) => words.every((word) => hay(one).includes(word)));
79
+ return say(found.length ? found.map(line).join("\n") : `No public station matches "${String(args["query"])}". stations_list shows them all.`);
80
+ }
81
+ if (name === "station_share") {
82
+ const wanted = String(args["station"] ?? "");
83
+ const station = all.find((one) => one.page === wanted || one.id === wanted || (wanted && one.page.includes(`/station/${wanted}`)));
84
+ if (!station)
85
+ return say("Which station? Pass a link or id from stations_list.", true);
86
+ const origin = new URL(station.page).origin;
87
+ const answer = await (options.fetcher ?? fetch)(`${origin}/api/v1/share`, {
88
+ method: "POST",
89
+ headers: { "content-type": "application/json" },
90
+ body: JSON.stringify({ url: args["url"], basis: args["basis"], note: args["note"] ?? "", by: args["by"] ?? "", agree: args["agree"] === true }),
91
+ signal: AbortSignal.timeout(20_000),
92
+ });
93
+ const body = (await answer.json().catch(() => ({})));
94
+ if (!answer.ok)
95
+ return say(`${String(body["error"] ?? `HTTP ${answer.status}`)} (${String(body["code"] ?? answer.status)})`, true);
96
+ return say(`Shared. It is being fetched and checked now; how it goes: ${origin}${String(body["status"])}`);
97
+ }
98
+ return say(`No such tool: ${name}`, true);
99
+ }
100
+ catch (error) {
101
+ return say(`Could not read the station guide: ${error.message}`, true);
102
+ }
103
+ }
package/dist/mcp.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { wavOf, type Window } from "./transcribe.ts";
2
2
  import { type Described } from "./media-local.ts";
3
3
  import { type StationToolOptions } from "./station-mcp.ts";
4
+ import { type PublicToolOptions } from "./directory-mcp.ts";
4
5
  export declare const PROTOCOL_VERSION = "2025-06-18";
5
6
  interface Request {
6
7
  jsonrpc: "2.0";
@@ -16,6 +17,8 @@ interface ToolDefinition {
16
17
  export declare const TOOLS: ToolDefinition[];
17
18
  export interface McpOptions {
18
19
  fetcher?: typeof fetch;
20
+ /** Where the public station tools read the guide; nixamp.com's by default. */
21
+ guide?: PublicToolOptions["guide"];
19
22
  /** Where stations are asked; the tests hand in a local daemon. */
20
23
  stations?: StationToolOptions;
21
24
  /** Injected by the tests; the session on disk otherwise. */
package/dist/mcp.js CHANGED
@@ -31,6 +31,7 @@ import { describeFile, keepFile } from "./media-local.js";
31
31
  import { fileFingerprint, idFrom, languageCode, mediaOfUrl, transcriptIdOf } from "./transcripts.js";
32
32
  import { personaLines, readPersona, readVoices, writePersona } from "./profile.js";
33
33
  import { STATION_TOOLS, callStationTool, isStationTool } from "./station-mcp.js";
34
+ import { PUBLIC_TOOLS, callPublicTool, isPublicTool } from "./directory-mcp.js";
34
35
  import { LIBRARY_TOOLS, callLibraryTool, isLibraryTool } from "./library-mcp.js";
35
36
  export const PROTOCOL_VERSION = "2025-06-18";
36
37
  const STRING = { type: "string" };
@@ -218,6 +219,7 @@ export const TOOLS = [
218
219
  },
219
220
  },
220
221
  ...STATION_TOOLS,
222
+ ...PUBLIC_TOOLS,
221
223
  ...LIBRARY_TOOLS,
222
224
  ];
223
225
  function said(line) {
@@ -250,6 +252,14 @@ function describe(row) {
250
252
  * pipe, and so the same call is reachable from anywhere else that wants it.
251
253
  */
252
254
  export async function callTool(name, args, options = {}) {
255
+ // What is on anywhere: nixamp.com's guide, open to anybody.
256
+ if (isPublicTool(name)) {
257
+ return callPublicTool(name, args, {
258
+ ...(options.guide ? { guide: options.guide } : {}),
259
+ ...(options.fetcher ? { fetcher: options.fetcher } : {}),
260
+ ...(options.session?.site ? { site: options.session.site } : {}),
261
+ });
262
+ }
253
263
  // Stations first: on this machine they need the daemon, not a sign-in.
254
264
  if (isStationTool(name) || isLibraryTool(name)) {
255
265
  const local = { ...(options.fetcher ? { fetcher: options.fetcher } : {}), ...(options.session !== undefined ? { session: options.session } : {}), ...(options.stations ?? {}) };
package/dist/server.js CHANGED
@@ -64,7 +64,7 @@ import { handleAiRoutes, isPublicSharePath } from "./ai-routes.js";
64
64
  import { AiRadio } from "./ai-radio.js";
65
65
  import { ListenerShares } from "./listener-shares.js";
66
66
  import { STATION_APP_CSP, STATION_PAGE, STATION_PAGE_CSP, STATION_PAGE_HTML } from "./station-page.js";
67
- import { GUIDE_CSP, guideHosts, PeerGuides, renderGuide } from "./station-guide.js";
67
+ import { GUIDE_CSP, guideHosts, guidePeers, peerBase, PeerGuides, renderGuide } from "./station-guide.js";
68
68
  import { StationRunner } from "./station-runner.js";
69
69
  import { StationError, StationStore, channelOf, stationOfChannel } from "./stations.js";
70
70
  import { STATION_CONTROL_LIMIT, StationUsage, limitsFromEnv } from "./station-metering.js";
@@ -103,6 +103,7 @@ import { LANGUAGES, Translator } from "./translate.js";
103
103
  import { StoredTranslations } from "./translate-jobs.js";
104
104
  import { Transcripts, fileFingerprint, formatOf, idFrom, languageCode, linesFrom, mediaOfLive, mediaOfUrl, toSrt, toText, toVtt, transcriptIdOf, wire, } from "./transcripts.js";
105
105
  import { handleMessage as mcpMessage } from "./mcp.js";
106
+ import { PUBLIC_TOOLS, callPublicTool, isPublicTool } from "./directory-mcp.js";
106
107
  import { Media, factsFromRequest, holderFrom, mediaId, openFileListing, openFileOf } from "./media.js";
107
108
  import { mediaPage } from "./media-page.js";
108
109
  import { describeFile, refreshChanged } from "./media-local.js";
@@ -2158,6 +2159,10 @@ export function createHandler(engine, options) {
2158
2159
  !isStationListenPath(path) &&
2159
2160
  // Sharing a track and reporting one are for anybody the guide reaches.
2160
2161
  !isPublicSharePath(path) &&
2162
+ // What is on, as MCP, for anybody.
2163
+ path !== "/mcp/public" &&
2164
+ // A station's short link, which sends the browser to wherever it plays.
2165
+ !/^\/watch\/[a-z0-9]{10}$/.test(path) &&
2161
2166
  !STATION_PAGE.test(path) &&
2162
2167
  !isPublicAsset &&
2163
2168
  !isGuidePage &&
@@ -2215,6 +2220,32 @@ export function createHandler(engine, options) {
2215
2220
  }
2216
2221
  return { allowed: false, account: "" };
2217
2222
  };
2223
+ /*
2224
+ * /watch/<station id>: a short link that works on any server with a
2225
+ * guide -- nixamp.com, or a guide host like live.streaming -- for a
2226
+ * public, listed station here or on a peer (on nixamp.com, every server
2227
+ * in the directory). It sends the browser to the station's own page, on
2228
+ * the server that plays it. Nothing private or unlisted is ever found.
2229
+ */
2230
+ const stationShortLink = /^\/watch\/([a-z0-9]{10})$/.exec(path);
2231
+ if (stationShortLink && options.stations && (request.method === "GET" || request.method === "HEAD")) {
2232
+ const encrypted = request.socket.encrypted === true;
2233
+ const own = guideEntries({
2234
+ store: options.stations.store, runner: options.stations.runner, channels: options.channels, canControl: false, account: "",
2235
+ select: () => null, trackFor: (file) => trackFor(engine, file),
2236
+ base: `${encrypted ? "https" : "http"}://${request.headers.host ?? "localhost"}`,
2237
+ publicBase: options.stations.publicBase?.() ?? "", json, readBody, headers: CORS,
2238
+ });
2239
+ const found = own.find((one) => one.id === stationShortLink[1])
2240
+ ?? (options.stations.peers ? (await options.stations.peers.entries()).find((one) => one.id === stationShortLink[1]) : undefined);
2241
+ if (!found || !/^https?:\/\//.test(found.page)) {
2242
+ json(response, 404, { error: "no public station with that id", code: "not_found" });
2243
+ return;
2244
+ }
2245
+ response.writeHead(302, { location: found.page, "cache-control": "no-store", "referrer-policy": "no-referrer" });
2246
+ response.end();
2247
+ return;
2248
+ }
2218
2249
  if (isGuidePage) {
2219
2250
  const encrypted = request.socket.encrypted === true;
2220
2251
  const entries = guideEntries({
@@ -2233,7 +2264,7 @@ export function createHandler(engine, options) {
2233
2264
  });
2234
2265
  const own = new Set(entries.map((one) => one.page));
2235
2266
  const others = options.stations.peers ? (await options.stations.peers.entries()).filter((one) => !own.has(one.page)) : [];
2236
- const page = renderGuide([...entries, ...others]);
2267
+ const page = renderGuide([...entries, ...others], options.stations.publicBase?.() ?? "");
2237
2268
  response.writeHead(200, {
2238
2269
  "content-type": "text/html; charset=utf-8",
2239
2270
  "cache-control": "no-cache",
@@ -3440,6 +3471,77 @@ export function createHandler(engine, options) {
3440
3471
  * GET: nothing here has to be pushed, so a client is told so and gets
3441
3472
  * on with POST.
3442
3473
  */
3474
+ /*
3475
+ * nixamp.com/mcp/public: what is on, anywhere, for any agent with no
3476
+ * account. /mcp stays a 401 without a token, because that 401 is how an
3477
+ * MCP client finds out it should sign in (OAuth discovery).
3478
+ */
3479
+ if (path === "/mcp/public" && options.stations) {
3480
+ if (request.method === "OPTIONS") {
3481
+ response.writeHead(204, CORS);
3482
+ response.end();
3483
+ return;
3484
+ }
3485
+ if (request.method !== "POST") {
3486
+ json(response, 405, { error: "POST a JSON-RPC message" });
3487
+ return;
3488
+ }
3489
+ const stationGuide = async () => {
3490
+ if (!options.stations)
3491
+ return [];
3492
+ const own = guideEntries({
3493
+ store: options.stations.store, runner: options.stations.runner, channels: options.channels, canControl: false, account: "",
3494
+ select: () => null, trackFor: (file) => trackFor(engine, file), base: options.site ?? "", publicBase: options.stations.publicBase?.() ?? "",
3495
+ json, readBody, headers: CORS,
3496
+ });
3497
+ const shown = new Set(own.map((one) => one.page));
3498
+ const others = options.stations.peers ? (await options.stations.peers.entries()).filter((one) => !shown.has(one.page)) : [];
3499
+ return [...own, ...others];
3500
+ };
3501
+ let parsed;
3502
+ try {
3503
+ parsed = JSON.parse(await readBody(request, 256 * 1024));
3504
+ }
3505
+ catch {
3506
+ json(response, 400, { jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } });
3507
+ return;
3508
+ }
3509
+ const messages = (Array.isArray(parsed) ? parsed : [parsed]);
3510
+ const answers = [];
3511
+ for (const message of messages) {
3512
+ const id = message?.id ?? null;
3513
+ const method = typeof message?.method === "string" ? message.method : "";
3514
+ if (method.startsWith("notifications/"))
3515
+ continue;
3516
+ if (method === "initialize") {
3517
+ answers.push({ jsonrpc: "2.0", id, result: {
3518
+ protocolVersion: message.params?.["protocolVersion"] ?? "2025-06-18",
3519
+ capabilities: { tools: { listChanged: false } },
3520
+ serverInfo: { name: "nixamp", title: "nixamp: every public radio and TV station", version: "2" },
3521
+ instructions: "Find what is on across every nixamp server (stations_list, station_find) and share a track to a station's server (station_share). Sign in (a nixamp token as the bearer, or OAuth) for watch parties, rooms, transcripts and managing your own stations.",
3522
+ } });
3523
+ }
3524
+ else if (method === "ping") {
3525
+ answers.push({ jsonrpc: "2.0", id, result: {} });
3526
+ }
3527
+ else if (method === "tools/list") {
3528
+ answers.push({ jsonrpc: "2.0", id, result: { tools: PUBLIC_TOOLS } });
3529
+ }
3530
+ else if (method === "tools/call" && isPublicTool(String(message.params?.["name"] ?? ""))) {
3531
+ answers.push({ jsonrpc: "2.0", id, result: await callPublicTool(String(message.params["name"]), (message.params?.["arguments"] ?? {}), { guide: stationGuide }) });
3532
+ }
3533
+ else {
3534
+ answers.push({ jsonrpc: "2.0", id, error: { code: -32001, message: "only the station tools are public; for the rest use /mcp with a nixamp token as the bearer (`nixamp token create`), or OAuth" } });
3535
+ }
3536
+ }
3537
+ if (answers.length === 0) {
3538
+ response.writeHead(202, CORS);
3539
+ response.end();
3540
+ return;
3541
+ }
3542
+ json(response, 200, Array.isArray(parsed) ? answers : answers[0]);
3543
+ return;
3544
+ }
3443
3545
  if (path === "/mcp" && options.accounts && options.site) {
3444
3546
  if (request.method === "GET") {
3445
3547
  response.writeHead(405, { ...CORS, allow: "POST, DELETE" });
@@ -3457,6 +3559,18 @@ export function createHandler(engine, options) {
3457
3559
  }
3458
3560
  const token = tokenFrom(request.headers);
3459
3561
  const who = await options.accounts.whoIs(token);
3562
+ const stationGuide = async () => {
3563
+ if (!options.stations)
3564
+ return [];
3565
+ const own = guideEntries({
3566
+ store: options.stations.store, runner: options.stations.runner, channels: options.channels, canControl: false, account: "",
3567
+ select: () => null, trackFor: (file) => trackFor(engine, file), base: options.site ?? "", publicBase: options.stations.publicBase?.() ?? "",
3568
+ json, readBody, headers: CORS,
3569
+ });
3570
+ const shown = new Set(own.map((one) => one.page));
3571
+ const others = options.stations.peers ? (await options.stations.peers.entries()).filter((one) => !shown.has(one.page)) : [];
3572
+ return [...own, ...others];
3573
+ };
3460
3574
  if (who === null) {
3461
3575
  const error = JSON.stringify({ error: "sign in: a nixamp token as the bearer (`nixamp token create`), or OAuth" });
3462
3576
  response.writeHead(401, {
@@ -3484,7 +3598,7 @@ export function createHandler(engine, options) {
3484
3598
  answers.push({ jsonrpc: "2.0", id: null, error: { code: -32600, message: "invalid request" } });
3485
3599
  continue;
3486
3600
  }
3487
- const answer = await mcpMessage(message, { session, fetcher: fetch });
3601
+ const answer = await mcpMessage(message, { session, fetcher: fetch, guide: stationGuide });
3488
3602
  if (answer)
3489
3603
  answers.push(answer);
3490
3604
  }
@@ -6353,7 +6467,13 @@ export async function serve(argv, version = "0.1.0") {
6353
6467
  trustedProxies: () => new Set([...(process.env["NIXAMP_TRUSTED_PROXIES"] ?? "").split(","), ...(readConfig().trustedProxies ?? [])].map((one) => one.trim()).filter(Boolean)),
6354
6468
  // Known once the server has worked out its addresses, after it is listening.
6355
6469
  publicBase: () => (publishable_?.url ?? "").replace(/\/+$/, ""),
6356
- peers: new PeerGuides(),
6470
+ // Other servers' guides: the configured peers, and on a directory
6471
+ // (nixamp.com) every server listed in it, so its guide is every public
6472
+ // station anywhere.
6473
+ peers: new PeerGuides(() => {
6474
+ const listed = directory ? directory.list().map((one) => peerBase(one.url)).filter(Boolean).slice(0, 100) : [];
6475
+ return [...new Set([...guidePeers(), ...listed])];
6476
+ }),
6357
6477
  };
6358
6478
  // Music handed over from BitTorrented, copied into a folder of our own.
6359
6479
  const importer = new Importer(join(stateDir(), `handoffs-${options.port}.json`), {
@@ -18,8 +18,8 @@ export interface GuideEntry {
18
18
  * gateway that cannot run its script against this server (the Moshpit's
19
19
  * /n/<name>), and for a crawler. Everything from a station is escaped.
20
20
  */
21
- export declare function renderGuide(entries: GuideEntry[]): string;
22
- export declare const GUIDE_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>Live \u00B7 stations</title>\n<meta name=\"description\" content=\"Radio and TV stations, live now: AI-composed music, tracks shared by listeners, and more.\">\n<link rel=\"icon\" href=\"data:,\">\n<style>\n:root{--bg:#f6f5f2;--fg:#17171a;--muted:#5d5d66;--card:#fff;--line:#dedcd6;--accent:#1f6feb;--live:#0b7a3b;--warn:#a1460b}\n@media (prefers-color-scheme:dark){:root{--bg:#121214;--fg:#ececf1;--muted:#a4a4b0;--card:#1c1c20;--line:#2c2c33;--accent:#6aa7ff;--live:#3fbf7f;--warn:#f0a35e}}\n*{box-sizing:border-box}html,body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.5 system-ui,-apple-system,\"Segoe UI\",sans-serif}\nmain{max-width:880px;margin:0 auto;padding:40px 16px}\nh1{margin:0 0 4px;font-size:1.8rem}p.lead{color:var(--muted);margin:0 0 24px}\nh2{font-size:1rem;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);margin:28px 0 10px}\n.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:12px}\n.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px;position:relative}\n.card:hover,.card:focus-within{border-color:var(--accent)}\na.station{color:inherit;text-decoration:none;display:block}a.station:focus-visible{outline:2px solid var(--accent);outline-offset:4px;border-radius:6px}\n.name{font-weight:700;font-size:1.05rem}.now{color:var(--muted);font-size:.9rem;margin-top:6px;min-height:1.4em}\n.live{color:var(--live);font-size:.8rem;font-weight:700}.empty{color:var(--muted)}\nbutton.report{margin-top:10px;background:none;border:0;padding:0;color:var(--muted);font:inherit;font-size:.8rem;text-decoration:underline;cursor:pointer}\nbutton.report:hover{color:var(--warn)}\n#share{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px;margin-top:12px}\n#share label{display:block;font-size:.9rem;margin:10px 0 4px;color:var(--muted)}\n#share input[type=url],#share input[type=text],#share select{width:100%;padding:9px 10px;border:1px solid var(--line);border-radius:8px;background:var(--bg);color:var(--fg);font:inherit}\n#share .agree{display:flex;gap:8px;align-items:flex-start;color:var(--fg);margin-top:12px}\n#share button[type=submit]{margin-top:14px;background:var(--accent);color:#fff;border:0;border-radius:8px;padding:10px 16px;font:inherit;font-weight:600;cursor:pointer}\n#share .or{color:var(--muted);font-size:.85rem;margin:8px 0 0}\n#status{margin-top:10px;font-size:.9rem;min-height:1.4em}\nfooter{margin-top:40px;color:var(--muted);font-size:.85rem}footer a{color:inherit}\n</style>\n</head>\n<body>\n<main>\n <h1>Live</h1>\n <p class=\"lead\">Radio and TV, on the air now. Pick one and press play.</p>\n <h2 id=\"ai-title\" hidden>AI Radio</h2>\n <div id=\"ai\" class=\"grid\" aria-live=\"polite\"></div>\n <h2 id=\"shared-title\" hidden>Shared by listeners</h2>\n <div id=\"shared\" class=\"grid\" aria-live=\"polite\"></div>\n <h2 id=\"radio-title\" hidden>Radio</h2>\n <div id=\"radio\" class=\"grid\" aria-live=\"polite\"></div>\n <h2 id=\"tv-title\" hidden>TV</h2>\n <div id=\"tv\" class=\"grid\" aria-live=\"polite\"></div>\n <p id=\"empty\" class=\"empty\">Nothing is on the air here yet.</p>\n <h2 id=\"share-title\" hidden>Share a track</h2>\n <form id=\"share\" hidden>\n <label for=\"url\">A link to an MP3</label>\n <input id=\"url\" name=\"url\" type=\"url\" placeholder=\"https://example.com/my-song.mp3\" inputmode=\"url\">\n <p class=\"or\">or upload one (up to <span id=\"max\">60 MB</span>)</p>\n <input id=\"file\" name=\"file\" type=\"file\" accept=\"audio/*,.mp3,.ogg,.flac,.m4a,.wav\">\n <label for=\"basis\">Why it may be broadcast</label>\n <select id=\"basis\" name=\"basis\" required>\n <option value=\"owned\">I made it</option>\n <option value=\"permission\">I have the artist's permission</option>\n <option value=\"licensed\">It is licensed for broadcast</option>\n <option value=\"creative-commons\">It is Creative Commons (allows sharing)</option>\n <option value=\"public-domain\">It is in the public domain</option>\n </select>\n <label for=\"note\">Anything we should know (licence, link to the artist)</label>\n <input id=\"note\" name=\"note\" type=\"text\" maxlength=\"300\">\n <label for=\"by\">Credit me as (optional)</label>\n <input id=\"by\" name=\"by\" type=\"text\" maxlength=\"60\">\n <label class=\"agree\"><input type=\"checkbox\" name=\"agree\" value=\"1\"> I have the right to share this for broadcast here. AI files it under a genre and checks it before it airs; anything can be reported off the air.</label>\n <button type=\"submit\">Share it</button>\n <div id=\"status\" role=\"status\" aria-live=\"polite\"></div>\n </form>\n <footer>Played by <a href=\"https://nixamp.com\">nixamp</a>, on the Moshpit.</footer>\n</main>\n<script>\n(async () => {\n const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text) n.textContent = text; return n; };\n const say = (text) => { document.querySelector(\"#status\").textContent = text; };\n const report = async (id, button) => {\n if (!confirm(\"Report the track playing now? It comes off the air until the owner looks at it.\")) return;\n button.disabled = true;\n try {\n const r = await fetch(\"/api/v1/share/report\", { method: \"POST\", headers: { \"content-type\": \"application/json\" }, body: JSON.stringify({ station: id }) });\n const body = await r.json();\n button.textContent = body.reported ? \"Reported: off the air\" : \"Nothing shared is playing\";\n } catch { button.textContent = \"Could not report; try again\"; button.disabled = false; }\n };\n const draw = (stations) => {\n for (const group of [\"ai\", \"shared\", \"radio\", \"tv\"]) {\n const grid = document.querySelector(\"#\" + group);\n grid.replaceChildren();\n const list = stations.filter((s) => (s.group || (s.kind === \"video\" ? \"tv\" : \"radio\")) === group);\n document.querySelector(\"#\" + group + \"-title\").hidden = list.length === 0;\n for (const s of list) {\n const card = el(\"div\", \"card\");\n const a = el(\"a\", \"station\");\n a.href = s.page;\n a.append(el(\"div\", \"live\", s.state === \"live\" ? \"\u25CF LIVE\" : \"off the air\"), el(\"div\", \"name\", s.name),\n el(\"div\", \"now\", s.nowPlaying ? [s.nowPlaying.title, s.nowPlaying.artist].filter(Boolean).join(\" \u2014 \") : \"\"));\n card.append(a);\n if (group === \"shared\" && !s.peer) {\n const b = el(\"button\", \"report\", \"Report this track\");\n b.type = \"button\";\n b.addEventListener(\"click\", () => report(s.id, b));\n card.append(b);\n }\n grid.append(card);\n }\n }\n document.querySelector(\"#empty\").hidden = stations.length > 0;\n };\n const load = async () => {\n try {\n const r = await fetch(\"/api/v1/stations/guide\", { cache: \"no-store\" });\n if (r.ok && (r.headers.get(\"content-type\") || \"\").includes(\"json\")) draw((await r.json()).stations);\n } catch {}\n };\n const watch = async (status) => {\n for (let i = 0; i < 120; i++) {\n await new Promise((done) => setTimeout(done, 3000));\n try {\n const share = (await (await fetch(status, { cache: \"no-store\" })).json()).share;\n if (!share) return;\n const words = { fetching: \"Fetching it\u2026\", checking: \"Listening to it and checking it\u2026\", held: \"Waiting for the owner to look at it\", approved: \"On the air\", rejected: \"Not accepted\", failed: \"Could not use it\", withdrawn: \"Withdrawn\", reported: \"Reported\" };\n say((words[share.state] || share.state) + (share.genre ? \" \u00B7 \" + share.genre : \"\") + (share.reason ? \" \u2014 \" + share.reason : \"\"));\n if (![\"fetching\", \"checking\"].includes(share.state)) { load(); return; }\n } catch {}\n }\n };\n const form = document.querySelector(\"#share\");\n try {\n const info = await (await fetch(\"/api/v1/share\", { cache: \"no-store\" })).json();\n if (info.open) {\n document.querySelector(\"#share-title\").hidden = false;\n form.hidden = false;\n document.querySelector(\"#max\").textContent = info.maxMb + \" MB\";\n }\n } catch {}\n form.addEventListener(\"submit\", async (event) => {\n event.preventDefault();\n const data = new FormData(form);\n const file = data.get(\"file\");\n const link = String(data.get(\"url\") || \"\").trim();\n const fields = { basis: String(data.get(\"basis\") || \"\"), note: String(data.get(\"note\") || \"\"), by: String(data.get(\"by\") || \"\"), agree: data.get(\"agree\") ? \"1\" : \"\" };\n if (!fields.agree) { say(\"Tick the box to confirm you have the right to share it.\"); return; }\n if (!link && !(file && file.size)) { say(\"Paste a link to an MP3, or choose a file.\"); return; }\n say(file && file.size && !link ? \"Uploading\u2026\" : \"Sending\u2026\");\n try {\n const r = file && file.size && !link\n ? await fetch(\"/api/v1/share/upload?\" + new URLSearchParams({ ...fields, name: file.name }), { method: \"PUT\", headers: { \"content-type\": file.type || \"audio/mpeg\" }, body: file })\n : await fetch(\"/api/v1/share\", { method: \"POST\", headers: { \"content-type\": \"application/json\" }, body: JSON.stringify({ ...fields, agree: true, url: link }) });\n const body = await r.json();\n if (!r.ok) { say(body.error || \"That did not work.\"); return; }\n form.reset();\n say(\"Got it. Checking it now\u2026\");\n watch(body.status);\n } catch { say(\"Could not reach the station; try again.\"); }\n });\n await load();\n setInterval(load, 15000);\n})();\n</script>\n</body>\n</html>\n";
21
+ export declare function renderGuide(entries: GuideEntry[], apiBase?: string): string;
22
+ export declare const GUIDE_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>Live \u00B7 stations</title>\n<meta name=\"description\" content=\"Radio and TV stations, live now: AI-composed music, tracks shared by listeners, and more.\">\n<link rel=\"icon\" href=\"data:,\">\n<style>\n:root{--bg:#f6f5f2;--fg:#17171a;--muted:#5d5d66;--card:#fff;--line:#dedcd6;--accent:#1f6feb;--live:#0b7a3b;--warn:#a1460b}\n@media (prefers-color-scheme:dark){:root{--bg:#121214;--fg:#ececf1;--muted:#a4a4b0;--card:#1c1c20;--line:#2c2c33;--accent:#6aa7ff;--live:#3fbf7f;--warn:#f0a35e}}\n*{box-sizing:border-box}html,body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.5 system-ui,-apple-system,\"Segoe UI\",sans-serif}\nmain{max-width:880px;margin:0 auto;padding:40px 16px}\nh1{margin:0 0 4px;font-size:1.8rem}p.lead{color:var(--muted);margin:0 0 24px}\nh2{font-size:1rem;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);margin:28px 0 10px}\n.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:12px}\n.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px;position:relative}\n.card:hover,.card:focus-within{border-color:var(--accent)}\na.station{color:inherit;text-decoration:none;display:block}a.station:focus-visible{outline:2px solid var(--accent);outline-offset:4px;border-radius:6px}\n.name{font-weight:700;font-size:1.05rem}.now{color:var(--muted);font-size:.9rem;margin-top:6px;min-height:1.4em}\n.live{color:var(--live);font-size:.8rem;font-weight:700}.empty{color:var(--muted)}\nbutton.report{margin-top:10px;background:none;border:0;padding:0;color:var(--muted);font:inherit;font-size:.8rem;text-decoration:underline;cursor:pointer}\nbutton.report:hover{color:var(--warn)}\n#share{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px;margin-top:12px}\n#share label{display:block;font-size:.9rem;margin:10px 0 4px;color:var(--muted)}\n#share input[type=url],#share input[type=text],#share select{width:100%;padding:9px 10px;border:1px solid var(--line);border-radius:8px;background:var(--bg);color:var(--fg);font:inherit}\n#share .agree{display:flex;gap:8px;align-items:flex-start;color:var(--fg);margin-top:12px}\n#share button[type=submit]{margin-top:14px;background:var(--accent);color:#fff;border:0;border-radius:8px;padding:10px 16px;font:inherit;font-weight:600;cursor:pointer}\n#share .or{color:var(--muted);font-size:.85rem;margin:8px 0 0}\n#status{margin-top:10px;font-size:.9rem;min-height:1.4em}\nfooter{margin-top:40px;color:var(--muted);font-size:.85rem}footer a{color:inherit}\n</style>\n</head>\n<body>\n<main>\n <h1>Live</h1>\n <p class=\"lead\">Radio and TV, on the air now. Pick one and press play.</p>\n <h2 id=\"ai-title\" hidden>AI Radio</h2>\n <div id=\"ai\" class=\"grid\" aria-live=\"polite\"></div>\n <h2 id=\"shared-title\" hidden>Shared by listeners</h2>\n <div id=\"shared\" class=\"grid\" aria-live=\"polite\"></div>\n <h2 id=\"radio-title\" hidden>Radio</h2>\n <div id=\"radio\" class=\"grid\" aria-live=\"polite\"></div>\n <h2 id=\"tv-title\" hidden>TV</h2>\n <div id=\"tv\" class=\"grid\" aria-live=\"polite\"></div>\n <p id=\"empty\" class=\"empty\">Nothing is on the air here yet.</p>\n <h2 id=\"share-title\" hidden>Share a track</h2>\n <form id=\"share\" hidden>\n <label for=\"url\">A link to an MP3</label>\n <input id=\"url\" name=\"url\" type=\"url\" placeholder=\"https://example.com/my-song.mp3\" inputmode=\"url\">\n <p class=\"or\">or upload one (up to <span id=\"max\">60 MB</span>)</p>\n <input id=\"file\" name=\"file\" type=\"file\" accept=\"audio/*,.mp3,.ogg,.flac,.m4a,.wav\">\n <label for=\"basis\">Why it may be broadcast</label>\n <select id=\"basis\" name=\"basis\" required>\n <option value=\"owned\">I made it</option>\n <option value=\"permission\">I have the artist's permission</option>\n <option value=\"licensed\">It is licensed for broadcast</option>\n <option value=\"creative-commons\">It is Creative Commons (allows sharing)</option>\n <option value=\"public-domain\">It is in the public domain</option>\n </select>\n <label for=\"note\">Anything we should know (licence, link to the artist)</label>\n <input id=\"note\" name=\"note\" type=\"text\" maxlength=\"300\">\n <label for=\"by\">Credit me as (optional)</label>\n <input id=\"by\" name=\"by\" type=\"text\" maxlength=\"60\">\n <label class=\"agree\"><input type=\"checkbox\" name=\"agree\" value=\"1\"> I have the right to share this for broadcast here. AI files it under a genre and checks it before it airs; anything can be reported off the air.</label>\n <button type=\"submit\">Share it</button>\n <div id=\"status\" role=\"status\" aria-live=\"polite\"></div>\n </form>\n <footer>Played by <a href=\"https://nixamp.com\">nixamp</a>, on the Moshpit.</footer>\n</main>\n<script>\n(async () => {\n const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text) n.textContent = text; return n; };\n const say = (text) => { document.querySelector(\"#status\").textContent = text; };\n // This server's own address: through a gateway (pit.moshcode.sh/n/...) the\n // page's origin is not the server's, and a relative fetch would ask the gateway.\n const API = document.body.dataset.api || \"\";\n const report = async (s, button) => {\n // Click twice: a sandboxed page (the gateway's) cannot open confirm().\n if (button.dataset.armed !== \"1\") { button.dataset.armed = \"1\"; button.textContent = \"Click again to take it off the air\"; return; }\n button.disabled = true;\n try {\n const r = await fetch(new URL(s.page).origin + \"/api/v1/share/report\", { method: \"POST\", headers: { \"content-type\": \"application/json\" }, body: JSON.stringify({ station: s.id }) });\n const body = await r.json();\n button.textContent = body.reported ? \"Reported: off the air\" : \"Nothing shared is playing\";\n } catch { button.textContent = \"Could not report; try again\"; button.disabled = false; }\n };\n const draw = (stations) => {\n for (const group of [\"ai\", \"shared\", \"radio\", \"tv\"]) {\n const grid = document.querySelector(\"#\" + group);\n grid.replaceChildren();\n const list = stations.filter((s) => (s.group || (s.kind === \"video\" ? \"tv\" : \"radio\")) === group);\n document.querySelector(\"#\" + group + \"-title\").hidden = list.length === 0;\n for (const s of list) {\n const card = el(\"div\", \"card\");\n const a = el(\"a\", \"station\");\n a.href = s.page;\n a.append(el(\"div\", \"live\", s.state === \"live\" ? \"\u25CF LIVE\" : \"off the air\"), el(\"div\", \"name\", s.name),\n el(\"div\", \"now\", s.nowPlaying ? [s.nowPlaying.title, s.nowPlaying.artist].filter(Boolean).join(\" \u2014 \") : \"\"));\n card.append(a);\n if (group === \"shared\") {\n const b = el(\"button\", \"report\", \"Report this track\");\n b.type = \"button\";\n b.addEventListener(\"click\", () => report(s, b));\n card.append(b);\n }\n grid.append(card);\n }\n }\n document.querySelector(\"#empty\").hidden = stations.length > 0;\n };\n const load = async () => {\n try {\n const r = await fetch(API + \"/api/v1/stations/guide\", { cache: \"no-store\" });\n if (r.ok && (r.headers.get(\"content-type\") || \"\").includes(\"json\")) draw((await r.json()).stations);\n } catch {}\n };\n const watch = async (status) => {\n for (let i = 0; i < 120; i++) {\n await new Promise((done) => setTimeout(done, 3000));\n try {\n const share = (await (await fetch(API + status, { cache: \"no-store\" })).json()).share;\n if (!share) return;\n const words = { fetching: \"Fetching it\u2026\", checking: \"Listening to it and checking it\u2026\", held: \"Waiting for the owner to look at it\", approved: \"On the air\", rejected: \"Not accepted\", failed: \"Could not use it\", withdrawn: \"Withdrawn\", reported: \"Reported\" };\n say((words[share.state] || share.state) + (share.genre ? \" \u00B7 \" + share.genre : \"\") + (share.reason ? \" \u2014 \" + share.reason : \"\"));\n if (![\"fetching\", \"checking\"].includes(share.state)) { load(); return; }\n } catch {}\n }\n };\n const form = document.querySelector(\"#share\");\n try {\n const info = await (await fetch(API + \"/api/v1/share\", { cache: \"no-store\" })).json();\n if (info.open) {\n document.querySelector(\"#share-title\").hidden = false;\n form.hidden = false;\n document.querySelector(\"#max\").textContent = info.maxMb + \" MB\";\n }\n } catch {}\n form.addEventListener(\"submit\", async (event) => {\n event.preventDefault();\n const data = new FormData(form);\n const file = data.get(\"file\");\n const link = String(data.get(\"url\") || \"\").trim();\n const fields = { basis: String(data.get(\"basis\") || \"\"), note: String(data.get(\"note\") || \"\"), by: String(data.get(\"by\") || \"\"), agree: data.get(\"agree\") ? \"1\" : \"\" };\n if (!fields.agree) { say(\"Tick the box to confirm you have the right to share it.\"); return; }\n if (!link && !(file && file.size)) { say(\"Paste a link to an MP3, or choose a file.\"); return; }\n say(file && file.size && !link ? \"Uploading\u2026\" : \"Sending\u2026\");\n try {\n const r = file && file.size && !link\n ? await fetch(API + \"/api/v1/share/upload?\" + new URLSearchParams({ ...fields, name: file.name }), { method: \"PUT\", headers: { \"content-type\": file.type || \"audio/mpeg\" }, body: file })\n : await fetch(API + \"/api/v1/share\", { method: \"POST\", headers: { \"content-type\": \"application/json\" }, body: JSON.stringify({ ...fields, agree: true, url: link }) });\n const body = await r.json();\n if (!r.ok) { say(body.error || \"That did not work.\"); return; }\n form.reset();\n say(\"Got it. Checking it now\u2026\");\n watch(body.status);\n } catch { say(\"Could not reach the station; try again.\"); }\n });\n await load();\n setInterval(load, 15000);\n})();\n</script>\n</body>\n</html>\n";
23
23
  export declare const GUIDE_CSP: string;
24
24
  /**
25
25
  * The hosts whose front page is the guide: `guideHosts` in config.json
@@ -36,11 +36,15 @@ const SCRIPT = `
36
36
  (async () => {
37
37
  const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text) n.textContent = text; return n; };
38
38
  const say = (text) => { document.querySelector("#status").textContent = text; };
39
- const report = async (id, button) => {
40
- if (!confirm("Report the track playing now? It comes off the air until the owner looks at it.")) return;
39
+ // This server's own address: through a gateway (pit.moshcode.sh/n/...) the
40
+ // page's origin is not the server's, and a relative fetch would ask the gateway.
41
+ const API = document.body.dataset.api || "";
42
+ const report = async (s, button) => {
43
+ // Click twice: a sandboxed page (the gateway's) cannot open confirm().
44
+ if (button.dataset.armed !== "1") { button.dataset.armed = "1"; button.textContent = "Click again to take it off the air"; return; }
41
45
  button.disabled = true;
42
46
  try {
43
- const r = await fetch("/api/v1/share/report", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ station: id }) });
47
+ const r = await fetch(new URL(s.page).origin + "/api/v1/share/report", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ station: s.id }) });
44
48
  const body = await r.json();
45
49
  button.textContent = body.reported ? "Reported: off the air" : "Nothing shared is playing";
46
50
  } catch { button.textContent = "Could not report; try again"; button.disabled = false; }
@@ -58,10 +62,10 @@ const SCRIPT = `
58
62
  a.append(el("div", "live", s.state === "live" ? "● LIVE" : "off the air"), el("div", "name", s.name),
59
63
  el("div", "now", s.nowPlaying ? [s.nowPlaying.title, s.nowPlaying.artist].filter(Boolean).join(" — ") : ""));
60
64
  card.append(a);
61
- if (group === "shared" && !s.peer) {
65
+ if (group === "shared") {
62
66
  const b = el("button", "report", "Report this track");
63
67
  b.type = "button";
64
- b.addEventListener("click", () => report(s.id, b));
68
+ b.addEventListener("click", () => report(s, b));
65
69
  card.append(b);
66
70
  }
67
71
  grid.append(card);
@@ -71,7 +75,7 @@ const SCRIPT = `
71
75
  };
72
76
  const load = async () => {
73
77
  try {
74
- const r = await fetch("/api/v1/stations/guide", { cache: "no-store" });
78
+ const r = await fetch(API + "/api/v1/stations/guide", { cache: "no-store" });
75
79
  if (r.ok && (r.headers.get("content-type") || "").includes("json")) draw((await r.json()).stations);
76
80
  } catch {}
77
81
  };
@@ -79,7 +83,7 @@ const SCRIPT = `
79
83
  for (let i = 0; i < 120; i++) {
80
84
  await new Promise((done) => setTimeout(done, 3000));
81
85
  try {
82
- const share = (await (await fetch(status, { cache: "no-store" })).json()).share;
86
+ const share = (await (await fetch(API + status, { cache: "no-store" })).json()).share;
83
87
  if (!share) return;
84
88
  const words = { fetching: "Fetching it…", checking: "Listening to it and checking it…", held: "Waiting for the owner to look at it", approved: "On the air", rejected: "Not accepted", failed: "Could not use it", withdrawn: "Withdrawn", reported: "Reported" };
85
89
  say((words[share.state] || share.state) + (share.genre ? " · " + share.genre : "") + (share.reason ? " — " + share.reason : ""));
@@ -89,7 +93,7 @@ const SCRIPT = `
89
93
  };
90
94
  const form = document.querySelector("#share");
91
95
  try {
92
- const info = await (await fetch("/api/v1/share", { cache: "no-store" })).json();
96
+ const info = await (await fetch(API + "/api/v1/share", { cache: "no-store" })).json();
93
97
  if (info.open) {
94
98
  document.querySelector("#share-title").hidden = false;
95
99
  form.hidden = false;
@@ -107,8 +111,8 @@ const SCRIPT = `
107
111
  say(file && file.size && !link ? "Uploading…" : "Sending…");
108
112
  try {
109
113
  const r = file && file.size && !link
110
- ? await fetch("/api/v1/share/upload?" + new URLSearchParams({ ...fields, name: file.name }), { method: "PUT", headers: { "content-type": file.type || "audio/mpeg" }, body: file })
111
- : await fetch("/api/v1/share", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...fields, agree: true, url: link }) });
114
+ ? await fetch(API + "/api/v1/share/upload?" + new URLSearchParams({ ...fields, name: file.name }), { method: "PUT", headers: { "content-type": file.type || "audio/mpeg" }, body: file })
115
+ : await fetch(API + "/api/v1/share", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...fields, agree: true, url: link }) });
112
116
  const body = await r.json();
113
117
  if (!r.ok) { say(body.error || "That did not work."); return; }
114
118
  form.reset();
@@ -130,7 +134,7 @@ const GROUPS = ["ai", "shared", "radio", "tv"];
130
134
  * gateway that cannot run its script against this server (the Moshpit's
131
135
  * /n/<name>), and for a crawler. Everything from a station is escaped.
132
136
  */
133
- export function renderGuide(entries) {
137
+ export function renderGuide(entries, apiBase = "") {
134
138
  const groupOf = (one) => one.group ?? (one.kind === "video" ? "tv" : "radio");
135
139
  const cards = (group) => entries
136
140
  .filter((one) => groupOf(one) === group && /^https?:\/\//.test(one.page))
@@ -143,7 +147,8 @@ export function renderGuide(entries) {
143
147
  .replace(`<h2 id="${group}-title" hidden>`, made ? `<h2 id="${group}-title">` : `<h2 id="${group}-title" hidden>`)
144
148
  .replace(`<div id="${group}" class="grid" aria-live="polite"></div>`, `<div id="${group}" class="grid" aria-live="polite">${made}</div>`);
145
149
  }
146
- return page.replace('<p id="empty" class="empty">', entries.length ? '<p id="empty" class="empty" hidden>' : '<p id="empty" class="empty">');
150
+ const api = /^https:\/\/[^\s"<>]+$/.test(apiBase) ? apiBase.replace(/\/+$/, "") : "";
151
+ 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>");
147
152
  }
148
153
  export const GUIDE_HTML = `<!doctype html>
149
154
  <html lang="en">
@@ -197,7 +202,7 @@ export const GUIDE_HTML = `<!doctype html>
197
202
  </html>
198
203
  `;
199
204
  export const GUIDE_CSP = [
200
- "default-src 'none'", `script-src ${hash(SCRIPT)}`, `style-src ${hash(STYLE)}`, "connect-src 'self'",
205
+ "default-src 'none'", `script-src ${hash(SCRIPT)}`, `style-src ${hash(STYLE)}`, "connect-src 'self' https:",
201
206
  "img-src 'self' data:", "base-uri 'none'", "form-action 'none'", "frame-ancestors 'none'",
202
207
  ].join("; ");
203
208
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.38.1",
3
+ "version": "0.39.1",
4
4
  "description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
5
5
  "license": "MIT",
6
6
  "type": "module",