nixamp 0.38.1 → 0.39.0
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/directory-mcp.d.ts +89 -0
- package/dist/directory-mcp.js +103 -0
- package/dist/mcp.d.ts +3 -0
- package/dist/mcp.js +10 -0
- package/dist/server.js +96 -4
- package/dist/station-guide.d.ts +2 -2
- package/dist/station-guide.js +18 -13
- package/package.json +1 -1
- package/src/directory-mcp.ts +122 -0
- package/src/mcp.ts +12 -0
- package/src/server.ts +85 -4
- package/src/station-guide.ts +18 -13
- package/web/dist/assets/{ar-COUOgLwt.js → ar-COqm9FxF.js} +1 -1
- package/web/dist/assets/{cs-BAbCGcwj.js → cs-6729Q4KU.js} +1 -1
- package/web/dist/assets/{da-BbuDvjI-.js → da-bJ46c6s_.js} +1 -1
- package/web/dist/assets/{de-CwD_N7qJ.js → de-DpbosSoV.js} +1 -1
- package/web/dist/assets/{es-Ck7wfYxi.js → es-BY8eqYp8.js} +1 -1
- package/web/dist/assets/{fi-BdHztPZ5.js → fi-BWvyjy5X.js} +1 -1
- package/web/dist/assets/{fr-DspyIX0H.js → fr-B4QPmqIb.js} +1 -1
- package/web/dist/assets/{hi-CafGjrd-.js → hi-DolWtyiy.js} +1 -1
- package/web/dist/assets/{hu-WPQAw701.js → hu-E-d7J-V3.js} +1 -1
- package/web/dist/assets/{id-CpnYKVLb.js → id-SQFjOWso.js} +1 -1
- package/web/dist/assets/{it-pT4i7fpZ.js → it-PJGrlYIh.js} +1 -1
- package/web/dist/assets/{main-CjbWfOjh.js → main-C2dTdpLb.js} +2 -2
- package/web/dist/assets/{nl-Cv_EX-9Z.js → nl-Bibi6Sse.js} +1 -1
- package/web/dist/assets/{ru-DnLA1MM6.js → ru-D5NK9_Ra.js} +1 -1
- package/web/dist/assets/{sv-55BevY51.js → sv-D7uXulb7.js} +1 -1
- package/web/dist/assets/{uk-CSDxbIns.js → uk-DlRN_4fA.js} +1 -1
- package/web/dist/assets/{vi-B1Op-uBs.js → vi-DiRYsKhl.js} +1 -1
- package/web/dist/assets/{zh-re8w8jaX.js → zh-CVpHCND2.js} +1 -1
- package/web/dist/index.html +1 -1
- 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,8 @@ 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" &&
|
|
2161
2164
|
!STATION_PAGE.test(path) &&
|
|
2162
2165
|
!isPublicAsset &&
|
|
2163
2166
|
!isGuidePage &&
|
|
@@ -2233,7 +2236,7 @@ export function createHandler(engine, options) {
|
|
|
2233
2236
|
});
|
|
2234
2237
|
const own = new Set(entries.map((one) => one.page));
|
|
2235
2238
|
const others = options.stations.peers ? (await options.stations.peers.entries()).filter((one) => !own.has(one.page)) : [];
|
|
2236
|
-
const page = renderGuide([...entries, ...others]);
|
|
2239
|
+
const page = renderGuide([...entries, ...others], options.stations.publicBase?.() ?? "");
|
|
2237
2240
|
response.writeHead(200, {
|
|
2238
2241
|
"content-type": "text/html; charset=utf-8",
|
|
2239
2242
|
"cache-control": "no-cache",
|
|
@@ -3440,6 +3443,77 @@ export function createHandler(engine, options) {
|
|
|
3440
3443
|
* GET: nothing here has to be pushed, so a client is told so and gets
|
|
3441
3444
|
* on with POST.
|
|
3442
3445
|
*/
|
|
3446
|
+
/*
|
|
3447
|
+
* nixamp.com/mcp/public: what is on, anywhere, for any agent with no
|
|
3448
|
+
* account. /mcp stays a 401 without a token, because that 401 is how an
|
|
3449
|
+
* MCP client finds out it should sign in (OAuth discovery).
|
|
3450
|
+
*/
|
|
3451
|
+
if (path === "/mcp/public" && options.stations) {
|
|
3452
|
+
if (request.method === "OPTIONS") {
|
|
3453
|
+
response.writeHead(204, CORS);
|
|
3454
|
+
response.end();
|
|
3455
|
+
return;
|
|
3456
|
+
}
|
|
3457
|
+
if (request.method !== "POST") {
|
|
3458
|
+
json(response, 405, { error: "POST a JSON-RPC message" });
|
|
3459
|
+
return;
|
|
3460
|
+
}
|
|
3461
|
+
const stationGuide = async () => {
|
|
3462
|
+
if (!options.stations)
|
|
3463
|
+
return [];
|
|
3464
|
+
const own = guideEntries({
|
|
3465
|
+
store: options.stations.store, runner: options.stations.runner, channels: options.channels, canControl: false, account: "",
|
|
3466
|
+
select: () => null, trackFor: (file) => trackFor(engine, file), base: options.site ?? "", publicBase: options.stations.publicBase?.() ?? "",
|
|
3467
|
+
json, readBody, headers: CORS,
|
|
3468
|
+
});
|
|
3469
|
+
const shown = new Set(own.map((one) => one.page));
|
|
3470
|
+
const others = options.stations.peers ? (await options.stations.peers.entries()).filter((one) => !shown.has(one.page)) : [];
|
|
3471
|
+
return [...own, ...others];
|
|
3472
|
+
};
|
|
3473
|
+
let parsed;
|
|
3474
|
+
try {
|
|
3475
|
+
parsed = JSON.parse(await readBody(request, 256 * 1024));
|
|
3476
|
+
}
|
|
3477
|
+
catch {
|
|
3478
|
+
json(response, 400, { jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } });
|
|
3479
|
+
return;
|
|
3480
|
+
}
|
|
3481
|
+
const messages = (Array.isArray(parsed) ? parsed : [parsed]);
|
|
3482
|
+
const answers = [];
|
|
3483
|
+
for (const message of messages) {
|
|
3484
|
+
const id = message?.id ?? null;
|
|
3485
|
+
const method = typeof message?.method === "string" ? message.method : "";
|
|
3486
|
+
if (method.startsWith("notifications/"))
|
|
3487
|
+
continue;
|
|
3488
|
+
if (method === "initialize") {
|
|
3489
|
+
answers.push({ jsonrpc: "2.0", id, result: {
|
|
3490
|
+
protocolVersion: message.params?.["protocolVersion"] ?? "2025-06-18",
|
|
3491
|
+
capabilities: { tools: { listChanged: false } },
|
|
3492
|
+
serverInfo: { name: "nixamp", title: "nixamp: every public radio and TV station", version: "2" },
|
|
3493
|
+
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.",
|
|
3494
|
+
} });
|
|
3495
|
+
}
|
|
3496
|
+
else if (method === "ping") {
|
|
3497
|
+
answers.push({ jsonrpc: "2.0", id, result: {} });
|
|
3498
|
+
}
|
|
3499
|
+
else if (method === "tools/list") {
|
|
3500
|
+
answers.push({ jsonrpc: "2.0", id, result: { tools: PUBLIC_TOOLS } });
|
|
3501
|
+
}
|
|
3502
|
+
else if (method === "tools/call" && isPublicTool(String(message.params?.["name"] ?? ""))) {
|
|
3503
|
+
answers.push({ jsonrpc: "2.0", id, result: await callPublicTool(String(message.params["name"]), (message.params?.["arguments"] ?? {}), { guide: stationGuide }) });
|
|
3504
|
+
}
|
|
3505
|
+
else {
|
|
3506
|
+
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" } });
|
|
3507
|
+
}
|
|
3508
|
+
}
|
|
3509
|
+
if (answers.length === 0) {
|
|
3510
|
+
response.writeHead(202, CORS);
|
|
3511
|
+
response.end();
|
|
3512
|
+
return;
|
|
3513
|
+
}
|
|
3514
|
+
json(response, 200, Array.isArray(parsed) ? answers : answers[0]);
|
|
3515
|
+
return;
|
|
3516
|
+
}
|
|
3443
3517
|
if (path === "/mcp" && options.accounts && options.site) {
|
|
3444
3518
|
if (request.method === "GET") {
|
|
3445
3519
|
response.writeHead(405, { ...CORS, allow: "POST, DELETE" });
|
|
@@ -3457,6 +3531,18 @@ export function createHandler(engine, options) {
|
|
|
3457
3531
|
}
|
|
3458
3532
|
const token = tokenFrom(request.headers);
|
|
3459
3533
|
const who = await options.accounts.whoIs(token);
|
|
3534
|
+
const stationGuide = async () => {
|
|
3535
|
+
if (!options.stations)
|
|
3536
|
+
return [];
|
|
3537
|
+
const own = guideEntries({
|
|
3538
|
+
store: options.stations.store, runner: options.stations.runner, channels: options.channels, canControl: false, account: "",
|
|
3539
|
+
select: () => null, trackFor: (file) => trackFor(engine, file), base: options.site ?? "", publicBase: options.stations.publicBase?.() ?? "",
|
|
3540
|
+
json, readBody, headers: CORS,
|
|
3541
|
+
});
|
|
3542
|
+
const shown = new Set(own.map((one) => one.page));
|
|
3543
|
+
const others = options.stations.peers ? (await options.stations.peers.entries()).filter((one) => !shown.has(one.page)) : [];
|
|
3544
|
+
return [...own, ...others];
|
|
3545
|
+
};
|
|
3460
3546
|
if (who === null) {
|
|
3461
3547
|
const error = JSON.stringify({ error: "sign in: a nixamp token as the bearer (`nixamp token create`), or OAuth" });
|
|
3462
3548
|
response.writeHead(401, {
|
|
@@ -3484,7 +3570,7 @@ export function createHandler(engine, options) {
|
|
|
3484
3570
|
answers.push({ jsonrpc: "2.0", id: null, error: { code: -32600, message: "invalid request" } });
|
|
3485
3571
|
continue;
|
|
3486
3572
|
}
|
|
3487
|
-
const answer = await mcpMessage(message, { session, fetcher: fetch });
|
|
3573
|
+
const answer = await mcpMessage(message, { session, fetcher: fetch, guide: stationGuide });
|
|
3488
3574
|
if (answer)
|
|
3489
3575
|
answers.push(answer);
|
|
3490
3576
|
}
|
|
@@ -6353,7 +6439,13 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
6353
6439
|
trustedProxies: () => new Set([...(process.env["NIXAMP_TRUSTED_PROXIES"] ?? "").split(","), ...(readConfig().trustedProxies ?? [])].map((one) => one.trim()).filter(Boolean)),
|
|
6354
6440
|
// Known once the server has worked out its addresses, after it is listening.
|
|
6355
6441
|
publicBase: () => (publishable_?.url ?? "").replace(/\/+$/, ""),
|
|
6356
|
-
|
|
6442
|
+
// Other servers' guides: the configured peers, and on a directory
|
|
6443
|
+
// (nixamp.com) every server listed in it, so its guide is every public
|
|
6444
|
+
// station anywhere.
|
|
6445
|
+
peers: new PeerGuides(() => {
|
|
6446
|
+
const listed = directory ? directory.list().map((one) => peerBase(one.url)).filter(Boolean).slice(0, 100) : [];
|
|
6447
|
+
return [...new Set([...guidePeers(), ...listed])];
|
|
6448
|
+
}),
|
|
6357
6449
|
};
|
|
6358
6450
|
// Music handed over from BitTorrented, copied into a folder of our own.
|
|
6359
6451
|
const importer = new Importer(join(stateDir(), `handoffs-${options.port}.json`), {
|
package/dist/station-guide.d.ts
CHANGED
|
@@ -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
|
package/dist/station-guide.js
CHANGED
|
@@ -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
|
-
|
|
40
|
-
|
|
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"
|
|
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
|
|
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
|
-
|
|
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
|
/**
|