nixamp 0.29.1 → 0.30.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 (53) hide show
  1. package/dist/channels.js +4 -1
  2. package/dist/directory.d.ts +3 -1
  3. package/dist/directory.js +8 -2
  4. package/dist/main.d.ts +0 -8
  5. package/dist/main.js +9 -0
  6. package/dist/server.d.ts +27 -1
  7. package/dist/server.js +162 -6
  8. package/dist/station-cli.d.ts +3 -0
  9. package/dist/station-cli.js +201 -0
  10. package/dist/station-page.d.ts +5 -0
  11. package/dist/station-page.js +137 -0
  12. package/dist/station-routes.d.ts +60 -0
  13. package/dist/station-routes.js +249 -0
  14. package/dist/station-runner.d.ts +67 -0
  15. package/dist/station-runner.js +219 -0
  16. package/dist/station-usage.d.ts +2 -0
  17. package/dist/station-usage.js +20 -0
  18. package/dist/stations.d.ts +148 -0
  19. package/dist/stations.js +331 -0
  20. package/package.json +1 -1
  21. package/src/channels.ts +4 -1
  22. package/src/directory.ts +10 -2
  23. package/src/main.ts +8 -0
  24. package/src/server.ts +169 -7
  25. package/src/station-cli.ts +195 -0
  26. package/src/station-page.ts +144 -0
  27. package/src/station-routes.ts +301 -0
  28. package/src/station-runner.ts +244 -0
  29. package/src/station-usage.ts +20 -0
  30. package/src/stations.ts +407 -0
  31. package/web/dist/assets/{ar-D6_ZFDan.js → ar-CTUDxy1J.js} +1 -1
  32. package/web/dist/assets/{cs-DEPGOGEV.js → cs-m4JTaO3o.js} +1 -1
  33. package/web/dist/assets/{da-CQw7SPC9.js → da-DHnJElYB.js} +1 -1
  34. package/web/dist/assets/{de-OWnobbah.js → de-DkxPohnf.js} +1 -1
  35. package/web/dist/assets/{es-qMpyH9bM.js → es-oY_G83d7.js} +1 -1
  36. package/web/dist/assets/{fi-DpqqwJTD.js → fi-fCO8XRdg.js} +1 -1
  37. package/web/dist/assets/{fr-BiO_ceen.js → fr-CSupf-IN.js} +1 -1
  38. package/web/dist/assets/{hi-D-q5wZuY.js → hi-Dve7PJsC.js} +1 -1
  39. package/web/dist/assets/{hls-3VKVEQE3-D2W4K8VR.js → hls-3VKVEQE3-D5-AkKSR.js} +1 -1
  40. package/web/dist/assets/{hu-FIjHyhVC.js → hu-D1KYamzV.js} +1 -1
  41. package/web/dist/assets/{id-gmB6lu56.js → id-Cofp_4aQ.js} +1 -1
  42. package/web/dist/assets/{index-UmGvzuun.js → index-7mCQZGNF.js} +2 -2
  43. package/web/dist/assets/{it-Dxfo713A.js → it-62iXQWQD.js} +1 -1
  44. package/web/dist/assets/{mpegts-BT4XGr_E.js → mpegts-BT5ESacM.js} +1 -1
  45. package/web/dist/assets/{mpegts-LO6RVLD6-CpZnlq0W.js → mpegts-LO6RVLD6-BgHd8PFT.js} +1 -1
  46. package/web/dist/assets/{nl-mk5eN3Io.js → nl-CzRRG-CN.js} +1 -1
  47. package/web/dist/assets/{ru-B2psU0kN.js → ru-OykC1LJQ.js} +1 -1
  48. package/web/dist/assets/{sv-Cg6waoNZ.js → sv-B8FXBDHm.js} +1 -1
  49. package/web/dist/assets/{uk-BcFkukLd.js → uk-DhAJBjwH.js} +1 -1
  50. package/web/dist/assets/{vi-CnqIC1Sz.js → vi-CdBSRReC.js} +1 -1
  51. package/web/dist/assets/{zh-CvnXIFvN.js → zh-wGWNyBEZ.js} +1 -1
  52. package/web/dist/index.html +1 -1
  53. package/web/dist/sw.js +22 -22
package/src/server.ts CHANGED
@@ -36,7 +36,7 @@ import {
36
36
  import { Ingest, normaliseFormat } from "./ingest.ts";
37
37
  import {
38
38
  BACKLOG_SECONDS, Channels, cleanId, generatedId, rememberChannels, rememberedChannels, rememberedNow,
39
- type Channel, type ChannelInfo, type RememberedChannel,
39
+ type Channel, type ChannelInfo, type PullResume, type RememberedChannel,
40
40
  } from "./channels.ts";
41
41
  import { RtmpListeners } from "./rtmp-in.ts";
42
42
  import { Accounts, clearedCookie, sessionCookie, tokenFrom } from "./accounts.ts";
@@ -71,6 +71,10 @@ import { ArtCache, coverArtOf, stillFrom } from "./art.ts";
71
71
  import { PartyLine, telnyxSms } from "./partyline.ts";
72
72
  import { HlsPackagers, segmentType, withKey } from "./hls.ts";
73
73
  import { refusedDestination } from "./destinations.ts";
74
+ import { handleStations, isStationListenPath } from "./station-routes.ts";
75
+ import { STATION_PAGE, STATION_PAGE_CSP, STATION_PAGE_HTML } from "./station-page.ts";
76
+ import { StationRunner } from "./station-runner.ts";
77
+ import { StationError, StationStore, channelOf, stationOfChannel, type Station } from "./stations.ts";
74
78
  import { CompressionService } from "./compression/service.ts";
75
79
  import { handleChannelCompression, handleCompressionApi, handleStaticRelay, type RouteContext } from "./compression/routes.ts";
76
80
  import { DEFAULT_SITE as NICHEDB, Enricher, type EnrichKind, FIXTURE_TTL_MS } from "./enrich.ts";
@@ -1112,6 +1116,64 @@ export interface KnownSource {
1112
1116
  /** A picture and a line about it, from the site or the list that named it. */
1113
1117
  art?: string;
1114
1118
  about?: string;
1119
+ /** A station's rotation (see PullResume.rotation). */
1120
+ rotation?: PullResume["rotation"];
1121
+ /** kbps for an audio rendition; 192 when unset. */
1122
+ audioBitrate?: number;
1123
+ }
1124
+
1125
+ /**
1126
+ * A station's files, chosen by track index (as /api/folders/live takes them)
1127
+ * or by a folder under the library; null when neither was given. Only files
1128
+ * on this machine and inside the library: a station never reaches past it.
1129
+ */
1130
+ export function selectStationFiles(engine: Engine, body: { items?: unknown; folder?: unknown }): string[] | null {
1131
+ const root = engine.snapshot(false).root;
1132
+ if (Array.isArray(body.items)) {
1133
+ const indices = [...new Set(body.items.filter((one): one is number => Number.isInteger(one) && one >= 0))];
1134
+ const files = indices.map((index) => engine.trackPath(index)).filter((one): one is string => typeof one === "string" && !isRemote(one));
1135
+ if (indices.length !== body.items.length || files.length !== indices.length) {
1136
+ throw new StationError("unknown_items", "one or more of those tracks are not files in this library");
1137
+ }
1138
+ return files;
1139
+ }
1140
+ if (typeof body.folder === "string") {
1141
+ const folder = body.folder.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
1142
+ if (folder.split("/").includes("..")) throw new StationError("invalid_folder", "a folder inside the library, please");
1143
+ const files: string[] = [];
1144
+ const count = engine.snapshot(false).trackCount;
1145
+ for (let index = 0; index < count; index++) {
1146
+ const file = engine.trackPath(index);
1147
+ if (!file || isRemote(file)) continue;
1148
+ const served = relative(root, file).replaceAll("\\", "/");
1149
+ if (served.startsWith("../")) continue;
1150
+ if (folder === "" || served.startsWith(`${folder}/`)) files.push(file);
1151
+ }
1152
+ if (files.length === 0) throw new StationError("empty_folder", "that folder has no files that play");
1153
+ return files;
1154
+ }
1155
+ return null;
1156
+ }
1157
+
1158
+ const tracksByPath = new WeakMap<Engine, { revision: number; byPath: Map<string, { title: string; artist: string; album: string }> }>();
1159
+
1160
+ /** What the library knows about one file: its tags, for a now-playing line. */
1161
+ export function trackFor(engine: Engine, file: string): { title: string; artist: string; album: string } | null {
1162
+ const revision = engine.snapshot(false).revision;
1163
+ let index = tracksByPath.get(engine);
1164
+ if (!index || index.revision !== revision) {
1165
+ const tracks = engine.snapshot().tracks ?? [];
1166
+ const byPath = new Map<string, { title: string; artist: string; album: string }>();
1167
+ tracks.forEach((track, at) => {
1168
+ const path = engine.trackPath(at);
1169
+ // Tags only: a title that is the file's own name is not a tag.
1170
+ const named = track.title && !looksLikeFileName(track.title) ? track.title : "";
1171
+ if (path) byPath.set(path, { title: named, artist: track.artist ?? "", album: track.album ?? "" });
1172
+ });
1173
+ index = { revision, byPath };
1174
+ tracksByPath.set(engine, index);
1175
+ }
1176
+ return index.byPath.get(file) ?? null;
1115
1177
  }
1116
1178
 
1117
1179
  /** How many times a source that answers nothing is asked, and how far apart. */
@@ -1190,7 +1252,7 @@ export async function pullChannel(
1190
1252
  ]
1191
1253
  // No picture in it, so none is invented: MP3 is the thing every browser
1192
1254
  // plays and the thing a listener can join halfway through.
1193
- : ["-vn", "-c:a", "libmp3lame", "-b:a", "192k", "-f", "mp3"];
1255
+ : ["-vn", "-c:a", "libmp3lame", "-b:a", `${known.audioBitrate ?? 192}k`, "-f", "mp3"];
1194
1256
  // A transport stream is read further into before it is decoded, and given
1195
1257
  // the timestamps a recording cut mid-stream does not carry. Ahead of the
1196
1258
  // caller's own input arguments, which are headers for the address itself.
@@ -1201,7 +1263,7 @@ export async function pullChannel(
1201
1263
  const tagged = looksLikeFileName(name) && codecs.tags?.title ? codecs.tags.title : name;
1202
1264
  const channel = channels.pull(
1203
1265
  id, tagged, source, encode, kind, true, undefined, opening, kind === "video" ? audio : "",
1204
- { live, position: known.position ?? 0, ...(playlist ? { playlist, playlistAt: known.playlistAt ?? 0 } : {}) },
1266
+ { live, position: known.position ?? 0, ...(playlist ? { playlist, playlistAt: known.playlistAt ?? 0 } : {}), ...(known.rotation ? { rotation: known.rotation } : {}) },
1205
1267
  // Known before the first dial: whether the source is a transport stream
1206
1268
  // decides whether it can be read here for a source-boundary relay.
1207
1269
  assumed ? undefined : codecs,
@@ -1644,6 +1706,8 @@ async function readBytes(request: IncomingMessage, limit: number): Promise<Uint8
1644
1706
  }
1645
1707
 
1646
1708
  export interface HandlerOptions {
1709
+ /** This server's stations and what keeps them on the air. */
1710
+ stations?: { store: StationStore; runner: StationRunner };
1647
1711
  web: string | null;
1648
1712
  /** Branded clients on this same backend, keyed by an explicitly configured host. */
1649
1713
  webSites?: ReadonlyMap<string, import("./web-sites.ts").WebSite>;
@@ -2575,6 +2639,9 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2575
2639
  path !== "/api/health" &&
2576
2640
  path !== "/api/directory" &&
2577
2641
  !isSignInPath(path) &&
2642
+ // A station's link is its own key, checked by the station.
2643
+ !isStationListenPath(path) &&
2644
+ !STATION_PAGE.test(path) &&
2578
2645
  !eventChannelRequest
2579
2646
  ) {
2580
2647
  let scope = scopeOf(keyFrom(request, url), key, listenKey);
@@ -2612,6 +2679,56 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2612
2679
  }
2613
2680
  }
2614
2681
 
2682
+ // Whether this request may administer: the control key, an open server,
2683
+ // or the owner's nixamp.com session. Asked by the routes that do not go
2684
+ // through the admin gate further down.
2685
+ const controlOf = async (): Promise<{ allowed: boolean; account: string }> => {
2686
+ if (key === null) return { allowed: true, account: "" };
2687
+ if (scopeOf(keyFrom(request, url), key, listenKey) === "control") return { allowed: true, account: "" };
2688
+ if (options.owner) {
2689
+ const session = tokenFrom(request.headers) || url.searchParams.get("session") || "";
2690
+ if (session !== "" && (await options.owner.check(false, session)).allowed) {
2691
+ return { allowed: true, account: (await options.owner.accountFor(session)) || "" };
2692
+ }
2693
+ }
2694
+ return { allowed: false, account: "" };
2695
+ };
2696
+
2697
+ // The page a station's link opens. The same bytes for every station: it
2698
+ // reads its id and token from its own address.
2699
+ if (options.stations && STATION_PAGE.test(path) && (request.method === "GET" || request.method === "HEAD")) {
2700
+ response.writeHead(200, {
2701
+ "content-type": "text/html; charset=utf-8",
2702
+ "cache-control": "no-cache",
2703
+ "content-security-policy": STATION_PAGE_CSP,
2704
+ "referrer-policy": "no-referrer",
2705
+ "x-content-type-options": "nosniff",
2706
+ });
2707
+ response.end(request.method === "HEAD" ? undefined : STATION_PAGE_HTML);
2708
+ return;
2709
+ }
2710
+
2711
+ if (options.stations && (path === "/api/v1/stations" || path.startsWith("/api/v1/stations/"))) {
2712
+ const control = await controlOf();
2713
+ const encrypted = (request.socket as { encrypted?: boolean }).encrypted === true;
2714
+ const forwarded = behindProxy ? String(request.headers["x-forwarded-proto"] ?? "").split(",")[0]?.trim() : "";
2715
+ const scheme = forwarded === "https" || encrypted ? "https" : "http";
2716
+ await handleStations(request, response, path, url, {
2717
+ store: options.stations.store,
2718
+ runner: options.stations.runner,
2719
+ channels: options.channels!,
2720
+ canControl: control.allowed,
2721
+ account: control.account,
2722
+ select: (body) => selectStationFiles(engine, body),
2723
+ trackFor: (file) => trackFor(engine, file),
2724
+ base: `${scheme}://${request.headers.host ?? "localhost"}`,
2725
+ json,
2726
+ readBody,
2727
+ headers: CORS,
2728
+ });
2729
+ return;
2730
+ }
2731
+
2615
2732
  if (path === "/api/health") {
2616
2733
  json(response, 200, { name: "nixamp", version: options.version, media: options.media });
2617
2734
  return;
@@ -4268,7 +4385,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
4268
4385
  // offering something that always failed.
4269
4386
  carries: options.carries !== false && Boolean(options.channels),
4270
4387
  },
4271
- channels: (options.channels?.list() ?? []).map((one) => ({
4388
+ channels: (options.channels?.list() ?? []).filter((one) => !stationOfChannel(one.id)).map((one) => ({
4272
4389
  id: one.id,
4273
4390
  name: one.name,
4274
4391
  via: one.via,
@@ -4899,7 +5016,8 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
4899
5016
  // on, and the address a channel is pulled from is the one thing about
4900
5017
  // it that is not theirs to have.
4901
5018
  json(response, 200, {
4902
- channels: options.channels.list().map(({ source: _source, ...shown }) => shown),
5019
+ // Stations answer for themselves, under /api/v1/stations.
5020
+ channels: options.channels.list().filter((one) => !stationOfChannel(one.id)).map(({ source: _source, ...shown }) => shown),
4903
5021
  listeners: options.channels.listeners,
4904
5022
  });
4905
5023
  return;
@@ -4912,6 +5030,12 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
4912
5030
  const rest = path.slice("/api/channels/".length);
4913
5031
  const [rawId, action, file] = rest.split("/");
4914
5032
  const id = cleanId(rawId);
5033
+ // A station's channel is heard through the station's own link, which
5034
+ // knows whether it is private. Through here, only by its owner.
5035
+ if (stationOfChannel(id) && !(await controlOf()).allowed) {
5036
+ json(response, 404, { error: "nothing is playing on that channel" });
5037
+ return;
5038
+ }
4915
5039
  const event = await eventForChannel();
4916
5040
  if (event) {
4917
5041
  const account = await options.accounts?.whoIs(tokenFrom(request.headers)) ?? null;
@@ -6410,6 +6534,9 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
6410
6534
  // same ffmpeg that carries the channels. No ffmpeg, no outro, and a show
6411
6535
  // that ends closes its channel as before.
6412
6536
  const outro = new Outro({ ffmpeg: tools.ffmpeg, dir: join(stateDir(), "outro"), onEvent: (message) => console.log(message) });
6537
+ // Made once the channels exist; told of every channel that ends, so a
6538
+ // station that died is brought back and one that was stopped is not.
6539
+ let stationRunner: StationRunner | null = null;
6413
6540
  const channels = new Channels({
6414
6541
  ffmpeg: tools.ffmpeg,
6415
6542
  ffprobe: tools.ffprobe,
@@ -6420,8 +6547,25 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
6420
6547
  onEnd: (info) => {
6421
6548
  console.log(` "${info.id}" stopped.`);
6422
6549
  art.forget(info.id);
6550
+ stationRunner?.onChannelEnd(info);
6423
6551
  },
6424
6552
  });
6553
+ const stationStore = new StationStore(StationStore.fileFor(stateDir(), options.port));
6554
+ stationRunner = new StationRunner({
6555
+ store: stationStore,
6556
+ channels,
6557
+ pull: async (channelId, name, files, known) =>
6558
+ Boolean(await pullChannel(channels, tools.ffprobe, channelId, name, files[0]!, [], "", {
6559
+ playlist: files,
6560
+ live: true,
6561
+ rotation: known.rotation,
6562
+ audioBitrate: known.audioBitrate,
6563
+ })),
6564
+ // The library as it is now, or as it was chosen while it is still being read.
6565
+ roots: () => [engine.snapshot(false).root || chosen].filter((one) => one !== ""),
6566
+ log: (line) => console.log(line),
6567
+ });
6568
+ const stations = { store: stationStore, runner: stationRunner };
6425
6569
  // Channels as HLS, on demand, for Safari on a phone: one ffmpeg copying a
6426
6570
  // channel's fragments into short files while somebody is asking for them.
6427
6571
  // What things are, from nichedb.dev, remembered beside the keys so a
@@ -6470,6 +6614,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
6470
6614
  if (!channel) console.log(` "${one.id}" is already on.`);
6471
6615
  });
6472
6616
  }
6617
+ stationRunner.restore();
6473
6618
  // Where each film has got to, written down every so often, so a restart
6474
6619
  // picks it up from about there rather than from the start. Only when it
6475
6620
  // has changed: a server carrying nothing but live television writes
@@ -7012,6 +7157,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
7012
7157
  media: options.media,
7013
7158
  owner,
7014
7159
  channels,
7160
+ stations,
7015
7161
  rememberChannels: remembering,
7016
7162
  catalogs,
7017
7163
  publishUrls: () => publishUrls,
@@ -7441,6 +7587,12 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
7441
7587
  * A declaration rather than an assignment, so it exists from the moment the
7442
7588
  * function is entered -- the port is open well before this line is reached.
7443
7589
  */
7590
+ /** The station behind this channel, when its owner made it public and listed it. */
7591
+ function listedStation(channelId: string): Station | undefined {
7592
+ const station = stationStore.get(stationOfChannel(channelId));
7593
+ return station && station.visibility === "public" && station.listed ? station : undefined;
7594
+ }
7595
+
7444
7596
  function makePublisher(): Publisher | null {
7445
7597
  if (!publishable_) return null;
7446
7598
  const listen = shareLink(publishable_.url, listenKey, false);
@@ -7464,10 +7616,20 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
7464
7616
  // which channels are on. A directory row that says "5,717 files,
7465
7617
  // live: CNN" is one somebody can decide about; "5717 tracks" was not.
7466
7618
  playing: () => engine.snapshot(false).playing,
7467
- channels: () => channels.list().map((one) => one.name),
7619
+ // Stations only when their owner listed them; every other channel as before.
7620
+ channels: () => channels.list().filter((one) => !stationOfChannel(one.id) || listedStation(one.id)).map((one) => one.name),
7468
7621
  // The same with what a link preview needs: the id a join link names
7469
7622
  // a channel by, a picture, a line.
7470
- lineup: () => channels.list().map((one) => lineupEntry(listen, one)),
7623
+ lineup: () => channels.list()
7624
+ .filter((one) => !stationOfChannel(one.id) || listedStation(one.id))
7625
+ .map((one) => {
7626
+ const entry = lineupEntry(listen, one);
7627
+ const station = listedStation(one.id);
7628
+ // A station is listened to through its own link, never the server's.
7629
+ return station
7630
+ ? { ...entry, listen: `${publishable_!.url}/station/${station.id}?t=${encodeURIComponent(station.listenToken)}` }
7631
+ : entry;
7632
+ }),
7471
7633
  // From `nixamp login`. The directory will not list a stream it cannot
7472
7634
  // attribute to somebody, because a listing is now a phone code that
7473
7635
  // costs money to answer.
@@ -0,0 +1,195 @@
1
+ /**
2
+ * `nixamp station`: stations from a terminal, a script or a cron job.
3
+ *
4
+ * Talks to the daemon on this machine with its control key, or to another
5
+ * server with --url and --key, exactly as `nixamp admin` does. Every command
6
+ * takes --json for a script; without it, a line a person can read. Exit codes
7
+ * a script can branch on: 0 done, 1 the server said no (the reason and a
8
+ * stable code are printed), 2 the command was wrong, 3 the server could not
9
+ * be reached.
10
+ *
11
+ * Nothing here publishes as a side effect. `create` and `start` leave a
12
+ * station private; `publish` is its own command and says on what basis.
13
+ */
14
+ import { resolveTarget } from "./admin.ts";
15
+ import { readState } from "./daemon.ts";
16
+ import { KEY_HEADER } from "./share.ts";
17
+
18
+ import { USAGE } from "./station-usage.ts";
19
+
20
+ export { USAGE };
21
+
22
+ function flag(argv: string[], name: string): string | undefined {
23
+ const at = argv.indexOf(name);
24
+ return at === -1 ? undefined : argv[at + 1];
25
+ }
26
+
27
+ function has(argv: string[], name: string): boolean {
28
+ return argv.includes(name);
29
+ }
30
+
31
+ interface Answer {
32
+ status: number;
33
+ body: Record<string, unknown>;
34
+ }
35
+
36
+ class Unreachable extends Error {}
37
+
38
+ export async function station(argv: string[]): Promise<number> {
39
+ const [command, id] = argv;
40
+ const json = has(argv, "--json");
41
+ if (!command || command === "help" || has(argv, "--help")) {
42
+ console.log(USAGE);
43
+ return command ? 0 : 2;
44
+ }
45
+ let target;
46
+ try {
47
+ target = resolveTarget(argv);
48
+ } catch (error) {
49
+ console.error((error as Error).message);
50
+ return 3;
51
+ }
52
+ const headers: Record<string, string> = { "content-type": "application/json", ...(target.key ? { [KEY_HEADER]: target.key } : {}) };
53
+ const call = async (method: string, path: string, body?: unknown): Promise<Answer> => {
54
+ let response: Response;
55
+ try {
56
+ response = await fetch(`${target.url}${path}`, { method, headers, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
57
+ } catch (error) {
58
+ throw new Unreachable(`could not reach ${target.url}: ${(error as Error).message}`);
59
+ }
60
+ const text = await response.text();
61
+ let parsed: Record<string, unknown> = {};
62
+ try { parsed = JSON.parse(text) as Record<string, unknown>; } catch { parsed = { error: text }; }
63
+ return { status: response.status, body: parsed };
64
+ };
65
+ const needId = (): string | null => {
66
+ if (!id || id.startsWith("--")) {
67
+ console.error(`nixamp station ${command} needs a station id. \`nixamp station list\` shows them.`);
68
+ return null;
69
+ }
70
+ return id;
71
+ };
72
+ const show = (answer: Answer, line: (body: Record<string, unknown>) => string): number => {
73
+ if (json) {
74
+ console.log(JSON.stringify(answer.body, null, 2));
75
+ } else if (answer.status < 400) {
76
+ console.log(line(answer.body));
77
+ }
78
+ if (answer.status >= 400) {
79
+ if (!json) console.error(`nixamp: ${String(answer.body["error"] ?? "refused")} (${String(answer.body["code"] ?? answer.status)})`);
80
+ return 1;
81
+ }
82
+ return 0;
83
+ };
84
+ const describe = (station: Record<string, unknown>): string => {
85
+ const links = (station["links"] ?? {}) as Record<string, string>;
86
+ return [
87
+ `${String(station["name"])} [${String(station["id"])}]`,
88
+ ` state ${String(station["state"])} (wanted ${String(station["desired"])})`,
89
+ ` visibility ${String(station["visibility"])}${station["listed"] ? ", listed in the directory" : ""}`,
90
+ ` rotation ${station["shuffle"] ? "shuffle" : "in order"}${station["repeat"] ? ", repeating" : ", once"}; ${(station["items"] as unknown[] | undefined)?.length ?? 0} files at ${String(station["bitrate"])} kbps`,
91
+ ...(station["error"] ? [` problem ${String(station["error"])}`] : []),
92
+ ...((station["excluded"] as unknown[] | undefined)?.length ? [` left out ${(station["excluded"] as unknown[]).length} files (nixamp station retry)`] : []),
93
+ ...(station["visibility"] !== "private" && links["page"] ? [` listen ${links["page"]}`] : []),
94
+ ].join("\n");
95
+ };
96
+
97
+ try {
98
+ switch (command) {
99
+ case "list": {
100
+ const answer = await call("GET", "/api/v1/stations?limit=200");
101
+ return show(answer, (body) => {
102
+ const all = (body["stations"] as Record<string, unknown>[] | undefined) ?? [];
103
+ return all.length === 0 ? "No stations yet. nixamp station create --name NAME --folder PATH" : all.map(describe).join("\n\n");
104
+ });
105
+ }
106
+ case "create": {
107
+ const name = flag(argv, "--name");
108
+ if (!name) {
109
+ console.error("nixamp station create needs --name");
110
+ return 2;
111
+ }
112
+ const items = flag(argv, "--items");
113
+ let folder = flag(argv, "--folder");
114
+ if (folder !== undefined) {
115
+ // A path under the library is taken relative to it.
116
+ const root = readState()?.source ?? "";
117
+ if (root && folder.startsWith(root)) folder = folder.slice(root.length);
118
+ }
119
+ if (items === undefined && folder === undefined) {
120
+ console.error("nixamp station create needs --folder PATH or --items 0,1,2");
121
+ return 2;
122
+ }
123
+ const bitrate = flag(argv, "--bitrate");
124
+ const answer = await call("POST", "/api/v1/stations", {
125
+ name,
126
+ ...(items !== undefined ? { items: items.split(",").map((one) => Number(one.trim())) } : { folder }),
127
+ shuffle: has(argv, "--shuffle"),
128
+ repeat: !has(argv, "--no-repeat"),
129
+ ...(bitrate ? { bitrate: Number(bitrate) } : {}),
130
+ });
131
+ return show(answer, (body) => `Made (private, stopped):\n${describe(body["station"] as Record<string, unknown>)}`);
132
+ }
133
+ case "show":
134
+ case "status": {
135
+ const station = needId();
136
+ if (!station) return 2;
137
+ return show(await call("GET", `/api/v1/stations/${station}`), (body) => describe(body["station"] as Record<string, unknown>));
138
+ }
139
+ case "start":
140
+ case "stop":
141
+ case "skip":
142
+ case "retry": {
143
+ const station = needId();
144
+ if (!station) return 2;
145
+ return show(await call("POST", `/api/v1/stations/${station}/${command}`, {}), (body) => describe(body["station"] as Record<string, unknown>));
146
+ }
147
+ case "publish": {
148
+ const station = needId();
149
+ if (!station) return 2;
150
+ const basis = flag(argv, "--basis");
151
+ const unlisted = has(argv, "--unlisted");
152
+ const access: Record<string, unknown> = {
153
+ visibility: unlisted ? "unlisted" : "public",
154
+ listed: !unlisted && !has(argv, "--no-list"),
155
+ ...(basis ? { rights: { basis, note: flag(argv, "--note") ?? "" } } : {}),
156
+ };
157
+ return show(await call("POST", `/api/v1/stations/${station}/access`, access), (body) => describe(body["station"] as Record<string, unknown>));
158
+ }
159
+ case "unpublish": {
160
+ const station = needId();
161
+ if (!station) return 2;
162
+ const access = has(argv, "--private") ? { visibility: "private" } : { listed: false };
163
+ return show(await call("POST", `/api/v1/stations/${station}/access`, access), (body) => {
164
+ const dropped = Number(body["dropped"] ?? 0);
165
+ return `${describe(body["station"] as Record<string, unknown>)}${dropped ? `\n ${dropped} listener(s) disconnected; old links no longer work.` : ""}`;
166
+ });
167
+ }
168
+ case "link": {
169
+ const station = needId();
170
+ if (!station) return 2;
171
+ const answer = has(argv, "--rotate")
172
+ ? await call("POST", `/api/v1/stations/${station}/rotate-link`, {})
173
+ : await call("GET", `/api/v1/stations/${station}`);
174
+ return show(answer, (body) => {
175
+ const links = ((body["station"] as Record<string, unknown>)["links"] ?? {}) as Record<string, string>;
176
+ return [`page ${links["page"]}`, `stream ${links["stream"]}`, `m3u ${links["m3u"]}`].join("\n");
177
+ });
178
+ }
179
+ case "delete": {
180
+ const station = needId();
181
+ if (!station) return 2;
182
+ return show(await call("DELETE", `/api/v1/stations/${station}`), () => `Deleted ${station}. Its files were not touched.`);
183
+ }
184
+ default:
185
+ console.error(`nixamp station: no command "${command}".\n\n${USAGE}`);
186
+ return 2;
187
+ }
188
+ } catch (error) {
189
+ if (error instanceof Unreachable) {
190
+ console.error(`nixamp: ${error.message}`);
191
+ return 3;
192
+ }
193
+ throw error;
194
+ }
195
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * The page a station's link opens: its name, what is on, and Play.
3
+ *
4
+ * Served by the station's own server, without the server's key, because the
5
+ * link is the key: the page reads `t` from its own address and asks the
6
+ * station's state and stream with it. Nothing about the station is written
7
+ * into the HTML, so the page is the same bytes for every station and can be
8
+ * locked down with a content security policy naming its script and style by
9
+ * hash. A private station, or a wrong link, shows "not available" -- the
10
+ * same answer, so the page cannot be used to find out which stations exist.
11
+ */
12
+ import { createHash } from "node:crypto";
13
+
14
+ const STYLE = `
15
+ :root{--bg:#f6f5f2;--fg:#17171a;--muted:#5d5d66;--card:#fff;--line:#dedcd6;--accent:#1f6feb;--live:#0b7a3b;--warn:#9a6700;--off:#8c2f39}
16
+ @media (prefers-color-scheme:dark){:root{--bg:#121214;--fg:#ececf1;--muted:#a4a4b0;--card:#1c1c20;--line:#2c2c33;--accent:#6aa7ff;--live:#3fbf7f;--warn:#e3b341;--off:#f0838f}}
17
+ *{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}
18
+ main{max-width:560px;margin:0 auto;padding:48px 16px}
19
+ .card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:24px}
20
+ h1{font-size:1.6rem;line-height:1.2;margin:0 0 4px}
21
+ .about{color:var(--muted);margin:0 0 20px}
22
+ .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)}
23
+ .chip::before{content:"";width:8px;height:8px;border-radius:50%;background:currentColor}
24
+ .live{color:var(--live)}.reconnecting{color:var(--warn)}.unavailable,.stopped{color:var(--off)}
25
+ .now{margin:20px 0;min-height:3em}.now .title{font-weight:600}.now .by{color:var(--muted)}
26
+ .controls{display:flex;align-items:center;gap:16px;flex-wrap:wrap}
27
+ button{font:inherit;font-weight:600;border:0;border-radius:999px;padding:12px 28px;background:var(--accent);color:#fff;cursor:pointer;min-width:120px}
28
+ button:disabled{opacity:.5;cursor:not-allowed}
29
+ button:focus-visible,input:focus-visible,a:focus-visible{outline:3px solid var(--accent);outline-offset:3px}
30
+ label{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:.9rem}
31
+ input[type=range]{width:120px;accent-color:var(--accent)}
32
+ .small{margin-top:20px;font-size:.85rem;color:var(--muted)}.small a{color:inherit}
33
+ .sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
34
+ @media (prefers-reduced-motion:no-preference){.live::before{animation:pulse 2s ease-in-out infinite}}
35
+ @keyframes pulse{50%{opacity:.35}}
36
+ `;
37
+
38
+ const SCRIPT = `
39
+ (() => {
40
+ const id = location.pathname.split("/").filter(Boolean)[1] || "";
41
+ const token = new URLSearchParams(location.search).get("t") || "";
42
+ const q = "?t=" + encodeURIComponent(token);
43
+ const api = "/api/v1/stations/" + encodeURIComponent(id);
44
+ const $ = (s) => document.querySelector(s);
45
+ const audio = new Audio();
46
+ audio.preload = "none";
47
+ let wanted = false, retry = 0, timer = 0, last = null;
48
+ const chip = (state, text) => { const c = $("#state"); c.className = "chip " + state; c.textContent = text; };
49
+ const say = (text) => { $("#announce").textContent = text; };
50
+ const draw = (s) => {
51
+ last = s;
52
+ document.title = s.name + " · nixamp";
53
+ $("#name").textContent = s.name;
54
+ $("#about").textContent = s.description || "";
55
+ $("#about").hidden = !s.description;
56
+ const playing = s.nowPlaying;
57
+ $("#title").textContent = playing ? (playing.title || "Untitled") : (s.state === "live" ? "On the air" : "");
58
+ $("#by").textContent = playing ? [playing.artist, playing.album].filter(Boolean).join(" · ") : "";
59
+ if (wanted && retry > 0) chip("reconnecting", "Reconnecting");
60
+ else if (s.state === "live") chip("live", "Live");
61
+ else if (s.state === "starting" || s.state === "recovering") chip("reconnecting", "Coming back");
62
+ else if (s.state === "stopped") chip("stopped", "Off the air");
63
+ else chip("unavailable", "Unavailable");
64
+ $("#play").disabled = !(s.state === "live" || s.state === "recovering" || s.state === "starting");
65
+ $("#m3u").href = api + "/listen.m3u" + q;
66
+ };
67
+ const poll = async () => {
68
+ try {
69
+ const r = await fetch(api + "/state" + q, { cache: "no-store" });
70
+ if (r.status === 404) { $("#name").textContent = "This station is not available"; chip("unavailable", "Unavailable"); $("#play").disabled = true; stop(); return; }
71
+ if (r.ok) draw(await r.json());
72
+ } catch {}
73
+ };
74
+ // Always the live edge: a fresh request, never a cached or paused one.
75
+ const join = () => { audio.src = api + "/stream" + q + "&n=" + Date.now(); audio.play().catch(() => { wanted = false; button(); }); };
76
+ const stop = () => { wanted = false; clearTimeout(timer); audio.pause(); audio.removeAttribute("src"); audio.load(); button(); };
77
+ const button = () => { const b = $("#play"); b.textContent = wanted ? "Stop" : "Play"; b.setAttribute("aria-pressed", String(wanted)); };
78
+ const again = () => {
79
+ if (!wanted) return;
80
+ retry += 1;
81
+ if (last) draw(last);
82
+ clearTimeout(timer);
83
+ timer = setTimeout(join, Math.min(15000, 1000 * 2 ** Math.min(retry, 4)));
84
+ };
85
+ audio.addEventListener("playing", () => { if (retry) say("Back on"); retry = 0; if (last) draw(last); });
86
+ audio.addEventListener("error", again);
87
+ audio.addEventListener("ended", again);
88
+ audio.addEventListener("stalled", () => { if (wanted && audio.readyState < 3) again(); });
89
+ $("#play").addEventListener("click", () => { if (wanted) { stop(); say("Stopped"); } else { wanted = true; retry = 0; button(); join(); say("Playing"); } });
90
+ $("#volume").addEventListener("input", (e) => { audio.volume = Number(e.target.value); });
91
+ poll();
92
+ setInterval(poll, 5000);
93
+ })();
94
+ `;
95
+
96
+ const hash = (text: string): string => `'sha256-${createHash("sha256").update(text).digest("base64")}'`;
97
+
98
+ const PAGE = `<!doctype html>
99
+ <html lang="en">
100
+ <head>
101
+ <meta charset="utf-8">
102
+ <meta name="viewport" content="width=device-width, initial-scale=1">
103
+ <meta name="robots" content="noindex">
104
+ <title>Station · nixamp</title>
105
+ <style>${STYLE}</style>
106
+ </head>
107
+ <body>
108
+ <main>
109
+ <div class="card">
110
+ <span id="state" class="chip unavailable" role="status">Loading</span>
111
+ <h1 id="name">&nbsp;</h1>
112
+ <p id="about" class="about" hidden></p>
113
+ <div class="now" aria-live="polite"><div id="title" class="title"></div><div id="by" class="by"></div></div>
114
+ <div class="controls">
115
+ <button id="play" type="button" aria-pressed="false" disabled>Play</button>
116
+ <label>Volume <input id="volume" type="range" min="0" max="1" step="0.05" value="1"></label>
117
+ </div>
118
+ <p class="small"><a id="m3u" href="#">Open in another player (.m3u)</a> · Pausing here does not pause the station.</p>
119
+ </div>
120
+ <p class="small">Played by <a href="https://nixamp.com">nixamp</a>.</p>
121
+ <div id="announce" class="sr" aria-live="assertive"></div>
122
+ </main>
123
+ <script>${SCRIPT}</script>
124
+ </body>
125
+ </html>
126
+ `;
127
+
128
+ /** A station page's path: /station/<id>. */
129
+ export const STATION_PAGE = /^\/station\/([a-z0-9]{10})\/?$/;
130
+
131
+ export const STATION_PAGE_HTML = PAGE;
132
+
133
+ /** Only its own script and style, only its own server, never framed. */
134
+ export const STATION_PAGE_CSP = [
135
+ "default-src 'none'",
136
+ `script-src ${hash(SCRIPT)}`,
137
+ `style-src ${hash(STYLE)}`,
138
+ "connect-src 'self'",
139
+ "media-src 'self'",
140
+ "img-src 'self'",
141
+ "base-uri 'none'",
142
+ "form-action 'none'",
143
+ "frame-ancestors 'none'",
144
+ ].join("; ");