nixamp 0.28.35 → 0.28.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/audio.d.ts +1 -1
  2. package/dist/audio.js +2 -2
  3. package/dist/channels.d.ts +10 -17
  4. package/dist/channels.js +88 -85
  5. package/dist/fragments.d.ts +5 -0
  6. package/dist/fragments.js +51 -0
  7. package/dist/playlist-input.d.ts +26 -0
  8. package/dist/playlist-input.js +95 -0
  9. package/dist/server.js +6 -7
  10. package/package.json +1 -1
  11. package/src/audio.ts +2 -2
  12. package/src/channels.ts +83 -85
  13. package/src/fragments.ts +46 -0
  14. package/src/playlist-input.ts +99 -0
  15. package/src/server.ts +6 -6
  16. package/web/dist/assets/{ar-B8r60Lgd.js → ar-CRLmcpxV.js} +1 -1
  17. package/web/dist/assets/{cs-C_jKpXHz.js → cs-C7lpT9_7.js} +1 -1
  18. package/web/dist/assets/{da-qhb4qzGL.js → da-C6icvAjb.js} +1 -1
  19. package/web/dist/assets/{de-B1U3oniY.js → de-B6Viw_1k.js} +1 -1
  20. package/web/dist/assets/{es-jT9qQhJM.js → es-h3YK7MBo.js} +1 -1
  21. package/web/dist/assets/{fi-CD8a3RyK.js → fi-tF8W-0sw.js} +1 -1
  22. package/web/dist/assets/{fr-CBYEPF15.js → fr-CPVGzn8e.js} +1 -1
  23. package/web/dist/assets/{hi-DS7Jlgq3.js → hi-DiPAdtgw.js} +1 -1
  24. package/web/dist/assets/{hls-3VKVEQE3-BVtRqG0G.js → hls-3VKVEQE3-C_fSzE0p.js} +1 -1
  25. package/web/dist/assets/{hu-BZ__rJ87.js → hu-BWP27GQV.js} +1 -1
  26. package/web/dist/assets/{id-DkM6FK8S.js → id-C9v9_aJK.js} +1 -1
  27. package/web/dist/assets/index-_aGaTsm2.js +3 -0
  28. package/web/dist/assets/index-jLCo7PIA.css +1 -0
  29. package/web/dist/assets/{it-DmhEUJ4D.js → it-DtOTP5Bt.js} +1 -1
  30. package/web/dist/assets/{mpegts-DGMTjRf5.js → mpegts-DBK5aOjj.js} +1 -1
  31. package/web/dist/assets/{mpegts-LO6RVLD6-Cct2UCqZ.js → mpegts-LO6RVLD6-0zaiW5HW.js} +1 -1
  32. package/web/dist/assets/{nl-D-gXjVr5.js → nl-CAX3A01l.js} +1 -1
  33. package/web/dist/assets/{ru-BbqAccEb.js → ru-DuLcjNsF.js} +1 -1
  34. package/web/dist/assets/{sv-BMdjgsVR.js → sv-C3ZewPxY.js} +1 -1
  35. package/web/dist/assets/{uk-DLs9Y4CC.js → uk-D7PUipxA.js} +1 -1
  36. package/web/dist/assets/{vi-_Y4nCAgc.js → vi-wUJlLppj.js} +1 -1
  37. package/web/dist/assets/{zh-DIJTp7RL.js → zh-Bd_9PHqO.js} +1 -1
  38. package/web/dist/index.html +2 -2
  39. package/web/dist/sw.js +23 -23
  40. package/web/dist/assets/index-BaDD_PRM.css +0 -1
  41. package/web/dist/assets/index-Dkxbx4QN.js +0 -3
package/dist/server.js CHANGED
@@ -904,7 +904,7 @@ export async function pullChannel(channels, ffprobe, id, name, source, input = [
904
904
  if (assumed)
905
905
  console.log(` "${id}": the source would not say what it holds; carrying it as ${kind}.`);
906
906
  // A film has a length and a place to go back to; a live source has neither.
907
- // A list is a station: joined where it is, and never seeked.
907
+ // Listeners join a playlist live; its producer resumes the saved entry and position.
908
908
  const playlist = known.playlist && known.playlist.length > 0 ? known.playlist : null;
909
909
  const live = playlist ? true : (known.live ?? !((codecs.duration ?? 0) > 0));
910
910
  const encode = kind === "video"
@@ -926,14 +926,12 @@ export async function pullChannel(channels, ffprobe, id, name, source, input = [
926
926
  // MP3's tags name the episode and the show, and "Inspiring Founders" is a
927
927
  // card; "ep-0412-final.mp3" is not.
928
928
  const tagged = looksLikeFileName(name) && codecs.tags?.title ? codecs.tags.title : name;
929
- const channel = channels.pull(id, tagged, source, encode, kind, true, undefined, opening, kind === "video" ? audio : "", { live, position: known.position ?? 0, ...(playlist ? { playlist } : {}) },
929
+ const channel = channels.pull(id, tagged, source, encode, kind, true, undefined, opening, kind === "video" ? audio : "", { live, position: known.position ?? 0, ...(playlist ? { playlist, playlistAt: known.playlistAt ?? 0 } : {}) },
930
930
  // Known before the first dial: whether the source is a transport stream
931
931
  // decides whether it can be read here for a source-boundary relay.
932
932
  assumed ? undefined : codecs);
933
933
  if (channel && !assumed)
934
934
  channel.info.codecs = codecs;
935
- if (channel && playlist && known.playlistAt !== undefined)
936
- channel.info.playlistAt = known.playlistAt;
937
935
  if (channel) {
938
936
  // Its picture from wherever it came with one, and a line about it: what
939
937
  // was given, else the show and the album the file's own tags name.
@@ -965,7 +963,7 @@ export async function pullChannel(channels, ffprobe, id, name, source, input = [
965
963
  // told otherwise would cut fMP4 segments for a stream that did not need
966
964
  // them.
967
965
  if (channel && kind === "video") {
968
- channel.info.emits = encode.includes("libx264") ? "h264" : codecs.video || "h264";
966
+ channel.info.emits = (playlist && playlist.length > 1 && playlist.every(one => !/^https?:\/\//i.test(one))) || encode.includes("libx264") ? "h264" : codecs.video || "h264";
969
967
  }
970
968
  return channel;
971
969
  }
@@ -5588,6 +5586,7 @@ export async function serve(argv, version = "0.1.0") {
5588
5586
  const outro = new Outro({ ffmpeg: tools.ffmpeg, dir: join(stateDir(), "outro"), onEvent: (message) => console.log(message) });
5589
5587
  const channels = new Channels({
5590
5588
  ffmpeg: tools.ffmpeg,
5589
+ ffprobe: tools.ffprobe,
5591
5590
  ...(tools.carries ? { outro: (kind) => outro.clip(kind) } : {}),
5592
5591
  onOutro: (info) => console.log(` "${info.id}" has ended; the outro plays for an hour.`),
5593
5592
  onStart: (info) => console.log(` ${info.name} is publishing to "${info.id}" (${info.format} over ${info.via}).`),
@@ -5627,7 +5626,7 @@ export async function serve(argv, version = "0.1.0") {
5627
5626
  // every restart used to take CNN off the air until somebody noticed.
5628
5627
  const remembering = (list) => rememberChannels(stateDir(), options.port, list);
5629
5628
  for (const one of rememberedChannels(stateDir(), options.port)) {
5630
- const where = one.position && !one.live ? `, from ${Math.floor(one.position / 60)}m${Math.floor(one.position % 60)}s` : "";
5629
+ const where = one.position && (!one.live || one.playlist?.length) ? `, from ${Math.floor(one.position / 60)}m${Math.floor(one.position % 60)}s` : "";
5631
5630
  console.log(` Putting "${one.id}" (${one.name}) back on the air${where}.`);
5632
5631
  // With what was written down about it: what it holds, so the source is
5633
5632
  // not asked again, and where it had got to, so a film carries on.
@@ -5651,7 +5650,7 @@ export async function serve(argv, version = "0.1.0") {
5651
5650
  let lastRemembered = "";
5652
5651
  setInterval(() => {
5653
5652
  const now = rememberedNow(channels);
5654
- // A list moves on without a position: which entry is on is the thing to keep.
5653
+ // Both the entry and its elapsed time advance during a directory live.
5655
5654
  if (!now.some((one) => one.position !== undefined || one.playlist))
5656
5655
  return;
5657
5656
  const text = JSON.stringify(now);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.28.35",
3
+ "version": "0.28.36",
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
@@ -442,7 +442,7 @@ export interface Codecs {
442
442
  * answering other requests, and a synchronous probe per media request is how
443
443
  * the whole library came to be tagged with the process wedged solid.
444
444
  */
445
- export async function codecsOf(tools: Tools, path: string, input: string[] = []): Promise<Codecs> {
445
+ export async function codecsOf(tools: Tools, path: string, input: string[] = [], signal?: AbortSignal): Promise<Codecs> {
446
446
  const [cmd, ...rest] = tools.ffprobe;
447
447
  const empty: Codecs = { video: "", audio: "", container: "" };
448
448
  if (!cmd) return empty;
@@ -469,7 +469,7 @@ export async function codecsOf(tools: Tools, path: string, input: string[] = [])
469
469
  ...input,
470
470
  path,
471
471
  ],
472
- { stdio: ["ignore", "pipe", "ignore"] },
472
+ { stdio: ["ignore", "pipe", "ignore"], signal },
473
473
  );
474
474
  let out = "";
475
475
  child.stdout.on("data", (chunk: Buffer) => {
package/src/channels.ts CHANGED
@@ -16,11 +16,11 @@
16
16
  */
17
17
  import { spawn, type ChildProcess } from "node:child_process";
18
18
  import { randomBytes } from "node:crypto";
19
- import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
19
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
20
20
  import { dirname, join } from "node:path";
21
- import { tmpdir } from "node:os";
21
+ import { PlaylistInputStream } from "./playlist-input.ts";
22
22
  import type { Readable } from "node:stream";
23
- import { Fragments, isOpening } from "./fragments.ts";
23
+ import { FragmentClock, Fragments, isOpening } from "./fragments.ts";
24
24
 
25
25
  /** Somewhere for a channel's audio to go. A response, in practice. */
26
26
  export interface Listener {
@@ -59,9 +59,9 @@ export interface ChannelInfo {
59
59
  redials?: number;
60
60
  /**
61
61
  * For a channel that plays a list: the entries, in order, and which one is
62
- * on. When one ends the next is dialled at once, and the last is followed
63
- * by the first: a station, not a file. A restart picks up at the entry
64
- * that was on.
62
+ * on. Local files are normalized into one continuous stream. After the
63
+ * final entry the show ends. A restart picks up at the saved entry and
64
+ * position within it.
65
65
  */
66
66
  playlist?: string[];
67
67
  playlistAt?: number;
@@ -121,6 +121,7 @@ export interface PullResume {
121
121
  position: number;
122
122
  /** For a list of things rather than one: every entry, in order. */
123
123
  playlist?: string[];
124
+ playlistAt?: number;
124
125
  }
125
126
 
126
127
  /**
@@ -188,18 +189,9 @@ export const BACKLOG_AUDIO = 64 * 1024;
188
189
  */
189
190
  export const LISTENER_QUEUE = 16 * 1024 * 1024;
190
191
 
191
- /**
192
- * The backlog is really a number of seconds, and four megabytes was that
193
- * number for the stream we happened to have.
194
- *
195
- * Six seconds of 720p is about 4 MB. Six seconds of a 1080p transport stream
196
- * copied straight through is nearer 12, and of 4K nearer 30 -- so a fixed
197
- * 4 MB hands a 4K joiner under a second of video, which is the live edge with
198
- * no cushion, which is the play-wait-play loop the backlog exists to prevent.
199
- * So the cap follows the stream: seconds times the rate it is actually
200
- * running at, between the old floor and a ceiling that keeps a channel's
201
- * memory bounded whatever it is carrying.
202
- */
192
+ /** Media-time window for complete MP4 fragments. Byte limits are a separate
193
+ * memory ceiling; the measured byte rate is only a fallback for inputs with
194
+ * no usable fragment timestamps. It must never make low-bitrate video old. */
203
195
  export const BACKLOG_SECONDS = 6;
204
196
  export const BACKLOG_VIDEO_MAX = 48 * 1024 * 1024;
205
197
  /**
@@ -253,6 +245,7 @@ export function cleanId(value: unknown, fallback = "main"): string {
253
245
 
254
246
  export interface ChannelOptions {
255
247
  ffmpeg: string[];
248
+ ffprobe?: string[];
256
249
  onStart?: (info: ChannelInfo) => void;
257
250
  onEnd?: (info: ChannelInfo) => void;
258
251
  /**
@@ -314,6 +307,8 @@ export class Channel {
314
307
  * can begin at; for MP3 any point will do, a frame announces itself.
315
308
  */
316
309
  private recent: Buffer[] = [];
310
+ private fragmentClock = new FragmentClock();
311
+ private fragmentTimes = new WeakMap<Buffer, number>();
317
312
  private recentBytes = 0;
318
313
  /** The rate window: when it opened, what has arrived in it, and what the last closed one measured. */
319
314
  private rateStart = 0;
@@ -330,8 +325,6 @@ export class Channel {
330
325
  private readonly sourceTaps = new Set<Listener>();
331
326
  /** Pulls the plug on the current read-through, when there is one. */
332
327
  private throughAbort: AbortController | null = null;
333
- /** ffmpeg concat manifest for a local multi-file live. */
334
- private concatListPath: string | null = null;
335
328
 
336
329
  constructor(
337
330
  readonly info: ChannelInfo,
@@ -403,42 +396,27 @@ export class Channel {
403
396
  if (this.info.kind === "video") this.fragments = new Fragments();
404
397
  const [command, ...prefix] = this.options.ffmpeg as [string, ...string[]];
405
398
  this.info.live = resume.live;
406
- if (!resume.live) this.info.position = Math.max(0, resume.position);
399
+ if (!resume.live || resume.playlist?.length) this.info.position = Math.max(0, resume.position);
407
400
  // A list plays entry by entry: the one that is on is what gets dialled,
408
401
  // and an entry that ended is followed by the next, at once.
409
402
  const list = resume.playlist && resume.playlist.length > 0 ? resume.playlist : null;
410
- // A local directory live must be one ffmpeg input. Starting a new ffmpeg
411
- // process for every file makes browsers see a new MP4 stream at each
412
- // boundary, even when the HTTP listener stays attached. The concat
413
- // demuxer keeps timestamps and the output pipe continuous.
414
- const concatList = list && list.length > 1 && list.every((one) => !/^https?:\/\//i.test(one))
415
- ? list
416
- : null;
417
- if (this.concatListPath) {
418
- rmSync(this.concatListPath, { force: true });
419
- this.concatListPath = null;
420
- }
421
- if (concatList) {
422
- const dir = mkdtempSync(join(tmpdir(), "nixamp-playlist-"));
423
- this.concatListPath = join(dir, "playlist.txt");
424
- const quote = (one: string): string => one.replaceAll("'", "'\\''");
425
- writeFileSync(this.concatListPath, concatList.map((one) => `file '${quote(one)}'`).join("\n") + "\n");
426
- }
403
+ const localPlaylist = list && list.length > 1 && list.every(one => !/^https?:\/\//i.test(one)) ? list : null;
427
404
  let at = 0;
428
405
  if (list) {
429
406
  this.info.playlist = list;
430
- at = Math.min(Math.max(0, this.info.playlistAt ?? 0), list.length - 1);
407
+ at = Math.min(Math.max(0, resume.playlistAt ?? this.info.playlistAt ?? 0), list.length - 1);
431
408
  this.info.playlistAt = at;
432
409
  }
433
410
  let current = list ? (list[at] as string) : source;
434
411
  // The last entry is the end of the show, not a way back to the first:
435
412
  // a list that played through is over, and says so with the outro.
436
- this.advance = list && list.length > 1 && !concatList
413
+ this.advance = list && list.length > 1 && !localPlaylist
437
414
  ? () => {
438
415
  if (at + 1 >= list.length) return false;
439
416
  at += 1;
440
417
  this.info.playlistAt = at;
441
418
  current = list[at] as string;
419
+ this.info.position = 0;
442
420
  return true;
443
421
  }
444
422
  : null;
@@ -457,7 +435,7 @@ export class Channel {
457
435
  // the place was written down a moment ago and a moment of it twice is
458
436
  // better than a moment of it missing. A live source is joined as is,
459
437
  // and a film that has barely started is started.
460
- const from = resume.live ? 0 : Math.max(0, Math.floor((this.info.position ?? 0) - REWIND));
438
+ const from = resume.live && !list ? 0 : Math.max(0, Math.floor((this.info.position ?? 0) - REWIND));
461
439
  const seek = from > 0 ? ["-ss", String(from)] : [];
462
440
  // Read the source here rather than in ffmpeg, when a policy wants the
463
441
  // original bytes and the source is the kind that can be. ffmpeg then
@@ -467,26 +445,19 @@ export class Channel {
467
445
  this.throughAbort?.abort();
468
446
  this.throughAbort = null;
469
447
  // The outro is a file read by ffmpeg itself: a pipe cannot loop.
470
- const through = this.outroOn ? null : this.options.through?.(this.info, from, input, audio) ?? null;
471
- this.info.teed = through !== null;
472
- const concatInput = this.concatListPath
473
- ? [
474
- ...(paced ? ["-re"] : []),
475
- // Directory lives may contain different codecs, dimensions, or
476
- // timestamp bases. Copying the first file's streams can make
477
- // ffmpeg reject later entries and leave the audience on entry 1.
478
- // The output settings below normalize the entire concat stream.
479
- "-f", "concat", "-safe", "0", "-i", this.concatListPath,
480
- ]
481
- : null;
482
- const concatEncode = this.concatListPath && this.info.kind === "video"
483
- ? [
484
- "-c:v", "libx264", "-preset", "veryfast", "-crf", "23",
485
- "-pix_fmt", "yuv420p", "-g", "48", "-keyint_min", "48", "-sc_threshold", "0",
486
- "-c:a", "aac", "-b:a", "160k", "-ac", "2",
487
- "-f", "mp4", "-movflags", "frag_keyframe+empty_moov+default_base_moof",
488
- ]
489
- : null;
448
+ const playlistInput = localPlaylist ? new PlaylistInputStream({
449
+ ffmpeg: this.options.ffmpeg,
450
+ ffprobe: this.options.ffprobe ?? this.options.ffmpeg.map(one => one.replace(/ffmpeg(?=(?:\.exe)?$)/, "ffprobe")),
451
+ files: localPlaylist, at: this.info.playlistAt ?? 0, position: from,
452
+ video: this.info.kind === "video", width: this.info.codecs?.width, height: this.info.codecs?.height,
453
+ }) : null;
454
+ const through = this.outroOn ? null : playlistInput
455
+ ? { format: "mpegts", open: async (signal: AbortSignal) => playlistInput.open(signal) }
456
+ : this.options.through?.(this.info, from, input, audio) ?? null;
457
+ this.info.teed = through !== null && !playlistInput;
458
+ const playlistEncode = !playlistInput ? null : this.info.kind === "video"
459
+ ? ["-c", "copy", "-bsf:a", "aac_adtstoasc", "-f", "mp4", "-movflags", "frag_keyframe+empty_moov+default_base_moof"]
460
+ : ["-af", "aresample=async=1:first_pts=0", ...encode];
490
461
  const child = spawn(
491
462
  command,
492
463
  [
@@ -508,7 +479,7 @@ export class Channel {
508
479
  // headers, which only a source read through us leaves out,
509
480
  // are no use to a pipe and are not here (a source that needs
510
481
  // them is not read through us in the first place).
511
- ...input,
482
+ ...(playlistInput ? ["-probesize", "32768", "-analyzeduration", "1000000"] : input),
512
483
  "-f", through.format,
513
484
  ...(paced ? ["-re"] : []),
514
485
  "-i", "pipe:0",
@@ -529,12 +500,12 @@ export class Channel {
529
500
  // What the source's site expects on the request: a user agent, a
530
501
  // referer, a cookie. A link resolved by yt-dlp comes with these,
531
502
  // and a CDN that got them from yt-dlp and not from us answers 403.
532
- ...(concatInput ?? [...input, ...seek, "-i", current]),
503
+ ...input, ...seek, "-i", current,
533
504
  // The sound, when the site keeps it apart from the picture: a
534
505
  // second input, dialled the same way, that the encode maps in.
535
506
  ...(audio ? [...remoteArgs, ...(paced ? ["-re"] : []), ...input, ...seek, "-i", audio] : []),
536
507
  ]),
537
- ...(concatEncode ?? encode),
508
+ ...(playlistEncode ?? encode),
538
509
  "pipe:1",
539
510
  ],
540
511
  { stdio: [through ? "pipe" : "ignore", "pipe", "pipe", "pipe"] },
@@ -551,18 +522,26 @@ export class Channel {
551
522
  let progress = "";
552
523
  (child.stdio[3] as Readable | null)?.on("data", (chunk: Buffer) => {
553
524
  progress = (progress + chunk.toString("utf8")).slice(-4000);
554
- if (this.child !== child || resume.live) return;
525
+ if (this.child !== child || (resume.live && !list)) return;
555
526
  const lines = progress.split("\n");
556
527
  progress = lines.pop() ?? "";
557
528
  for (const line of lines) {
558
529
  const match = /^out_time_us=(\d+)/.exec(line.trim());
559
- if (match) this.info.position = from + Number(match[1]) / 1e6;
530
+ if (match) {
531
+ const seconds = Number(match[1]) / 1e6;
532
+ if (playlistInput) {
533
+ const place = playlistInput.place(seconds);
534
+ this.info.playlistAt = place.at;
535
+ this.info.position = place.position;
536
+ } else this.info.position = from + seconds;
537
+ }
560
538
  }
561
539
  });
562
540
  (child.stdio[3] as Readable | null)?.on("error", () => undefined);
563
541
  child.stdout?.on("data", (chunk: Buffer) => {
564
542
  // An ffmpeg that was replaced can still have a chunk in the pipe.
565
543
  if (this.child !== child) return;
544
+ if (!sent) this.info.error = undefined;
566
545
  sent = true;
567
546
  this.info.bytes += chunk.byteLength;
568
547
  this.rearm(child);
@@ -572,8 +551,12 @@ export class Channel {
572
551
  drain(child.stderr, (tail) => { this.stderr = tail; });
573
552
  // Only the ffmpeg we are currently running gets to say the source
574
553
  // dropped. One that was killed to make way for a restart is not news.
575
- child.on("error", () => { if (this.child === child) this.dropped(sent); });
576
- child.on("close", () => { if (this.child === child) this.dropped(sent); });
554
+ child.on("error", () => { if (this.child === child) this.dropped(sent, false); });
555
+ child.on("close", (code, signal) => {
556
+ if (this.child !== child) return;
557
+ if (playlistInput?.error) this.stderr = playlistInput.error.message;
558
+ this.dropped(sent, code === 0 && !signal && (!playlistInput || playlistInput.complete));
559
+ });
577
560
  };
578
561
 
579
562
  this.redial = dial;
@@ -596,6 +579,7 @@ export class Channel {
596
579
  const kind = this.info.kind ?? "audio";
597
580
  const old = this.child;
598
581
  this.child = null;
582
+ this.throughAbort?.abort();
599
583
  if (this.watchdog) clearTimeout(this.watchdog);
600
584
  this.watchdog = null;
601
585
  if (this.timer) clearTimeout(this.timer);
@@ -642,7 +626,7 @@ export class Channel {
642
626
  this.info.playlistAt = 0;
643
627
  this.startOver();
644
628
  const show = this.dialed;
645
- this.pull(show.source, show.encode, show.paced, show.stall, show.input, show.audio, { ...show.resume, position: 0 });
629
+ this.pull(show.source, show.encode, show.paced, show.stall, show.input, show.audio, { ...show.resume, position: 0, playlistAt: 0 });
646
630
  return true;
647
631
  }
648
632
  const dial = this.redial;
@@ -676,6 +660,8 @@ export class Channel {
676
660
  private startOver(preserveListeners = false): void {
677
661
  if (this.info.kind === "video") this.fragments = new Fragments();
678
662
  this.recent = [];
663
+ this.fragmentClock = new FragmentClock();
664
+ this.fragmentTimes = new WeakMap();
679
665
  this.recentBytes = 0;
680
666
  // A new source may be a different size of stream, and the rate measured
681
667
  // off the old one is not evidence about this one.
@@ -734,7 +720,7 @@ export class Channel {
734
720
  * worth dialling again, and a URL that has never once produced a byte is a
735
721
  * mistake somebody made, and retrying it for ever helps nobody.
736
722
  */
737
- private dropped(sent: boolean): void {
723
+ private dropped(sent: boolean, clean = false): void {
738
724
  if (this.closing || !this.redial) return;
739
725
  // An outro that stopped is over; there is nothing after it.
740
726
  if (this.outroOn) {
@@ -742,26 +728,26 @@ export class Channel {
742
728
  return;
743
729
  }
744
730
  this.child = null;
731
+ this.throughAbort?.abort();
745
732
  if (this.watchdog) clearTimeout(this.watchdog);
746
733
  this.watchdog = null;
747
734
  const said = lastLine(this.stderr);
748
735
  if (said) this.info.error = said;
749
- this.failures = sent ? 0 : this.failures + 1;
736
+ this.failures = sent && clean ? 0 : this.failures + 1;
750
737
  if (this.failures >= GIVE_UP) {
751
738
  this.close();
752
739
  return;
753
740
  }
754
- // A list moves on. An entry that played to its end is not a source that
755
- // dropped: the next is dialled now, and it is not a redial. One that
756
- // gave nothing is skipped the same way, counted as the failure it was,
757
- // so a list of dead links gives up rather than cycling for ever.
758
- const moved = this.advance?.() ?? false;
741
+ // Only a clean EOF advances a per-URL playlist. Decoder errors, a
742
+ // watchdog kill, or a failed normalized input recover at the saved place
743
+ // with bounded retries; an MP4 header alone is not a completed show.
744
+ const moved = clean ? this.advance?.() ?? false : false;
759
745
  // A show that ended: a film or a podcast that played to its end, a
760
746
  // list whose last entry did. That is not a source that dropped, and it
761
747
  // is not dialled again from the top; it is over, and the outro says
762
748
  // so. A live feed that stopped is a feed that dropped, and is redialled.
763
749
  const list = (this.info.playlist?.length ?? 0) > 0;
764
- if (sent && !moved && (this.info.live === false || list)) {
750
+ if (clean && sent && !moved && (this.info.live === false || list)) {
765
751
  void this.endShow();
766
752
  return;
767
753
  }
@@ -798,7 +784,19 @@ export class Channel {
798
784
  }
799
785
  const cap = this.backlogCap();
800
786
  for (const box of this.fragments.push(chunk)) {
801
- if (!isOpening(boxType(box))) this.remember(box, cap, true);
787
+ const time = this.fragmentClock.read(box);
788
+ if (time !== null) this.fragmentTimes.set(box, time);
789
+ if (!isOpening(boxType(box))) this.remember(box, time === null ? cap : BACKLOG_VIDEO_MAX, true);
790
+ if (time !== null) {
791
+ // Drop complete old fragments even when six seconds occupy only a
792
+ // few kilobytes (slides, static cameras, or a paused game screen).
793
+ while (this.recent.length > 0) {
794
+ const oldest = this.fragmentTimes.get(this.recent[0]!);
795
+ if (oldest === undefined || time - oldest < BACKLOG_SECONDS) break;
796
+ do { this.recentBytes -= this.recent.shift()!.byteLength; }
797
+ while (this.recent.length && boxType(this.recent[0]!) !== "moof");
798
+ }
799
+ }
802
800
  this.send(box);
803
801
  }
804
802
  }
@@ -978,8 +976,11 @@ export class Channel {
978
976
  // Wait for ffmpeg to take it, or for the pipe to go: a pipe that
979
977
  // closed never drains, and waiting on it would hold the read open.
980
978
  await new Promise<void>((done) => {
981
- stdin.once("drain", done);
982
- stdin.once("close", done);
979
+ const finish = (): void => {
980
+ stdin.off("drain", finish); stdin.off("close", finish); done();
981
+ };
982
+ stdin.once("drain", finish);
983
+ stdin.once("close", finish);
983
984
  });
984
985
  }
985
986
  }
@@ -1063,10 +1064,7 @@ export class Channel {
1063
1064
  this.child = null;
1064
1065
  this.throughAbort?.abort();
1065
1066
  this.throughAbort = null;
1066
- if (this.concatListPath) {
1067
- rmSync(dirname(this.concatListPath), { force: true, recursive: true });
1068
- this.concatListPath = null;
1069
- }
1067
+
1070
1068
  this.endTaps();
1071
1069
  try {
1072
1070
  child?.stdin?.end();
@@ -1437,7 +1435,7 @@ export function rememberedNow(channels: Channels): RememberedChannel[] {
1437
1435
  if (one.kind) kept.kind = one.kind;
1438
1436
  if (one.codecs) kept.codecs = one.codecs;
1439
1437
  if (typeof one.live === "boolean") kept.live = one.live;
1440
- if (!one.live && typeof one.position === "number" && one.position > 0) kept.position = Math.floor(one.position);
1438
+ if ((!one.live || one.playlist?.length) && typeof one.position === "number" && one.position > 0) kept.position = Math.floor(one.position);
1441
1439
  if (one.startedBy) kept.startedBy = one.startedBy;
1442
1440
  if (one.playlist && one.playlist.length > 0) {
1443
1441
  kept.playlist = one.playlist;
package/src/fragments.ts CHANGED
@@ -64,6 +64,52 @@ export function isOpening(type: string): boolean {
64
64
  return type === "ftyp" || type === "moov";
65
65
  }
66
66
 
67
+ function children(box: Buffer): Box[] {
68
+ let rest = box.subarray(box.readUInt32BE(0) === 1 ? BIG : HEADER);
69
+ const found: Box[] = [];
70
+ for (;;) {
71
+ const next = firstBox(rest);
72
+ if (!next) return found;
73
+ found.push(next.box);
74
+ rest = next.rest;
75
+ }
76
+ }
77
+
78
+ /** Decode timestamps use each track's own timescale, never its byte rate. */
79
+ export class FragmentClock {
80
+ private scales = new Map<number, number>();
81
+ read(box: Buffer): number | null {
82
+ const type = box.toString("latin1", 4, 8);
83
+ if (type === "moov") {
84
+ for (const track of children(box).filter(one => one.type === "trak")) {
85
+ const parts = children(track.bytes);
86
+ const tkhd = parts.find(one => one.type === "tkhd")?.bytes;
87
+ const mdia = parts.find(one => one.type === "mdia")?.bytes;
88
+ const mdhd = mdia && children(mdia).find(one => one.type === "mdhd")?.bytes;
89
+ if (!tkhd || !mdhd) continue;
90
+ const idAt = tkhd[8] === 1 ? 28 : 20;
91
+ const scaleAt = mdhd[8] === 1 ? 28 : 20;
92
+ if (tkhd.length < idAt + 4 || mdhd.length < scaleAt + 4) continue;
93
+ const scale = mdhd.readUInt32BE(scaleAt);
94
+ if (scale) this.scales.set(tkhd.readUInt32BE(idAt), scale);
95
+ }
96
+ }
97
+ if (type !== "moof") return null;
98
+ const times: number[] = [];
99
+ for (const track of children(box).filter(one => one.type === "traf")) {
100
+ const parts = children(track.bytes);
101
+ const tfhd = parts.find(one => one.type === "tfhd")?.bytes;
102
+ const tfdt = parts.find(one => one.type === "tfdt")?.bytes;
103
+ if (!tfhd || tfhd.length < 16 || !tfdt || tfdt.length < (tfdt[8] === 1 ? 20 : 16)) continue;
104
+ const scale = this.scales.get(tfhd.readUInt32BE(12));
105
+ if (!scale) continue;
106
+ const time = tfdt[8] === 1 ? Number(tfdt.readBigUInt64BE(12)) : tfdt.readUInt32BE(12);
107
+ times.push(time / scale);
108
+ }
109
+ return times.length ? Math.min(...times) : null;
110
+ }
111
+ }
112
+
67
113
  /**
68
114
  * A fragmented MP4 arriving in pieces, handed back a box at a time.
69
115
  *
@@ -0,0 +1,99 @@
1
+ import { spawn } from "node:child_process";
2
+ import type { Readable } from "node:stream";
3
+ import { codecsOf } from "./audio.ts";
4
+
5
+ export interface PlaylistInput {
6
+ ffmpeg: string[];
7
+ ffprobe: string[];
8
+ files: string[];
9
+ at: number;
10
+ position: number;
11
+ video: boolean;
12
+ width?: number;
13
+ height?: number;
14
+ }
15
+
16
+ /** Decode each file independently; feed one persistent muxer a fixed stream
17
+ * profile and continuous timestamps. A concat demuxer cannot decode a list
18
+ * whose input codecs differ. Only one entry is encoded at a time, with pipe
19
+ * backpressure bounding read-ahead independently of the directory's size. */
20
+ export class PlaylistInputStream {
21
+ private entries: { at: number; start: number; position: number }[] = [];
22
+ error: Error | null = null;
23
+ complete = false;
24
+ constructor(private readonly options: PlaylistInput) {}
25
+
26
+ place(seconds: number): { at: number; position: number } {
27
+ const entry = this.entries.findLast(one => seconds >= one.start) ?? this.entries[0];
28
+ return entry ? { at: entry.at, position: Math.max(0, seconds - entry.start + entry.position) }
29
+ : { at: this.options.at, position: this.options.position };
30
+ }
31
+
32
+ async *open(signal: AbortSignal): AsyncGenerator<Buffer> {
33
+ const o = this.options;
34
+ const [command, ...prefix] = o.ffmpeg as [string, ...string[]];
35
+ const ratio = Math.min(1, 1280 / (o.width || 1280), 720 / (o.height || 720));
36
+ const width = Math.max(2, Math.floor((o.width || 1280) * ratio / 2) * 2);
37
+ const height = Math.max(2, Math.floor((o.height || 720) * ratio / 2) * 2);
38
+ let offset = 0;
39
+ try {
40
+ for (let at = o.at; at < o.files.length && !signal.aborted; at++) {
41
+ const file = o.files[at]!;
42
+ const codecs = await codecsOf({ ffmpeg: o.ffmpeg, ffprobe: o.ffprobe, play: null }, file, [], AbortSignal.any([signal, AbortSignal.timeout(10_000)]));
43
+ if (signal.aborted) return;
44
+ if (!codecs.audio && !codecs.video) throw new Error(`Cannot decode playlist entry ${at + 1}`);
45
+ const position = at === o.at ? o.position : 0;
46
+ const duration = (codecs.duration ?? 0) - position;
47
+ if (duration <= 0) throw new Error(`No playable duration for playlist entry ${at + 1}`);
48
+ this.entries.push({ at, start: offset, position });
49
+ const hasVideo = Boolean(codecs.video);
50
+ const extra = o.video && !hasVideo
51
+ ? ["-f", "lavfi", "-i", `color=c=black:s=${width}x${height}:r=30`]
52
+ : o.video && !codecs.audio ? ["-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo"] : [];
53
+ const args = [
54
+ ...prefix, "-hide_banner", "-loglevel", "error", "-nostdin", "-filter_threads", "2",
55
+ "-threads", "2", ...(position > 0 ? ["-ss", String(position)] : []), "-i", file,
56
+ ...extra, "-t", String(duration),
57
+ ...(o.video ? [
58
+ "-map", hasVideo ? "0:v:0" : "1:v:0", "-map", codecs.audio ? "0:a:0" : "1:a:0",
59
+ "-vf", `fps=30,scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2,setsar=1,setpts=PTS-STARTPTS`,
60
+ "-c:v", "libx264", "-threads", "2", "-preset", "veryfast", "-tune", "zerolatency",
61
+ "-crf", "23", "-pix_fmt", "yuv420p", "-g", "60", "-keyint_min", "60", "-sc_threshold", "0",
62
+ ] : ["-map", "0:a:0", "-vn"]),
63
+ "-af", "aresample=48000,asetpts=PTS-STARTPTS", "-c:a", "aac", "-b:a", "160k", "-ac", "2",
64
+ "-mpegts_flags", "+initial_discontinuity", "-output_ts_offset", String(offset), "-mpegts_copyts", "1", "-muxdelay", "0", "-muxpreload", "0",
65
+ "-f", "mpegts", "pipe:1",
66
+ ];
67
+ const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
68
+ let tail = "";
69
+ child.stderr.on("data", chunk => { tail = (tail + chunk.toString()).slice(-1500); });
70
+ const closed = new Promise<number | null>((resolve) => {
71
+ child.once("error", error => { tail = error.message; resolve(-1); });
72
+ child.once("close", code => resolve(code));
73
+ });
74
+ const abort = (): void => { child.kill("SIGKILL"); };
75
+ signal.addEventListener("abort", abort, { once: true });
76
+ try {
77
+ for await (const chunk of child.stdout as Readable) {
78
+ if (signal.aborted) return;
79
+ yield chunk as Buffer;
80
+ }
81
+ const code = await closed;
82
+ if (signal.aborted) return;
83
+ if (code !== 0) throw new Error(`Playlist entry ${at + 1} failed: ${tail.trim()}`);
84
+ } finally {
85
+ signal.removeEventListener("abort", abort);
86
+ abort();
87
+ await closed;
88
+ }
89
+ // Match the frame grid used above so rounding cannot accumulate a
90
+ // backwards timestamp over hundreds of short files.
91
+ offset += o.video ? Math.ceil(duration * 30) / 30 : duration;
92
+ }
93
+ this.complete = !signal.aborted;
94
+ } catch (error) {
95
+ this.error = error instanceof Error ? error : new Error(String(error));
96
+ throw this.error;
97
+ }
98
+ }
99
+ }