nixamp 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -51,7 +51,7 @@ import { Catalogs, shownCatalog, shownEntry } from "./catalogs.js";
51
51
  import { Porkbun, isIPv4, isIPv6 } from "./dns.js";
52
52
  import { NameError, Names } from "./names.js";
53
53
  import { AcmeIssuer, Certs } from "./certs.js";
54
- import { claimName, fetchCert, labelFor, readCertFiles, writeCertFiles } from "./naming.js";
54
+ import { claimName, fetchCert, humanizeSource, labelFor, readCertFiles, writeCertFiles } from "./naming.js";
55
55
  import { forbiddenLibrary, readLibrary } from "./library.js";
56
56
  import { createThrottle, presentedCredential } from "@profullstack/throttle";
57
57
  import { Durable } from "./durable.js";
@@ -880,7 +880,9 @@ export async function pullChannel(channels, ffprobe, id, name, source, input = [
880
880
  if (assumed)
881
881
  console.log(` "${id}": the source would not say what it holds; carrying it as ${kind}.`);
882
882
  // A film has a length and a place to go back to; a live source has neither.
883
- const live = known.live ?? !((codecs.duration ?? 0) > 0);
883
+ // A list is a station: joined where it is, and never seeked.
884
+ const playlist = known.playlist && known.playlist.length > 0 ? known.playlist : null;
885
+ const live = playlist ? true : (known.live ?? !((codecs.duration ?? 0) > 0));
884
886
  const encode = kind === "video"
885
887
  ? [
886
888
  // Which streams from which input, when there are two: the picture
@@ -896,12 +898,14 @@ export async function pullChannel(channels, ffprobe, id, name, source, input = [
896
898
  // the timestamps a recording cut mid-stream does not carry. Ahead of the
897
899
  // caller's own input arguments, which are headers for the address itself.
898
900
  const opening = [...transportInputArgs(source, codecs.container), ...input];
899
- const channel = channels.pull(id, name, source, encode, kind, true, undefined, opening, kind === "video" ? audio : "", { live, position: known.position ?? 0 },
901
+ const channel = channels.pull(id, name, source, encode, kind, true, undefined, opening, kind === "video" ? audio : "", { live, position: known.position ?? 0, ...(playlist ? { playlist } : {}) },
900
902
  // Known before the first dial: whether the source is a transport stream
901
903
  // decides whether it can be read here for a source-boundary relay.
902
904
  assumed ? undefined : codecs);
903
905
  if (channel && !assumed)
904
906
  channel.info.codecs = codecs;
907
+ if (channel && playlist && known.playlistAt !== undefined)
908
+ channel.info.playlistAt = known.playlistAt;
905
909
  // What comes out, as opposed to what went in. An H.265 source copied
906
910
  // through stays H.265; one re-encoded arrives as H.264, and a packager
907
911
  // told otherwise would cut fMP4 segments for a stream that did not need
@@ -943,11 +947,16 @@ function shownLink(channelId, link, channel) {
943
947
  channel: channelId,
944
948
  name: channel?.name ?? link.title,
945
949
  live,
946
- video: link.video,
950
+ // Whether there is a picture is the channel's answer where there is one:
951
+ // a list is guessed at as video until its first entry has been probed,
952
+ // and a station of podcasts opened a video element for nothing.
953
+ video: channel?.kind ? channel.kind !== "audio" : link.video,
947
954
  duration: link.duration,
948
955
  extractor: link.extractor,
949
956
  // A live has no whole to keep; a bare file can be fetched by the browser itself.
950
957
  download: !live && link.extractor !== "direct",
958
+ // How many things a list holds, for the page to say.
959
+ entries: link.playlist?.length ?? 0,
951
960
  };
952
961
  }
953
962
  export function liveOnes(engine) {
@@ -1057,14 +1066,15 @@ function htmlText(value) {
1057
1066
  *
1058
1067
  * Read from the page rather than hard-coded, because one NixAmp serves several
1059
1068
  * branded clients and each brings its own head. og:site_name first, then the
1060
- * part of the title before the dash, then the hostname.
1069
+ * part of the title before the dash or the colon ("nixamp: broadcast live
1070
+ * radio..." is nixamp), then the hostname.
1061
1071
  */
1062
1072
  export function brandOf(shell, site) {
1063
1073
  const named = /<meta\s+property="og:site_name"\s+content="([^"]+)"/i.exec(shell);
1064
1074
  if (named?.[1])
1065
1075
  return named[1];
1066
1076
  const titled = /<title>([^<]*)<\/title>/i.exec(shell);
1067
- const head = titled?.[1]?.split(/\s[—-]\s/)[0]?.trim();
1077
+ const head = titled?.[1]?.split(/\s[—-]\s|:\s/)[0]?.trim();
1068
1078
  if (head)
1069
1079
  return head;
1070
1080
  try {
@@ -1090,6 +1100,65 @@ function eventDocument(shell, event, site) {
1090
1100
  .replace(/<meta property="og:description" content="[^"]*"\s*\/>/, `<meta property="og:description" content="${htmlText(description)}" />`)
1091
1101
  .replace("</head>", `${event.visibility === "public" ? "" : '<meta name="robots" content="noindex,nofollow" />'}\n <meta property="og:url" content="${htmlText(canonical)}" />\n <link rel="canonical" href="${htmlText(canonical)}" />\n <script type="application/ld+json">${structured}</script>\n </head>`);
1092
1102
  }
1103
+ /**
1104
+ * What a join link is for, named by something this server actually knows.
1105
+ *
1106
+ * A link preview reads the page before any script runs, so the shell has to
1107
+ * say what is on the air. On the directory that is the listing the link
1108
+ * points at, and the channel on it if one is asked for; on a server it is
1109
+ * the server itself, or one of its channels. A name that only appears in the
1110
+ * query is not used: a page that titled itself with whatever the address said
1111
+ * would be a preview anyone could put words in.
1112
+ */
1113
+ export function joinSubject(url, options) {
1114
+ const play = url.searchParams.get("play") ?? "";
1115
+ const wanted = play.startsWith("channel:") ? play.slice("channel:".length) : "";
1116
+ const link = url.searchParams.get("url") ?? "";
1117
+ if (options.directory) {
1118
+ if (link === "")
1119
+ return null;
1120
+ const listings = options.directory.list();
1121
+ const listing = listings.find((one) => one.url === link) ?? listings.find((one) => {
1122
+ try {
1123
+ return new URL(one.url).origin === new URL(link).origin;
1124
+ }
1125
+ catch {
1126
+ return false;
1127
+ }
1128
+ });
1129
+ if (!listing)
1130
+ return null;
1131
+ if (wanted !== "" && listing.channels.includes(wanted))
1132
+ return { title: wanted, where: listing.name };
1133
+ return { title: listing.name, where: "" };
1134
+ }
1135
+ // A link to somewhere else is that server's to name.
1136
+ if (link !== "")
1137
+ return null;
1138
+ const name = options.serverName ?? "";
1139
+ if (wanted !== "") {
1140
+ const channel = options.channels?.list().find((one) => one.id === wanted || one.name === wanted);
1141
+ return channel ? { title: channel.name, where: name } : null;
1142
+ }
1143
+ return name === "" ? null : { title: name, where: "" };
1144
+ }
1145
+ /** The shell, titled for what a join link opens. */
1146
+ export function joinDocument(shell, subject, site) {
1147
+ const brand = brandOf(shell, site);
1148
+ const shellTitle = /<title>([^<]*)<\/title>/i.exec(shell)?.[1] ?? "";
1149
+ const title = `${subject.title} — ${brand}`;
1150
+ const description = subject.where === ""
1151
+ ? `${subject.title} is live on ${brand}. Tune in free, no account needed.`
1152
+ : `${subject.title} is live on ${subject.where}. Tune in free on ${brand}, no account needed.`;
1153
+ return shell
1154
+ .replace(/<title>.*?<\/title>/s, `<title>${htmlText(title)}</title>`)
1155
+ .replace(/<meta name="description" content="[^"]*"\s*\/>/, `<meta name="description" content="${htmlText(description)}" />`)
1156
+ .replace(/<meta property="og:title" content="[^"]*"\s*\/>/, `<meta property="og:title" content="${htmlText(subject.title)}" />`)
1157
+ .replace(/<meta property="og:description" content="[^"]*"\s*\/>/, `<meta property="og:description" content="${htmlText(description)}" />`)
1158
+ // What the tab is called once the page takes over, so it does not stack
1159
+ // the channel on a title that already names the channel.
1160
+ .replace("</head>", `<meta name="nixamp-shell-title" content="${htmlText(shellTitle)}" />\n </head>`);
1161
+ }
1093
1162
  function json(response, code, body) {
1094
1163
  const text = JSON.stringify(body);
1095
1164
  response.writeHead(code, {
@@ -2409,6 +2478,10 @@ export function createHandler(engine, options) {
2409
2478
  // one nobody can dial is not worth showing.
2410
2479
  code: state?.code ?? "",
2411
2480
  url: state?.url ?? "",
2481
+ // Whether a link can go live here at all. The hosted directory
2482
+ // has no ffmpeg, and a page that offered to go live on it was
2483
+ // offering something that always failed.
2484
+ carries: options.carries !== false && Boolean(options.channels),
2412
2485
  },
2413
2486
  channels: (options.channels?.list() ?? []).map((one) => ({
2414
2487
  id: one.id,
@@ -2425,6 +2498,8 @@ export function createHandler(engine, options) {
2425
2498
  // How it has been going, for whoever may do something about it.
2426
2499
  redials: one.redials ?? 0,
2427
2500
  error: one.error ?? "",
2501
+ // For a list: how long it is, and which entry is on, from 0.
2502
+ ...(one.playlist ? { entries: one.playlist.length, entry: one.playlistAt ?? 0 } : {}),
2428
2503
  // The member who put it on, when one did: theirs to take off.
2429
2504
  startedBy: one.startedBy ?? "",
2430
2505
  })),
@@ -2683,6 +2758,12 @@ export function createHandler(engine, options) {
2683
2758
  json(response, 503, { error: "this server cannot carry channels" });
2684
2759
  return;
2685
2760
  }
2761
+ if (options.carries === false) {
2762
+ json(response, 503, {
2763
+ error: `${options.serverName ?? "this server"} has no ffmpeg, so it cannot carry a link. Pick a server to go live on.`,
2764
+ });
2765
+ return;
2766
+ }
2686
2767
  const channelId = linkChannelId(link);
2687
2768
  const known = links.get(link);
2688
2769
  // A member's is always a live: a decoder started on somebody else's
@@ -2756,7 +2837,7 @@ export function createHandler(engine, options) {
2756
2837
  });
2757
2838
  return;
2758
2839
  }
2759
- const started = await pullChannel(options.channels, options.ffprobe ?? ["ffprobe"], channelId, resolved.title, resolved.media, inputArgsFor(resolved.headers), resolved.audio);
2840
+ const started = await pullChannel(options.channels, options.ffprobe ?? ["ffprobe"], channelId, resolved.title, resolved.media, inputArgsFor(resolved.headers), resolved.audio, resolved.playlist ? { playlist: resolved.playlist } : {});
2760
2841
  if (!started) {
2761
2842
  json(response, 409, { error: "that link is already starting" });
2762
2843
  return;
@@ -3720,6 +3801,14 @@ export function createHandler(engine, options) {
3720
3801
  }
3721
3802
  catch { }
3722
3803
  }
3804
+ if (file.endsWith("index.html") && path === "/") {
3805
+ const subject = joinSubject(url, options);
3806
+ const shell = subject ? readIfPossible(file) : null;
3807
+ if (subject && shell !== null) {
3808
+ html(response, 200, joinDocument(shell, subject, options.invites?.site ?? "https://nixamp.com"));
3809
+ return;
3810
+ }
3811
+ }
3723
3812
  sendFile(request, response, file);
3724
3813
  return;
3725
3814
  }
@@ -4089,6 +4178,9 @@ export async function serve(argv, version = "0.1.0") {
4089
4178
  throw new Error("nixamp serve: which folder? Say `nixamp library ~/Music` once, or `nixamp daemon start ~/Music`.");
4090
4179
  }
4091
4180
  const root = isRemote(chosen) ? chosen : resolve(chosen);
4181
+ // What the stream is called where people see it: the name given, else the
4182
+ // folder or playlist it was started on, in words, else this machine.
4183
+ const streamName = options.name || humanizeSource(root) || hostname();
4092
4184
  const why = isRemote(root) ? "" : forbiddenLibrary(root);
4093
4185
  if (why) {
4094
4186
  throw new Error(`nixamp will not serve ${why}. Pick a folder with your media in it: nixamp library ~/Music`);
@@ -4186,6 +4278,7 @@ export async function serve(argv, version = "0.1.0") {
4186
4278
  ...(one.codecs ? { codecs: one.codecs } : {}),
4187
4279
  ...(one.position !== undefined ? { position: one.position } : {}),
4188
4280
  ...(one.live !== undefined ? { live: one.live } : {}),
4281
+ ...(one.playlist ? { playlist: one.playlist, playlistAt: one.playlistAt ?? 0 } : {}),
4189
4282
  }).then((channel) => {
4190
4283
  if (!channel)
4191
4284
  console.log(` "${one.id}" is already on.`);
@@ -4198,7 +4291,8 @@ export async function serve(argv, version = "0.1.0") {
4198
4291
  let lastRemembered = "";
4199
4292
  setInterval(() => {
4200
4293
  const now = rememberedNow(channels);
4201
- if (!now.some((one) => one.position !== undefined))
4294
+ // A list moves on without a position: which entry is on is the thing to keep.
4295
+ if (!now.some((one) => one.position !== undefined || one.playlist))
4202
4296
  return;
4203
4297
  const text = JSON.stringify(now);
4204
4298
  if (text === lastRemembered)
@@ -4548,14 +4642,14 @@ export async function serve(argv, version = "0.1.0") {
4548
4642
  rememberChannels: remembering,
4549
4643
  catalogs,
4550
4644
  publishUrls: () => publishUrls,
4551
- serverName: options.name || hostname(),
4645
+ serverName: streamName,
4552
4646
  homeSource: root,
4553
4647
  live: {
4554
4648
  status: () => ({
4555
4649
  live: publisher !== null,
4556
4650
  code: listing?.code ?? "",
4557
4651
  channelCodes: listing?.channelCodes ?? {},
4558
- name: listing?.name ?? (options.name || hostname()),
4652
+ name: listing?.name ?? streamName,
4559
4653
  url: listing?.url ?? (publishable_ ? shareLink(publishable_.url, listenKey, false) : ""),
4560
4654
  // Whether going live is even possible here. A laptop behind a router
4561
4655
  // with no address the world can reach cannot be listed, and a button
@@ -4609,6 +4703,7 @@ export async function serve(argv, version = "0.1.0") {
4609
4703
  // A cookie jar beside the state, when the operator has put one there,
4610
4704
  // for the sites that will not talk to a datacenter without one.
4611
4705
  ytdlp: tools.ytdlp ?? null,
4706
+ carries: tools.carries !== false,
4612
4707
  cookies: cookiesFile(),
4613
4708
  hls,
4614
4709
  compression,
@@ -4916,7 +5011,7 @@ export async function serve(argv, version = "0.1.0") {
4916
5011
  const audio = audioLink(publishable_.url, listenKey);
4917
5012
  return new Publisher({
4918
5013
  directory: DEFAULT_DIRECTORY,
4919
- name: options.name || hostname(),
5014
+ name: streamName,
4920
5015
  url: listen,
4921
5016
  audio,
4922
5017
  // The control link, for the owner to open this machine as its
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/audio.ts CHANGED
@@ -31,6 +31,12 @@ export interface Tools {
31
31
  play: string[] | null;
32
32
  /** yt-dlp, which turns a pasted page into a media address; null when there is none. */
33
33
  ytdlp?: string[] | null;
34
+ /**
35
+ * Whether an ffmpeg was actually found, as opposed to guessed at. A server
36
+ * without one cannot carry a channel, and should say so before a link is
37
+ * resolved and probed and blamed for it.
38
+ */
39
+ carries?: boolean;
34
40
  }
35
41
 
36
42
  function works(argv: string[], flag = "-version"): boolean {
@@ -112,6 +118,7 @@ export function detectTools(): Tools {
112
118
  ffprobe: ffprobe ?? ["ffprobe"],
113
119
  play,
114
120
  ytdlp,
121
+ carries: ffmpeg !== null,
115
122
  };
116
123
  }
117
124
 
@@ -439,7 +446,10 @@ export async function codecsOf(tools: Tools, path: string, input: string[] = [])
439
446
  ...rest,
440
447
  "-v", "quiet",
441
448
  "-print_format", "json",
442
- "-show_entries", "format=format_name,duration:stream=codec_type,codec_name,width,height",
449
+ // The disposition too: an MP3 with its cover art in it carries that
450
+ // art as a video stream of one JPEG, and a probe that took it for a
451
+ // picture put a podcast on the air as a film with no frames.
452
+ "-show_entries", "format=format_name,duration:stream=codec_type,codec_name,width,height:stream_disposition=attached_pic",
443
453
  // A transport stream needs looking further into than a file with an
444
454
  // index does: there is no header listing the tracks, only packets, and
445
455
  // a 4K recording can carry a second of null padding and a long gap to
@@ -461,14 +471,17 @@ export async function codecsOf(tools: Tools, path: string, input: string[] = [])
461
471
  child.on("close", () => {
462
472
  try {
463
473
  const parsed = JSON.parse(out) as {
464
- streams?: { codec_type?: string; codec_name?: string; width?: number; height?: number }[];
474
+ streams?: {
475
+ codec_type?: string; codec_name?: string; width?: number; height?: number;
476
+ disposition?: { attached_pic?: number };
477
+ }[];
465
478
  format?: { format_name?: string; duration?: string };
466
479
  };
467
480
  const streams = parsed.streams ?? [];
468
481
  // ffprobe prints seconds as a string, and "N/A" for a stream with no
469
482
  // end; both of those read as 0.
470
483
  const seconds = Number(parsed.format?.duration ?? 0);
471
- const picture = streams.find((s) => s.codec_type === "video");
484
+ const picture = streams.find((s) => s.codec_type === "video" && !isCoverArt(s));
472
485
  return done({
473
486
  video: picture?.codec_name ?? "",
474
487
  audio: streams.find((s) => s.codec_type === "audio")?.codec_name ?? "",
@@ -484,6 +497,22 @@ export async function codecsOf(tools: Tools, path: string, input: string[] = [])
484
497
  });
485
498
  }
486
499
 
500
+ /**
501
+ * Cover art is not a picture.
502
+ *
503
+ * An MP3, FLAC or M4A with its sleeve embedded shows ffprobe a video stream:
504
+ * one JPEG or PNG, flagged as an attached picture. Carried as video it is a
505
+ * channel whose picture never gets a second frame -- ffmpeg writes nothing
506
+ * and the source is dialled again and again -- so a sleeve counts for
507
+ * nothing and the sound decides. A still-image codec with no such flag is
508
+ * the same thing from a container that does not flag it.
509
+ */
510
+ const STILL_IMAGE = new Set(["png", "bmp", "gif", "tiff", "webp"]);
511
+ export function isCoverArt(stream: { codec_name?: string; disposition?: { attached_pic?: number } }): boolean {
512
+ if (stream.disposition?.attached_pic === 1) return true;
513
+ return STILL_IMAGE.has((stream.codec_name ?? "").toLowerCase());
514
+ }
515
+
487
516
  /** How much of a transport stream is read before deciding what is in it. */
488
517
  export const TRANSPORT_PROBE_BYTES = 20 * 1024 * 1024;
489
518
  export const TRANSPORT_ANALYSE_US = 10_000_000;
package/src/channels.ts CHANGED
@@ -56,6 +56,14 @@ export interface ChannelInfo {
56
56
  error?: string;
57
57
  /** How many times the source has been dialled again since it started. */
58
58
  redials?: number;
59
+ /**
60
+ * For a channel that plays a list: the entries, in order, and which one is
61
+ * on. When one ends the next is dialled at once, and the last is followed
62
+ * by the first: a station, not a file. A restart picks up at the entry
63
+ * that was on.
64
+ */
65
+ playlist?: string[];
66
+ playlistAt?: number;
59
67
  /**
60
68
  * For a pulled film: how far into it we are, in seconds, read off ffmpeg
61
69
  * as it goes. This is what a restart and a redial go back to. A live
@@ -95,6 +103,8 @@ export interface PullResume {
95
103
  live: boolean;
96
104
  /** Seconds into a film to start from. */
97
105
  position: number;
106
+ /** For a list of things rather than one: every entry, in order. */
107
+ playlist?: string[];
98
108
  }
99
109
 
100
110
  /**
@@ -246,6 +256,8 @@ export class Channel {
246
256
  private fragments: Fragments | null = null;
247
257
  /** For a pulled channel: what to run, and how many times it has failed. */
248
258
  private redial: (() => void) | null = null;
259
+ /** For a channel playing a list: move to the next entry. Null when there is no list. */
260
+ private advance: (() => boolean) | null = null;
249
261
  private failures = 0;
250
262
  private timer: ReturnType<typeof setTimeout> | null = null;
251
263
  /** Fires when a pulled source has said nothing for STALL. */
@@ -349,19 +361,37 @@ export class Channel {
349
361
  this.stall = stall;
350
362
  if (this.info.kind === "video") this.fragments = new Fragments();
351
363
  const [command, ...prefix] = this.options.ffmpeg as [string, ...string[]];
352
- const remote = /^https?:\/\//i.test(source);
353
364
  this.info.live = resume.live;
354
365
  if (!resume.live) this.info.position = Math.max(0, resume.position);
355
- // What every remote input is told: dial again when the CDN drops it, and
356
- // give up on a socket that has gone quiet. Input options apply to the
357
- // input that follows them, so a second input is told again.
358
- const remoteArgs = remote
359
- ? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5", "-rw_timeout", String(stall * 1000)]
360
- : [];
366
+ // A list plays entry by entry: the one that is on is what gets dialled,
367
+ // and an entry that ended is followed by the next, at once.
368
+ const list = resume.playlist && resume.playlist.length > 0 ? resume.playlist : null;
369
+ let at = 0;
370
+ if (list) {
371
+ this.info.playlist = list;
372
+ at = Math.min(Math.max(0, this.info.playlistAt ?? 0), list.length - 1);
373
+ this.info.playlistAt = at;
374
+ }
375
+ let current = list ? (list[at] as string) : source;
376
+ this.advance = list && list.length > 1
377
+ ? () => {
378
+ at = (at + 1) % list.length;
379
+ this.info.playlistAt = at;
380
+ current = list[at] as string;
381
+ return true;
382
+ }
383
+ : null;
361
384
 
362
385
  const dial = (): void => {
363
386
  if (this.closing) return;
364
387
  this.stderr = "";
388
+ const remote = /^https?:\/\//i.test(current);
389
+ // What every remote input is told: dial again when the CDN drops it, and
390
+ // give up on a socket that has gone quiet. Input options apply to the
391
+ // input that follows them, so a second input is told again.
392
+ const remoteArgs = remote
393
+ ? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5", "-rw_timeout", String(stall * 1000)]
394
+ : [];
365
395
  // Where to pick a film up from: a little before where it was, since
366
396
  // the place was written down a moment ago and a moment of it twice is
367
397
  // better than a moment of it missing. A live source is joined as is,
@@ -421,7 +451,7 @@ export class Channel {
421
451
  // and a CDN that got them from yt-dlp and not from us answers 403.
422
452
  ...input,
423
453
  ...seek,
424
- "-i", source,
454
+ "-i", current,
425
455
  // The sound, when the site keeps it apart from the picture: a
426
456
  // second input, dialled the same way, that the encode maps in.
427
457
  ...(audio ? [...remoteArgs, ...(paced ? ["-re"] : []), ...input, ...seek, "-i", audio] : []),
@@ -584,13 +614,20 @@ export class Channel {
584
614
  this.close();
585
615
  return;
586
616
  }
587
- this.info.redials = (this.info.redials ?? 0) + 1;
617
+ // A list moves on. An entry that played to its end is not a source that
618
+ // dropped: the next is dialled now, and it is not a redial. One that
619
+ // gave nothing is skipped the same way, counted as the failure it was,
620
+ // so a list of dead links gives up rather than cycling for ever.
621
+ const moved = this.advance?.() ?? false;
622
+ const ended = moved && sent;
623
+ if (ended) this.info.error = undefined;
624
+ else this.info.redials = (this.info.redials ?? 0) + 1;
588
625
  this.startOver();
589
626
  const dial = this.redial;
590
627
  this.timer = setTimeout(() => {
591
628
  this.timer = null;
592
629
  dial();
593
- }, REDIAL);
630
+ }, ended ? 0 : REDIAL);
594
631
  // A redial is not a reason to keep the process alive at exit.
595
632
  this.timer.unref?.();
596
633
  }
@@ -1171,6 +1208,9 @@ export interface RememberedChannel {
1171
1208
  live?: boolean;
1172
1209
  /** The member who put it on, so it is still theirs after a restart. */
1173
1210
  startedBy?: string;
1211
+ /** For a list: every entry, and which was on, so it carries on from there. */
1212
+ playlist?: string[];
1213
+ playlistAt?: number;
1174
1214
  }
1175
1215
 
1176
1216
  const REMEMBERED = "channels.json";
@@ -1207,6 +1247,11 @@ export function rememberedChannels(dir: string, port: number): RememberedChannel
1207
1247
  if (typeof one["position"] === "number" && Number.isFinite(one["position"]) && one["position"] > 0) kept.position = one["position"];
1208
1248
  if (typeof one["live"] === "boolean") kept.live = one["live"];
1209
1249
  if (typeof one["startedBy"] === "string" && one["startedBy"] !== "") kept.startedBy = one["startedBy"];
1250
+ if (Array.isArray(one["playlist"])) {
1251
+ const entries = (one["playlist"] as unknown[]).filter((entry): entry is string => typeof entry === "string" && entry !== "");
1252
+ if (entries.length > 0) kept.playlist = entries;
1253
+ if (typeof one["playlistAt"] === "number" && Number.isInteger(one["playlistAt"]) && one["playlistAt"] >= 0) kept.playlistAt = one["playlistAt"];
1254
+ }
1210
1255
  return kept;
1211
1256
  });
1212
1257
  } catch {
@@ -1230,6 +1275,10 @@ export function rememberedNow(channels: Channels): RememberedChannel[] {
1230
1275
  if (typeof one.live === "boolean") kept.live = one.live;
1231
1276
  if (!one.live && typeof one.position === "number" && one.position > 0) kept.position = Math.floor(one.position);
1232
1277
  if (one.startedBy) kept.startedBy = one.startedBy;
1278
+ if (one.playlist && one.playlist.length > 0) {
1279
+ kept.playlist = one.playlist;
1280
+ kept.playlistAt = one.playlistAt ?? 0;
1281
+ }
1233
1282
  return kept;
1234
1283
  });
1235
1284
  }
package/src/links.ts CHANGED
@@ -21,6 +21,7 @@
21
21
  */
22
22
  import { createHash } from "node:crypto";
23
23
  import { spawn } from "node:child_process";
24
+ import { parseCatalog } from "./catalogs.ts";
24
25
 
25
26
  export interface ResolvedLink {
26
27
  /** What to call it: the page's title, or the file's name for a bare file. */
@@ -50,6 +51,12 @@ export interface ResolvedLink {
50
51
  ext: string;
51
52
  /** The page it came from. */
52
53
  page: string;
54
+ /**
55
+ * For a pasted .m3u: every entry in it, in order. The channel plays them
56
+ * one after another and starts over at the end, a station rather than a
57
+ * file; `media` is the first, for the probe that decides what it holds.
58
+ */
59
+ playlist?: string[];
53
60
  }
54
61
 
55
62
  /**
@@ -71,6 +78,86 @@ export function isDirectMedia(url: string): boolean {
71
78
  }
72
79
  }
73
80
 
81
+ /**
82
+ * A plain m3u: a list of things to play, one per line, which is not a thing
83
+ * ffmpeg reads. The .m3u8 of HLS is a list of segments of one thing, which
84
+ * it does, and stays in DIRECT above.
85
+ */
86
+ const PLAYLIST = /\.m3u(\?.*)?$/i;
87
+
88
+ export function isPlaylistLink(url: string): boolean {
89
+ try {
90
+ const parsed = new URL(url);
91
+ return PLAYLIST.test(parsed.pathname + parsed.search);
92
+ } catch {
93
+ return false;
94
+ }
95
+ }
96
+
97
+ /** How much of a playlist is worth reading: a list, not a library dump. */
98
+ export const PLAYLIST_MAX_BYTES = 2_000_000;
99
+ export const PLAYLIST_MAX_ENTRIES = 1000;
100
+ export const PLAYLIST_FETCH_TIMEOUT_MS = 15_000;
101
+
102
+ /**
103
+ * What a playlist's text holds: the entries, in order, resolved against
104
+ * where the list was fetched from. Some things called .m3u are HLS after
105
+ * all -- a segment list with #EXT-X- tags -- and those are one stream for
106
+ * ffmpeg to read as it is, not a list of streams.
107
+ */
108
+ export function playlistFrom(text: string, base: string): { hls: boolean; sources: string[] } {
109
+ if (/^#EXT-X-/m.test(text)) return { hls: true, sources: [] };
110
+ const sources = parseCatalog(text, base)
111
+ .map((entry) => entry.source)
112
+ .filter((source) => /^(https?|rtmps?):\/\//i.test(source))
113
+ .slice(0, PLAYLIST_MAX_ENTRIES);
114
+ return { hls: false, sources };
115
+ }
116
+
117
+ /**
118
+ * A pasted .m3u, read and turned into a channel's worth of entries. Fetched
119
+ * here rather than by yt-dlp, which would take the first entry and stop, or
120
+ * by ffmpeg, which does not read a plain list at all.
121
+ */
122
+ export async function resolvePlaylist(
123
+ url: string,
124
+ options: { timeoutMs?: number; fetcher?: typeof fetch } = {},
125
+ ): Promise<ResolvedLink | { error: string }> {
126
+ const get = options.fetcher ?? fetch;
127
+ const abort = new AbortController();
128
+ const timer = setTimeout(() => abort.abort(), options.timeoutMs ?? PLAYLIST_FETCH_TIMEOUT_MS);
129
+ let text = "";
130
+ try {
131
+ const answer = await get(url, { signal: abort.signal, headers: { "user-agent": "nixamp" }, redirect: "follow" });
132
+ if (!answer.ok) return { error: `that playlist could not be fetched (HTTP ${answer.status})` };
133
+ text = (await answer.text()).slice(0, PLAYLIST_MAX_BYTES);
134
+ } catch (error) {
135
+ const why = abort.signal.aborted ? "took too long to answer" : ((error as Error).message || "could not be fetched");
136
+ return { error: `that playlist ${why}` };
137
+ } finally {
138
+ clearTimeout(timer);
139
+ }
140
+ const list = playlistFrom(text, url);
141
+ if (list.hls) return directLink(url);
142
+ const [first] = list.sources;
143
+ if (!first) return { error: "that playlist has nothing in it this can play" };
144
+ return {
145
+ title: fileNameOf(url).replace(/\.m3u$/i, "") || "Playlist",
146
+ media: first,
147
+ audio: "",
148
+ // A station: joined where it is, never seeked, and with no end to save.
149
+ live: true,
150
+ duration: 0,
151
+ // Whether there is a picture is the first entry's to say; ffprobe settles it.
152
+ video: true,
153
+ headers: {},
154
+ extractor: "playlist",
155
+ ext: "",
156
+ page: url,
157
+ playlist: list.sources,
158
+ };
159
+ }
160
+
74
161
  /** A link somebody pasted, or "" when it is not one this can play. */
75
162
  export function playableLink(entered: unknown): string {
76
163
  if (typeof entered !== "string") return "";
@@ -313,6 +400,7 @@ export async function resolveLink(
313
400
  options: { cookies?: string; timeoutMs?: number; format?: string } = {},
314
401
  ): Promise<ResolvedLink | { error: string }> {
315
402
  if (isDirectMedia(url)) return directLink(url);
403
+ if (isPlaylistLink(url)) return resolvePlaylist(url, { timeoutMs: options.timeoutMs });
316
404
  // Without yt-dlp, a link is taken as the media it may well be: an IPTV
317
405
  // feed has no extension and no page behind it, and ffprobe -- asked next,
318
406
  // before anything goes on the air -- tells a stream from a web page in a
package/src/naming.ts CHANGED
@@ -195,3 +195,44 @@ export function labelFor(name: string, hostname: string): string {
195
195
  }
196
196
  return "server";
197
197
  }
198
+
199
+ /** File endings that name a playlist rather than a folder, dropped from a title. */
200
+ const PLAYLIST_ENDING = /\.(m3u8?|pls|xspf|txt|json)$/i;
201
+
202
+ /**
203
+ * What to call a stream that was started on a path, for people.
204
+ *
205
+ * `nixamp serve ~/Music/live-sets_2024` used to be listed as the machine's
206
+ * hostname, which tells a stranger nothing. The last segment of the path is
207
+ * what the person who made the folder called it, so that is the title: the
208
+ * separators that a filesystem forces become spaces, a playlist loses its
209
+ * ending, and each lowercase word gets a capital. Words with a capital in
210
+ * them already are left alone, so "DJ" and "LoFi" stay as written, and a
211
+ * dash or dot between two digits stays too, so a date is still a date.
212
+ * Empty when the path has no segment worth saying, and the caller falls
213
+ * back to whatever it used before.
214
+ */
215
+ export function humanizeSource(source: string): string {
216
+ const remote = /^[a-z][a-z0-9+.-]*:\/\//i.test(source);
217
+ let last = "";
218
+ try {
219
+ const path = remote ? new URL(source).pathname : source;
220
+ last = path.split("/").filter(Boolean).pop() ?? "";
221
+ if (remote) last = decodeURIComponent(last);
222
+ } catch {
223
+ return "";
224
+ }
225
+ if (last === "" || last === "~" || last === ".") return "";
226
+ const words = last
227
+ .replace(PLAYLIST_ENDING, "")
228
+ .replace(/(?<!\d)[-_.+]+|[-_.+]+(?!\d)/g, " ")
229
+ .replace(/\s+/g, " ")
230
+ .trim();
231
+ if (words === "") return "";
232
+ return words
233
+ .split(" ")
234
+ .map((word) => (word === word.toLowerCase() ? word.charAt(0).toUpperCase() + word.slice(1) : word))
235
+ .join(" ")
236
+ .slice(0, 60)
237
+ .trim();
238
+ }