nixamp 0.9.11 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/audio.ts CHANGED
@@ -28,12 +28,14 @@ export interface Tools {
28
28
  ffprobe: string[];
29
29
  /** Argv prefix for the player, or null when nothing can make sound here. */
30
30
  play: string[] | null;
31
+ /** yt-dlp, which turns a pasted page into a media address; null when there is none. */
32
+ ytdlp?: string[] | null;
31
33
  }
32
34
 
33
- function works(argv: string[]): boolean {
35
+ function works(argv: string[], flag = "-version"): boolean {
34
36
  const [cmd, ...rest] = argv;
35
37
  if (!cmd) return false;
36
- const r = spawnSync(cmd, [...rest, "-version"], { encoding: "utf8", timeout: 10_000 });
38
+ const r = spawnSync(cmd, [...rest, flag], { encoding: "utf8", timeout: 10_000 });
37
39
  return !r.error && r.status === 0;
38
40
  }
39
41
 
@@ -92,10 +94,16 @@ export function detectTools(): Tools {
92
94
  const ffmpeg = pick("ffmpeg");
93
95
  const ffprobe = pick("ffprobe");
94
96
  const play = pick("ffplay");
97
+ // yt-dlp is not an ffmpeg, so mise's ffmpeg tree is not where it lives;
98
+ // the installer puts it beside nixamp, and pip puts it in ~/.local/bin too.
99
+ // Asked with its own spelling: ffmpeg answers -version, yt-dlp only --version.
100
+ const ytdlp = [["yt-dlp"], [join(homedir(), ".local", "bin", "yt-dlp")], ["/usr/local/bin/yt-dlp"], ["/usr/bin/yt-dlp"], ["/opt/homebrew/bin/yt-dlp"]]
101
+ .find((argv) => works(argv, "--version")) ?? null;
95
102
  return {
96
103
  ffmpeg: ffmpeg ?? ["ffmpeg"],
97
104
  ffprobe: ffprobe ?? ["ffprobe"],
98
105
  play,
106
+ ytdlp,
99
107
  };
100
108
  }
101
109
 
@@ -392,7 +400,7 @@ export interface Codecs {
392
400
  * answering other requests, and a synchronous probe per media request is how
393
401
  * the whole library came to be tagged with the process wedged solid.
394
402
  */
395
- export async function codecsOf(tools: Tools, path: string): Promise<Codecs> {
403
+ export async function codecsOf(tools: Tools, path: string, input: string[] = []): Promise<Codecs> {
396
404
  const [cmd, ...rest] = tools.ffprobe;
397
405
  const empty: Codecs = { video: "", audio: "", container: "" };
398
406
  if (!cmd) return empty;
@@ -405,6 +413,8 @@ export async function codecsOf(tools: Tools, path: string): Promise<Codecs> {
405
413
  "-v", "quiet",
406
414
  "-print_format", "json",
407
415
  "-show_entries", "format=format_name:stream=codec_type,codec_name",
416
+ // Headers the source's site expects, for a link resolved by yt-dlp.
417
+ ...input,
408
418
  path,
409
419
  ],
410
420
  { stdio: ["ignore", "pipe", "ignore"] },
package/src/channels.ts CHANGED
@@ -219,7 +219,7 @@ export class Channel {
219
219
  * because you looked away, and a room where the picture depends on who is
220
220
  * in it is not a room anybody can be invited to.
221
221
  */
222
- pull(source: string, encode: string[], paced = true, stall = STALL): void {
222
+ pull(source: string, encode: string[], paced = true, stall = STALL, input: string[] = []): void {
223
223
  this.stall = stall;
224
224
  if (this.info.kind === "video") this.fragments = new Fragments();
225
225
  const [command, ...prefix] = this.options.ffmpeg as [string, ...string[]];
@@ -247,6 +247,10 @@ export class Channel {
247
247
  // hour of film in ninety seconds and a room that cannot be in it
248
248
  // together; a live source is already paced and loses nothing.
249
249
  ...(paced ? ["-re"] : []),
250
+ // What the source's site expects on the request: a user agent, a
251
+ // referer, a cookie. A link resolved by yt-dlp comes with these,
252
+ // and a CDN that got them from yt-dlp and not from us answers 403.
253
+ ...input,
250
254
  "-i", source,
251
255
  ...encode,
252
256
  "pipe:1",
@@ -612,6 +616,7 @@ export class Channels {
612
616
  kind: "audio" | "video",
613
617
  paced = true,
614
618
  stall = STALL,
619
+ input: string[] = [],
615
620
  ): Channel | null {
616
621
  if (this.open.has(id)) return null;
617
622
  const channel = new Channel(
@@ -630,7 +635,7 @@ export class Channels {
630
635
  (gone) => this.open.delete(gone),
631
636
  );
632
637
  this.open.set(id, channel);
633
- channel.pull(source, encode, paced, stall);
638
+ channel.pull(source, encode, paced, stall, input);
634
639
  return channel;
635
640
  }
636
641
 
package/src/directory.ts CHANGED
@@ -73,6 +73,12 @@ export interface Listing {
73
73
  playing: boolean;
74
74
  /** The live channels on it, by name: what a visitor could actually watch. */
75
75
  channels: string[];
76
+ /**
77
+ * A phone code for each of those channels, by name: its own room, so the
78
+ * people calling about one live are not put in with the people calling
79
+ * about another on the same server.
80
+ */
81
+ channelCodes: Record<string, string>;
76
82
  /** Set by the directory from the request, never by the publisher. */
77
83
  updatedAt: number;
78
84
  /** When this stream first announced itself: the "started at" a caller hears. */
@@ -283,6 +289,11 @@ export class Directory {
283
289
  // listing always meant, and no channels is the honest empty list.
284
290
  playing: announcement.playing ?? true,
285
291
  channels: announcement.channels ?? [],
292
+ channelCodes: this.codesFor(
293
+ announcement.channels ?? [],
294
+ existing?.channelCodes ?? (previously && "channelCodes" in previously ? previously.channelCodes : undefined),
295
+ id,
296
+ ),
286
297
  updatedAt: this.now(),
287
298
  // A stream that never stopped keeps its original start. One that did
288
299
  // starts again now, because that is what a caller is being told about.
@@ -305,10 +316,55 @@ export class Directory {
305
316
  return [...this.items.values()].sort((a, b) => b.updatedAt - a.updatedAt || a.id.localeCompare(b.id));
306
317
  }
307
318
 
308
- /** The live stream on this code, if there is one. */
319
+ /**
320
+ * The live stream on this code, if there is one.
321
+ *
322
+ * A channel's code answers as the channel: the same listing, named for the
323
+ * channel and playing nothing else, so the phone line says "the live room
324
+ * for FIBA World Cup" rather than the server's name and whatever its own
325
+ * player has on.
326
+ */
309
327
  liveByCode(code: string): Listing | undefined {
310
328
  this.sweep();
311
- return [...this.items.values()].find((item) => item.code === code);
329
+ const own = [...this.items.values()].find((item) => item.code === code);
330
+ if (own !== undefined) return own;
331
+ for (const item of this.items.values()) {
332
+ const channel = Object.entries(item.channelCodes).find(([, one]) => one === code)?.[0];
333
+ if (channel !== undefined) return { ...item, code, name: channel, nowPlaying: "" };
334
+ }
335
+ return undefined;
336
+ }
337
+
338
+ /**
339
+ * A code for each channel, kept while the channel stays on.
340
+ *
341
+ * One code per server meant every live on it shared a room: somebody
342
+ * calling about the basketball landed with the people talking about the
343
+ * film. Each live is its own room now, and a channel that is still there at
344
+ * the next heartbeat keeps the code it was given.
345
+ */
346
+ private codesFor(channels: string[], had: Record<string, string> | undefined, own = ""): Record<string, string> {
347
+ const codes: Record<string, string> = {};
348
+ for (const name of channels) {
349
+ if (codes[name] !== undefined) continue;
350
+ const kept = had?.[name];
351
+ codes[name] = kept !== undefined && !this.taken(kept, codes, own) ? kept : this.freeCode(codes, own);
352
+ }
353
+ return codes;
354
+ }
355
+
356
+ /**
357
+ * Whether a code is somebody's already: a stream's own, a channel's on any
358
+ * stream, or a recently-ended stream's. A listing renewing itself is not
359
+ * "somebody else", or its channels would be re-coded every heartbeat.
360
+ */
361
+ private taken(code: string, besides: Record<string, string> = {}, own = ""): boolean {
362
+ if (Object.values(besides).includes(code)) return true;
363
+ for (const item of this.items.values()) {
364
+ if (item.code === code) return true;
365
+ if (item.id !== own && Object.values(item.channelCodes).includes(code)) return true;
366
+ }
367
+ return [...this.ended.values()].some((i) => i.code === code);
312
368
  }
313
369
 
314
370
  /**
@@ -372,15 +428,12 @@ export class Directory {
372
428
  this.mirror?.save(record);
373
429
  }
374
430
 
375
- /** A code no live and no recently-ended stream is using. */
376
- private freeCode(): string {
431
+ /** A code no live stream, no channel on one, and no recently-ended stream is using. */
432
+ private freeCode(besides: Record<string, string> = {}, own = ""): string {
377
433
  for (let tries = 0; tries < 40; tries += 1) {
378
434
  const code = this.randomCode();
379
435
  if (code.length !== 6) continue;
380
- const taken =
381
- [...this.items.values()].some((i) => i.code === code) ||
382
- [...this.ended.values()].some((i) => i.code === code);
383
- if (!taken) return code;
436
+ if (!this.taken(code, besides, own)) return code;
384
437
  }
385
438
  return "";
386
439
  }
package/src/links.ts ADDED
Binary file
package/src/owner.ts CHANGED
@@ -111,7 +111,7 @@ export const ADMIN_PATHS = [
111
111
  * `/api/live` itself is the public listen address -- the one the phone line is
112
112
  * handed. Gating it by prefix would shut the front door to lock the office.
113
113
  */
114
- const LIVE_CONTROL = ["/api/live/state", "/api/live/start", "/api/live/stop"];
114
+ const LIVE_CONTROL = ["/api/live/start", "/api/live/stop"];
115
115
 
116
116
  export function needsAdmin(path: string, method = "GET"): boolean {
117
117
  if (LIVE_CONTROL.includes(path)) return true;
package/src/publish.ts CHANGED
@@ -52,6 +52,16 @@ export interface PublishTarget {
52
52
  onConfig?: (config: unknown) => void;
53
53
  /** Called when the directory refused us for want of an account. */
54
54
  onRefused?: () => void;
55
+ /**
56
+ * Called with the listing every heartbeat, not only the first.
57
+ *
58
+ * The directory forgets everything when it restarts, and the next heartbeat
59
+ * lists this server again under a new phone code. A server that only kept
60
+ * the first answer went on showing the old code -- to the Share panel, the
61
+ * player, and anybody sent the link -- while the phone line knew only the
62
+ * new one.
63
+ */
64
+ onListed?: (listing: Listing) => void;
55
65
  }
56
66
 
57
67
  /**
@@ -128,6 +138,7 @@ export class Publisher {
128
138
  const listing = (await response.json()) as Listing & { config?: unknown };
129
139
  this.id = listing.id;
130
140
  if (listing.config !== undefined) this.target.onConfig?.(listing.config);
141
+ this.target.onListed?.(listing);
131
142
  return listing;
132
143
  } catch {
133
144
  // The directory being down is not a reason for a player to stop playing.
package/src/server.ts CHANGED
@@ -51,6 +51,10 @@ import { stateDir } from "./daemon.ts";
51
51
  import { readSession } from "./session.ts";
52
52
  import { Directory, ENDED_TTL_MS, parseAnnouncement, type Listing } from "./directory.ts";
53
53
  import { PartyLine, telnyxSms } from "./partyline.ts";
54
+ import {
55
+ contentTypeFor, downloadArgs, fileNameFor, inputArgsFor, linkChannelId, playableLink, resolveLink, saveFormat,
56
+ type ResolvedLink,
57
+ } from "./links.ts";
54
58
  import { CALL_IN_NUMBER, OPT_IN_PATH, optInPage } from "./optin.ts";
55
59
  import pg from "pg";
56
60
  import { Follows, phoneFrom } from "./follows.ts";
@@ -978,15 +982,50 @@ export async function pullChannel(
978
982
  id: string,
979
983
  name: string,
980
984
  source: string,
985
+ input: string[] = [],
981
986
  ): Promise<Channel | null> {
982
- const codecs = await codecsOf({ ffmpeg: [], ffprobe, play: null }, source);
987
+ const codecs = await codecsOf({ ffmpeg: [], ffprobe, play: null }, source, input);
983
988
  const kind = codecs.video === "" ? "audio" : "video";
984
989
  const encode = kind === "video"
985
990
  ? videoArgs(codecs)
986
991
  // No picture in it, so none is invented: MP3 is the thing every browser
987
992
  // plays and the thing a listener can join halfway through.
988
993
  : ["-vn", "-c:a", "libmp3lame", "-b:a", "192k", "-f", "mp3"];
989
- return channels.pull(id, name, source, encode, kind);
994
+ return channels.pull(id, name, source, encode, kind, true, undefined, input);
995
+ }
996
+
997
+ /** What a link resolves to, kept so a download can be named without asking twice. */
998
+ const links = new Map<string, ResolvedLink>();
999
+
1000
+ /**
1001
+ * A Netscape cookies file beside the state, if the operator has put one there.
1002
+ *
1003
+ * YouTube and Vimeo refuse a datacenter without a signed-in cookie; the
1004
+ * person who runs the server can export one from their browser and drop it
1005
+ * at ~/.local/state/nixamp/cookies.txt, and every link is asked for with it.
1006
+ */
1007
+ export function cookiesFile(): string {
1008
+ const path = join(stateDir(), "cookies.txt");
1009
+ try {
1010
+ return statSync(path).isFile() ? path : "";
1011
+ } catch {
1012
+ return "";
1013
+ }
1014
+ }
1015
+
1016
+ /** What the page is told about a link it asked to play. */
1017
+ function shownLink(channelId: string, link: ResolvedLink) {
1018
+ return {
1019
+ kind: "live",
1020
+ channel: channelId,
1021
+ name: link.title,
1022
+ live: link.live,
1023
+ video: link.video,
1024
+ duration: link.duration,
1025
+ extractor: link.extractor,
1026
+ // A live has no whole to keep; a bare file can be fetched by the browser itself.
1027
+ download: !link.live && link.extractor !== "direct",
1028
+ };
990
1029
  }
991
1030
 
992
1031
  export function liveOnes(engine: Engine): { name: string; at: number; tracks: number }[] {
@@ -1122,6 +1161,10 @@ export interface HandlerOptions {
1122
1161
  ffmpeg?: string[];
1123
1162
  /** Where ffprobe is, for asking what is inside a file before re-encoding it. */
1124
1163
  ffprobe?: string[];
1164
+ /** yt-dlp, which turns a pasted page into a media address. Null when there is none. */
1165
+ ytdlp?: string[] | null;
1166
+ /** A Netscape cookies file for sites that want a signed-in browser, when there is one. */
1167
+ cookies?: string;
1125
1168
  /** Who is listening, for the admin view. */
1126
1169
  connections?: Connections;
1127
1170
  /**
@@ -1177,7 +1220,11 @@ export interface HandlerOptions {
1177
1220
  * and starting it. It is an action now, because that is what it is.
1178
1221
  */
1179
1222
  live?: {
1180
- status: () => { live: boolean; code: string; name: string; url: string; possible: boolean };
1223
+ status: () => {
1224
+ live: boolean; code: string; name: string; url: string; possible: boolean;
1225
+ /** A phone code per live channel, by name, as the directory assigned them. */
1226
+ channelCodes?: Record<string, string>;
1227
+ };
1181
1228
  start: () => Promise<{ live: boolean; code: string; name: string; url: string; error?: string }>;
1182
1229
  stop: () => Promise<void>;
1183
1230
  /**
@@ -2363,6 +2410,11 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2363
2410
  const streams = options.directory.list().map(({ admin, ...stream }) => ({
2364
2411
  ...stream,
2365
2412
  callers: onThePhone ? onThePhone.listenersOn(stream.code) : 0,
2413
+ // And on the phone for each live on it, by name: every live is its
2414
+ // own room, so each has its own count.
2415
+ channelCallers: Object.fromEntries(
2416
+ Object.entries(stream.channelCodes).map(([name, code]) => [name, onThePhone ? onThePhone.listenersOn(code) : 0]),
2417
+ ),
2366
2418
  ...(admin && me !== null && stream.ownerId === me.id ? { admin } : {}),
2367
2419
  }));
2368
2420
  // Recently ended too, because following exists to hear about
@@ -2533,6 +2585,9 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2533
2585
  // Whether it has a picture, so the page puts it in the element
2534
2586
  // that can show one. Never the source: that is the owner's.
2535
2587
  kind: one.kind ?? "audio",
2588
+ // Its own room on the phone line, as the directory assigned it;
2589
+ // empty until the next heartbeat has told the directory it is on.
2590
+ code: state?.channelCodes?.[one.name] ?? "",
2536
2591
  // How it has been going, for whoever may do something about it.
2537
2592
  redials: one.redials ?? 0,
2538
2593
  error: one.error ?? "",
@@ -2721,6 +2776,118 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2721
2776
  return;
2722
2777
  }
2723
2778
 
2779
+ // --- any link, played -------------------------------------------------
2780
+ //
2781
+ // Paste a page -- YouTube, a podcast, SoundCloud, a TikTok live -- and the
2782
+ // server works out where the media is and plays it as a channel of its
2783
+ // own, the way a catalog entry is played: started for whoever asked,
2784
+ // stopped a minute after the last viewer leaves. Open to anyone holding
2785
+ // the link, like picking something from a catalog.
2786
+ if (path === "/api/links/play" && request.method === "POST") {
2787
+ let body: { url?: unknown } = {};
2788
+ try {
2789
+ body = JSON.parse(await readBody(request)) as typeof body;
2790
+ } catch {
2791
+ json(response, 400, { error: "bad JSON" });
2792
+ return;
2793
+ }
2794
+ const link = playableLink(body.url);
2795
+ if (link === "") {
2796
+ json(response, 400, { error: "that is not a link this can play" });
2797
+ return;
2798
+ }
2799
+ if (!options.channels) {
2800
+ json(response, 503, { error: "this server cannot carry channels" });
2801
+ return;
2802
+ }
2803
+ const channelId = linkChannelId(link);
2804
+ const known = links.get(link);
2805
+ if (options.channels.has(channelId) && known) {
2806
+ json(response, 200, shownLink(channelId, known));
2807
+ return;
2808
+ }
2809
+ if (!options.channels.has(channelId) && options.channels.ephemeralCount >= MAX_ON_DEMAND) {
2810
+ json(response, 429, { error: `this server is already carrying ${MAX_ON_DEMAND} channels on demand; try again in a minute` });
2811
+ return;
2812
+ }
2813
+ const resolved = known ?? await resolveLink(options.ytdlp ?? null, link, { cookies: options.cookies ?? "" });
2814
+ if ("error" in resolved) {
2815
+ json(response, 422, { error: resolved.error });
2816
+ return;
2817
+ }
2818
+ links.set(link, resolved);
2819
+ if (!options.channels.has(channelId)) {
2820
+ const started = await pullChannel(
2821
+ options.channels, options.ffprobe ?? ["ffprobe"], channelId, resolved.title, resolved.media,
2822
+ inputArgsFor(resolved.headers),
2823
+ );
2824
+ if (!started) {
2825
+ json(response, 409, { error: "that link is already starting" });
2826
+ return;
2827
+ }
2828
+ options.channels.ephemeral(channelId);
2829
+ }
2830
+ json(response, 200, shownLink(channelId, resolved));
2831
+ return;
2832
+ }
2833
+
2834
+ // The whole thing, to keep. The server fetches it through yt-dlp and
2835
+ // hands the bytes straight on as a download, so the person's own machine
2836
+ // ends up with the file and the server keeps nothing. A live stream has
2837
+ // no whole to hand over.
2838
+ if (path === "/api/links/download") {
2839
+ const link = playableLink(url.searchParams.get("url"));
2840
+ if (link === "") {
2841
+ json(response, 400, { error: "that is not a link this can fetch" });
2842
+ return;
2843
+ }
2844
+ if (!options.ytdlp || options.ytdlp.length === 0) {
2845
+ json(response, 503, { error: "this server has no yt-dlp to fetch with" });
2846
+ return;
2847
+ }
2848
+ const resolved = links.get(link) ?? await resolveLink(options.ytdlp, link, { cookies: options.cookies ?? "" });
2849
+ if ("error" in resolved) {
2850
+ json(response, 422, { error: resolved.error });
2851
+ return;
2852
+ }
2853
+ links.set(link, resolved);
2854
+ if (resolved.live) {
2855
+ json(response, 409, { error: "that is live; there is no whole file to download yet" });
2856
+ return;
2857
+ }
2858
+ const audioOnly = url.searchParams.get("audio") === "1" || !resolved.video;
2859
+ // Named for the format the download will actually take, which is not
2860
+ // always the one played: a track played from an HLS playlist is saved
2861
+ // as the plain MP3 the site also offers, and ".m4a" on an MP3 is a
2862
+ // file nothing will open.
2863
+ const saved = await resolveLink(options.ytdlp, link, { cookies: options.cookies ?? "", format: saveFormat(audioOnly) });
2864
+ const fileName = fileNameFor("error" in saved ? resolved : { ...resolved, ext: saved.ext || resolved.ext }, audioOnly);
2865
+ const [command, ...prefix] = options.ytdlp as [string, ...string[]];
2866
+ const child = spawn(command, [...prefix, ...downloadArgs(link, audioOnly, options.cookies ?? "")], {
2867
+ stdio: ["ignore", "pipe", "pipe"],
2868
+ });
2869
+ response.writeHead(200, {
2870
+ "content-type": contentTypeFor(fileName),
2871
+ "content-disposition": `attachment; filename="${fileName.replace(/["\\]/g, "")}"; filename*=UTF-8''${encodeURIComponent(fileName)}`,
2872
+ "cache-control": "no-store",
2873
+ });
2874
+ child.stdout?.pipe(response);
2875
+ let complaint = "";
2876
+ child.stderr?.on("data", (chunk: Buffer) => {
2877
+ if (complaint.length < 20_000) complaint += chunk.toString("utf8");
2878
+ });
2879
+ child.on("error", () => response.end());
2880
+ child.on("close", (code) => {
2881
+ if (code !== 0) console.log(` a download of ${link} failed: ${complaint.split("\n").filter((one) => one.startsWith("ERROR")).pop() ?? code}`);
2882
+ response.end();
2883
+ });
2884
+ // The person closed the tab: stop fetching what nobody will keep.
2885
+ response.on("close", () => {
2886
+ if (child.exitCode === null) child.kill("SIGKILL");
2887
+ });
2888
+ return;
2889
+ }
2890
+
2724
2891
  // A channel is one publisher and everybody listening to them. Two or three
2725
2892
  // devices can publish at once, each to their own channel, and a listener
2726
2893
  // picks which to hear.
@@ -4163,6 +4330,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
4163
4330
  status: () => ({
4164
4331
  live: publisher !== null,
4165
4332
  code: listing?.code ?? "",
4333
+ channelCodes: listing?.channelCodes ?? {},
4166
4334
  name: listing?.name ?? (options.name || hostname()),
4167
4335
  url: listing?.url ?? (publishable_ ? shareLink(publishable_.url, listenKey, false) : ""),
4168
4336
  // Whether going live is even possible here. A laptop behind a router
@@ -4210,6 +4378,11 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
4210
4378
  paywall,
4211
4379
  ffmpeg: tools.ffmpeg,
4212
4380
  ffprobe: tools.ffprobe,
4381
+ // For a pasted link: where its media is, and the whole of it to keep.
4382
+ // A cookie jar beside the state, when the operator has put one there,
4383
+ // for the sites that will not talk to a datacenter without one.
4384
+ ytdlp: tools.ytdlp ?? null,
4385
+ cookies: cookiesFile(),
4213
4386
  ...(tls ? { tls } : {}),
4214
4387
  // Untagged, so a directory of five thousand files answers at once; the
4215
4388
  // tags follow through `tag` below.
@@ -4557,6 +4730,14 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
4557
4730
  console.log(" nixamp.com would not list this stream: it needs an account.");
4558
4731
  console.log(" Run `nixamp login` (or `nixamp signup`) and start again.");
4559
4732
  },
4733
+ // Every heartbeat, so the code this server shows is the code the
4734
+ // phone line knows, whatever the directory has forgotten meanwhile.
4735
+ onListed: (fresh) => {
4736
+ if (listing && listing.code !== fresh.code) {
4737
+ console.log(` nixamp.com listed this stream again; the phone code is now ${fresh.code}.`);
4738
+ }
4739
+ listing = fresh;
4740
+ },
4560
4741
  nowPlaying: () => {
4561
4742
  const snapshot = engine.snapshot();
4562
4743
  return snapshot.tracks?.[snapshot.index]?.title ?? "";
package/src/share.ts CHANGED
@@ -354,8 +354,10 @@ export function allowedForListening(path: string): boolean {
354
354
  if (path === "/api/source" || path.startsWith("/api/source/")) return false;
355
355
  // Listing this machine in a public directory is not listening to it. The
356
356
  // listen address itself, /api/live, stays open: that is the thing a listen
357
- // key is for.
358
- if (path === "/api/live/state" || path === "/api/live/start" || path === "/api/live/stop") return false;
357
+ // key is for -- and so is knowing the phone code, which /api/live/state
358
+ // answers: a joiner is told the number to call and the code to key, and
359
+ // the code is in the public directory anyway.
360
+ if (path === "/api/live/start" || path === "/api/live/stop") return false;
359
361
  return true;
360
362
  }
361
363
 
@@ -1 +1 @@
1
- import{t as e}from"./index-Dnk9CNfO.js";var t=3;async function n(n){let{media:r,src:i,isTv:a}=n,{default:o}=await e(async()=>{let{default:e}=await import(`./hls-n74Cnh8A.js`);return{default:e}},[]);if(!o.isSupported())return n.onError(`This browser cannot play HLS streams.`),{destroy:()=>void 0,levels:()=>[]};let s=new o({...a?{maxBufferLength:60,maxMaxBufferLength:120,backBufferLength:30,liveSyncDurationCount:4}:{backBufferLength:90},enableWorker:!0}),c=0,l=!1;s.on(o.Events.ERROR,(e,r)=>{if(!l&&r.fatal){if(c>=t){n.onError(`This stream kept failing and has been stopped.`),s.destroy();return}switch(c+=1,r.type){case o.ErrorTypes.NETWORK_ERROR:n.onNotice(`Reconnecting…`),s.startLoad();break;case o.ErrorTypes.MEDIA_ERROR:n.onNotice(`Recovering…`),s.recoverMediaError();break;default:n.onError(`This stream could not be played.`),s.destroy()}}}),s.on(o.Events.MANIFEST_PARSED,()=>{l||(n.onNotice(null),n.onReady?.({live:s.levels.length>0&&!Number.isFinite(r.duration),levels:u()}))}),s.on(o.Events.LEVEL_LOADED,(e,t)=>{l||n.onReady?.({live:t.details.live,levels:u()})}),s.on(o.Events.FRAG_BUFFERED,()=>{l||n.onNotice(null)});function u(){return s.levels.map((e,t)=>({index:t,height:e.height||null,bitrate:e.bitrate||null,label:e.height?`${String(e.height)}p`:`${String(Math.round((e.bitrate||0)/1e3))}k`}))}return s.loadSource(i),s.attachMedia(r),{destroy(){l=!0,s.destroy()},levels:u,setLevel(e){s.currentLevel=e},currentLevel:()=>s.autoLevelEnabled?-1:s.currentLevel}}export{n as createHlsEngine};
1
+ import{t as e}from"./index-Bqujphpd.js";var t=3;async function n(n){let{media:r,src:i,isTv:a}=n,{default:o}=await e(async()=>{let{default:e}=await import(`./hls-n74Cnh8A.js`);return{default:e}},[]);if(!o.isSupported())return n.onError(`This browser cannot play HLS streams.`),{destroy:()=>void 0,levels:()=>[]};let s=new o({...a?{maxBufferLength:60,maxMaxBufferLength:120,backBufferLength:30,liveSyncDurationCount:4}:{backBufferLength:90},enableWorker:!0}),c=0,l=!1;s.on(o.Events.ERROR,(e,r)=>{if(!l&&r.fatal){if(c>=t){n.onError(`This stream kept failing and has been stopped.`),s.destroy();return}switch(c+=1,r.type){case o.ErrorTypes.NETWORK_ERROR:n.onNotice(`Reconnecting…`),s.startLoad();break;case o.ErrorTypes.MEDIA_ERROR:n.onNotice(`Recovering…`),s.recoverMediaError();break;default:n.onError(`This stream could not be played.`),s.destroy()}}}),s.on(o.Events.MANIFEST_PARSED,()=>{l||(n.onNotice(null),n.onReady?.({live:s.levels.length>0&&!Number.isFinite(r.duration),levels:u()}))}),s.on(o.Events.LEVEL_LOADED,(e,t)=>{l||n.onReady?.({live:t.details.live,levels:u()})}),s.on(o.Events.FRAG_BUFFERED,()=>{l||n.onNotice(null)});function u(){return s.levels.map((e,t)=>({index:t,height:e.height||null,bitrate:e.bitrate||null,label:e.height?`${String(e.height)}p`:`${String(Math.round((e.bitrate||0)/1e3))}k`}))}return s.loadSource(i),s.attachMedia(r),{destroy(){l=!0,s.destroy()},levels:u,setLevel(e){s.currentLevel=e},currentLevel:()=>s.autoLevelEnabled?-1:s.currentLevel}}export{n as createHlsEngine};