nixamp 0.18.1 → 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/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;
@@ -497,6 +500,12 @@ export interface HandlerOptions {
497
500
  ffmpeg?: string[];
498
501
  /** Where ffprobe is, for asking what is inside a file before re-encoding it. */
499
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;
500
509
  /** yt-dlp, which turns a pasted page into a media address. Null when there is none. */
501
510
  ytdlp?: string[] | null;
502
511
  /** Channels as HLS, for Safari on a phone, which plays a live stream no other way. */
package/dist/server.js CHANGED
@@ -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) {
@@ -2469,6 +2478,10 @@ export function createHandler(engine, options) {
2469
2478
  // one nobody can dial is not worth showing.
2470
2479
  code: state?.code ?? "",
2471
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),
2472
2485
  },
2473
2486
  channels: (options.channels?.list() ?? []).map((one) => ({
2474
2487
  id: one.id,
@@ -2485,6 +2498,8 @@ export function createHandler(engine, options) {
2485
2498
  // How it has been going, for whoever may do something about it.
2486
2499
  redials: one.redials ?? 0,
2487
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 } : {}),
2488
2503
  // The member who put it on, when one did: theirs to take off.
2489
2504
  startedBy: one.startedBy ?? "",
2490
2505
  })),
@@ -2743,6 +2758,12 @@ export function createHandler(engine, options) {
2743
2758
  json(response, 503, { error: "this server cannot carry channels" });
2744
2759
  return;
2745
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
+ }
2746
2767
  const channelId = linkChannelId(link);
2747
2768
  const known = links.get(link);
2748
2769
  // A member's is always a live: a decoder started on somebody else's
@@ -2816,7 +2837,7 @@ export function createHandler(engine, options) {
2816
2837
  });
2817
2838
  return;
2818
2839
  }
2819
- 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 } : {});
2820
2841
  if (!started) {
2821
2842
  json(response, 409, { error: "that link is already starting" });
2822
2843
  return;
@@ -4257,6 +4278,7 @@ export async function serve(argv, version = "0.1.0") {
4257
4278
  ...(one.codecs ? { codecs: one.codecs } : {}),
4258
4279
  ...(one.position !== undefined ? { position: one.position } : {}),
4259
4280
  ...(one.live !== undefined ? { live: one.live } : {}),
4281
+ ...(one.playlist ? { playlist: one.playlist, playlistAt: one.playlistAt ?? 0 } : {}),
4260
4282
  }).then((channel) => {
4261
4283
  if (!channel)
4262
4284
  console.log(` "${one.id}" is already on.`);
@@ -4269,7 +4291,8 @@ export async function serve(argv, version = "0.1.0") {
4269
4291
  let lastRemembered = "";
4270
4292
  setInterval(() => {
4271
4293
  const now = rememberedNow(channels);
4272
- 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))
4273
4296
  return;
4274
4297
  const text = JSON.stringify(now);
4275
4298
  if (text === lastRemembered)
@@ -4680,6 +4703,7 @@ export async function serve(argv, version = "0.1.0") {
4680
4703
  // A cookie jar beside the state, when the operator has put one there,
4681
4704
  // for the sites that will not talk to a datacenter without one.
4682
4705
  ytdlp: tools.ytdlp ?? null,
4706
+ carries: tools.carries !== false,
4683
4707
  cookies: cookiesFile(),
4684
4708
  hls,
4685
4709
  compression,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.18.1",
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;