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/audio.d.ts CHANGED
@@ -15,6 +15,12 @@ export interface Tools {
15
15
  play: string[] | null;
16
16
  /** yt-dlp, which turns a pasted page into a media address; null when there is none. */
17
17
  ytdlp?: string[] | null;
18
+ /**
19
+ * Whether an ffmpeg was actually found, as opposed to guessed at. A server
20
+ * without one cannot carry a channel, and should say so before a link is
21
+ * resolved and probed and blamed for it.
22
+ */
23
+ carries?: boolean;
18
24
  }
19
25
  /**
20
26
  * Find the tools. A bare `ffmpeg` on PATH is tried first; mise shims are common
@@ -120,6 +126,12 @@ export interface Codecs {
120
126
  * the whole library came to be tagged with the process wedged solid.
121
127
  */
122
128
  export declare function codecsOf(tools: Tools, path: string, input?: string[]): Promise<Codecs>;
129
+ export declare function isCoverArt(stream: {
130
+ codec_name?: string;
131
+ disposition?: {
132
+ attached_pic?: number;
133
+ };
134
+ }): boolean;
123
135
  /** How much of a transport stream is read before deciding what is in it. */
124
136
  export declare const TRANSPORT_PROBE_BYTES: number;
125
137
  export declare const TRANSPORT_ANALYSE_US = 10000000;
package/dist/audio.js CHANGED
@@ -91,6 +91,7 @@ export function detectTools() {
91
91
  ffprobe: ffprobe ?? ["ffprobe"],
92
92
  play,
93
93
  ytdlp,
94
+ carries: ffmpeg !== null,
94
95
  };
95
96
  }
96
97
  export function probe(tools, path) {
@@ -365,7 +366,10 @@ export async function codecsOf(tools, path, input = []) {
365
366
  ...rest,
366
367
  "-v", "quiet",
367
368
  "-print_format", "json",
368
- "-show_entries", "format=format_name,duration:stream=codec_type,codec_name,width,height",
369
+ // The disposition too: an MP3 with its cover art in it carries that
370
+ // art as a video stream of one JPEG, and a probe that took it for a
371
+ // picture put a podcast on the air as a film with no frames.
372
+ "-show_entries", "format=format_name,duration:stream=codec_type,codec_name,width,height:stream_disposition=attached_pic",
369
373
  // A transport stream needs looking further into than a file with an
370
374
  // index does: there is no header listing the tracks, only packets, and
371
375
  // a 4K recording can carry a second of null padding and a long gap to
@@ -389,7 +393,7 @@ export async function codecsOf(tools, path, input = []) {
389
393
  // ffprobe prints seconds as a string, and "N/A" for a stream with no
390
394
  // end; both of those read as 0.
391
395
  const seconds = Number(parsed.format?.duration ?? 0);
392
- const picture = streams.find((s) => s.codec_type === "video");
396
+ const picture = streams.find((s) => s.codec_type === "video" && !isCoverArt(s));
393
397
  return done({
394
398
  video: picture?.codec_name ?? "",
395
399
  audio: streams.find((s) => s.codec_type === "audio")?.codec_name ?? "",
@@ -405,6 +409,22 @@ export async function codecsOf(tools, path, input = []) {
405
409
  });
406
410
  });
407
411
  }
412
+ /**
413
+ * Cover art is not a picture.
414
+ *
415
+ * An MP3, FLAC or M4A with its sleeve embedded shows ffprobe a video stream:
416
+ * one JPEG or PNG, flagged as an attached picture. Carried as video it is a
417
+ * channel whose picture never gets a second frame -- ffmpeg writes nothing
418
+ * and the source is dialled again and again -- so a sleeve counts for
419
+ * nothing and the sound decides. A still-image codec with no such flag is
420
+ * the same thing from a container that does not flag it.
421
+ */
422
+ const STILL_IMAGE = new Set(["png", "bmp", "gif", "tiff", "webp"]);
423
+ export function isCoverArt(stream) {
424
+ if (stream.disposition?.attached_pic === 1)
425
+ return true;
426
+ return STILL_IMAGE.has((stream.codec_name ?? "").toLowerCase());
427
+ }
408
428
  /** How much of a transport stream is read before deciding what is in it. */
409
429
  export const TRANSPORT_PROBE_BYTES = 20 * 1024 * 1024;
410
430
  export const TRANSPORT_ANALYSE_US = 10_000_000;
@@ -33,6 +33,14 @@ export interface ChannelInfo {
33
33
  error?: string;
34
34
  /** How many times the source has been dialled again since it started. */
35
35
  redials?: number;
36
+ /**
37
+ * For a channel that plays a list: the entries, in order, and which one is
38
+ * on. When one ends the next is dialled at once, and the last is followed
39
+ * by the first: a station, not a file. A restart picks up at the entry
40
+ * that was on.
41
+ */
42
+ playlist?: string[];
43
+ playlistAt?: number;
36
44
  /**
37
45
  * For a pulled film: how far into it we are, in seconds, read off ffmpeg
38
46
  * as it goes. This is what a restart and a redial go back to. A live
@@ -78,6 +86,8 @@ export interface PullResume {
78
86
  live: boolean;
79
87
  /** Seconds into a film to start from. */
80
88
  position: number;
89
+ /** For a list of things rather than one: every entry, in order. */
90
+ playlist?: string[];
81
91
  }
82
92
  /**
83
93
  * A source read by us rather than by ffmpeg: what to tell ffmpeg it is,
@@ -187,6 +197,8 @@ export declare class Channel {
187
197
  private fragments;
188
198
  /** For a pulled channel: what to run, and how many times it has failed. */
189
199
  private redial;
200
+ /** For a channel playing a list: move to the next entry. Null when there is no list. */
201
+ private advance;
190
202
  private failures;
191
203
  private timer;
192
204
  /** Fires when a pulled source has said nothing for STALL. */
@@ -478,6 +490,9 @@ export interface RememberedChannel {
478
490
  live?: boolean;
479
491
  /** The member who put it on, so it is still theirs after a restart. */
480
492
  startedBy?: string;
493
+ /** For a list: every entry, and which was on, so it carries on from there. */
494
+ playlist?: string[];
495
+ playlistAt?: number;
481
496
  }
482
497
  export declare function rememberedChannels(dir: string, port: number): RememberedChannel[];
483
498
  /**
package/dist/channels.js CHANGED
@@ -133,6 +133,8 @@ export class Channel {
133
133
  fragments = null;
134
134
  /** For a pulled channel: what to run, and how many times it has failed. */
135
135
  redial = null;
136
+ /** For a channel playing a list: move to the next entry. Null when there is no list. */
137
+ advance = null;
136
138
  failures = 0;
137
139
  timer = null;
138
140
  /** Fires when a pulled source has said nothing for STALL. */
@@ -220,20 +222,38 @@ export class Channel {
220
222
  if (this.info.kind === "video")
221
223
  this.fragments = new Fragments();
222
224
  const [command, ...prefix] = this.options.ffmpeg;
223
- const remote = /^https?:\/\//i.test(source);
224
225
  this.info.live = resume.live;
225
226
  if (!resume.live)
226
227
  this.info.position = Math.max(0, resume.position);
227
- // What every remote input is told: dial again when the CDN drops it, and
228
- // give up on a socket that has gone quiet. Input options apply to the
229
- // input that follows them, so a second input is told again.
230
- const remoteArgs = remote
231
- ? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5", "-rw_timeout", String(stall * 1000)]
232
- : [];
228
+ // A list plays entry by entry: the one that is on is what gets dialled,
229
+ // and an entry that ended is followed by the next, at once.
230
+ const list = resume.playlist && resume.playlist.length > 0 ? resume.playlist : null;
231
+ let at = 0;
232
+ if (list) {
233
+ this.info.playlist = list;
234
+ at = Math.min(Math.max(0, this.info.playlistAt ?? 0), list.length - 1);
235
+ this.info.playlistAt = at;
236
+ }
237
+ let current = list ? list[at] : source;
238
+ this.advance = list && list.length > 1
239
+ ? () => {
240
+ at = (at + 1) % list.length;
241
+ this.info.playlistAt = at;
242
+ current = list[at];
243
+ return true;
244
+ }
245
+ : null;
233
246
  const dial = () => {
234
247
  if (this.closing)
235
248
  return;
236
249
  this.stderr = "";
250
+ const remote = /^https?:\/\//i.test(current);
251
+ // What every remote input is told: dial again when the CDN drops it, and
252
+ // give up on a socket that has gone quiet. Input options apply to the
253
+ // input that follows them, so a second input is told again.
254
+ const remoteArgs = remote
255
+ ? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5", "-rw_timeout", String(stall * 1000)]
256
+ : [];
237
257
  // Where to pick a film up from: a little before where it was, since
238
258
  // the place was written down a moment ago and a moment of it twice is
239
259
  // better than a moment of it missing. A live source is joined as is,
@@ -291,7 +311,7 @@ export class Channel {
291
311
  // and a CDN that got them from yt-dlp and not from us answers 403.
292
312
  ...input,
293
313
  ...seek,
294
- "-i", source,
314
+ "-i", current,
295
315
  // The sound, when the site keeps it apart from the picture: a
296
316
  // second input, dialled the same way, that the encode maps in.
297
317
  ...(audio ? [...remoteArgs, ...(paced ? ["-re"] : []), ...input, ...seek, "-i", audio] : []),
@@ -461,13 +481,22 @@ export class Channel {
461
481
  this.close();
462
482
  return;
463
483
  }
464
- this.info.redials = (this.info.redials ?? 0) + 1;
484
+ // A list moves on. An entry that played to its end is not a source that
485
+ // dropped: the next is dialled now, and it is not a redial. One that
486
+ // gave nothing is skipped the same way, counted as the failure it was,
487
+ // so a list of dead links gives up rather than cycling for ever.
488
+ const moved = this.advance?.() ?? false;
489
+ const ended = moved && sent;
490
+ if (ended)
491
+ this.info.error = undefined;
492
+ else
493
+ this.info.redials = (this.info.redials ?? 0) + 1;
465
494
  this.startOver();
466
495
  const dial = this.redial;
467
496
  this.timer = setTimeout(() => {
468
497
  this.timer = null;
469
498
  dial();
470
- }, REDIAL);
499
+ }, ended ? 0 : REDIAL);
471
500
  // A redial is not a reason to keep the process alive at exit.
472
501
  this.timer.unref?.();
473
502
  }
@@ -1032,6 +1061,13 @@ export function rememberedChannels(dir, port) {
1032
1061
  kept.live = one["live"];
1033
1062
  if (typeof one["startedBy"] === "string" && one["startedBy"] !== "")
1034
1063
  kept.startedBy = one["startedBy"];
1064
+ if (Array.isArray(one["playlist"])) {
1065
+ const entries = one["playlist"].filter((entry) => typeof entry === "string" && entry !== "");
1066
+ if (entries.length > 0)
1067
+ kept.playlist = entries;
1068
+ if (typeof one["playlistAt"] === "number" && Number.isInteger(one["playlistAt"]) && one["playlistAt"] >= 0)
1069
+ kept.playlistAt = one["playlistAt"];
1070
+ }
1035
1071
  return kept;
1036
1072
  });
1037
1073
  }
@@ -1060,6 +1096,10 @@ export function rememberedNow(channels) {
1060
1096
  kept.position = Math.floor(one.position);
1061
1097
  if (one.startedBy)
1062
1098
  kept.startedBy = one.startedBy;
1099
+ if (one.playlist && one.playlist.length > 0) {
1100
+ kept.playlist = one.playlist;
1101
+ kept.playlistAt = one.playlistAt ?? 0;
1102
+ }
1063
1103
  return kept;
1064
1104
  });
1065
1105
  }
package/dist/links.d.ts CHANGED
@@ -26,8 +26,40 @@ export interface ResolvedLink {
26
26
  ext: string;
27
27
  /** The page it came from. */
28
28
  page: string;
29
+ /**
30
+ * For a pasted .m3u: every entry in it, in order. The channel plays them
31
+ * one after another and starts over at the end, a station rather than a
32
+ * file; `media` is the first, for the probe that decides what it holds.
33
+ */
34
+ playlist?: string[];
29
35
  }
30
36
  export declare function isDirectMedia(url: string): boolean;
37
+ export declare function isPlaylistLink(url: string): boolean;
38
+ /** How much of a playlist is worth reading: a list, not a library dump. */
39
+ export declare const PLAYLIST_MAX_BYTES = 2000000;
40
+ export declare const PLAYLIST_MAX_ENTRIES = 1000;
41
+ export declare const PLAYLIST_FETCH_TIMEOUT_MS = 15000;
42
+ /**
43
+ * What a playlist's text holds: the entries, in order, resolved against
44
+ * where the list was fetched from. Some things called .m3u are HLS after
45
+ * all -- a segment list with #EXT-X- tags -- and those are one stream for
46
+ * ffmpeg to read as it is, not a list of streams.
47
+ */
48
+ export declare function playlistFrom(text: string, base: string): {
49
+ hls: boolean;
50
+ sources: string[];
51
+ };
52
+ /**
53
+ * A pasted .m3u, read and turned into a channel's worth of entries. Fetched
54
+ * here rather than by yt-dlp, which would take the first entry and stop, or
55
+ * by ffmpeg, which does not read a plain list at all.
56
+ */
57
+ export declare function resolvePlaylist(url: string, options?: {
58
+ timeoutMs?: number;
59
+ fetcher?: typeof fetch;
60
+ }): Promise<ResolvedLink | {
61
+ error: string;
62
+ }>;
31
63
  /** A link somebody pasted, or "" when it is not one this can play. */
32
64
  export declare function playableLink(entered: unknown): string;
33
65
  /** A channel id for a link: stable, so two people pasting the same link share one decoder. */
package/dist/links.js 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.js";
24
25
  /**
25
26
  * Formats that are a file, not a page.
26
27
  *
@@ -40,6 +41,85 @@ export function isDirectMedia(url) {
40
41
  return false;
41
42
  }
42
43
  }
44
+ /**
45
+ * A plain m3u: a list of things to play, one per line, which is not a thing
46
+ * ffmpeg reads. The .m3u8 of HLS is a list of segments of one thing, which
47
+ * it does, and stays in DIRECT above.
48
+ */
49
+ const PLAYLIST = /\.m3u(\?.*)?$/i;
50
+ export function isPlaylistLink(url) {
51
+ try {
52
+ const parsed = new URL(url);
53
+ return PLAYLIST.test(parsed.pathname + parsed.search);
54
+ }
55
+ catch {
56
+ return false;
57
+ }
58
+ }
59
+ /** How much of a playlist is worth reading: a list, not a library dump. */
60
+ export const PLAYLIST_MAX_BYTES = 2_000_000;
61
+ export const PLAYLIST_MAX_ENTRIES = 1000;
62
+ export const PLAYLIST_FETCH_TIMEOUT_MS = 15_000;
63
+ /**
64
+ * What a playlist's text holds: the entries, in order, resolved against
65
+ * where the list was fetched from. Some things called .m3u are HLS after
66
+ * all -- a segment list with #EXT-X- tags -- and those are one stream for
67
+ * ffmpeg to read as it is, not a list of streams.
68
+ */
69
+ export function playlistFrom(text, base) {
70
+ if (/^#EXT-X-/m.test(text))
71
+ return { hls: true, sources: [] };
72
+ const sources = parseCatalog(text, base)
73
+ .map((entry) => entry.source)
74
+ .filter((source) => /^(https?|rtmps?):\/\//i.test(source))
75
+ .slice(0, PLAYLIST_MAX_ENTRIES);
76
+ return { hls: false, sources };
77
+ }
78
+ /**
79
+ * A pasted .m3u, read and turned into a channel's worth of entries. Fetched
80
+ * here rather than by yt-dlp, which would take the first entry and stop, or
81
+ * by ffmpeg, which does not read a plain list at all.
82
+ */
83
+ export async function resolvePlaylist(url, options = {}) {
84
+ const get = options.fetcher ?? fetch;
85
+ const abort = new AbortController();
86
+ const timer = setTimeout(() => abort.abort(), options.timeoutMs ?? PLAYLIST_FETCH_TIMEOUT_MS);
87
+ let text = "";
88
+ try {
89
+ const answer = await get(url, { signal: abort.signal, headers: { "user-agent": "nixamp" }, redirect: "follow" });
90
+ if (!answer.ok)
91
+ return { error: `that playlist could not be fetched (HTTP ${answer.status})` };
92
+ text = (await answer.text()).slice(0, PLAYLIST_MAX_BYTES);
93
+ }
94
+ catch (error) {
95
+ const why = abort.signal.aborted ? "took too long to answer" : (error.message || "could not be fetched");
96
+ return { error: `that playlist ${why}` };
97
+ }
98
+ finally {
99
+ clearTimeout(timer);
100
+ }
101
+ const list = playlistFrom(text, url);
102
+ if (list.hls)
103
+ return directLink(url);
104
+ const [first] = list.sources;
105
+ if (!first)
106
+ return { error: "that playlist has nothing in it this can play" };
107
+ return {
108
+ title: fileNameOf(url).replace(/\.m3u$/i, "") || "Playlist",
109
+ media: first,
110
+ audio: "",
111
+ // A station: joined where it is, never seeked, and with no end to save.
112
+ live: true,
113
+ duration: 0,
114
+ // Whether there is a picture is the first entry's to say; ffprobe settles it.
115
+ video: true,
116
+ headers: {},
117
+ extractor: "playlist",
118
+ ext: "",
119
+ page: url,
120
+ playlist: list.sources,
121
+ };
122
+ }
43
123
  /** A link somebody pasted, or "" when it is not one this can play. */
44
124
  export function playableLink(entered) {
45
125
  if (typeof entered !== "string")
@@ -277,6 +357,8 @@ export const RESOLVE_TIMEOUT_MS = 60_000;
277
357
  export async function resolveLink(ytdlp, url, options = {}) {
278
358
  if (isDirectMedia(url))
279
359
  return directLink(url);
360
+ if (isPlaylistLink(url))
361
+ return resolvePlaylist(url, { timeoutMs: options.timeoutMs });
280
362
  // Without yt-dlp, a link is taken as the media it may well be: an IPTV
281
363
  // feed has no extension and no page behind it, and ffprobe -- asked next,
282
364
  // before anything goes on the air -- tells a stream from a web page in a
package/dist/naming.d.ts CHANGED
@@ -55,3 +55,17 @@ export declare function readCertFiles(stateDir: string, host: string): {
55
55
  * first label of its hostname, made safe for a subdomain.
56
56
  */
57
57
  export declare function labelFor(name: string, hostname: string): string;
58
+ /**
59
+ * What to call a stream that was started on a path, for people.
60
+ *
61
+ * `nixamp serve ~/Music/live-sets_2024` used to be listed as the machine's
62
+ * hostname, which tells a stranger nothing. The last segment of the path is
63
+ * what the person who made the folder called it, so that is the title: the
64
+ * separators that a filesystem forces become spaces, a playlist loses its
65
+ * ending, and each lowercase word gets a capital. Words with a capital in
66
+ * them already are left alone, so "DJ" and "LoFi" stay as written, and a
67
+ * dash or dot between two digits stays too, so a date is still a date.
68
+ * Empty when the path has no segment worth saying, and the caller falls
69
+ * back to whatever it used before.
70
+ */
71
+ export declare function humanizeSource(source: string): string;
package/dist/naming.js CHANGED
@@ -148,3 +148,46 @@ export function labelFor(name, hostname) {
148
148
  }
149
149
  return "server";
150
150
  }
151
+ /** File endings that name a playlist rather than a folder, dropped from a title. */
152
+ const PLAYLIST_ENDING = /\.(m3u8?|pls|xspf|txt|json)$/i;
153
+ /**
154
+ * What to call a stream that was started on a path, for people.
155
+ *
156
+ * `nixamp serve ~/Music/live-sets_2024` used to be listed as the machine's
157
+ * hostname, which tells a stranger nothing. The last segment of the path is
158
+ * what the person who made the folder called it, so that is the title: the
159
+ * separators that a filesystem forces become spaces, a playlist loses its
160
+ * ending, and each lowercase word gets a capital. Words with a capital in
161
+ * them already are left alone, so "DJ" and "LoFi" stay as written, and a
162
+ * dash or dot between two digits stays too, so a date is still a date.
163
+ * Empty when the path has no segment worth saying, and the caller falls
164
+ * back to whatever it used before.
165
+ */
166
+ export function humanizeSource(source) {
167
+ const remote = /^[a-z][a-z0-9+.-]*:\/\//i.test(source);
168
+ let last = "";
169
+ try {
170
+ const path = remote ? new URL(source).pathname : source;
171
+ last = path.split("/").filter(Boolean).pop() ?? "";
172
+ if (remote)
173
+ last = decodeURIComponent(last);
174
+ }
175
+ catch {
176
+ return "";
177
+ }
178
+ if (last === "" || last === "~" || last === ".")
179
+ return "";
180
+ const words = last
181
+ .replace(PLAYLIST_ENDING, "")
182
+ .replace(/(?<!\d)[-_.+]+|[-_.+]+(?!\d)/g, " ")
183
+ .replace(/\s+/g, " ")
184
+ .trim();
185
+ if (words === "")
186
+ return "";
187
+ return words
188
+ .split(" ")
189
+ .map((word) => (word === word.toLowerCase() ? word.charAt(0).toUpperCase() + word.slice(1) : word))
190
+ .join(" ")
191
+ .slice(0, 60)
192
+ .trim();
193
+ }
package/dist/server.d.ts CHANGED
@@ -400,6 +400,9 @@ export interface KnownSource {
400
400
  codecs?: Codecs;
401
401
  position?: number;
402
402
  live?: boolean;
403
+ /** A list of entries to play in turn, and which one was on. */
404
+ playlist?: string[];
405
+ playlistAt?: number;
403
406
  }
404
407
  /** How many times a source that answers nothing is asked, and how far apart. */
405
408
  export declare const PROBE_TRIES = 3;
@@ -458,9 +461,29 @@ export declare function isSignInPath(path: string): boolean;
458
461
  *
459
462
  * Read from the page rather than hard-coded, because one NixAmp serves several
460
463
  * branded clients and each brings its own head. og:site_name first, then the
461
- * part of the title before the dash, then the hostname.
464
+ * part of the title before the dash or the colon ("nixamp: broadcast live
465
+ * radio..." is nixamp), then the hostname.
462
466
  */
463
467
  export declare function brandOf(shell: string, site: string): string;
468
+ /**
469
+ * What a join link is for, named by something this server actually knows.
470
+ *
471
+ * A link preview reads the page before any script runs, so the shell has to
472
+ * say what is on the air. On the directory that is the listing the link
473
+ * points at, and the channel on it if one is asked for; on a server it is
474
+ * the server itself, or one of its channels. A name that only appears in the
475
+ * query is not used: a page that titled itself with whatever the address said
476
+ * would be a preview anyone could put words in.
477
+ */
478
+ export declare function joinSubject(url: URL, options: Pick<HandlerOptions, "directory" | "channels" | "serverName">): {
479
+ title: string;
480
+ where: string;
481
+ } | null;
482
+ /** The shell, titled for what a join link opens. */
483
+ export declare function joinDocument(shell: string, subject: {
484
+ title: string;
485
+ where: string;
486
+ }, site: string): string;
464
487
  export interface HandlerOptions {
465
488
  web: string | null;
466
489
  media: boolean;
@@ -477,6 +500,12 @@ export interface HandlerOptions {
477
500
  ffmpeg?: string[];
478
501
  /** Where ffprobe is, for asking what is inside a file before re-encoding it. */
479
502
  ffprobe?: string[];
503
+ /**
504
+ * Whether this server can carry a channel at all: false where no ffmpeg
505
+ * was found, as on the hosted directory, which then says so instead of
506
+ * resolving a link, probing it, and blaming the site.
507
+ */
508
+ carries?: boolean;
480
509
  /** yt-dlp, which turns a pasted page into a media address. Null when there is none. */
481
510
  ytdlp?: string[] | null;
482
511
  /** Channels as HLS, for Safari on a phone, which plays a live stream no other way. */