nixamp 0.32.0 → 0.34.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/channels.d.ts +11 -2
- package/dist/channels.js +26 -4
- package/dist/handoff-routes.d.ts +17 -0
- package/dist/handoff-routes.js +64 -0
- package/dist/handoff.d.ts +89 -0
- package/dist/handoff.js +358 -0
- package/dist/library-index.d.ts +9 -0
- package/dist/library-index.js +19 -0
- package/dist/oauth-server.d.ts +1 -0
- package/dist/oauth-server.js +1 -0
- package/dist/owner.js +4 -1
- package/dist/server.d.ts +3 -0
- package/dist/server.js +53 -4
- package/dist/station-page.d.ts +1 -1
- package/dist/station-page.js +25 -4
- package/dist/station-routes.js +54 -1
- package/package.json +1 -1
- package/src/channels.ts +23 -4
- package/src/handoff-routes.ts +73 -0
- package/src/handoff.ts +383 -0
- package/src/library-index.ts +21 -0
- package/src/oauth-server.ts +1 -0
- package/src/owner.ts +5 -2
- package/src/server.ts +55 -4
- package/src/station-page.ts +25 -4
- package/src/station-routes.ts +53 -1
- package/web/dist/assets/ar-DoW0wbjC.js +1 -0
- package/web/dist/assets/cs-CjKqCjy5.js +1 -0
- package/web/dist/assets/da-BQERb7Qz.js +1 -0
- package/web/dist/assets/de-CKKbRy6c.js +1 -0
- package/web/dist/assets/es-CL3v5Og9.js +1 -0
- package/web/dist/assets/fi-DgV81qNU.js +1 -0
- package/web/dist/assets/fr-BGumytzF.js +1 -0
- package/web/dist/assets/hi-CAQ9elqo.js +1 -0
- package/web/dist/assets/hls-3VKVEQE3-Ceri0flt.js +1 -0
- package/web/dist/assets/hu-UJNgJVWX.js +1 -0
- package/web/dist/assets/id-BqQOlrF-.js +1 -0
- package/web/dist/assets/it-Cs8G6mvW.js +1 -0
- package/web/dist/assets/{index-D-PZ4nGC.js → main-Y1Rvn1GX.js} +2 -2
- package/web/dist/assets/mpegts-LO6RVLD6-CPI8xaZ-.js +1 -0
- package/web/dist/assets/{mpegts-CabjhxTI.js → mpegts-jS_Diizz.js} +1 -1
- package/web/dist/assets/nl-BJXQ9nqo.js +1 -0
- package/web/dist/assets/radio-B58WgC1j.js +1 -0
- package/web/dist/assets/radio-CC8ihiO_.css +1 -0
- package/web/dist/assets/ru-XmVkvNY8.js +1 -0
- package/web/dist/assets/styles-D70tSrIe.js +1 -0
- package/web/dist/assets/styles-qC62Gona.css +1 -0
- package/web/dist/assets/sv-pVm9p66e.js +1 -0
- package/web/dist/assets/uk-4hcyaD3e.js +1 -0
- package/web/dist/assets/vi-B7MipBlv.js +1 -0
- package/web/dist/assets/zh-B5YVExOW.js +1 -0
- package/web/dist/index.html +4 -2
- package/web/dist/radio.html +134 -0
- package/web/dist/sw.js +26 -23
- package/web/dist/assets/ar-C_4gIadE.js +0 -1
- package/web/dist/assets/cs-B18R5adY.js +0 -1
- package/web/dist/assets/da-B6w5URQ2.js +0 -1
- package/web/dist/assets/de-Dlhr9ikG.js +0 -1
- package/web/dist/assets/es-BIpArFOC.js +0 -1
- package/web/dist/assets/fi-o9W4XS3X.js +0 -1
- package/web/dist/assets/fr-BgVwhPsP.js +0 -1
- package/web/dist/assets/hi-zIXqrXNC.js +0 -1
- package/web/dist/assets/hls-3VKVEQE3-5pAjDDLr.js +0 -1
- package/web/dist/assets/hu-Dpn_DIRi.js +0 -1
- package/web/dist/assets/id-Cu1V6gAm.js +0 -1
- package/web/dist/assets/index-Bo3utcDw.css +0 -1
- package/web/dist/assets/it-BNcoJEYQ.js +0 -1
- package/web/dist/assets/mpegts-LO6RVLD6-CRSxiR1J.js +0 -1
- package/web/dist/assets/nl-gvivlDEE.js +0 -1
- package/web/dist/assets/ru-BxQnVrHu.js +0 -1
- package/web/dist/assets/sv-BP1JhAR8.js +0 -1
- package/web/dist/assets/uk-ChYhgzlf.js +0 -1
- package/web/dist/assets/vi-CD7vzLwP.js +0 -1
- package/web/dist/assets/zh-BmGS-RcE.js +0 -1
package/dist/owner.js
CHANGED
|
@@ -52,7 +52,10 @@ export class Owner {
|
|
|
52
52
|
return "";
|
|
53
53
|
}
|
|
54
54
|
const body = (await answer.json());
|
|
55
|
-
|
|
55
|
+
// A token some app holds for the person counts here only if they gave
|
|
56
|
+
// that app the stations scope; otherwise it is not them at this door.
|
|
57
|
+
const scoped = body.grant === undefined || (Array.isArray(body.grant.scope) && body.grant.scope.includes("stations"));
|
|
58
|
+
const id = scoped && typeof body.account?.id === "string" ? body.account.id : "";
|
|
56
59
|
this.cache.set(token, { id, at: now });
|
|
57
60
|
return id;
|
|
58
61
|
}
|
package/dist/server.d.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { PartyLine } from "./partyline.ts";
|
|
|
23
23
|
import { HlsPackagers } from "./hls.ts";
|
|
24
24
|
import { LibraryIndex, type Probed } from "./library-index.ts";
|
|
25
25
|
import { ListenerStore } from "./library-user.ts";
|
|
26
|
+
import { Importer } from "./handoff.ts";
|
|
26
27
|
import { StationRunner } from "./station-runner.ts";
|
|
27
28
|
import { StationStore } from "./stations.ts";
|
|
28
29
|
import { CompressionService } from "./compression/service.ts";
|
|
@@ -575,6 +576,8 @@ export interface HandlerOptions {
|
|
|
575
576
|
retag: (tracks: Track[]) => void;
|
|
576
577
|
listeners?: ListenerStore;
|
|
577
578
|
};
|
|
579
|
+
/** Music handed over from BitTorrented. */
|
|
580
|
+
importer?: Importer;
|
|
578
581
|
/** This server's stations and what keeps them on the air. */
|
|
579
582
|
stations?: {
|
|
580
583
|
store: StationStore;
|
package/dist/server.js
CHANGED
|
@@ -58,6 +58,8 @@ import { handleStations, isStationListenPath } from "./station-routes.js";
|
|
|
58
58
|
import { handleLibrary } from "./library-routes.js";
|
|
59
59
|
import { LibraryIndex, probeItem, trackOf } from "./library-index.js";
|
|
60
60
|
import { ListenerStore } from "./library-user.js";
|
|
61
|
+
import { Importer } from "./handoff.js";
|
|
62
|
+
import { handleHandoffs } from "./handoff-routes.js";
|
|
61
63
|
import { STATION_PAGE, STATION_PAGE_CSP, STATION_PAGE_HTML } from "./station-page.js";
|
|
62
64
|
import { StationRunner } from "./station-runner.js";
|
|
63
65
|
import { StationError, StationStore, stationOfChannel } from "./stations.js";
|
|
@@ -2227,6 +2229,17 @@ export function createHandler(engine, options) {
|
|
|
2227
2229
|
});
|
|
2228
2230
|
return;
|
|
2229
2231
|
}
|
|
2232
|
+
if (options.importer && path.startsWith("/api/v1/integrations/bittorrented/")) {
|
|
2233
|
+
const control = await controlOf();
|
|
2234
|
+
await handleHandoffs(request, response, path, {
|
|
2235
|
+
importer: options.importer,
|
|
2236
|
+
canWrite: control.allowed,
|
|
2237
|
+
owner: control.account || "owner",
|
|
2238
|
+
json,
|
|
2239
|
+
readBody,
|
|
2240
|
+
});
|
|
2241
|
+
return;
|
|
2242
|
+
}
|
|
2230
2243
|
if (options.stations && (path === "/api/v1/stations" || path.startsWith("/api/v1/stations/"))) {
|
|
2231
2244
|
const control = await controlOf();
|
|
2232
2245
|
const encrypted = request.socket.encrypted === true;
|
|
@@ -2343,12 +2356,17 @@ export function createHandler(engine, options) {
|
|
|
2343
2356
|
return;
|
|
2344
2357
|
}
|
|
2345
2358
|
if (path === "/api/v1/auth/me") {
|
|
2346
|
-
const
|
|
2359
|
+
const token = tokenFrom(request.headers);
|
|
2360
|
+
const who = await accounts.whoIs(token);
|
|
2347
2361
|
if (who === null) {
|
|
2348
2362
|
json(response, 401, { error: "not signed in" });
|
|
2349
2363
|
return;
|
|
2350
2364
|
}
|
|
2351
|
-
|
|
2365
|
+
// An app acting for the person says so, with what it was allowed:
|
|
2366
|
+
// a server asking "is this my owner?" must not take a token handed to
|
|
2367
|
+
// some other app as the owner themselves.
|
|
2368
|
+
const grant = options.authServer ? await options.authServer.grantFor(token) : null;
|
|
2369
|
+
json(response, 200, { account: who, ...(grant ? { grant: { clientId: grant.clientId, scope: grant.scope } } : {}) });
|
|
2352
2370
|
return;
|
|
2353
2371
|
}
|
|
2354
2372
|
/**
|
|
@@ -3599,11 +3617,28 @@ export function createHandler(engine, options) {
|
|
|
3599
3617
|
// point: a share link in a terminal you closed is a server you have lost.
|
|
3600
3618
|
if ((path === "/api/v1/servers" || path.startsWith("/api/v1/servers/")) && options.servers && options.accounts) {
|
|
3601
3619
|
const servers = options.servers;
|
|
3602
|
-
const
|
|
3620
|
+
const token = tokenFrom(request.headers);
|
|
3621
|
+
const who = await options.accounts.whoIs(token);
|
|
3603
3622
|
if (who === null) {
|
|
3604
3623
|
json(response, 401, { error: "not signed in" });
|
|
3605
3624
|
return;
|
|
3606
3625
|
}
|
|
3626
|
+
// An app acting for the person sees the list only with a scope that
|
|
3627
|
+
// says so, may not change it, and never gets a control key: those are
|
|
3628
|
+
// the person's, and an app that held one could drive the machine.
|
|
3629
|
+
const grant = options.authServer ? await options.authServer.grantFor(token) : null;
|
|
3630
|
+
if (grant) {
|
|
3631
|
+
if (!(grant.scope.includes("streams") || grant.scope.includes("stations"))) {
|
|
3632
|
+
json(response, 403, { error: "this app was not given access to your servers", code: "insufficient_scope" });
|
|
3633
|
+
return;
|
|
3634
|
+
}
|
|
3635
|
+
if (!(path === "/api/v1/servers" && request.method === "GET")) {
|
|
3636
|
+
json(response, 403, { error: "an app may list your servers, not change them", code: "insufficient_scope" });
|
|
3637
|
+
return;
|
|
3638
|
+
}
|
|
3639
|
+
json(response, 200, { servers: (await servers.list(who.id)).map(({ key: _key, ...shown }) => shown) });
|
|
3640
|
+
return;
|
|
3641
|
+
}
|
|
3607
3642
|
if (path === "/api/v1/servers" && request.method === "GET") {
|
|
3608
3643
|
json(response, 200, { servers: await servers.list(who.id) });
|
|
3609
3644
|
return;
|
|
@@ -5590,7 +5625,10 @@ export function createHandler(engine, options) {
|
|
|
5590
5625
|
return;
|
|
5591
5626
|
}
|
|
5592
5627
|
let file = null;
|
|
5593
|
-
|
|
5628
|
+
// The library and radio console is a page of its own, at its own names.
|
|
5629
|
+
if (/^\/(radio|library)\/?$/.test(path) && isFile(join(web, "radio.html")))
|
|
5630
|
+
file = join(web, "radio.html");
|
|
5631
|
+
else if (isFile(direct))
|
|
5594
5632
|
file = direct;
|
|
5595
5633
|
else if (isFile(join(direct, "index.html")))
|
|
5596
5634
|
file = join(direct, "index.html");
|
|
@@ -6144,6 +6182,16 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
6144
6182
|
log: (line) => console.log(line),
|
|
6145
6183
|
});
|
|
6146
6184
|
const stations = { store: stationStore, runner: stationRunner };
|
|
6185
|
+
// Music handed over from BitTorrented, copied into a folder of our own.
|
|
6186
|
+
const importer = new Importer(join(stateDir(), `handoffs-${options.port}.json`), {
|
|
6187
|
+
index: libraryIndex,
|
|
6188
|
+
stations: stationStore,
|
|
6189
|
+
root: join(stateDir(), "imports", "bittorrented"),
|
|
6190
|
+
probe: probeFile,
|
|
6191
|
+
guard: (url) => refusedDestination(url),
|
|
6192
|
+
log: (line) => console.log(line),
|
|
6193
|
+
});
|
|
6194
|
+
importer.resume();
|
|
6147
6195
|
// Channels as HLS, on demand, for Safari on a phone: one ffmpeg copying a
|
|
6148
6196
|
// channel's fragments into short files while somebody is asking for them.
|
|
6149
6197
|
// What things are, from nichedb.dev, remembered beside the keys so a
|
|
@@ -6724,6 +6772,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
6724
6772
|
channels,
|
|
6725
6773
|
stations,
|
|
6726
6774
|
library,
|
|
6775
|
+
importer,
|
|
6727
6776
|
rememberChannels: remembering,
|
|
6728
6777
|
catalogs,
|
|
6729
6778
|
publishUrls: () => publishUrls,
|
package/dist/station-page.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** A station page's path: /station/<id>. */
|
|
2
2
|
export declare const STATION_PAGE: RegExp;
|
|
3
|
-
export declare const STATION_PAGE_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<meta name=\"robots\" content=\"noindex\">\n<link rel=\"icon\" href=\"data:,\">\n<title>Station \u00B7 nixamp</title>\n<style>\n:root{--bg:#f6f5f2;--fg:#17171a;--muted:#5d5d66;--card:#fff;--line:#dedcd6;--accent:#1f6feb;--live:#0b7a3b;--warn:#9a6700;--off:#8c2f39}\n@media (prefers-color-scheme:dark){:root{--bg:#121214;--fg:#ececf1;--muted:#a4a4b0;--card:#1c1c20;--line:#2c2c33;--accent:#6aa7ff;--live:#3fbf7f;--warn:#e3b341;--off:#f0838f}}\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:560px;margin:0 auto;padding:48px 16px}\n.card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:24px}\nh1{font-size:1.6rem;line-height:1.2;margin:0 0 4px}\n.about{color:var(--muted);margin:0 0 20px}\n.chip{display:inline-flex;align-items:center;gap:8px;font-size:.85rem;font-weight:600;padding:4px 10px;border-radius:999px;border:1px solid var(--line)}\n.chip::before{content:\"\";width:8px;height:8px;border-radius:50%;background:currentColor}\n.live{color:var(--live)}.reconnecting{color:var(--warn)}.unavailable,.stopped{color:var(--off)}\n.now{margin:20px 0;min-height:3em}.now .title{font-weight:600}.now .by{color:var(--muted)}\n.controls{display:flex;align-items:center;gap:16px;flex-wrap:wrap}\nbutton{font:inherit;font-weight:600;border:0;border-radius:999px;padding:12px 28px;background:var(--accent);color:#fff;cursor:pointer;min-width:120px}\nbutton:disabled{opacity:.5;cursor:not-allowed}\nbutton:focus-visible,input:focus-visible,a:focus-visible{outline:3px solid var(--accent);outline-offset:3px}\nlabel{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:.9rem}\ninput[type=range]{width:120px;accent-color:var(--accent)}\n.small{margin-top:20px;font-size:.85rem;color:var(--muted)}.small a{color:inherit}\n.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}\n@media (prefers-reduced-motion:no-preference){.live::before{animation:pulse 2s ease-in-out infinite}}\n@keyframes pulse{50%{opacity:.35}}\n</style>\n</head>\n<body>\n<main>\n <div class=\"card\">\n <span id=\"state\" class=\"chip unavailable\" role=\"status\">Loading</span>\n <h1 id=\"name\"> </h1>\n <p id=\"about\" class=\"about\" hidden></p>\n <div class=\"now\" aria-live=\"polite\"><div id=\"title\" class=\"title\"></div><div id=\"by\" class=\"by\"></div></div>\n <div class=\"controls\">\n <button id=\"play\" type=\"button\" aria-pressed=\"false\" disabled>Play</button>\n <label>Volume <input id=\"volume\" type=\"range\" min=\"0\" max=\"1\" step=\"0.05\" value=\"1\"></label>\n </div>\n <p class=\"small\"><a id=\"m3u\" href=\"#\">Open in another player (.m3u)</a> \u00B7 Pausing here does not pause the station.</p>\n </div>\n <p class=\"small\">Played by <a href=\"https://nixamp.com\">nixamp</a>.</p>\n <div id=\"announce\" class=\"sr\" aria-live=\"assertive\"></div>\n</main>\n<script>\n(() => {\n const id = location.pathname.split(\"/\").filter(Boolean)[1] || \"\";\n const token = new URLSearchParams(location.search).get(\"t\") || \"\";\n const q = \"?t=\" + encodeURIComponent(token);\n const api = \"/api/v1/stations/\" + encodeURIComponent(id);\n const $ = (s) => document.querySelector(s);\n const audio = new Audio();\n audio.preload = \"none\";\n let wanted = false, retry = 0, timer = 0, last = null;\n const chip = (state, text) => { const c = $(\"#state\"); c.className = \"chip \" + state; c.textContent = text; };\n const say = (text) => { $(\"#announce\").textContent = text; };\n const draw = (s) => {\n last = s;\n document.title = s.name + \" \u00B7 nixamp\";\n $(\"#name\").textContent = s.name;\n $(\"#about\").textContent = s.description || \"\";\n $(\"#about\").hidden = !s.description;\n
|
|
3
|
+
export declare const STATION_PAGE_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<meta name=\"robots\" content=\"noindex\">\n<link rel=\"icon\" href=\"data:,\">\n<title>Station \u00B7 nixamp</title>\n<style>\n:root{--bg:#f6f5f2;--fg:#17171a;--muted:#5d5d66;--card:#fff;--line:#dedcd6;--accent:#1f6feb;--live:#0b7a3b;--warn:#9a6700;--off:#8c2f39}\n@media (prefers-color-scheme:dark){:root{--bg:#121214;--fg:#ececf1;--muted:#a4a4b0;--card:#1c1c20;--line:#2c2c33;--accent:#6aa7ff;--live:#3fbf7f;--warn:#e3b341;--off:#f0838f}}\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:560px;margin:0 auto;padding:48px 16px}\n.card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:24px}\nh1{font-size:1.6rem;line-height:1.2;margin:0 0 4px}\n.about{color:var(--muted);margin:0 0 20px}\n.chip{display:inline-flex;align-items:center;gap:8px;font-size:.85rem;font-weight:600;padding:4px 10px;border-radius:999px;border:1px solid var(--line)}\n.chip::before{content:\"\";width:8px;height:8px;border-radius:50%;background:currentColor}\n.live{color:var(--live)}.reconnecting{color:var(--warn)}.unavailable,.stopped{color:var(--off)}\n.now{margin:20px 0;min-height:3em}.now .title{font-weight:600}.now .by{color:var(--muted)}\n.controls{display:flex;align-items:center;gap:16px;flex-wrap:wrap}\nbutton{font:inherit;font-weight:600;border:0;border-radius:999px;padding:12px 28px;background:var(--accent);color:#fff;cursor:pointer;min-width:120px}\nbutton:disabled{opacity:.5;cursor:not-allowed}\nbutton:focus-visible,input:focus-visible,a:focus-visible{outline:3px solid var(--accent);outline-offset:3px}\nlabel{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:.9rem}\ninput[type=range]{width:120px;accent-color:var(--accent)}\n.small{margin-top:20px;font-size:.85rem;color:var(--muted)}.small a{color:inherit}\n.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}\n@media (prefers-reduced-motion:no-preference){.live::before{animation:pulse 2s ease-in-out infinite}}\n@keyframes pulse{50%{opacity:.35}}\n</style>\n</head>\n<body>\n<main>\n <div class=\"card\">\n <span id=\"state\" class=\"chip unavailable\" role=\"status\">Loading</span>\n <h1 id=\"name\"> </h1>\n <p id=\"about\" class=\"about\" hidden></p>\n <div class=\"now\" aria-live=\"polite\"><div id=\"title\" class=\"title\"></div><div id=\"by\" class=\"by\"></div></div>\n <div class=\"controls\">\n <button id=\"play\" type=\"button\" aria-pressed=\"false\" disabled>Play</button>\n <label>Volume <input id=\"volume\" type=\"range\" min=\"0\" max=\"1\" step=\"0.05\" value=\"1\"></label>\n </div>\n <p class=\"small\"><a id=\"m3u\" href=\"#\">Open in another player (.m3u)</a> \u00B7 Pausing here does not pause the station.</p>\n </div>\n <p class=\"small\">Played by <a href=\"https://nixamp.com\">nixamp</a>.</p>\n <div id=\"announce\" class=\"sr\" aria-live=\"assertive\"></div>\n</main>\n<script>\n(() => {\n const id = location.pathname.split(\"/\").filter(Boolean)[1] || \"\";\n const token = new URLSearchParams(location.search).get(\"t\") || \"\";\n const q = \"?t=\" + encodeURIComponent(token);\n const api = \"/api/v1/stations/\" + encodeURIComponent(id);\n const $ = (s) => document.querySelector(s);\n const audio = new Audio();\n audio.preload = \"none\";\n let wanted = false, retry = 0, timer = 0, last = null, live = false;\n const chip = (state, text) => { const c = $(\"#state\"); c.className = \"chip \" + state; c.textContent = text; };\n const say = (text) => { $(\"#announce\").textContent = text; };\n const draw = (s) => {\n last = s;\n document.title = s.name + \" \u00B7 nixamp\";\n $(\"#name\").textContent = s.name;\n $(\"#about\").textContent = s.description || \"\";\n $(\"#about\").hidden = !s.description;\n if (!live) title(s);\n if (wanted && retry > 0) chip(\"reconnecting\", \"Reconnecting\");\n else if (s.state === \"live\") chip(\"live\", \"Live\");\n else if (s.state === \"starting\" || s.state === \"recovering\") chip(\"reconnecting\", \"Coming back\");\n else if (s.state === \"stopped\") chip(\"stopped\", \"Off the air\");\n else chip(\"unavailable\", \"Unavailable\");\n $(\"#play\").disabled = !(s.state === \"live\" || s.state === \"recovering\" || s.state === \"starting\");\n $(\"#m3u\").href = api + \"/listen.m3u\" + q;\n };\n const title = (s) => {\n const playing = s.nowPlaying;\n $(\"#title\").textContent = playing ? (playing.title || \"Untitled\") : (s.state === \"live\" ? \"On the air\" : \"\");\n $(\"#by\").textContent = playing ? [playing.artist, playing.album].filter(Boolean).join(\" \u00B7 \") : \"\";\n };\n // What is ahead of the speaker: the sound the player holds and has not\n // played yet. A new title waits that long, so it changes with the song.\n const ahead = () => {\n if (!wanted || audio.paused || audio.buffered.length === 0) return 0;\n return Math.max(0, Math.min(30, audio.buffered.end(audio.buffered.length - 1) - audio.currentTime));\n };\n const listen = () => {\n if (!window.EventSource) return;\n const events = new EventSource(api + \"/events\" + q);\n events.addEventListener(\"hello\", (e) => { live = true; title(JSON.parse(e.data)); });\n events.addEventListener(\"now\", (e) => {\n const s = JSON.parse(e.data);\n setTimeout(() => { title(s); say(s.nowPlaying ? \"Now playing \" + (s.nowPlaying.title || \"\") : \"\"); }, ahead() * 1000);\n });\n events.addEventListener(\"state\", () => poll());\n events.onerror = () => { live = false; };\n };\n const poll = async () => {\n try {\n const r = await fetch(api + \"/state\" + q, { cache: \"no-store\" });\n if (r.status === 404) { $(\"#name\").textContent = \"This station is not available\"; chip(\"unavailable\", \"Unavailable\"); $(\"#play\").disabled = true; stop(); return; }\n if (r.ok) draw(await r.json());\n } catch {}\n };\n // Always the live edge: a fresh request, never a cached or paused one.\n const join = () => { audio.src = api + \"/stream\" + q + \"&n=\" + Date.now(); audio.play().catch(() => { wanted = false; button(); }); };\n const stop = () => { wanted = false; clearTimeout(timer); audio.pause(); audio.removeAttribute(\"src\"); audio.load(); button(); };\n const button = () => { const b = $(\"#play\"); b.textContent = wanted ? \"Stop\" : \"Play\"; b.setAttribute(\"aria-pressed\", String(wanted)); };\n const again = () => {\n if (!wanted) return;\n retry += 1;\n if (last) draw(last);\n clearTimeout(timer);\n timer = setTimeout(join, Math.min(15000, 1000 * 2 ** Math.min(retry, 4)));\n };\n audio.addEventListener(\"playing\", () => { if (retry) say(\"Back on\"); retry = 0; if (last) draw(last); });\n audio.addEventListener(\"error\", again);\n audio.addEventListener(\"ended\", again);\n audio.addEventListener(\"stalled\", () => { if (wanted && audio.readyState < 3) again(); });\n $(\"#play\").addEventListener(\"click\", () => { if (wanted) { stop(); say(\"Stopped\"); } else { wanted = true; retry = 0; button(); join(); say(\"Playing\"); } });\n $(\"#volume\").addEventListener(\"input\", (e) => { audio.volume = Number(e.target.value); });\n poll();\n listen();\n setInterval(poll, 5000);\n})();\n</script>\n</body>\n</html>\n";
|
|
4
4
|
/** Only its own script and style, only its own server, never framed. */
|
|
5
5
|
export declare const STATION_PAGE_CSP: string;
|
package/dist/station-page.js
CHANGED
|
@@ -42,7 +42,7 @@ const SCRIPT = `
|
|
|
42
42
|
const $ = (s) => document.querySelector(s);
|
|
43
43
|
const audio = new Audio();
|
|
44
44
|
audio.preload = "none";
|
|
45
|
-
let wanted = false, retry = 0, timer = 0, last = null;
|
|
45
|
+
let wanted = false, retry = 0, timer = 0, last = null, live = false;
|
|
46
46
|
const chip = (state, text) => { const c = $("#state"); c.className = "chip " + state; c.textContent = text; };
|
|
47
47
|
const say = (text) => { $("#announce").textContent = text; };
|
|
48
48
|
const draw = (s) => {
|
|
@@ -51,9 +51,7 @@ const SCRIPT = `
|
|
|
51
51
|
$("#name").textContent = s.name;
|
|
52
52
|
$("#about").textContent = s.description || "";
|
|
53
53
|
$("#about").hidden = !s.description;
|
|
54
|
-
|
|
55
|
-
$("#title").textContent = playing ? (playing.title || "Untitled") : (s.state === "live" ? "On the air" : "");
|
|
56
|
-
$("#by").textContent = playing ? [playing.artist, playing.album].filter(Boolean).join(" · ") : "";
|
|
54
|
+
if (!live) title(s);
|
|
57
55
|
if (wanted && retry > 0) chip("reconnecting", "Reconnecting");
|
|
58
56
|
else if (s.state === "live") chip("live", "Live");
|
|
59
57
|
else if (s.state === "starting" || s.state === "recovering") chip("reconnecting", "Coming back");
|
|
@@ -62,6 +60,28 @@ const SCRIPT = `
|
|
|
62
60
|
$("#play").disabled = !(s.state === "live" || s.state === "recovering" || s.state === "starting");
|
|
63
61
|
$("#m3u").href = api + "/listen.m3u" + q;
|
|
64
62
|
};
|
|
63
|
+
const title = (s) => {
|
|
64
|
+
const playing = s.nowPlaying;
|
|
65
|
+
$("#title").textContent = playing ? (playing.title || "Untitled") : (s.state === "live" ? "On the air" : "");
|
|
66
|
+
$("#by").textContent = playing ? [playing.artist, playing.album].filter(Boolean).join(" · ") : "";
|
|
67
|
+
};
|
|
68
|
+
// What is ahead of the speaker: the sound the player holds and has not
|
|
69
|
+
// played yet. A new title waits that long, so it changes with the song.
|
|
70
|
+
const ahead = () => {
|
|
71
|
+
if (!wanted || audio.paused || audio.buffered.length === 0) return 0;
|
|
72
|
+
return Math.max(0, Math.min(30, audio.buffered.end(audio.buffered.length - 1) - audio.currentTime));
|
|
73
|
+
};
|
|
74
|
+
const listen = () => {
|
|
75
|
+
if (!window.EventSource) return;
|
|
76
|
+
const events = new EventSource(api + "/events" + q);
|
|
77
|
+
events.addEventListener("hello", (e) => { live = true; title(JSON.parse(e.data)); });
|
|
78
|
+
events.addEventListener("now", (e) => {
|
|
79
|
+
const s = JSON.parse(e.data);
|
|
80
|
+
setTimeout(() => { title(s); say(s.nowPlaying ? "Now playing " + (s.nowPlaying.title || "") : ""); }, ahead() * 1000);
|
|
81
|
+
});
|
|
82
|
+
events.addEventListener("state", () => poll());
|
|
83
|
+
events.onerror = () => { live = false; };
|
|
84
|
+
};
|
|
65
85
|
const poll = async () => {
|
|
66
86
|
try {
|
|
67
87
|
const r = await fetch(api + "/state" + q, { cache: "no-store" });
|
|
@@ -87,6 +107,7 @@ const SCRIPT = `
|
|
|
87
107
|
$("#play").addEventListener("click", () => { if (wanted) { stop(); say("Stopped"); } else { wanted = true; retry = 0; button(); join(); say("Playing"); } });
|
|
88
108
|
$("#volume").addEventListener("input", (e) => { audio.volume = Number(e.target.value); });
|
|
89
109
|
poll();
|
|
110
|
+
listen();
|
|
90
111
|
setInterval(poll, 5000);
|
|
91
112
|
})();
|
|
92
113
|
`;
|
package/dist/station-routes.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { StationError, channelOf, tokensMatch, } from "./stations.js";
|
|
2
2
|
/** The three things a station's link opens. */
|
|
3
|
-
const LISTEN = /^\/api\/v1\/stations\/([a-z0-9]{10})\/(stream|state|listen\.m3u)$/;
|
|
3
|
+
const LISTEN = /^\/api\/v1\/stations\/([a-z0-9]{10})\/(stream|state|listen\.m3u|events)$/;
|
|
4
4
|
/** Whether this path is one a station's own link may open, without the server's key. */
|
|
5
5
|
export function isStationListenPath(path) {
|
|
6
6
|
return LISTEN.test(path);
|
|
@@ -80,6 +80,59 @@ export async function handleStations(request, response, path, url, ctx) {
|
|
|
80
80
|
return true;
|
|
81
81
|
}
|
|
82
82
|
const what = listen[2];
|
|
83
|
+
if (what === "events") {
|
|
84
|
+
// Now playing as it changes, for a player to show when its own sound
|
|
85
|
+
// gets there: each "now" is sent when the station's encoder moves on to
|
|
86
|
+
// the next file, and a listener is behind that by its own buffer.
|
|
87
|
+
response.writeHead(200, {
|
|
88
|
+
...ctx.headers,
|
|
89
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
90
|
+
"cache-control": "no-store",
|
|
91
|
+
connection: "keep-alive",
|
|
92
|
+
"x-accel-buffering": "no",
|
|
93
|
+
});
|
|
94
|
+
let instance = 0;
|
|
95
|
+
let lastFile = nowPlayingFile(ctx, station);
|
|
96
|
+
let lastState = ctx.runner.observed(station.id);
|
|
97
|
+
const send = (event, data) => {
|
|
98
|
+
response.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
99
|
+
};
|
|
100
|
+
const now = () => {
|
|
101
|
+
const view = publicView(ctx, station);
|
|
102
|
+
return { station: station.id, playback: `${station.id}:${ctx.channels.info(channelOf(station.id))?.startedAt ?? 0}`, track: `${station.id}:${instance}`, at: Date.now(), state: view.state, nowPlaying: view.nowPlaying };
|
|
103
|
+
};
|
|
104
|
+
send("hello", now());
|
|
105
|
+
const tick = setInterval(() => {
|
|
106
|
+
const current = ctx.store.get(station.id);
|
|
107
|
+
if (!current) {
|
|
108
|
+
response.end();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const file = nowPlayingFile(ctx, current);
|
|
112
|
+
const state = ctx.runner.observed(current.id);
|
|
113
|
+
if (file !== lastFile) {
|
|
114
|
+
lastFile = file;
|
|
115
|
+
instance += 1;
|
|
116
|
+
send("now", now());
|
|
117
|
+
}
|
|
118
|
+
else if (state !== lastState) {
|
|
119
|
+
send("state", now());
|
|
120
|
+
}
|
|
121
|
+
lastState = state;
|
|
122
|
+
}, 500);
|
|
123
|
+
tick.unref?.();
|
|
124
|
+
const beat = setInterval(() => response.write(": beat\n\n"), 20_000);
|
|
125
|
+
beat.unref?.();
|
|
126
|
+
const untrack = ctx.runner.track(station.id, { end: () => response.end() });
|
|
127
|
+
const leave = () => {
|
|
128
|
+
clearInterval(tick);
|
|
129
|
+
clearInterval(beat);
|
|
130
|
+
untrack();
|
|
131
|
+
};
|
|
132
|
+
request.on("close", leave);
|
|
133
|
+
response.on("close", leave);
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
83
136
|
if (what === "state") {
|
|
84
137
|
ctx.json(response, 200, publicView(ctx, station));
|
|
85
138
|
return true;
|
package/package.json
CHANGED
package/src/channels.ts
CHANGED
|
@@ -295,9 +295,28 @@ export class Channel {
|
|
|
295
295
|
/** The list being played entry by entry, when it is one; what skip() moves on. */
|
|
296
296
|
private playlistInput: PlaylistInputStream | null = null;
|
|
297
297
|
|
|
298
|
-
/**
|
|
298
|
+
/**
|
|
299
|
+
* On to the entry after the one listeners hear now. False when this is
|
|
300
|
+
* not a list of files.
|
|
301
|
+
*
|
|
302
|
+
* The list's decoder runs ahead of the speaker by whatever the pipes hold,
|
|
303
|
+
* so ending the entry it is decoding would skip the wrong song -- usually
|
|
304
|
+
* the next one, while this one plays on. Instead the channel is dialled
|
|
305
|
+
* again from the next entry, which also drops what was read ahead. Not a
|
|
306
|
+
* failure, so it does not count as a redial.
|
|
307
|
+
*/
|
|
299
308
|
skip(): boolean {
|
|
300
|
-
|
|
309
|
+
const list = this.info.playlist;
|
|
310
|
+
if (!this.playlistInput || !list?.length) return false;
|
|
311
|
+
const redials = this.info.redials;
|
|
312
|
+
this.info.playlistAt = ((this.info.playlistAt ?? 0) + 1) % list.length;
|
|
313
|
+
this.info.position = 0;
|
|
314
|
+
// MP3 frames carry on into the same connections, as a list advancing
|
|
315
|
+
// does; a picture needs a fresh start, and the player rejoins on its own.
|
|
316
|
+
const done = this.restart(this.info.kind === "audio");
|
|
317
|
+
if (redials === undefined) delete this.info.redials;
|
|
318
|
+
else this.info.redials = redials;
|
|
319
|
+
return done;
|
|
301
320
|
}
|
|
302
321
|
|
|
303
322
|
/** An entry's tags, as read when it was put on. */
|
|
@@ -635,7 +654,7 @@ export class Channel {
|
|
|
635
654
|
* decided the thing is worth another go, and should not inherit the four
|
|
636
655
|
* strikes a dead CDN ran up an hour ago.
|
|
637
656
|
*/
|
|
638
|
-
restart(): boolean {
|
|
657
|
+
restart(preserveListeners = false): boolean {
|
|
639
658
|
if (this.closing) return false;
|
|
640
659
|
// Started over from the outro: the show itself, from its beginning.
|
|
641
660
|
if (this.outroOn && this.dialed) {
|
|
@@ -666,7 +685,7 @@ export class Channel {
|
|
|
666
685
|
const old = this.child;
|
|
667
686
|
this.child = null;
|
|
668
687
|
old?.kill("SIGKILL");
|
|
669
|
-
this.startOver();
|
|
688
|
+
this.startOver(preserveListeners);
|
|
670
689
|
dial();
|
|
671
690
|
return true;
|
|
672
691
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /api/v1/integrations/bittorrented/handoffs: where BitTorrented hands music
|
|
3
|
+
* over. Only this server's owner -- the control key, the owner's session, or
|
|
4
|
+
* an app the owner gave the `stations` scope -- may hand anything over, list
|
|
5
|
+
* handoffs, retry one or delete its copies.
|
|
6
|
+
*/
|
|
7
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
8
|
+
import { publicHandoff, type Importer } from "./handoff.ts";
|
|
9
|
+
import { LibraryError } from "./library-index.ts";
|
|
10
|
+
|
|
11
|
+
export interface HandoffContext {
|
|
12
|
+
importer: Importer;
|
|
13
|
+
canWrite: boolean;
|
|
14
|
+
/** Whose handoffs: the owner's account, or "owner" for the control key. */
|
|
15
|
+
owner: string;
|
|
16
|
+
json: (response: ServerResponse, status: number, body: unknown) => void;
|
|
17
|
+
readBody: (request: IncomingMessage) => Promise<string>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const BASE = "/api/v1/integrations/bittorrented/handoffs";
|
|
21
|
+
|
|
22
|
+
export async function handleHandoffs(request: IncomingMessage, response: ServerResponse, path: string, ctx: HandoffContext): Promise<boolean> {
|
|
23
|
+
if (path !== BASE && !path.startsWith(`${BASE}/`)) return false;
|
|
24
|
+
const method = request.method ?? "GET";
|
|
25
|
+
const fail = (status: number, code: string, error: string): true => {
|
|
26
|
+
ctx.json(response, status, { error, code });
|
|
27
|
+
return true;
|
|
28
|
+
};
|
|
29
|
+
if (!ctx.canWrite) return fail(403, "forbidden", "only this server's owner (or an app they gave the stations scope) hands music over");
|
|
30
|
+
const body = async (): Promise<Record<string, unknown>> => {
|
|
31
|
+
const raw = await ctx.readBody(request);
|
|
32
|
+
if (raw.trim() === "") return {};
|
|
33
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
34
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new LibraryError("bad_json", "the body is not a JSON object");
|
|
35
|
+
return parsed as Record<string, unknown>;
|
|
36
|
+
};
|
|
37
|
+
try {
|
|
38
|
+
if (path === BASE) {
|
|
39
|
+
if (method === "GET") {
|
|
40
|
+
ctx.json(response, 200, { handoffs: ctx.importer.list(ctx.owner).map(publicHandoff) });
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
if (method === "POST") {
|
|
44
|
+
const { handoff, created } = ctx.importer.create(await body(), ctx.owner);
|
|
45
|
+
ctx.json(response, created ? 201 : 200, { handoff: publicHandoff(handoff), created });
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
return fail(405, "method_not_allowed", "GET or POST");
|
|
49
|
+
}
|
|
50
|
+
const match = /^\/api\/v1\/integrations\/bittorrented\/handoffs\/([0-9a-f]{16})(?:\/(retry))?$/.exec(path);
|
|
51
|
+
const handoff = match ? ctx.importer.get(match[1]!) : undefined;
|
|
52
|
+
if (!match || !handoff || handoff.owner !== ctx.owner) return fail(404, "not_found", "no such handoff");
|
|
53
|
+
if (match[2] === "retry") {
|
|
54
|
+
if (method !== "POST") return fail(405, "method_not_allowed", "POST");
|
|
55
|
+
ctx.json(response, 200, { handoff: publicHandoff(ctx.importer.retry(handoff.id, await body())) });
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
if (method === "GET") {
|
|
59
|
+
ctx.json(response, 200, { handoff: publicHandoff(handoff) });
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
if (method === "DELETE") {
|
|
63
|
+
const removed = ctx.importer.remove(handoff.id);
|
|
64
|
+
ctx.json(response, 200, { deleted: handoff.id, copiesRemoved: removed });
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
return fail(405, "method_not_allowed", "GET or DELETE");
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (error instanceof LibraryError) return fail(error.status, error.code, error.message);
|
|
70
|
+
if (error instanceof SyntaxError) return fail(400, "bad_json", "the body is not JSON");
|
|
71
|
+
return fail(500, "internal", error instanceof Error ? error.message : String(error));
|
|
72
|
+
}
|
|
73
|
+
}
|