nixamp 0.28.45 → 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 (78) hide show
  1. package/dist/audio.js +3 -0
  2. package/dist/channels.d.ts +14 -0
  3. package/dist/channels.js +20 -4
  4. package/dist/decoder-policy.d.ts +20 -0
  5. package/dist/decoder-policy.js +34 -0
  6. package/dist/destinations.d.ts +5 -0
  7. package/dist/destinations.js +82 -0
  8. package/dist/directory.d.ts +3 -1
  9. package/dist/directory.js +8 -2
  10. package/dist/library.d.ts +2 -0
  11. package/dist/library.js +4 -1
  12. package/dist/links.d.ts +4 -0
  13. package/dist/links.js +20 -2
  14. package/dist/main.d.ts +0 -8
  15. package/dist/main.js +9 -0
  16. package/dist/playlist-input.d.ts +46 -0
  17. package/dist/playlist-input.js +158 -54
  18. package/dist/publish-choice.d.ts +21 -0
  19. package/dist/publish-choice.js +17 -0
  20. package/dist/server.d.ts +27 -1
  21. package/dist/server.js +228 -17
  22. package/dist/share.d.ts +1 -1
  23. package/dist/share.js +5 -4
  24. package/dist/station-cli.d.ts +3 -0
  25. package/dist/station-cli.js +201 -0
  26. package/dist/station-page.d.ts +5 -0
  27. package/dist/station-page.js +137 -0
  28. package/dist/station-routes.d.ts +60 -0
  29. package/dist/station-routes.js +249 -0
  30. package/dist/station-runner.d.ts +67 -0
  31. package/dist/station-runner.js +219 -0
  32. package/dist/station-usage.d.ts +2 -0
  33. package/dist/station-usage.js +20 -0
  34. package/dist/stations.d.ts +148 -0
  35. package/dist/stations.js +331 -0
  36. package/package.json +1 -1
  37. package/src/audio.ts +3 -0
  38. package/src/channels.ts +30 -5
  39. package/src/decoder-policy.ts +33 -0
  40. package/src/destinations.ts +78 -0
  41. package/src/directory.ts +10 -2
  42. package/src/library.ts +6 -1
  43. package/src/links.ts +23 -4
  44. package/src/main.ts +8 -0
  45. package/src/playlist-input.ts +172 -50
  46. package/src/publish-choice.ts +35 -0
  47. package/src/server.ts +227 -18
  48. package/src/share.ts +5 -4
  49. package/src/station-cli.ts +195 -0
  50. package/src/station-page.ts +144 -0
  51. package/src/station-routes.ts +301 -0
  52. package/src/station-runner.ts +244 -0
  53. package/src/station-usage.ts +20 -0
  54. package/src/stations.ts +407 -0
  55. package/web/dist/assets/{ar-8fDonUoB.js → ar-CTUDxy1J.js} +1 -1
  56. package/web/dist/assets/{cs-DCClolTy.js → cs-m4JTaO3o.js} +1 -1
  57. package/web/dist/assets/{da-C3RPhXUd.js → da-DHnJElYB.js} +1 -1
  58. package/web/dist/assets/{de-CYBoEomk.js → de-DkxPohnf.js} +1 -1
  59. package/web/dist/assets/{es-DS3ls4xT.js → es-oY_G83d7.js} +1 -1
  60. package/web/dist/assets/{fi-DbCZpO89.js → fi-fCO8XRdg.js} +1 -1
  61. package/web/dist/assets/{fr-D0z3UpdL.js → fr-CSupf-IN.js} +1 -1
  62. package/web/dist/assets/{hi-BTU3sRlQ.js → hi-Dve7PJsC.js} +1 -1
  63. package/web/dist/assets/{hls-3VKVEQE3-CQXTVHn_.js → hls-3VKVEQE3-D5-AkKSR.js} +1 -1
  64. package/web/dist/assets/{hu-B37Q8ldT.js → hu-D1KYamzV.js} +1 -1
  65. package/web/dist/assets/{id-4pdXNa4G.js → id-Cofp_4aQ.js} +1 -1
  66. package/web/dist/assets/index-7mCQZGNF.js +22 -0
  67. package/web/dist/assets/{it-CTe69-UY.js → it-62iXQWQD.js} +1 -1
  68. package/web/dist/assets/{mpegts-BBmkDppm.js → mpegts-BT5ESacM.js} +1 -1
  69. package/web/dist/assets/{mpegts-LO6RVLD6-CUVdjYFi.js → mpegts-LO6RVLD6-BgHd8PFT.js} +1 -1
  70. package/web/dist/assets/{nl-CxrmHrMn.js → nl-CzRRG-CN.js} +1 -1
  71. package/web/dist/assets/{ru-zjVCfIKq.js → ru-OykC1LJQ.js} +1 -1
  72. package/web/dist/assets/{sv-CEoiT2Lr.js → sv-B8FXBDHm.js} +1 -1
  73. package/web/dist/assets/{uk-ZC6JzQvD.js → uk-DhAJBjwH.js} +1 -1
  74. package/web/dist/assets/{vi-D9anF-cl.js → vi-CdBSRReC.js} +1 -1
  75. package/web/dist/assets/{zh-BeLYdC0I.js → zh-wGWNyBEZ.js} +1 -1
  76. package/web/dist/index.html +1 -1
  77. package/web/dist/sw.js +22 -22
  78. package/web/dist/assets/index-BDBbN5Qy.js +0 -22
package/src/server.ts CHANGED
@@ -9,7 +9,9 @@
9
9
  * a WebSocket because SSE is plain HTTP: no dependency, and it reconnects on
10
10
  * its own when the laptop running the remote goes to sleep.
11
11
  */
12
- import { createReadStream, statSync } from "node:fs";
12
+ import { publishChoice, shouldRecord } from "./publish-choice.ts";
13
+ import { protocolArgs } from "./decoder-policy.ts";
14
+ import { createReadStream, existsSync, statSync } from "node:fs";
13
15
  import { createServer as createHttpServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
14
16
  import { createServer as createHttpsServer } from "node:https";
15
17
  import { randomBytes } from "node:crypto";
@@ -34,7 +36,7 @@ import {
34
36
  import { Ingest, normaliseFormat } from "./ingest.ts";
35
37
  import {
36
38
  BACKLOG_SECONDS, Channels, cleanId, generatedId, rememberChannels, rememberedChannels, rememberedNow,
37
- type Channel, type ChannelInfo, type RememberedChannel,
39
+ type Channel, type ChannelInfo, type PullResume, type RememberedChannel,
38
40
  } from "./channels.ts";
39
41
  import { RtmpListeners } from "./rtmp-in.ts";
40
42
  import { Accounts, clearedCookie, sessionCookie, tokenFrom } from "./accounts.ts";
@@ -68,6 +70,11 @@ import { Directory, ENDED_TTL_MS, parseAnnouncement, type LineupEntry, type List
68
70
  import { ArtCache, coverArtOf, stillFrom } from "./art.ts";
69
71
  import { PartyLine, telnyxSms } from "./partyline.ts";
70
72
  import { HlsPackagers, segmentType, withKey } from "./hls.ts";
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";
71
78
  import { CompressionService } from "./compression/service.ts";
72
79
  import { handleChannelCompression, handleCompressionApi, handleStaticRelay, type RouteContext } from "./compression/routes.ts";
73
80
  import { DEFAULT_SITE as NICHEDB, Enricher, type EnrichKind, FIXTURE_TTL_MS } from "./enrich.ts";
@@ -86,7 +93,7 @@ import { Porkbun, isIPv4, isIPv6, type DnsZone } from "./dns.ts";
86
93
  import { NameError, Names } from "./names.ts";
87
94
  import { AcmeIssuer, Certs } from "./certs.ts";
88
95
  import { claimName, fetchCert, humanizeSource, labelFor, readCertFiles, writeCertFiles } from "./naming.ts";
89
- import { forbiddenLibrary, readLibrary } from "./library.ts";
96
+ import { forbiddenLibrary, readConfig, readLibrary, writeConfig } from "./library.ts";
90
97
  import { createThrottle, presentedCredential, type Throttle } from "@profullstack/throttle";
91
98
  import { Durable } from "./durable.ts";
92
99
  import { notifyAll, resendEmail, webPush, type Notification } from "./notify.ts";
@@ -1109,6 +1116,64 @@ export interface KnownSource {
1109
1116
  /** A picture and a line about it, from the site or the list that named it. */
1110
1117
  art?: string;
1111
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;
1112
1177
  }
1113
1178
 
1114
1179
  /** How many times a source that answers nothing is asked, and how far apart. */
@@ -1187,7 +1252,7 @@ export async function pullChannel(
1187
1252
  ]
1188
1253
  // No picture in it, so none is invented: MP3 is the thing every browser
1189
1254
  // plays and the thing a listener can join halfway through.
1190
- : ["-vn", "-c:a", "libmp3lame", "-b:a", "192k", "-f", "mp3"];
1255
+ : ["-vn", "-c:a", "libmp3lame", "-b:a", `${known.audioBitrate ?? 192}k`, "-f", "mp3"];
1191
1256
  // A transport stream is read further into before it is decoded, and given
1192
1257
  // the timestamps a recording cut mid-stream does not carry. Ahead of the
1193
1258
  // caller's own input arguments, which are headers for the address itself.
@@ -1198,7 +1263,7 @@ export async function pullChannel(
1198
1263
  const tagged = looksLikeFileName(name) && codecs.tags?.title ? codecs.tags.title : name;
1199
1264
  const channel = channels.pull(
1200
1265
  id, tagged, source, encode, kind, true, undefined, opening, kind === "video" ? audio : "",
1201
- { 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 } : {}) },
1202
1267
  // Known before the first dial: whether the source is a transport stream
1203
1268
  // decides whether it can be read here for a source-boundary relay.
1204
1269
  assumed ? undefined : codecs,
@@ -1641,6 +1706,8 @@ async function readBytes(request: IncomingMessage, limit: number): Promise<Uint8
1641
1706
  }
1642
1707
 
1643
1708
  export interface HandlerOptions {
1709
+ /** This server's stations and what keeps them on the air. */
1710
+ stations?: { store: StationStore; runner: StationRunner };
1644
1711
  web: string | null;
1645
1712
  /** Branded clients on this same backend, keyed by an explicitly configured host. */
1646
1713
  webSites?: ReadonlyMap<string, import("./web-sites.ts").WebSite>;
@@ -1976,7 +2043,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
1976
2043
  json(response, 404, { error: "not found" });
1977
2044
  return;
1978
2045
  }
1979
- response.writeHead(302, { ...CORS, "set-cookie": keyCookie(offered), location: "/" });
2046
+ response.writeHead(302, { ...CORS, "set-cookie": keyCookie(offered, (request.socket as { encrypted?: boolean }).encrypted === true || (options.secureCookies ?? false)), location: "/" });
1980
2047
  response.end();
1981
2048
  return;
1982
2049
  }
@@ -2563,14 +2630,23 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2563
2630
  // port is open from another device before wondering whether the link is
2564
2631
  // wrong, and it says nothing about the library.
2565
2632
  const eventChannelRequest = Boolean(channelId && await eventForChannel());
2633
+ // Whether this request holds only the listen key. A listener may browse
2634
+ // and hear the library, but where it lives on this machine is the
2635
+ // owner's business: the absolute path names the user account and the disk.
2636
+ let listening = false;
2566
2637
  if (
2567
2638
  key !== null &&
2568
2639
  path !== "/api/health" &&
2569
2640
  path !== "/api/directory" &&
2570
2641
  !isSignInPath(path) &&
2642
+ // A station's link is its own key, checked by the station.
2643
+ !isStationListenPath(path) &&
2644
+ !STATION_PAGE.test(path) &&
2571
2645
  !eventChannelRequest
2572
2646
  ) {
2573
2647
  let scope = scopeOf(keyFrom(request, url), key, listenKey);
2648
+ // Kept for the routes below that answer a listener with less.
2649
+ const settle = (): void => { listening = scope === "listen"; };
2574
2650
  // A key is how somebody who was invited proves it. It is not the only way
2575
2651
  // to be allowed in: the person who owns this server is allowed in whether
2576
2652
  // or not they still have the link, and their nixamp.com session says who
@@ -2596,12 +2672,63 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2596
2672
  });
2597
2673
  return;
2598
2674
  }
2675
+ settle();
2599
2676
  if (scope === "listen" && !allowedForListening(path)) {
2600
2677
  json(response, 403, { error: "this link can listen, not drive" });
2601
2678
  return;
2602
2679
  }
2603
2680
  }
2604
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
+
2605
2732
  if (path === "/api/health") {
2606
2733
  json(response, 200, { name: "nixamp", version: options.version, media: options.media });
2607
2734
  return;
@@ -4258,7 +4385,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
4258
4385
  // offering something that always failed.
4259
4386
  carries: options.carries !== false && Boolean(options.channels),
4260
4387
  },
4261
- channels: (options.channels?.list() ?? []).map((one) => ({
4388
+ channels: (options.channels?.list() ?? []).filter((one) => !stationOfChannel(one.id)).map((one) => ({
4262
4389
  id: one.id,
4263
4390
  name: one.name,
4264
4391
  via: one.via,
@@ -4631,11 +4758,32 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
4631
4758
  json(response, 429, { error: `this server is already carrying ${MAX_ON_DEMAND} channels on demand; try again in a minute` });
4632
4759
  return;
4633
4760
  }
4634
- const resolved = known ?? await resolveLink(options.ytdlp ?? null, link, { cookies: options.cookies ?? "" });
4761
+ // A member's link is fetched from inside the owner's network, so it has
4762
+ // to lead somewhere public: the link, every hop of a playlist, and
4763
+ // whatever yt-dlp says the media is. The owner's is theirs to aim.
4764
+ const guard = liveBy && !known ? (one: string) => refusedDestination(one) : undefined;
4765
+ if (guard) {
4766
+ const refused = await guard(link);
4767
+ if (refused) {
4768
+ json(response, 403, { error: refused });
4769
+ return;
4770
+ }
4771
+ }
4772
+ const resolved = known ?? await resolveLink(options.ytdlp ?? null, link, { cookies: options.cookies ?? "", guard });
4635
4773
  if ("error" in resolved) {
4636
- json(response, 422, { error: resolved.error });
4774
+ json(response, guard && resolved.error.includes("private network") ? 403 : 422, { error: resolved.error });
4637
4775
  return;
4638
4776
  }
4777
+ if (guard) {
4778
+ const reached = [resolved.media, resolved.audio, ...(resolved.playlist ?? [])].filter((one) => one && /^(https?|rtmps?):\/\//i.test(one));
4779
+ for (const one of [...new Set(reached)].slice(0, 200)) {
4780
+ const refused = await guard(one);
4781
+ if (refused) {
4782
+ json(response, 403, { error: refused });
4783
+ return;
4784
+ }
4785
+ }
4786
+ }
4639
4787
  links.set(link, resolved);
4640
4788
  // A channel already on -- put back by a restart, which forgets where
4641
4789
  // it came from -- learns its picture and its line from the link pasted
@@ -4868,7 +5016,8 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
4868
5016
  // on, and the address a channel is pulled from is the one thing about
4869
5017
  // it that is not theirs to have.
4870
5018
  json(response, 200, {
4871
- 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),
4872
5021
  listeners: options.channels.listeners,
4873
5022
  });
4874
5023
  return;
@@ -4881,6 +5030,12 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
4881
5030
  const rest = path.slice("/api/channels/".length);
4882
5031
  const [rawId, action, file] = rest.split("/");
4883
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
+ }
4884
5039
  const event = await eventForChannel();
4885
5040
  if (event) {
4886
5041
  const account = await options.accounts?.whoIs(tokenFrom(request.headers)) ?? null;
@@ -5494,7 +5649,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
5494
5649
  }
5495
5650
 
5496
5651
  if (path === "/api/state") {
5497
- json(response, 200, engine.snapshot());
5652
+ json(response, 200, listening ? { ...engine.snapshot(), root: "" } : engine.snapshot());
5498
5653
  return;
5499
5654
  }
5500
5655
 
@@ -5562,7 +5717,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
5562
5717
  "x-accel-buffering": "no",
5563
5718
  });
5564
5719
  const send = (snapshot: Snapshot): void => {
5565
- response.write(`data: ${JSON.stringify(snapshot)}\n\n`);
5720
+ response.write(`data: ${JSON.stringify(listening ? { ...snapshot, root: "" } : snapshot)}\n\n`);
5566
5721
  };
5567
5722
  const unsubscribe = engine.subscribe(send);
5568
5723
  // A comment line keeps proxies from closing an idle stream.
@@ -6039,6 +6194,7 @@ async function liveAudio(
6039
6194
  // A live source has no beginning to seek from.
6040
6195
  ...(from > 1 && !isRemote(source) ? ["-ss", String(from)] : []),
6041
6196
  "-re",
6197
+ ...protocolArgs(source),
6042
6198
  "-i", source,
6043
6199
  ...(asVideo
6044
6200
  // Copied where it can be, because a room full of viewers is a room
@@ -6157,6 +6313,7 @@ function pipeFfmpeg(
6157
6313
  // when they are handed to it for a file on disk.
6158
6314
  ...(isRemote(source) ? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"] : []),
6159
6315
  ...inputArgs,
6316
+ ...protocolArgs(source),
6160
6317
  "-i", source,
6161
6318
  ...outputArgs,
6162
6319
  "-",
@@ -6341,6 +6498,10 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
6341
6498
  // still works after a restart -- and a server is restarted to pick up a new
6342
6499
  // version, which is to say often. `--new-key` mints a fresh pair and forgets
6343
6500
  // the old one, which is the way to revoke a link that got out.
6501
+ // Whether this port had keys before this start: a server that has run
6502
+ // here before, whose directory listing (see publishChoice) is kept.
6503
+ const established = existsSync(join(stateDir(), "keys.json")) &&
6504
+ (() => { try { return String(options.port) in (JSON.parse(readFileSync(join(stateDir(), "keys.json"), "utf8")) as object); } catch { return false; } })();
6344
6505
  const remembered = options.key ? rememberedKeys(stateDir(), options.port, options.newKey) : null;
6345
6506
  const key = remembered?.key ?? null;
6346
6507
  // Kept whether or not it is published, so `nixamp admin` and the operator
@@ -6373,6 +6534,9 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
6373
6534
  // same ffmpeg that carries the channels. No ffmpeg, no outro, and a show
6374
6535
  // that ends closes its channel as before.
6375
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;
6376
6540
  const channels = new Channels({
6377
6541
  ffmpeg: tools.ffmpeg,
6378
6542
  ffprobe: tools.ffprobe,
@@ -6383,8 +6547,25 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
6383
6547
  onEnd: (info) => {
6384
6548
  console.log(` "${info.id}" stopped.`);
6385
6549
  art.forget(info.id);
6550
+ stationRunner?.onChannelEnd(info);
6386
6551
  },
6387
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 };
6388
6569
  // Channels as HLS, on demand, for Safari on a phone: one ffmpeg copying a
6389
6570
  // channel's fragments into short files while somebody is asking for them.
6390
6571
  // What things are, from nichedb.dev, remembered beside the keys so a
@@ -6433,6 +6614,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
6433
6614
  if (!channel) console.log(` "${one.id}" is already on.`);
6434
6615
  });
6435
6616
  }
6617
+ stationRunner.restore();
6436
6618
  // Where each film has got to, written down every so often, so a restart
6437
6619
  // picks it up from about there rather than from the start. Only when it
6438
6620
  // has changed: a server carrying nothing but live television writes
@@ -6975,6 +7157,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
6975
7157
  media: options.media,
6976
7158
  owner,
6977
7159
  channels,
7160
+ stations,
6978
7161
  rememberChannels: remembering,
6979
7162
  catalogs,
6980
7163
  publishUrls: () => publishUrls,
@@ -7404,6 +7587,12 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
7404
7587
  * A declaration rather than an assignment, so it exists from the moment the
7405
7588
  * function is entered -- the port is open well before this line is reached.
7406
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
+
7407
7596
  function makePublisher(): Publisher | null {
7408
7597
  if (!publishable_) return null;
7409
7598
  const listen = shareLink(publishable_.url, listenKey, false);
@@ -7427,10 +7616,20 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
7427
7616
  // which channels are on. A directory row that says "5,717 files,
7428
7617
  // live: CNN" is one somebody can decide about; "5717 tracks" was not.
7429
7618
  playing: () => engine.snapshot(false).playing,
7430
- 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),
7431
7621
  // The same with what a link preview needs: the id a join link names
7432
7622
  // a channel by, a picture, a line.
7433
- 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
+ }),
7434
7633
  // From `nixamp login`. The directory will not list a stream it cannot
7435
7634
  // attribute to somebody, because a listing is now a phone code that
7436
7635
  // costs money to answer.
@@ -7475,10 +7674,20 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
7475
7674
  // public at all.
7476
7675
  //
7477
7676
  // Still asked when a person is at the terminal to answer, because that is
7478
- // somebody who can say no.
7479
- const wanted = options.publish === "yes" || !process.stdin.isTTY
7480
- ? true
7481
- : await confirm(`\n List this stream at ${DEFAULT_DIRECTORY}/directory so anyone can find it?\n It publishes ${listen} — listen only, not the controls.`);
7677
+ // somebody who can say no -- and since 0.29 the answer is kept, so a
7678
+ // daemon started later does what the person said, not what it guesses.
7679
+ const recorded = readConfig().publish;
7680
+ const choice = publishChoice(options.publish, recorded, Boolean(process.stdin.isTTY), established);
7681
+ const wanted = choice === "ask"
7682
+ ? await confirm(`\n List this stream at ${DEFAULT_DIRECTORY}/directory so anyone can find it?\n It publishes ${listen} — listen only, not the controls.`)
7683
+ : choice === "yes";
7684
+ if (shouldRecord(options.publish, recorded)) {
7685
+ try { writeConfig({ publish: wanted }); } catch { /* unwritable state: asked again next time */ }
7686
+ }
7687
+ if (!wanted && options.publish === "ask") {
7688
+ console.log("");
7689
+ console.log(` Not listed at ${DEFAULT_DIRECTORY}/directory. Start with --publish to list it.`);
7690
+ }
7482
7691
 
7483
7692
  if (wanted) {
7484
7693
  publisher = makePublisher();
package/src/share.ts CHANGED
@@ -136,11 +136,12 @@ export function keyFrom(request: IncomingMessage, url: URL): string | null {
136
136
  }
137
137
 
138
138
  /** The Set-Cookie for a browser that just opened the link. */
139
- export function keyCookie(key: string): string {
139
+ export function keyCookie(key: string, secure = false): string {
140
140
  // HttpOnly because nothing in the page reads it: the browser attaches it to
141
- // every same-origin request by itself. No Secure, because the whole point is
142
- // a plain-http address on your own network.
143
- return `${KEY_COOKIE}=${encodeURIComponent(key)}; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly`;
141
+ // every same-origin request by itself. Secure only when the link was opened
142
+ // over https: a plain-http address on your own network is still a thing,
143
+ // and a Secure cookie there would never be sent back.
144
+ return `${KEY_COOKIE}=${encodeURIComponent(key)}; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly${secure ? "; Secure" : ""}`;
144
145
  }
145
146
 
146
147
  /**
@@ -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
+ }