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/dist/channels.js CHANGED
@@ -1252,7 +1252,10 @@ export function rememberedChannels(dir, port) {
1252
1252
  */
1253
1253
  export function rememberedNow(channels) {
1254
1254
  return channels.list()
1255
- .filter((one) => one.via === "pull" && one.source && !channels.isEphemeral(one.id))
1255
+ // A station's channel is put back by its station, which knows whether it
1256
+ // is still wanted; remembering it here too would bring back one its
1257
+ // owner had stopped.
1258
+ .filter((one) => one.via === "pull" && one.source && !channels.isEphemeral(one.id) && !one.id.startsWith("station-"))
1256
1259
  .map((one) => {
1257
1260
  const kept = { id: one.id, name: one.name, source: one.source };
1258
1261
  if (one.kind)
@@ -146,6 +146,8 @@ export interface LineupEntry {
146
146
  kind: "audio" | "video";
147
147
  art: string;
148
148
  about: string;
149
+ /** A station's own listening page, on the same server; only for a station its owner listed. */
150
+ listen?: string;
149
151
  }
150
152
  /** Trim and flatten, so one publisher cannot draw a box in someone's terminal. */
151
153
  export declare function clean(value: unknown, max: number): string;
@@ -158,7 +160,7 @@ export declare function clean(value: unknown, max: number): string;
158
160
  export declare function publishable(raw: string): URL | null;
159
161
  export declare function parseAnnouncement(input: unknown): Announcement | null;
160
162
  /** One lineup entry as a publisher sent it, cleaned, or null when it is not one. */
161
- export declare function parseLineupEntry(input: unknown): LineupEntry | null;
163
+ export declare function parseLineupEntry(input: unknown, origin?: string): LineupEntry | null;
162
164
  /**
163
165
  * The registry. In memory on purpose: see the note at the top of the file.
164
166
  * One entry per URL, so a publisher restarting does not leave a ghost of
package/dist/directory.js CHANGED
@@ -95,12 +95,12 @@ export function parseAnnouncement(input) {
95
95
  }
96
96
  : {}),
97
97
  ...(Array.isArray(record["lineup"])
98
- ? { lineup: record["lineup"].map(parseLineupEntry).filter((one) => one !== null).slice(0, MAX_CHANNELS) }
98
+ ? { lineup: record["lineup"].map((one) => parseLineupEntry(one, listen.origin)).filter((one) => one !== null).slice(0, MAX_CHANNELS) }
99
99
  : {}),
100
100
  };
101
101
  }
102
102
  /** One lineup entry as a publisher sent it, cleaned, or null when it is not one. */
103
- export function parseLineupEntry(input) {
103
+ export function parseLineupEntry(input, origin = "") {
104
104
  if (typeof input !== "object" || input === null)
105
105
  return null;
106
106
  const record = input;
@@ -120,6 +120,12 @@ export function parseLineupEntry(input) {
120
120
  // page in a tag, and javascript: is a page in a tag that runs.
121
121
  art: publishable(art) !== null ? art : "",
122
122
  about: clean(record["about"], MAX_ABOUT),
123
+ // The same server as the listing, or nothing, like the audio address.
124
+ ...(() => {
125
+ const offered = typeof record["listen"] === "string" ? record["listen"].slice(0, MAX_ART) : "";
126
+ const parsed = offered && origin ? publishable(offered) : null;
127
+ return parsed !== null && parsed.origin === origin ? { listen: offered } : {};
128
+ })(),
123
129
  };
124
130
  }
125
131
  /**
package/dist/main.d.ts CHANGED
@@ -1,11 +1,3 @@
1
- /**
2
- * nixamp — it really whips the terminal's ass.
3
- *
4
- * bunx nixamp ~/Music
5
- * bunx nixamp track.flac
6
- *
7
- * ffmpeg decodes; we read every sample on its way to the speakers and draw it.
8
- */
9
1
  import { type BrailleCanvas, type Container, type Theme } from "@profullstack/hqtui";
10
2
  import { type Track } from "./audio.ts";
11
3
  export declare const BAND_COUNT = 24;
package/dist/main.js CHANGED
@@ -7,6 +7,7 @@ import { t as uiMessage, i18n, terminalUiLanguage, uiLanguage } from "./i18n.js"
7
7
  *
8
8
  * ffmpeg decodes; we read every sample on its way to the speakers and draw it.
9
9
  */
10
+ import { USAGE as STATION_USAGE } from "./station-usage.js";
10
11
  import { createApp, themes } from "@profullstack/hqtui";
11
12
  import { fileURLToPath } from "node:url";
12
13
  import { resolve } from "node:path";
@@ -206,6 +207,7 @@ keeps serving its browser remote. One per user.
206
207
 
207
208
  nixamp attach put the player back in front of it
208
209
  nixamp admin who is connected, and re-stream to them
210
+ nixamp station make a folder into a station others can join (see: nixamp station help)
209
211
 
210
212
  From inside the player, d hands the music to a daemon without stopping it.
211
213
  `,
@@ -367,6 +369,8 @@ q or d leaves; neither stops anything. \`nixamp daemon stop\` is what stops it.
367
369
  `,
368
370
  };
369
371
  export function helpFor(topic) {
372
+ if (topic === "station" || topic === "stations")
373
+ return STATION_USAGE;
370
374
  return (topic ? TOPICS[topic] : undefined) ?? HELP;
371
375
  }
372
376
  /**
@@ -524,6 +528,11 @@ export async function main() {
524
528
  process.exitCode = await runDaemon(rest);
525
529
  return;
526
530
  }
531
+ if (first === "station" || first === "stations") {
532
+ const { station } = await import("./station-cli.js");
533
+ process.exitCode = await station(rest);
534
+ return;
535
+ }
527
536
  if (first === "admin") {
528
537
  const { admin } = await import("./admin.js");
529
538
  await admin(rest);
package/dist/server.d.ts CHANGED
@@ -5,7 +5,7 @@ import { LiveVoice } from "./live-voice.ts";
5
5
  import { Connections } from "./connections.ts";
6
6
  import { Broadcaster, type Destination, type EncoderSettings } from "./broadcast.ts";
7
7
  import { Ingest } from "./ingest.ts";
8
- import { Channels, type Channel, type ChannelInfo, type RememberedChannel } from "./channels.ts";
8
+ import { Channels, type Channel, type ChannelInfo, type PullResume, type RememberedChannel } from "./channels.ts";
9
9
  import { Accounts } from "./accounts.ts";
10
10
  import { type ResetMail } from "./password-reset.ts";
11
11
  import { Handles } from "./handles.ts";
@@ -21,6 +21,8 @@ import { Directory, type LineupEntry } from "./directory.ts";
21
21
  import { ArtCache } from "./art.ts";
22
22
  import { PartyLine } from "./partyline.ts";
23
23
  import { HlsPackagers } from "./hls.ts";
24
+ import { StationRunner } from "./station-runner.ts";
25
+ import { StationStore } from "./stations.ts";
24
26
  import { CompressionService } from "./compression/service.ts";
25
27
  import { Enricher } from "./enrich.ts";
26
28
  import { Follows } from "./follows.ts";
@@ -438,7 +440,26 @@ export interface KnownSource {
438
440
  /** A picture and a line about it, from the site or the list that named it. */
439
441
  art?: string;
440
442
  about?: string;
443
+ /** A station's rotation (see PullResume.rotation). */
444
+ rotation?: PullResume["rotation"];
445
+ /** kbps for an audio rendition; 192 when unset. */
446
+ audioBitrate?: number;
441
447
  }
448
+ /**
449
+ * A station's files, chosen by track index (as /api/folders/live takes them)
450
+ * or by a folder under the library; null when neither was given. Only files
451
+ * on this machine and inside the library: a station never reaches past it.
452
+ */
453
+ export declare function selectStationFiles(engine: Engine, body: {
454
+ items?: unknown;
455
+ folder?: unknown;
456
+ }): string[] | null;
457
+ /** What the library knows about one file: its tags, for a now-playing line. */
458
+ export declare function trackFor(engine: Engine, file: string): {
459
+ title: string;
460
+ artist: string;
461
+ album: string;
462
+ } | null;
442
463
  /** How many times a source that answers nothing is asked, and how far apart. */
443
464
  export declare const PROBE_TRIES = 3;
444
465
  export declare const PROBE_RETRY_MS = 1500;
@@ -545,6 +566,11 @@ export declare function joinSubject(url: URL, options: Pick<HandlerOptions, "dir
545
566
  /** The shell, titled for what a join link opens, with its picture on the card. */
546
567
  export declare function joinDocument(shell: string, subject: JoinSubject, site: string): string;
547
568
  export interface HandlerOptions {
569
+ /** This server's stations and what keeps them on the air. */
570
+ stations?: {
571
+ store: StationStore;
572
+ runner: StationRunner;
573
+ };
548
574
  web: string | null;
549
575
  /** Branded clients on this same backend, keyed by an explicitly configured host. */
550
576
  webSites?: ReadonlyMap<string, import("./web-sites.ts").WebSite>;
package/dist/server.js CHANGED
@@ -54,6 +54,10 @@ import { ArtCache, coverArtOf, stillFrom } from "./art.js";
54
54
  import { PartyLine, telnyxSms } from "./partyline.js";
55
55
  import { HlsPackagers, segmentType, withKey } from "./hls.js";
56
56
  import { refusedDestination } from "./destinations.js";
57
+ import { handleStations, isStationListenPath } from "./station-routes.js";
58
+ import { STATION_PAGE, STATION_PAGE_CSP, STATION_PAGE_HTML } from "./station-page.js";
59
+ import { StationRunner } from "./station-runner.js";
60
+ import { StationError, StationStore, stationOfChannel } from "./stations.js";
57
61
  import { CompressionService } from "./compression/service.js";
58
62
  import { handleChannelCompression, handleCompressionApi, handleStaticRelay } from "./compression/routes.js";
59
63
  import { DEFAULT_SITE as NICHEDB, Enricher, FIXTURE_TTL_MS } from "./enrich.js";
@@ -876,6 +880,63 @@ export function memberLiveRefusal(channels, account) {
876
880
  }
877
881
  return "";
878
882
  }
883
+ /**
884
+ * A station's files, chosen by track index (as /api/folders/live takes them)
885
+ * or by a folder under the library; null when neither was given. Only files
886
+ * on this machine and inside the library: a station never reaches past it.
887
+ */
888
+ export function selectStationFiles(engine, body) {
889
+ const root = engine.snapshot(false).root;
890
+ if (Array.isArray(body.items)) {
891
+ const indices = [...new Set(body.items.filter((one) => Number.isInteger(one) && one >= 0))];
892
+ const files = indices.map((index) => engine.trackPath(index)).filter((one) => typeof one === "string" && !isRemote(one));
893
+ if (indices.length !== body.items.length || files.length !== indices.length) {
894
+ throw new StationError("unknown_items", "one or more of those tracks are not files in this library");
895
+ }
896
+ return files;
897
+ }
898
+ if (typeof body.folder === "string") {
899
+ const folder = body.folder.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
900
+ if (folder.split("/").includes(".."))
901
+ throw new StationError("invalid_folder", "a folder inside the library, please");
902
+ const files = [];
903
+ const count = engine.snapshot(false).trackCount;
904
+ for (let index = 0; index < count; index++) {
905
+ const file = engine.trackPath(index);
906
+ if (!file || isRemote(file))
907
+ continue;
908
+ const served = relative(root, file).replaceAll("\\", "/");
909
+ if (served.startsWith("../"))
910
+ continue;
911
+ if (folder === "" || served.startsWith(`${folder}/`))
912
+ files.push(file);
913
+ }
914
+ if (files.length === 0)
915
+ throw new StationError("empty_folder", "that folder has no files that play");
916
+ return files;
917
+ }
918
+ return null;
919
+ }
920
+ const tracksByPath = new WeakMap();
921
+ /** What the library knows about one file: its tags, for a now-playing line. */
922
+ export function trackFor(engine, file) {
923
+ const revision = engine.snapshot(false).revision;
924
+ let index = tracksByPath.get(engine);
925
+ if (!index || index.revision !== revision) {
926
+ const tracks = engine.snapshot().tracks ?? [];
927
+ const byPath = new Map();
928
+ tracks.forEach((track, at) => {
929
+ const path = engine.trackPath(at);
930
+ // Tags only: a title that is the file's own name is not a tag.
931
+ const named = track.title && !looksLikeFileName(track.title) ? track.title : "";
932
+ if (path)
933
+ byPath.set(path, { title: named, artist: track.artist ?? "", album: track.album ?? "" });
934
+ });
935
+ index = { revision, byPath };
936
+ tracksByPath.set(engine, index);
937
+ }
938
+ return index.byPath.get(file) ?? null;
939
+ }
879
940
  /** How many times a source that answers nothing is asked, and how far apart. */
880
941
  export const PROBE_TRIES = 3;
881
942
  export const PROBE_RETRY_MS = 1500;
@@ -944,7 +1005,7 @@ export async function pullChannel(channels, ffprobe, id, name, source, input = [
944
1005
  ]
945
1006
  // No picture in it, so none is invented: MP3 is the thing every browser
946
1007
  // plays and the thing a listener can join halfway through.
947
- : ["-vn", "-c:a", "libmp3lame", "-b:a", "192k", "-f", "mp3"];
1008
+ : ["-vn", "-c:a", "libmp3lame", "-b:a", `${known.audioBitrate ?? 192}k`, "-f", "mp3"];
948
1009
  // A transport stream is read further into before it is decoded, and given
949
1010
  // the timestamps a recording cut mid-stream does not carry. Ahead of the
950
1011
  // caller's own input arguments, which are headers for the address itself.
@@ -953,7 +1014,7 @@ export async function pullChannel(channels, ffprobe, id, name, source, input = [
953
1014
  // MP3's tags name the episode and the show, and "Inspiring Founders" is a
954
1015
  // card; "ep-0412-final.mp3" is not.
955
1016
  const tagged = looksLikeFileName(name) && codecs.tags?.title ? codecs.tags.title : name;
956
- const channel = channels.pull(id, tagged, source, encode, kind, true, undefined, opening, kind === "video" ? audio : "", { live, position: known.position ?? 0, ...(playlist ? { playlist, playlistAt: known.playlistAt ?? 0 } : {}) },
1017
+ const channel = channels.pull(id, tagged, source, encode, kind, true, undefined, opening, kind === "video" ? audio : "", { live, position: known.position ?? 0, ...(playlist ? { playlist, playlistAt: known.playlistAt ?? 0 } : {}), ...(known.rotation ? { rotation: known.rotation } : {}) },
957
1018
  // Known before the first dial: whether the source is a transport stream
958
1019
  // decides whether it can be read here for a source-boundary relay.
959
1020
  assumed ? undefined : codecs);
@@ -2072,6 +2133,9 @@ export function createHandler(engine, options) {
2072
2133
  path !== "/api/health" &&
2073
2134
  path !== "/api/directory" &&
2074
2135
  !isSignInPath(path) &&
2136
+ // A station's link is its own key, checked by the station.
2137
+ !isStationListenPath(path) &&
2138
+ !STATION_PAGE.test(path) &&
2075
2139
  !eventChannelRequest) {
2076
2140
  let scope = scopeOf(keyFrom(request, url), key, listenKey);
2077
2141
  // Kept for the routes below that answer a listener with less.
@@ -2108,6 +2172,55 @@ export function createHandler(engine, options) {
2108
2172
  return;
2109
2173
  }
2110
2174
  }
2175
+ // Whether this request may administer: the control key, an open server,
2176
+ // or the owner's nixamp.com session. Asked by the routes that do not go
2177
+ // through the admin gate further down.
2178
+ const controlOf = async () => {
2179
+ if (key === null)
2180
+ return { allowed: true, account: "" };
2181
+ if (scopeOf(keyFrom(request, url), key, listenKey) === "control")
2182
+ return { allowed: true, account: "" };
2183
+ if (options.owner) {
2184
+ const session = tokenFrom(request.headers) || url.searchParams.get("session") || "";
2185
+ if (session !== "" && (await options.owner.check(false, session)).allowed) {
2186
+ return { allowed: true, account: (await options.owner.accountFor(session)) || "" };
2187
+ }
2188
+ }
2189
+ return { allowed: false, account: "" };
2190
+ };
2191
+ // The page a station's link opens. The same bytes for every station: it
2192
+ // reads its id and token from its own address.
2193
+ if (options.stations && STATION_PAGE.test(path) && (request.method === "GET" || request.method === "HEAD")) {
2194
+ response.writeHead(200, {
2195
+ "content-type": "text/html; charset=utf-8",
2196
+ "cache-control": "no-cache",
2197
+ "content-security-policy": STATION_PAGE_CSP,
2198
+ "referrer-policy": "no-referrer",
2199
+ "x-content-type-options": "nosniff",
2200
+ });
2201
+ response.end(request.method === "HEAD" ? undefined : STATION_PAGE_HTML);
2202
+ return;
2203
+ }
2204
+ if (options.stations && (path === "/api/v1/stations" || path.startsWith("/api/v1/stations/"))) {
2205
+ const control = await controlOf();
2206
+ const encrypted = request.socket.encrypted === true;
2207
+ const forwarded = behindProxy ? String(request.headers["x-forwarded-proto"] ?? "").split(",")[0]?.trim() : "";
2208
+ const scheme = forwarded === "https" || encrypted ? "https" : "http";
2209
+ await handleStations(request, response, path, url, {
2210
+ store: options.stations.store,
2211
+ runner: options.stations.runner,
2212
+ channels: options.channels,
2213
+ canControl: control.allowed,
2214
+ account: control.account,
2215
+ select: (body) => selectStationFiles(engine, body),
2216
+ trackFor: (file) => trackFor(engine, file),
2217
+ base: `${scheme}://${request.headers.host ?? "localhost"}`,
2218
+ json,
2219
+ readBody,
2220
+ headers: CORS,
2221
+ });
2222
+ return;
2223
+ }
2111
2224
  if (path === "/api/health") {
2112
2225
  json(response, 200, { name: "nixamp", version: options.version, media: options.media });
2113
2226
  return;
@@ -3852,7 +3965,7 @@ export function createHandler(engine, options) {
3852
3965
  // offering something that always failed.
3853
3966
  carries: options.carries !== false && Boolean(options.channels),
3854
3967
  },
3855
- channels: (options.channels?.list() ?? []).map((one) => ({
3968
+ channels: (options.channels?.list() ?? []).filter((one) => !stationOfChannel(one.id)).map((one) => ({
3856
3969
  id: one.id,
3857
3970
  name: one.name,
3858
3971
  via: one.via,
@@ -4478,7 +4591,8 @@ export function createHandler(engine, options) {
4478
4591
  // on, and the address a channel is pulled from is the one thing about
4479
4592
  // it that is not theirs to have.
4480
4593
  json(response, 200, {
4481
- channels: options.channels.list().map(({ source: _source, ...shown }) => shown),
4594
+ // Stations answer for themselves, under /api/v1/stations.
4595
+ channels: options.channels.list().filter((one) => !stationOfChannel(one.id)).map(({ source: _source, ...shown }) => shown),
4482
4596
  listeners: options.channels.listeners,
4483
4597
  });
4484
4598
  return;
@@ -4490,6 +4604,12 @@ export function createHandler(engine, options) {
4490
4604
  const rest = path.slice("/api/channels/".length);
4491
4605
  const [rawId, action, file] = rest.split("/");
4492
4606
  const id = cleanId(rawId);
4607
+ // A station's channel is heard through the station's own link, which
4608
+ // knows whether it is private. Through here, only by its owner.
4609
+ if (stationOfChannel(id) && !(await controlOf()).allowed) {
4610
+ json(response, 404, { error: "nothing is playing on that channel" });
4611
+ return;
4612
+ }
4493
4613
  const event = await eventForChannel();
4494
4614
  if (event) {
4495
4615
  const account = await options.accounts?.whoIs(tokenFrom(request.headers)) ?? null;
@@ -5932,6 +6052,9 @@ export async function serve(argv, version = "0.1.0") {
5932
6052
  // same ffmpeg that carries the channels. No ffmpeg, no outro, and a show
5933
6053
  // that ends closes its channel as before.
5934
6054
  const outro = new Outro({ ffmpeg: tools.ffmpeg, dir: join(stateDir(), "outro"), onEvent: (message) => console.log(message) });
6055
+ // Made once the channels exist; told of every channel that ends, so a
6056
+ // station that died is brought back and one that was stopped is not.
6057
+ let stationRunner = null;
5935
6058
  const channels = new Channels({
5936
6059
  ffmpeg: tools.ffmpeg,
5937
6060
  ffprobe: tools.ffprobe,
@@ -5941,8 +6064,24 @@ export async function serve(argv, version = "0.1.0") {
5941
6064
  onEnd: (info) => {
5942
6065
  console.log(` "${info.id}" stopped.`);
5943
6066
  art.forget(info.id);
6067
+ stationRunner?.onChannelEnd(info);
5944
6068
  },
5945
6069
  });
6070
+ const stationStore = new StationStore(StationStore.fileFor(stateDir(), options.port));
6071
+ stationRunner = new StationRunner({
6072
+ store: stationStore,
6073
+ channels,
6074
+ pull: async (channelId, name, files, known) => Boolean(await pullChannel(channels, tools.ffprobe, channelId, name, files[0], [], "", {
6075
+ playlist: files,
6076
+ live: true,
6077
+ rotation: known.rotation,
6078
+ audioBitrate: known.audioBitrate,
6079
+ })),
6080
+ // The library as it is now, or as it was chosen while it is still being read.
6081
+ roots: () => [engine.snapshot(false).root || chosen].filter((one) => one !== ""),
6082
+ log: (line) => console.log(line),
6083
+ });
6084
+ const stations = { store: stationStore, runner: stationRunner };
5946
6085
  // Channels as HLS, on demand, for Safari on a phone: one ffmpeg copying a
5947
6086
  // channel's fragments into short files while somebody is asking for them.
5948
6087
  // What things are, from nichedb.dev, remembered beside the keys so a
@@ -5991,6 +6130,7 @@ export async function serve(argv, version = "0.1.0") {
5991
6130
  console.log(` "${one.id}" is already on.`);
5992
6131
  });
5993
6132
  }
6133
+ stationRunner.restore();
5994
6134
  // Where each film has got to, written down every so often, so a restart
5995
6135
  // picks it up from about there rather than from the start. Only when it
5996
6136
  // has changed: a server carrying nothing but live television writes
@@ -6520,6 +6660,7 @@ export async function serve(argv, version = "0.1.0") {
6520
6660
  media: options.media,
6521
6661
  owner,
6522
6662
  channels,
6663
+ stations,
6523
6664
  rememberChannels: remembering,
6524
6665
  catalogs,
6525
6666
  publishUrls: () => publishUrls,
@@ -6929,6 +7070,11 @@ export async function serve(argv, version = "0.1.0") {
6929
7070
  * A declaration rather than an assignment, so it exists from the moment the
6930
7071
  * function is entered -- the port is open well before this line is reached.
6931
7072
  */
7073
+ /** The station behind this channel, when its owner made it public and listed it. */
7074
+ function listedStation(channelId) {
7075
+ const station = stationStore.get(stationOfChannel(channelId));
7076
+ return station && station.visibility === "public" && station.listed ? station : undefined;
7077
+ }
6932
7078
  function makePublisher() {
6933
7079
  if (!publishable_)
6934
7080
  return null;
@@ -6953,10 +7099,20 @@ export async function serve(argv, version = "0.1.0") {
6953
7099
  // which channels are on. A directory row that says "5,717 files,
6954
7100
  // live: CNN" is one somebody can decide about; "5717 tracks" was not.
6955
7101
  playing: () => engine.snapshot(false).playing,
6956
- channels: () => channels.list().map((one) => one.name),
7102
+ // Stations only when their owner listed them; every other channel as before.
7103
+ channels: () => channels.list().filter((one) => !stationOfChannel(one.id) || listedStation(one.id)).map((one) => one.name),
6957
7104
  // The same with what a link preview needs: the id a join link names
6958
7105
  // a channel by, a picture, a line.
6959
- lineup: () => channels.list().map((one) => lineupEntry(listen, one)),
7106
+ lineup: () => channels.list()
7107
+ .filter((one) => !stationOfChannel(one.id) || listedStation(one.id))
7108
+ .map((one) => {
7109
+ const entry = lineupEntry(listen, one);
7110
+ const station = listedStation(one.id);
7111
+ // A station is listened to through its own link, never the server's.
7112
+ return station
7113
+ ? { ...entry, listen: `${publishable_.url}/station/${station.id}?t=${encodeURIComponent(station.listenToken)}` }
7114
+ : entry;
7115
+ }),
6960
7116
  // From `nixamp login`. The directory will not list a stream it cannot
6961
7117
  // attribute to somebody, because a listing is now a phone code that
6962
7118
  // costs money to answer.
@@ -0,0 +1,3 @@
1
+ import { USAGE } from "./station-usage.ts";
2
+ export { USAGE };
3
+ export declare function station(argv: string[]): Promise<number>;
@@ -0,0 +1,201 @@
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.js";
15
+ import { readState } from "./daemon.js";
16
+ import { KEY_HEADER } from "./share.js";
17
+ import { USAGE } from "./station-usage.js";
18
+ export { USAGE };
19
+ function flag(argv, name) {
20
+ const at = argv.indexOf(name);
21
+ return at === -1 ? undefined : argv[at + 1];
22
+ }
23
+ function has(argv, name) {
24
+ return argv.includes(name);
25
+ }
26
+ class Unreachable extends Error {
27
+ }
28
+ export async function station(argv) {
29
+ const [command, id] = argv;
30
+ const json = has(argv, "--json");
31
+ if (!command || command === "help" || has(argv, "--help")) {
32
+ console.log(USAGE);
33
+ return command ? 0 : 2;
34
+ }
35
+ let target;
36
+ try {
37
+ target = resolveTarget(argv);
38
+ }
39
+ catch (error) {
40
+ console.error(error.message);
41
+ return 3;
42
+ }
43
+ const headers = { "content-type": "application/json", ...(target.key ? { [KEY_HEADER]: target.key } : {}) };
44
+ const call = async (method, path, body) => {
45
+ let response;
46
+ try {
47
+ response = await fetch(`${target.url}${path}`, { method, headers, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
48
+ }
49
+ catch (error) {
50
+ throw new Unreachable(`could not reach ${target.url}: ${error.message}`);
51
+ }
52
+ const text = await response.text();
53
+ let parsed = {};
54
+ try {
55
+ parsed = JSON.parse(text);
56
+ }
57
+ catch {
58
+ parsed = { error: text };
59
+ }
60
+ return { status: response.status, body: parsed };
61
+ };
62
+ const needId = () => {
63
+ if (!id || id.startsWith("--")) {
64
+ console.error(`nixamp station ${command} needs a station id. \`nixamp station list\` shows them.`);
65
+ return null;
66
+ }
67
+ return id;
68
+ };
69
+ const show = (answer, line) => {
70
+ if (json) {
71
+ console.log(JSON.stringify(answer.body, null, 2));
72
+ }
73
+ else if (answer.status < 400) {
74
+ console.log(line(answer.body));
75
+ }
76
+ if (answer.status >= 400) {
77
+ if (!json)
78
+ console.error(`nixamp: ${String(answer.body["error"] ?? "refused")} (${String(answer.body["code"] ?? answer.status)})`);
79
+ return 1;
80
+ }
81
+ return 0;
82
+ };
83
+ const describe = (station) => {
84
+ const links = (station["links"] ?? {});
85
+ return [
86
+ `${String(station["name"])} [${String(station["id"])}]`,
87
+ ` state ${String(station["state"])} (wanted ${String(station["desired"])})`,
88
+ ` visibility ${String(station["visibility"])}${station["listed"] ? ", listed in the directory" : ""}`,
89
+ ` rotation ${station["shuffle"] ? "shuffle" : "in order"}${station["repeat"] ? ", repeating" : ", once"}; ${station["items"]?.length ?? 0} files at ${String(station["bitrate"])} kbps`,
90
+ ...(station["error"] ? [` problem ${String(station["error"])}`] : []),
91
+ ...(station["excluded"]?.length ? [` left out ${station["excluded"].length} files (nixamp station retry)`] : []),
92
+ ...(station["visibility"] !== "private" && links["page"] ? [` listen ${links["page"]}`] : []),
93
+ ].join("\n");
94
+ };
95
+ try {
96
+ switch (command) {
97
+ case "list": {
98
+ const answer = await call("GET", "/api/v1/stations?limit=200");
99
+ return show(answer, (body) => {
100
+ const all = body["stations"] ?? [];
101
+ return all.length === 0 ? "No stations yet. nixamp station create --name NAME --folder PATH" : all.map(describe).join("\n\n");
102
+ });
103
+ }
104
+ case "create": {
105
+ const name = flag(argv, "--name");
106
+ if (!name) {
107
+ console.error("nixamp station create needs --name");
108
+ return 2;
109
+ }
110
+ const items = flag(argv, "--items");
111
+ let folder = flag(argv, "--folder");
112
+ if (folder !== undefined) {
113
+ // A path under the library is taken relative to it.
114
+ const root = readState()?.source ?? "";
115
+ if (root && folder.startsWith(root))
116
+ folder = folder.slice(root.length);
117
+ }
118
+ if (items === undefined && folder === undefined) {
119
+ console.error("nixamp station create needs --folder PATH or --items 0,1,2");
120
+ return 2;
121
+ }
122
+ const bitrate = flag(argv, "--bitrate");
123
+ const answer = await call("POST", "/api/v1/stations", {
124
+ name,
125
+ ...(items !== undefined ? { items: items.split(",").map((one) => Number(one.trim())) } : { folder }),
126
+ shuffle: has(argv, "--shuffle"),
127
+ repeat: !has(argv, "--no-repeat"),
128
+ ...(bitrate ? { bitrate: Number(bitrate) } : {}),
129
+ });
130
+ return show(answer, (body) => `Made (private, stopped):\n${describe(body["station"])}`);
131
+ }
132
+ case "show":
133
+ case "status": {
134
+ const station = needId();
135
+ if (!station)
136
+ return 2;
137
+ return show(await call("GET", `/api/v1/stations/${station}`), (body) => describe(body["station"]));
138
+ }
139
+ case "start":
140
+ case "stop":
141
+ case "skip":
142
+ case "retry": {
143
+ const station = needId();
144
+ if (!station)
145
+ return 2;
146
+ return show(await call("POST", `/api/v1/stations/${station}/${command}`, {}), (body) => describe(body["station"]));
147
+ }
148
+ case "publish": {
149
+ const station = needId();
150
+ if (!station)
151
+ return 2;
152
+ const basis = flag(argv, "--basis");
153
+ const unlisted = has(argv, "--unlisted");
154
+ const access = {
155
+ visibility: unlisted ? "unlisted" : "public",
156
+ listed: !unlisted && !has(argv, "--no-list"),
157
+ ...(basis ? { rights: { basis, note: flag(argv, "--note") ?? "" } } : {}),
158
+ };
159
+ return show(await call("POST", `/api/v1/stations/${station}/access`, access), (body) => describe(body["station"]));
160
+ }
161
+ case "unpublish": {
162
+ const station = needId();
163
+ if (!station)
164
+ return 2;
165
+ const access = has(argv, "--private") ? { visibility: "private" } : { listed: false };
166
+ return show(await call("POST", `/api/v1/stations/${station}/access`, access), (body) => {
167
+ const dropped = Number(body["dropped"] ?? 0);
168
+ return `${describe(body["station"])}${dropped ? `\n ${dropped} listener(s) disconnected; old links no longer work.` : ""}`;
169
+ });
170
+ }
171
+ case "link": {
172
+ const station = needId();
173
+ if (!station)
174
+ return 2;
175
+ const answer = has(argv, "--rotate")
176
+ ? await call("POST", `/api/v1/stations/${station}/rotate-link`, {})
177
+ : await call("GET", `/api/v1/stations/${station}`);
178
+ return show(answer, (body) => {
179
+ const links = (body["station"]["links"] ?? {});
180
+ return [`page ${links["page"]}`, `stream ${links["stream"]}`, `m3u ${links["m3u"]}`].join("\n");
181
+ });
182
+ }
183
+ case "delete": {
184
+ const station = needId();
185
+ if (!station)
186
+ return 2;
187
+ return show(await call("DELETE", `/api/v1/stations/${station}`), () => `Deleted ${station}. Its files were not touched.`);
188
+ }
189
+ default:
190
+ console.error(`nixamp station: no command "${command}".\n\n${USAGE}`);
191
+ return 2;
192
+ }
193
+ }
194
+ catch (error) {
195
+ if (error instanceof Unreachable) {
196
+ console.error(`nixamp: ${error.message}`);
197
+ return 3;
198
+ }
199
+ throw error;
200
+ }
201
+ }