nixamp 0.13.0 → 0.14.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
@@ -92,6 +92,12 @@ export interface Codecs {
92
92
  * is a track no browser will play.
93
93
  */
94
94
  container: string;
95
+ /**
96
+ * How long it is, in seconds; 0 when it has no end, which is what tells a
97
+ * live channel from a film. A film has a place to go back to after a
98
+ * restart; a live channel is wherever it is now.
99
+ */
100
+ duration?: number;
95
101
  }
96
102
  /**
97
103
  * Ask ffprobe what the streams are, without holding the event loop.
package/dist/audio.js CHANGED
@@ -357,7 +357,7 @@ export async function codecsOf(tools, path, input = []) {
357
357
  ...rest,
358
358
  "-v", "quiet",
359
359
  "-print_format", "json",
360
- "-show_entries", "format=format_name:stream=codec_type,codec_name",
360
+ "-show_entries", "format=format_name,duration:stream=codec_type,codec_name",
361
361
  // Headers the source's site expects, for a link resolved by yt-dlp.
362
362
  ...input,
363
363
  path,
@@ -371,10 +371,14 @@ export async function codecsOf(tools, path, input = []) {
371
371
  try {
372
372
  const parsed = JSON.parse(out);
373
373
  const streams = parsed.streams ?? [];
374
+ // ffprobe prints seconds as a string, and "N/A" for a stream with no
375
+ // end; both of those read as 0.
376
+ const seconds = Number(parsed.format?.duration ?? 0);
374
377
  return done({
375
378
  video: streams.find((s) => s.codec_type === "video")?.codec_name ?? "",
376
379
  audio: streams.find((s) => s.codec_type === "audio")?.codec_name ?? "",
377
380
  container: parsed.format?.format_name ?? "",
381
+ duration: Number.isFinite(seconds) && seconds > 0 ? seconds : 0,
378
382
  });
379
383
  }
380
384
  catch {
@@ -27,7 +27,35 @@ export interface ChannelInfo {
27
27
  error?: string;
28
28
  /** How many times the source has been dialled again since it started. */
29
29
  redials?: number;
30
+ /**
31
+ * For a pulled film: how far into it we are, in seconds, read off ffmpeg
32
+ * as it goes. This is what a restart and a redial go back to. A live
33
+ * source has no such place, and says so with `live`.
34
+ */
35
+ position?: number;
36
+ live?: boolean;
37
+ /** What the source turned out to hold, so a restart need not ask again. */
38
+ codecs?: {
39
+ video: string;
40
+ audio: string;
41
+ container: string;
42
+ duration?: number;
43
+ };
44
+ }
45
+ /** Where a pulled source is picked up from, and whether it can be at all. */
46
+ export interface PullResume {
47
+ /** A live source is joined where it is now; a film is joined where it was. */
48
+ live: boolean;
49
+ /** Seconds into a film to start from. */
50
+ position: number;
30
51
  }
52
+ /**
53
+ * How far back of the saved place a film is picked up from, in seconds. The
54
+ * place is written down every so often and a restart lands between two
55
+ * writes; a few seconds repeated is a hiccup, a few seconds missed is a
56
+ * line of dialogue.
57
+ */
58
+ export declare const REWIND = 3;
31
59
  /** How long to wait before dialling a dropped source again. */
32
60
  export declare const REDIAL = 2000;
33
61
  /** How many times in a row a source may fail without ever sending anything. */
@@ -116,7 +144,7 @@ export declare class Channel {
116
144
  * because you looked away, and a room where the picture depends on who is
117
145
  * in it is not a room anybody can be invited to.
118
146
  */
119
- pull(source: string, encode: string[], paced?: boolean, stall?: number, input?: string[]): void;
147
+ pull(source: string, encode: string[], paced?: boolean, stall?: number, input?: string[], audio?: string, resume?: PullResume): void;
120
148
  /**
121
149
  * Start the source over, now.
122
150
  *
@@ -209,7 +237,7 @@ export declare class Channels {
209
237
  * about it is the same too, which is the point -- a re-stream stops being a
210
238
  * special case and becomes one more thing that is on.
211
239
  */
212
- pull(id: string, name: string, source: string, encode: string[], kind: "audio" | "video", paced?: boolean, stall?: number, input?: string[]): Channel | null;
240
+ pull(id: string, name: string, source: string, encode: string[], kind: "audio" | "video", paced?: boolean, stall?: number, input?: string[], audio?: string, resume?: PullResume): Channel | null;
213
241
  /**
214
242
  * Dial a pulled channel's source again, now. False for a channel that is
215
243
  * not there or is not ours to dial: a publisher's stream restarts at the
@@ -265,8 +293,33 @@ export interface RememberedChannel {
265
293
  id: string;
266
294
  name: string;
267
295
  source: string;
296
+ /**
297
+ * What it was, so a restart need not ask the source again. Asking is not
298
+ * free: a film on an IPTV panel allows one connection, and while the old
299
+ * ffmpeg's is still being counted a probe of it gets an error page and no
300
+ * streams -- which read as "no picture", and two films came back on the
301
+ * air as sound alone.
302
+ */
303
+ kind?: "audio" | "video";
304
+ codecs?: {
305
+ video: string;
306
+ audio: string;
307
+ container: string;
308
+ duration?: number;
309
+ };
310
+ /** Where a film had got to, in seconds, so it picks up there. */
311
+ position?: number;
312
+ /** A live source has nowhere to pick up from. */
313
+ live?: boolean;
268
314
  }
269
315
  export declare function rememberedChannels(dir: string, port: number): RememberedChannel[];
316
+ /**
317
+ * What to write down about the channels a server is carrying: the pulled,
318
+ * kept ones, with what they turned out to be and where they have got to.
319
+ * The same answer everywhere one is added, kept, or taken off, so that no
320
+ * path forgets the position.
321
+ */
322
+ export declare function rememberedNow(channels: Channels): RememberedChannel[];
270
323
  export declare function rememberChannels(dir: string, port: number, list: RememberedChannel[]): void;
271
324
  /** A channel id nobody chose, for a publisher that did not name one. */
272
325
  export declare function generatedId(): string;
package/dist/channels.js CHANGED
@@ -19,6 +19,13 @@ import { randomBytes } from "node:crypto";
19
19
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
20
20
  import { join } from "node:path";
21
21
  import { Fragments, isOpening } from "./fragments.js";
22
+ /**
23
+ * How far back of the saved place a film is picked up from, in seconds. The
24
+ * place is written down every so often and a restart lands between two
25
+ * writes; a few seconds repeated is a hiccup, a few seconds missed is a
26
+ * line of dialogue.
27
+ */
28
+ export const REWIND = 3;
22
29
  /** How long to wait before dialling a dropped source again. */
23
30
  export const REDIAL = 2000;
24
31
  /** How many times in a row a source may fail without ever sending anything. */
@@ -168,29 +175,48 @@ export class Channel {
168
175
  * because you looked away, and a room where the picture depends on who is
169
176
  * in it is not a room anybody can be invited to.
170
177
  */
171
- pull(source, encode, paced = true, stall = STALL, input = []) {
178
+ pull(source, encode, paced = true, stall = STALL, input = [], audio = "", resume = { live: true, position: 0 }) {
172
179
  this.stall = stall;
173
180
  if (this.info.kind === "video")
174
181
  this.fragments = new Fragments();
175
182
  const [command, ...prefix] = this.options.ffmpeg;
176
183
  const remote = /^https?:\/\//i.test(source);
184
+ this.info.live = resume.live;
185
+ if (!resume.live)
186
+ this.info.position = Math.max(0, resume.position);
187
+ // What every remote input is told: dial again when the CDN drops it, and
188
+ // give up on a socket that has gone quiet. Input options apply to the
189
+ // input that follows them, so a second input is told again.
190
+ const remoteArgs = remote
191
+ ? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5", "-rw_timeout", String(stall * 1000)]
192
+ : [];
177
193
  const dial = () => {
178
194
  if (this.closing)
179
195
  return;
180
196
  this.stderr = "";
197
+ // Where to pick a film up from: a little before where it was, since
198
+ // the place was written down a moment ago and a moment of it twice is
199
+ // better than a moment of it missing. A live source is joined as is,
200
+ // and a film that has barely started is started.
201
+ const from = resume.live ? 0 : Math.max(0, Math.floor((this.info.position ?? 0) - REWIND));
202
+ const seek = from > 0 ? ["-ss", String(from)] : [];
181
203
  const child = spawn(command, [
182
204
  ...prefix,
183
205
  "-hide_banner",
184
206
  "-loglevel", "error",
207
+ // How far it has got, once a second, on a pipe of its own: this is
208
+ // what is written down for a restart and what a redial goes back
209
+ // to. Not stderr, which is for what went wrong.
210
+ "-progress", "pipe:3",
211
+ "-stats_period", "1",
185
212
  // A dropped source is normal over hours, and a channel that dies
186
213
  // the first time a CDN hiccups is not a channel anybody can rely
187
214
  // on. ffmpeg redials on its own before we have to.
188
- ...(remote ? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"] : []),
189
215
  // A connection that stops answering is an error after this long,
190
- // and an error is a thing the reconnect above knows what to do
191
- // with. Without it a silent socket is waited on for ever. In
216
+ // and an error is a thing the reconnect knows what to do with.
217
+ // Without it a silent socket is waited on for ever. In
192
218
  // microseconds, as ffmpeg wants it.
193
- ...(remote ? ["-rw_timeout", String(stall * 1000)] : []),
219
+ ...remoteArgs,
194
220
  // Real time, always. A file read as fast as the disk allows is an
195
221
  // hour of film in ninety seconds and a room that cannot be in it
196
222
  // together; a live source is already paced and loses nothing.
@@ -199,13 +225,35 @@ export class Channel {
199
225
  // referer, a cookie. A link resolved by yt-dlp comes with these,
200
226
  // and a CDN that got them from yt-dlp and not from us answers 403.
201
227
  ...input,
228
+ ...seek,
202
229
  "-i", source,
230
+ // The sound, when the site keeps it apart from the picture: a
231
+ // second input, dialled the same way, that the encode maps in.
232
+ ...(audio ? [...remoteArgs, ...(paced ? ["-re"] : []), ...input, ...seek, "-i", audio] : []),
203
233
  ...encode,
204
234
  "pipe:1",
205
- ], { stdio: ["ignore", "pipe", "pipe"] });
235
+ ], { stdio: ["ignore", "pipe", "pipe", "pipe"] });
206
236
  let sent = false;
207
237
  this.child = child;
208
238
  this.rearm(child);
239
+ // ffmpeg's progress: key=value lines, out_time_us being how much it
240
+ // has written, from where it was told to start. Read whole lines,
241
+ // since a chunk can end mid-number. Drained whatever it says, for
242
+ // the same reason stderr is.
243
+ let progress = "";
244
+ child.stdio[3]?.on("data", (chunk) => {
245
+ progress = (progress + chunk.toString("utf8")).slice(-4000);
246
+ if (this.child !== child || resume.live)
247
+ return;
248
+ const lines = progress.split("\n");
249
+ progress = lines.pop() ?? "";
250
+ for (const line of lines) {
251
+ const match = /^out_time_us=(\d+)/.exec(line.trim());
252
+ if (match)
253
+ this.info.position = from + Number(match[1]) / 1e6;
254
+ }
255
+ });
256
+ child.stdio[3]?.on("error", () => undefined);
209
257
  child.stdout?.on("data", (chunk) => {
210
258
  // An ffmpeg that was replaced can still have a chunk in the pipe.
211
259
  if (this.child !== child)
@@ -560,7 +608,7 @@ export class Channels {
560
608
  * about it is the same too, which is the point -- a re-stream stops being a
561
609
  * special case and becomes one more thing that is on.
562
610
  */
563
- pull(id, name, source, encode, kind, paced = true, stall = STALL, input = []) {
611
+ pull(id, name, source, encode, kind, paced = true, stall = STALL, input = [], audio = "", resume = { live: true, position: 0 }) {
564
612
  if (this.open.has(id))
565
613
  return null;
566
614
  const channel = new Channel({
@@ -575,7 +623,7 @@ export class Channels {
575
623
  source,
576
624
  }, this.options, (gone) => this.open.delete(gone));
577
625
  this.open.set(id, channel);
578
- channel.pull(source, encode, paced, stall, input);
626
+ channel.pull(source, encode, paced, stall, input, audio, resume);
579
627
  return channel;
580
628
  }
581
629
  /**
@@ -671,15 +719,57 @@ export function rememberedChannels(dir, port) {
671
719
  const list = all[String(port)];
672
720
  if (!Array.isArray(list))
673
721
  return [];
674
- return list.filter((one) => typeof one === "object" && one !== null &&
722
+ return list
723
+ .filter((one) => typeof one === "object" && one !== null &&
675
724
  typeof one.id === "string" &&
676
725
  typeof one.name === "string" &&
677
- typeof one.source === "string");
726
+ typeof one.source === "string")
727
+ .map((one) => {
728
+ const kept = { id: one["id"], name: one["name"], source: one["source"] };
729
+ if (one["kind"] === "audio" || one["kind"] === "video")
730
+ kept.kind = one["kind"];
731
+ const codecs = one["codecs"];
732
+ if (codecs && typeof codecs === "object") {
733
+ const c = codecs;
734
+ if (typeof c["video"] === "string" && typeof c["audio"] === "string" && typeof c["container"] === "string") {
735
+ kept.codecs = { video: c["video"], audio: c["audio"], container: c["container"] };
736
+ if (typeof c["duration"] === "number" && Number.isFinite(c["duration"]))
737
+ kept.codecs.duration = c["duration"];
738
+ }
739
+ }
740
+ if (typeof one["position"] === "number" && Number.isFinite(one["position"]) && one["position"] > 0)
741
+ kept.position = one["position"];
742
+ if (typeof one["live"] === "boolean")
743
+ kept.live = one["live"];
744
+ return kept;
745
+ });
678
746
  }
679
747
  catch {
680
748
  return [];
681
749
  }
682
750
  }
751
+ /**
752
+ * What to write down about the channels a server is carrying: the pulled,
753
+ * kept ones, with what they turned out to be and where they have got to.
754
+ * The same answer everywhere one is added, kept, or taken off, so that no
755
+ * path forgets the position.
756
+ */
757
+ export function rememberedNow(channels) {
758
+ return channels.list()
759
+ .filter((one) => one.via === "pull" && one.source && !channels.isEphemeral(one.id))
760
+ .map((one) => {
761
+ const kept = { id: one.id, name: one.name, source: one.source };
762
+ if (one.kind)
763
+ kept.kind = one.kind;
764
+ if (one.codecs)
765
+ kept.codecs = one.codecs;
766
+ if (typeof one.live === "boolean")
767
+ kept.live = one.live;
768
+ if (!one.live && typeof one.position === "number" && one.position > 0)
769
+ kept.position = Math.floor(one.position);
770
+ return kept;
771
+ });
772
+ }
683
773
  export function rememberChannels(dir, port, list) {
684
774
  let all = {};
685
775
  try {
package/dist/links.d.ts CHANGED
@@ -3,6 +3,15 @@ export interface ResolvedLink {
3
3
  title: string;
4
4
  /** Where the bytes are: what ffmpeg is pointed at. */
5
5
  media: string;
6
+ /**
7
+ * The sound, when it comes separately. YouTube stopped offering a single
8
+ * stream with both picture and sound in it: every format is video alone
9
+ * or audio alone, and a player that insists on one file gets "Requested
10
+ * format is not available" for every link. So the two are asked for as a
11
+ * pair, and ffmpeg reads both and puts them together. "" when `media`
12
+ * already has the sound in it.
13
+ */
14
+ audio: string;
6
15
  /** Whether it is on now, which decides pacing and whether it can be saved. */
7
16
  live: boolean;
8
17
  /** Seconds, when known. */
@@ -41,6 +50,14 @@ export declare function directLink(url: string): ResolvedLink;
41
50
  * second ago. The user agent has its own flag; the rest go as one block.
42
51
  */
43
52
  export declare function inputArgsFor(headers: Record<string, string>): string[];
53
+ /**
54
+ * How ffmpeg is asked to hand over a picture and a sound as one file, down a
55
+ * pipe. yt-dlp merges the two only into a file it can seek in, so a pair
56
+ * cannot go down its pipe; ffmpeg copies both into a fragmented MP4 -- or
57
+ * Matroska when the picture is not MP4, since a VP9 picture in an MP4 is a
58
+ * file few things open -- and writes as it goes.
59
+ */
60
+ export declare function mergeDownloadArgs(link: ResolvedLink): string[];
44
61
  /** A file name for a download: the title, made safe, with the right ending. */
45
62
  export declare function fileNameFor(link: ResolvedLink, audioOnly: boolean): string;
46
63
  /** What a browser should call the bytes, from the file name's ending. */
package/dist/links.js CHANGED
Binary file
package/dist/server.d.ts CHANGED
@@ -20,6 +20,7 @@ import { Names } from "./names.ts";
20
20
  import { Certs } from "./certs.ts";
21
21
  import { type Throttle } from "@profullstack/throttle";
22
22
  import { type Notification } from "./notify.ts";
23
+ import { type Codecs } from "./audio.ts";
23
24
  import { type Tools, type Track } from "./audio.ts";
24
25
  import { type Command, type RemoteTrack, type Snapshot } from "./protocol.ts";
25
26
  export declare const SERVE_BAND_COUNT = 24;
@@ -363,6 +364,11 @@ export declare function answerWith(response: ServerResponse, refused: Response):
363
364
  * curious people from becoming a room full of decoders.
364
365
  */
365
366
  export declare const MAX_ON_DEMAND = 4;
367
+ /**
368
+ * How often where each film has got to is written down. A restart lands
369
+ * somewhere inside this, and REWIND covers the gap.
370
+ */
371
+ export declare const REMEMBER_EVERY_MS = 15000;
366
372
  /**
367
373
  * Probe a source and start carrying it as a channel of its own.
368
374
  *
@@ -370,7 +376,17 @@ export declare const MAX_ON_DEMAND = 4;
370
376
  * ones back, so that both agree on what a source is encoded as. Null when
371
377
  * that channel id is already on.
372
378
  */
373
- export declare function pullChannel(channels: Channels, ffprobe: string[], id: string, name: string, source: string, input?: string[]): Promise<Channel | null>;
379
+ /** What is already known about a source, so it need not be asked, or asked twice. */
380
+ export interface KnownSource {
381
+ kind?: "audio" | "video";
382
+ codecs?: Codecs;
383
+ position?: number;
384
+ live?: boolean;
385
+ }
386
+ /** How many times a source that answers nothing is asked, and how far apart. */
387
+ export declare const PROBE_TRIES = 3;
388
+ export declare const PROBE_RETRY_MS = 1500;
389
+ export declare function pullChannel(channels: Channels, ffprobe: string[], id: string, name: string, source: string, input?: string[], audio?: string, known?: KnownSource): Promise<Channel | null>;
374
390
  /**
375
391
  * A Netscape cookies file beside the state, if the operator has put one there.
376
392
  *
package/dist/server.js CHANGED
@@ -19,7 +19,7 @@ import { readFileSync } from "node:fs";
19
19
  import { Connections } from "./connections.js";
20
20
  import { Broadcaster, DEFAULT_ENCODER, PRESETS, redact, } from "./broadcast.js";
21
21
  import { Ingest, normaliseFormat } from "./ingest.js";
22
- import { Channels, cleanId, generatedId, rememberChannels, rememberedChannels, } from "./channels.js";
22
+ import { Channels, cleanId, generatedId, rememberChannels, rememberedChannels, rememberedNow, } from "./channels.js";
23
23
  import { RtmpListeners } from "./rtmp-in.js";
24
24
  import { Accounts, clearedCookie, sessionCookie, tokenFrom } from "./accounts.js";
25
25
  import { anonymousHandle, Handles } from "./handles.js";
@@ -36,7 +36,7 @@ import { Directory, ENDED_TTL_MS, parseAnnouncement } from "./directory.js";
36
36
  import { PartyLine, telnyxSms } from "./partyline.js";
37
37
  import { HlsPackagers, withKey } from "./hls.js";
38
38
  import { DEFAULT_SITE as NICHEDB, Enricher, FIXTURE_TTL_MS } from "./enrich.js";
39
- import { contentTypeFor, downloadArgs, fileNameFor, inputArgsFor, linkChannelId, playableLink, resolveLink, saveFormat, } from "./links.js";
39
+ import { contentTypeFor, downloadArgs, fileNameFor, inputArgsFor, linkChannelId, mergeDownloadArgs, playableLink, resolveLink, saveFormat, } from "./links.js";
40
40
  import { CALL_IN_NUMBER, OPT_IN_PATH, optInPage } from "./optin.js";
41
41
  import pg from "pg";
42
42
  import { Follows, phoneFrom } from "./follows.js";
@@ -763,21 +763,66 @@ export async function answerWith(response, refused) {
763
763
  */
764
764
  export const MAX_ON_DEMAND = 4;
765
765
  /**
766
- * Probe a source and start carrying it as a channel of its own.
767
- *
768
- * Shared by the request that puts one on and the boot that puts remembered
769
- * ones back, so that both agree on what a source is encoded as. Null when
770
- * that channel id is already on.
766
+ * How often where each film has got to is written down. A restart lands
767
+ * somewhere inside this, and REWIND covers the gap.
771
768
  */
772
- export async function pullChannel(channels, ffprobe, id, name, source, input = []) {
773
- const codecs = await codecsOf({ ffmpeg: [], ffprobe, play: null }, source, input);
774
- const kind = codecs.video === "" ? "audio" : "video";
769
+ export const REMEMBER_EVERY_MS = 15_000;
770
+ /** How many times a source that answers nothing is asked, and how far apart. */
771
+ export const PROBE_TRIES = 3;
772
+ export const PROBE_RETRY_MS = 1500;
773
+ export async function pullChannel(channels, ffprobe, id, name, source, input = [], audio = "", known = {}) {
774
+ const tools = { ffmpeg: [], ffprobe, play: null };
775
+ const empty = (c) => c.video === "" && c.audio === "";
776
+ // A pair is probed as a pair: the picture's file has no sound in it, and
777
+ // asked alone it would read as a silent film. The sound's codec comes from
778
+ // the sound's file; the picture's, and the container, from the picture's.
779
+ const probe = async () => {
780
+ const [picture, sound] = await Promise.all([
781
+ codecsOf(tools, source, input),
782
+ audio ? codecsOf(tools, audio, input) : Promise.resolve(null),
783
+ ]);
784
+ return sound ? { ...picture, audio: sound.audio } : picture;
785
+ };
786
+ // Known already: a restart puts back what it wrote down and asks nobody.
787
+ // Otherwise ask, and ask again when the answer is nothing: a film on an
788
+ // IPTV panel allows one connection, and while the last ffmpeg's is still
789
+ // being counted a probe gets an error page and no streams. Nothing, read
790
+ // as "no picture", is how two films came back on the air as sound alone.
791
+ let codecs = known.codecs && !empty(known.codecs) ? known.codecs : { video: "", audio: "", container: "" };
792
+ let assumed = false;
793
+ if (empty(codecs)) {
794
+ for (let attempt = 0; attempt < PROBE_TRIES; attempt++) {
795
+ if (attempt > 0)
796
+ await new Promise((r) => setTimeout(r, PROBE_RETRY_MS));
797
+ codecs = await probe();
798
+ if (!empty(codecs))
799
+ break;
800
+ }
801
+ assumed = empty(codecs);
802
+ }
803
+ // A source that would not say is carried as what it was last time, or as
804
+ // video: a picture-less MP4 still plays, whereas a film as MP3 is a film
805
+ // with no picture until somebody notices.
806
+ const kind = codecs.video !== "" ? "video" : codecs.audio !== "" ? "audio" : (known.kind ?? "video");
807
+ if (assumed)
808
+ console.log(` "${id}": the source would not say what it holds; carrying it as ${kind}.`);
809
+ // A film has a length and a place to go back to; a live source has neither.
810
+ const live = known.live ?? !((codecs.duration ?? 0) > 0);
775
811
  const encode = kind === "video"
776
- ? videoArgs(codecs)
812
+ ? [
813
+ // Which streams from which input, when there are two: the picture
814
+ // from the first, the sound from the second. With one input ffmpeg
815
+ // picks for itself, as it always did.
816
+ ...(audio ? ["-map", "0:v:0", "-map", "1:a:0"] : []),
817
+ ...videoArgs(codecs),
818
+ ]
777
819
  // No picture in it, so none is invented: MP3 is the thing every browser
778
820
  // plays and the thing a listener can join halfway through.
779
821
  : ["-vn", "-c:a", "libmp3lame", "-b:a", "192k", "-f", "mp3"];
780
- return channels.pull(id, name, source, encode, kind, true, undefined, input);
822
+ const channel = channels.pull(id, name, source, encode, kind, true, undefined, input, kind === "video" ? audio : "", { live, position: known.position ?? 0 });
823
+ if (channel && !assumed)
824
+ channel.info.codecs = codecs;
825
+ return channel;
781
826
  }
782
827
  /** What a link resolves to, kept so a download can be named without asking twice. */
783
828
  const links = new Map();
@@ -2284,11 +2329,7 @@ export function createHandler(engine, options) {
2284
2329
  }
2285
2330
  }
2286
2331
  options.channels.keep(channelId);
2287
- if (options.rememberChannels) {
2288
- options.rememberChannels(options.channels.list()
2289
- .filter((one) => one.via === "pull" && one.source && !options.channels?.isEphemeral(one.id))
2290
- .map((one) => ({ id: one.id, name: one.name, source: one.source })));
2291
- }
2332
+ options.rememberChannels?.(rememberedNow(options.channels));
2292
2333
  void options.live?.announce?.();
2293
2334
  json(response, 200, { channel: channelId, name: entry.title, kind: entry.live ? "live" : "vod" });
2294
2335
  return;
@@ -2387,7 +2428,7 @@ export function createHandler(engine, options) {
2387
2428
  }
2388
2429
  links.set(link, resolved);
2389
2430
  if (!options.channels.has(channelId)) {
2390
- const started = await pullChannel(options.channels, options.ffprobe ?? ["ffprobe"], channelId, resolved.title, resolved.media, inputArgsFor(resolved.headers));
2431
+ const started = await pullChannel(options.channels, options.ffprobe ?? ["ffprobe"], channelId, resolved.title, resolved.media, inputArgsFor(resolved.headers), resolved.audio);
2391
2432
  if (!started) {
2392
2433
  json(response, 409, { error: "that link is already starting" });
2393
2434
  return;
@@ -2430,11 +2471,14 @@ export function createHandler(engine, options) {
2430
2471
  // as the plain MP3 the site also offers, and ".m4a" on an MP3 is a
2431
2472
  // file nothing will open.
2432
2473
  const saved = await resolveLink(options.ytdlp, link, { cookies: options.cookies ?? "", format: saveFormat(audioOnly) });
2433
- const fileName = fileNameFor("error" in saved ? resolved : { ...resolved, ext: saved.ext || resolved.ext }, audioOnly);
2434
- const [command, ...prefix] = options.ytdlp;
2435
- const child = spawn(command, [...prefix, ...downloadArgs(link, audioOnly, options.cookies ?? "")], {
2436
- stdio: ["ignore", "pipe", "pipe"],
2437
- });
2474
+ const chosen = "error" in saved ? resolved : { ...resolved, ext: saved.ext || resolved.ext, media: saved.media, audio: saved.audio, headers: saved.headers };
2475
+ const fileName = fileNameFor(chosen, audioOnly);
2476
+ // A picture and a sound kept apart by the site are put together here
2477
+ // by ffmpeg, as they go: yt-dlp only merges into a file it can seek
2478
+ // in, which a pipe is not. Anything else yt-dlp hands over whole.
2479
+ const paired = !audioOnly && chosen.audio !== "";
2480
+ const [command, ...prefix] = (paired ? (options.ffmpeg ?? ["ffmpeg"]) : options.ytdlp);
2481
+ const child = spawn(command, [...prefix, ...(paired ? mergeDownloadArgs(chosen) : downloadArgs(link, audioOnly, options.cookies ?? ""))], { stdio: ["ignore", "pipe", "pipe"] });
2438
2482
  response.writeHead(200, {
2439
2483
  "content-type": contentTypeFor(fileName),
2440
2484
  "content-disposition": `attachment; filename="${fileName.replace(/["\\]/g, "")}"; filename*=UTF-8''${encodeURIComponent(fileName)}`,
@@ -2563,11 +2607,8 @@ export function createHandler(engine, options) {
2563
2607
  const stopped = channels.stop(id);
2564
2608
  // Taken off on purpose is forgotten on purpose: it must not come back
2565
2609
  // at the next restart.
2566
- if (stopped && options.rememberChannels) {
2567
- options.rememberChannels(channels.list()
2568
- .filter((one) => one.via === "pull" && one.source)
2569
- .map((one) => ({ id: one.id, name: one.name, source: one.source })));
2570
- }
2610
+ if (stopped)
2611
+ options.rememberChannels?.(rememberedNow(channels));
2571
2612
  json(response, stopped ? 200 : 404, { ok: stopped });
2572
2613
  return;
2573
2614
  }
@@ -2608,11 +2649,7 @@ export function createHandler(engine, options) {
2608
2649
  return;
2609
2650
  }
2610
2651
  channels.keep(id);
2611
- if (options.rememberChannels) {
2612
- options.rememberChannels(channels.list()
2613
- .filter((one) => one.via === "pull" && one.source && !channels.isEphemeral(one.id))
2614
- .map((one) => ({ id: one.id, name: one.name, source: one.source })));
2615
- }
2652
+ options.rememberChannels?.(rememberedNow(channels));
2616
2653
  void options.live?.announce?.();
2617
2654
  json(response, 200, { ok: true });
2618
2655
  return;
@@ -2665,14 +2702,7 @@ export function createHandler(engine, options) {
2665
2702
  return;
2666
2703
  }
2667
2704
  // Written down, so a restart puts it back on the air.
2668
- if (options.rememberChannels) {
2669
- options.rememberChannels([
2670
- ...channels.list()
2671
- .filter((one) => one.via === "pull" && one.source && one.id !== wanted)
2672
- .map((one) => ({ id: one.id, name: one.name, source: one.source })),
2673
- { id: wanted, name: called, source },
2674
- ]);
2675
- }
2705
+ options.rememberChannels?.(rememberedNow(channels));
2676
2706
  json(response, 200, { ok: true, channel: channel.info });
2677
2707
  return;
2678
2708
  }
@@ -3573,12 +3603,35 @@ export async function serve(argv, version = "0.1.0") {
3573
3603
  // every restart used to take CNN off the air until somebody noticed.
3574
3604
  const remembering = (list) => rememberChannels(stateDir(), options.port, list);
3575
3605
  for (const one of rememberedChannels(stateDir(), options.port)) {
3576
- console.log(` Putting "${one.id}" (${one.name}) back on the air.`);
3577
- void pullChannel(channels, tools.ffprobe, one.id, one.name, one.source).then((channel) => {
3606
+ const where = one.position && !one.live ? `, from ${Math.floor(one.position / 60)}m${Math.floor(one.position % 60)}s` : "";
3607
+ console.log(` Putting "${one.id}" (${one.name}) back on the air${where}.`);
3608
+ // With what was written down about it: what it holds, so the source is
3609
+ // not asked again, and where it had got to, so a film carries on.
3610
+ void pullChannel(channels, tools.ffprobe, one.id, one.name, one.source, [], "", {
3611
+ ...(one.kind ? { kind: one.kind } : {}),
3612
+ ...(one.codecs ? { codecs: one.codecs } : {}),
3613
+ ...(one.position !== undefined ? { position: one.position } : {}),
3614
+ ...(one.live !== undefined ? { live: one.live } : {}),
3615
+ }).then((channel) => {
3578
3616
  if (!channel)
3579
3617
  console.log(` "${one.id}" is already on.`);
3580
3618
  });
3581
3619
  }
3620
+ // Where each film has got to, written down every so often, so a restart
3621
+ // picks it up from about there rather than from the start. Only when it
3622
+ // has changed: a server carrying nothing but live television writes
3623
+ // nothing.
3624
+ let lastRemembered = "";
3625
+ setInterval(() => {
3626
+ const now = rememberedNow(channels);
3627
+ if (!now.some((one) => one.position !== undefined))
3628
+ return;
3629
+ const text = JSON.stringify(now);
3630
+ if (text === lastRemembered)
3631
+ return;
3632
+ lastRemembered = text;
3633
+ remembering(now);
3634
+ }, REMEMBER_EVERY_MS).unref();
3582
3635
  // The m3u catalogs kept here: read from disk now, and any that were never
3583
3636
  // read are fetched in the background so browsing does not wait on a provider.
3584
3637
  const catalogs = new Catalogs(stateDir(), options.port);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.13.0",
3
+ "version": "0.14.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
@@ -391,6 +391,12 @@ export interface Codecs {
391
391
  * is a track no browser will play.
392
392
  */
393
393
  container: string;
394
+ /**
395
+ * How long it is, in seconds; 0 when it has no end, which is what tells a
396
+ * live channel from a film. A film has a place to go back to after a
397
+ * restart; a live channel is wherever it is now.
398
+ */
399
+ duration?: number;
394
400
  }
395
401
 
396
402
  /**
@@ -412,7 +418,7 @@ export async function codecsOf(tools: Tools, path: string, input: string[] = [])
412
418
  ...rest,
413
419
  "-v", "quiet",
414
420
  "-print_format", "json",
415
- "-show_entries", "format=format_name:stream=codec_type,codec_name",
421
+ "-show_entries", "format=format_name,duration:stream=codec_type,codec_name",
416
422
  // Headers the source's site expects, for a link resolved by yt-dlp.
417
423
  ...input,
418
424
  path,
@@ -428,13 +434,17 @@ export async function codecsOf(tools: Tools, path: string, input: string[] = [])
428
434
  try {
429
435
  const parsed = JSON.parse(out) as {
430
436
  streams?: { codec_type?: string; codec_name?: string }[];
431
- format?: { format_name?: string };
437
+ format?: { format_name?: string; duration?: string };
432
438
  };
433
439
  const streams = parsed.streams ?? [];
440
+ // ffprobe prints seconds as a string, and "N/A" for a stream with no
441
+ // end; both of those read as 0.
442
+ const seconds = Number(parsed.format?.duration ?? 0);
434
443
  return done({
435
444
  video: streams.find((s) => s.codec_type === "video")?.codec_name ?? "",
436
445
  audio: streams.find((s) => s.codec_type === "audio")?.codec_name ?? "",
437
446
  container: parsed.format?.format_name ?? "",
447
+ duration: Number.isFinite(seconds) && seconds > 0 ? seconds : 0,
438
448
  });
439
449
  } catch {
440
450
  return done(empty);
package/src/channels.ts CHANGED
@@ -50,8 +50,33 @@ export interface ChannelInfo {
50
50
  error?: string;
51
51
  /** How many times the source has been dialled again since it started. */
52
52
  redials?: number;
53
+ /**
54
+ * For a pulled film: how far into it we are, in seconds, read off ffmpeg
55
+ * as it goes. This is what a restart and a redial go back to. A live
56
+ * source has no such place, and says so with `live`.
57
+ */
58
+ position?: number;
59
+ live?: boolean;
60
+ /** What the source turned out to hold, so a restart need not ask again. */
61
+ codecs?: { video: string; audio: string; container: string; duration?: number };
62
+ }
63
+
64
+ /** Where a pulled source is picked up from, and whether it can be at all. */
65
+ export interface PullResume {
66
+ /** A live source is joined where it is now; a film is joined where it was. */
67
+ live: boolean;
68
+ /** Seconds into a film to start from. */
69
+ position: number;
53
70
  }
54
71
 
72
+ /**
73
+ * How far back of the saved place a film is picked up from, in seconds. The
74
+ * place is written down every so often and a restart lands between two
75
+ * writes; a few seconds repeated is a hiccup, a few seconds missed is a
76
+ * line of dialogue.
77
+ */
78
+ export const REWIND = 3;
79
+
55
80
  /** How long to wait before dialling a dropped source again. */
56
81
  export const REDIAL = 2000;
57
82
  /** How many times in a row a source may fail without ever sending anything. */
@@ -219,30 +244,56 @@ export class Channel {
219
244
  * because you looked away, and a room where the picture depends on who is
220
245
  * in it is not a room anybody can be invited to.
221
246
  */
222
- pull(source: string, encode: string[], paced = true, stall = STALL, input: string[] = []): void {
247
+ pull(
248
+ source: string,
249
+ encode: string[],
250
+ paced = true,
251
+ stall = STALL,
252
+ input: string[] = [],
253
+ audio = "",
254
+ resume: PullResume = { live: true, position: 0 },
255
+ ): void {
223
256
  this.stall = stall;
224
257
  if (this.info.kind === "video") this.fragments = new Fragments();
225
258
  const [command, ...prefix] = this.options.ffmpeg as [string, ...string[]];
226
259
  const remote = /^https?:\/\//i.test(source);
260
+ this.info.live = resume.live;
261
+ if (!resume.live) this.info.position = Math.max(0, resume.position);
262
+ // What every remote input is told: dial again when the CDN drops it, and
263
+ // give up on a socket that has gone quiet. Input options apply to the
264
+ // input that follows them, so a second input is told again.
265
+ const remoteArgs = remote
266
+ ? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5", "-rw_timeout", String(stall * 1000)]
267
+ : [];
227
268
 
228
269
  const dial = (): void => {
229
270
  if (this.closing) return;
230
271
  this.stderr = "";
272
+ // Where to pick a film up from: a little before where it was, since
273
+ // the place was written down a moment ago and a moment of it twice is
274
+ // better than a moment of it missing. A live source is joined as is,
275
+ // and a film that has barely started is started.
276
+ const from = resume.live ? 0 : Math.max(0, Math.floor((this.info.position ?? 0) - REWIND));
277
+ const seek = from > 0 ? ["-ss", String(from)] : [];
231
278
  const child = spawn(
232
279
  command,
233
280
  [
234
281
  ...prefix,
235
282
  "-hide_banner",
236
283
  "-loglevel", "error",
284
+ // How far it has got, once a second, on a pipe of its own: this is
285
+ // what is written down for a restart and what a redial goes back
286
+ // to. Not stderr, which is for what went wrong.
287
+ "-progress", "pipe:3",
288
+ "-stats_period", "1",
237
289
  // A dropped source is normal over hours, and a channel that dies
238
290
  // the first time a CDN hiccups is not a channel anybody can rely
239
291
  // on. ffmpeg redials on its own before we have to.
240
- ...(remote ? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"] : []),
241
292
  // A connection that stops answering is an error after this long,
242
- // and an error is a thing the reconnect above knows what to do
243
- // with. Without it a silent socket is waited on for ever. In
293
+ // and an error is a thing the reconnect knows what to do with.
294
+ // Without it a silent socket is waited on for ever. In
244
295
  // microseconds, as ffmpeg wants it.
245
- ...(remote ? ["-rw_timeout", String(stall * 1000)] : []),
296
+ ...remoteArgs,
246
297
  // Real time, always. A file read as fast as the disk allows is an
247
298
  // hour of film in ninety seconds and a room that cannot be in it
248
299
  // together; a live source is already paced and loses nothing.
@@ -251,16 +302,36 @@ export class Channel {
251
302
  // referer, a cookie. A link resolved by yt-dlp comes with these,
252
303
  // and a CDN that got them from yt-dlp and not from us answers 403.
253
304
  ...input,
305
+ ...seek,
254
306
  "-i", source,
307
+ // The sound, when the site keeps it apart from the picture: a
308
+ // second input, dialled the same way, that the encode maps in.
309
+ ...(audio ? [...remoteArgs, ...(paced ? ["-re"] : []), ...input, ...seek, "-i", audio] : []),
255
310
  ...encode,
256
311
  "pipe:1",
257
312
  ],
258
- { stdio: ["ignore", "pipe", "pipe"] },
313
+ { stdio: ["ignore", "pipe", "pipe", "pipe"] },
259
314
  );
260
315
 
261
316
  let sent = false;
262
317
  this.child = child;
263
318
  this.rearm(child);
319
+ // ffmpeg's progress: key=value lines, out_time_us being how much it
320
+ // has written, from where it was told to start. Read whole lines,
321
+ // since a chunk can end mid-number. Drained whatever it says, for
322
+ // the same reason stderr is.
323
+ let progress = "";
324
+ (child.stdio[3] as Readable | null)?.on("data", (chunk: Buffer) => {
325
+ progress = (progress + chunk.toString("utf8")).slice(-4000);
326
+ if (this.child !== child || resume.live) return;
327
+ const lines = progress.split("\n");
328
+ progress = lines.pop() ?? "";
329
+ for (const line of lines) {
330
+ const match = /^out_time_us=(\d+)/.exec(line.trim());
331
+ if (match) this.info.position = from + Number(match[1]) / 1e6;
332
+ }
333
+ });
334
+ (child.stdio[3] as Readable | null)?.on("error", () => undefined);
264
335
  child.stdout?.on("data", (chunk: Buffer) => {
265
336
  // An ffmpeg that was replaced can still have a chunk in the pipe.
266
337
  if (this.child !== child) return;
@@ -617,6 +688,8 @@ export class Channels {
617
688
  paced = true,
618
689
  stall = STALL,
619
690
  input: string[] = [],
691
+ audio = "",
692
+ resume: PullResume = { live: true, position: 0 },
620
693
  ): Channel | null {
621
694
  if (this.open.has(id)) return null;
622
695
  const channel = new Channel(
@@ -635,7 +708,7 @@ export class Channels {
635
708
  (gone) => this.open.delete(gone),
636
709
  );
637
710
  this.open.set(id, channel);
638
- channel.pull(source, encode, paced, stall, input);
711
+ channel.pull(source, encode, paced, stall, input, audio, resume);
639
712
  return channel;
640
713
  }
641
714
 
@@ -749,6 +822,19 @@ export interface RememberedChannel {
749
822
  id: string;
750
823
  name: string;
751
824
  source: string;
825
+ /**
826
+ * What it was, so a restart need not ask the source again. Asking is not
827
+ * free: a film on an IPTV panel allows one connection, and while the old
828
+ * ffmpeg's is still being counted a probe of it gets an error page and no
829
+ * streams -- which read as "no picture", and two films came back on the
830
+ * air as sound alone.
831
+ */
832
+ kind?: "audio" | "video";
833
+ codecs?: { video: string; audio: string; container: string; duration?: number };
834
+ /** Where a film had got to, in seconds, so it picks up there. */
835
+ position?: number;
836
+ /** A live source has nowhere to pick up from. */
837
+ live?: boolean;
752
838
  }
753
839
 
754
840
  const REMEMBERED = "channels.json";
@@ -758,18 +844,53 @@ export function rememberedChannels(dir: string, port: number): RememberedChannel
758
844
  const all = JSON.parse(readFileSync(join(dir, REMEMBERED), "utf8")) as Record<string, unknown>;
759
845
  const list = all[String(port)];
760
846
  if (!Array.isArray(list)) return [];
761
- return list.filter(
762
- (one): one is RememberedChannel =>
763
- typeof one === "object" && one !== null &&
764
- typeof (one as RememberedChannel).id === "string" &&
765
- typeof (one as RememberedChannel).name === "string" &&
766
- typeof (one as RememberedChannel).source === "string",
767
- );
847
+ return list
848
+ .filter(
849
+ (one): one is Record<string, unknown> =>
850
+ typeof one === "object" && one !== null &&
851
+ typeof (one as RememberedChannel).id === "string" &&
852
+ typeof (one as RememberedChannel).name === "string" &&
853
+ typeof (one as RememberedChannel).source === "string",
854
+ )
855
+ .map((one) => {
856
+ const kept: RememberedChannel = { id: one["id"] as string, name: one["name"] as string, source: one["source"] as string };
857
+ if (one["kind"] === "audio" || one["kind"] === "video") kept.kind = one["kind"];
858
+ const codecs = one["codecs"];
859
+ if (codecs && typeof codecs === "object") {
860
+ const c = codecs as Record<string, unknown>;
861
+ if (typeof c["video"] === "string" && typeof c["audio"] === "string" && typeof c["container"] === "string") {
862
+ kept.codecs = { video: c["video"], audio: c["audio"], container: c["container"] };
863
+ if (typeof c["duration"] === "number" && Number.isFinite(c["duration"])) kept.codecs.duration = c["duration"];
864
+ }
865
+ }
866
+ if (typeof one["position"] === "number" && Number.isFinite(one["position"]) && one["position"] > 0) kept.position = one["position"];
867
+ if (typeof one["live"] === "boolean") kept.live = one["live"];
868
+ return kept;
869
+ });
768
870
  } catch {
769
871
  return [];
770
872
  }
771
873
  }
772
874
 
875
+ /**
876
+ * What to write down about the channels a server is carrying: the pulled,
877
+ * kept ones, with what they turned out to be and where they have got to.
878
+ * The same answer everywhere one is added, kept, or taken off, so that no
879
+ * path forgets the position.
880
+ */
881
+ export function rememberedNow(channels: Channels): RememberedChannel[] {
882
+ return channels.list()
883
+ .filter((one) => one.via === "pull" && one.source && !channels.isEphemeral(one.id))
884
+ .map((one) => {
885
+ const kept: RememberedChannel = { id: one.id, name: one.name, source: one.source as string };
886
+ if (one.kind) kept.kind = one.kind;
887
+ if (one.codecs) kept.codecs = one.codecs;
888
+ if (typeof one.live === "boolean") kept.live = one.live;
889
+ if (!one.live && typeof one.position === "number" && one.position > 0) kept.position = Math.floor(one.position);
890
+ return kept;
891
+ });
892
+ }
893
+
773
894
  export function rememberChannels(dir: string, port: number, list: RememberedChannel[]): void {
774
895
  let all: Record<string, unknown> = {};
775
896
  try {
package/src/links.ts CHANGED
Binary file
package/src/server.ts CHANGED
@@ -27,7 +27,7 @@ import {
27
27
  } from "./broadcast.ts";
28
28
  import { Ingest, normaliseFormat } from "./ingest.ts";
29
29
  import {
30
- Channels, cleanId, generatedId, rememberChannels, rememberedChannels,
30
+ Channels, cleanId, generatedId, rememberChannels, rememberedChannels, rememberedNow,
31
31
  type Channel, type RememberedChannel,
32
32
  } from "./channels.ts";
33
33
  import { RtmpListeners } from "./rtmp-in.ts";
@@ -54,7 +54,8 @@ import { PartyLine, telnyxSms } from "./partyline.ts";
54
54
  import { HlsPackagers, withKey } from "./hls.ts";
55
55
  import { DEFAULT_SITE as NICHEDB, Enricher, type EnrichKind, FIXTURE_TTL_MS } from "./enrich.ts";
56
56
  import {
57
- contentTypeFor, downloadArgs, fileNameFor, inputArgsFor, linkChannelId, playableLink, resolveLink, saveFormat,
57
+ contentTypeFor, downloadArgs, fileNameFor, inputArgsFor, linkChannelId, mergeDownloadArgs, playableLink, resolveLink,
58
+ saveFormat,
58
59
  type ResolvedLink,
59
60
  } from "./links.ts";
60
61
  import { CALL_IN_NUMBER, OPT_IN_PATH, optInPage } from "./optin.ts";
@@ -80,7 +81,7 @@ import {
80
81
  paywallFromEnv,
81
82
  } from "./paywall.ts";
82
83
  import { isRemote, playsInBrowser, sourceLabel } from "./sources.ts";
83
- import { codecsOf, probeAsync, videoArgs } from "./audio.ts";
84
+ import { codecsOf, probeAsync, videoArgs, type Codecs } from "./audio.ts";
84
85
  import {
85
86
  allowedForListening,
86
87
  elevate,
@@ -971,6 +972,12 @@ export async function answerWith(response: ServerResponse, refused: Response): P
971
972
  */
972
973
  export const MAX_ON_DEMAND = 4;
973
974
 
975
+ /**
976
+ * How often where each film has got to is written down. A restart lands
977
+ * somewhere inside this, and REWIND covers the gap.
978
+ */
979
+ export const REMEMBER_EVERY_MS = 15_000;
980
+
974
981
  /**
975
982
  * Probe a source and start carrying it as a channel of its own.
976
983
  *
@@ -978,6 +985,18 @@ export const MAX_ON_DEMAND = 4;
978
985
  * ones back, so that both agree on what a source is encoded as. Null when
979
986
  * that channel id is already on.
980
987
  */
988
+ /** What is already known about a source, so it need not be asked, or asked twice. */
989
+ export interface KnownSource {
990
+ kind?: "audio" | "video";
991
+ codecs?: Codecs;
992
+ position?: number;
993
+ live?: boolean;
994
+ }
995
+
996
+ /** How many times a source that answers nothing is asked, and how far apart. */
997
+ export const PROBE_TRIES = 3;
998
+ export const PROBE_RETRY_MS = 1500;
999
+
981
1000
  export async function pullChannel(
982
1001
  channels: Channels,
983
1002
  ffprobe: string[],
@@ -985,15 +1004,60 @@ export async function pullChannel(
985
1004
  name: string,
986
1005
  source: string,
987
1006
  input: string[] = [],
1007
+ audio = "",
1008
+ known: KnownSource = {},
988
1009
  ): Promise<Channel | null> {
989
- const codecs = await codecsOf({ ffmpeg: [], ffprobe, play: null }, source, input);
990
- const kind = codecs.video === "" ? "audio" : "video";
1010
+ const tools = { ffmpeg: [], ffprobe, play: null };
1011
+ const empty = (c: Codecs): boolean => c.video === "" && c.audio === "";
1012
+ // A pair is probed as a pair: the picture's file has no sound in it, and
1013
+ // asked alone it would read as a silent film. The sound's codec comes from
1014
+ // the sound's file; the picture's, and the container, from the picture's.
1015
+ const probe = async (): Promise<Codecs> => {
1016
+ const [picture, sound] = await Promise.all([
1017
+ codecsOf(tools, source, input),
1018
+ audio ? codecsOf(tools, audio, input) : Promise.resolve(null),
1019
+ ]);
1020
+ return sound ? { ...picture, audio: sound.audio } : picture;
1021
+ };
1022
+ // Known already: a restart puts back what it wrote down and asks nobody.
1023
+ // Otherwise ask, and ask again when the answer is nothing: a film on an
1024
+ // IPTV panel allows one connection, and while the last ffmpeg's is still
1025
+ // being counted a probe gets an error page and no streams. Nothing, read
1026
+ // as "no picture", is how two films came back on the air as sound alone.
1027
+ let codecs: Codecs = known.codecs && !empty(known.codecs) ? known.codecs : { video: "", audio: "", container: "" };
1028
+ let assumed = false;
1029
+ if (empty(codecs)) {
1030
+ for (let attempt = 0; attempt < PROBE_TRIES; attempt++) {
1031
+ if (attempt > 0) await new Promise((r) => setTimeout(r, PROBE_RETRY_MS));
1032
+ codecs = await probe();
1033
+ if (!empty(codecs)) break;
1034
+ }
1035
+ assumed = empty(codecs);
1036
+ }
1037
+ // A source that would not say is carried as what it was last time, or as
1038
+ // video: a picture-less MP4 still plays, whereas a film as MP3 is a film
1039
+ // with no picture until somebody notices.
1040
+ const kind = codecs.video !== "" ? "video" : codecs.audio !== "" ? "audio" : (known.kind ?? "video");
1041
+ if (assumed) console.log(` "${id}": the source would not say what it holds; carrying it as ${kind}.`);
1042
+ // A film has a length and a place to go back to; a live source has neither.
1043
+ const live = known.live ?? !((codecs.duration ?? 0) > 0);
991
1044
  const encode = kind === "video"
992
- ? videoArgs(codecs)
1045
+ ? [
1046
+ // Which streams from which input, when there are two: the picture
1047
+ // from the first, the sound from the second. With one input ffmpeg
1048
+ // picks for itself, as it always did.
1049
+ ...(audio ? ["-map", "0:v:0", "-map", "1:a:0"] : []),
1050
+ ...videoArgs(codecs),
1051
+ ]
993
1052
  // No picture in it, so none is invented: MP3 is the thing every browser
994
1053
  // plays and the thing a listener can join halfway through.
995
1054
  : ["-vn", "-c:a", "libmp3lame", "-b:a", "192k", "-f", "mp3"];
996
- return channels.pull(id, name, source, encode, kind, true, undefined, input);
1055
+ const channel = channels.pull(
1056
+ id, name, source, encode, kind, true, undefined, input, kind === "video" ? audio : "",
1057
+ { live, position: known.position ?? 0 },
1058
+ );
1059
+ if (channel && !assumed) channel.info.codecs = codecs;
1060
+ return channel;
997
1061
  }
998
1062
 
999
1063
  /** What a link resolves to, kept so a download can be named without asking twice. */
@@ -2754,13 +2818,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2754
2818
  }
2755
2819
  }
2756
2820
  options.channels.keep(channelId);
2757
- if (options.rememberChannels) {
2758
- options.rememberChannels(
2759
- options.channels.list()
2760
- .filter((one) => one.via === "pull" && one.source && !options.channels?.isEphemeral(one.id))
2761
- .map((one) => ({ id: one.id, name: one.name, source: one.source as string })),
2762
- );
2763
- }
2821
+ options.rememberChannels?.(rememberedNow(options.channels));
2764
2822
  void options.live?.announce?.();
2765
2823
  json(response, 200, { channel: channelId, name: entry.title, kind: entry.live ? "live" : "vod" });
2766
2824
  return;
@@ -2863,7 +2921,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2863
2921
  if (!options.channels.has(channelId)) {
2864
2922
  const started = await pullChannel(
2865
2923
  options.channels, options.ffprobe ?? ["ffprobe"], channelId, resolved.title, resolved.media,
2866
- inputArgsFor(resolved.headers),
2924
+ inputArgsFor(resolved.headers), resolved.audio,
2867
2925
  );
2868
2926
  if (!started) {
2869
2927
  json(response, 409, { error: "that link is already starting" });
@@ -2908,11 +2966,18 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2908
2966
  // as the plain MP3 the site also offers, and ".m4a" on an MP3 is a
2909
2967
  // file nothing will open.
2910
2968
  const saved = await resolveLink(options.ytdlp, link, { cookies: options.cookies ?? "", format: saveFormat(audioOnly) });
2911
- const fileName = fileNameFor("error" in saved ? resolved : { ...resolved, ext: saved.ext || resolved.ext }, audioOnly);
2912
- const [command, ...prefix] = options.ytdlp as [string, ...string[]];
2913
- const child = spawn(command, [...prefix, ...downloadArgs(link, audioOnly, options.cookies ?? "")], {
2914
- stdio: ["ignore", "pipe", "pipe"],
2915
- });
2969
+ const chosen = "error" in saved ? resolved : { ...resolved, ext: saved.ext || resolved.ext, media: saved.media, audio: saved.audio, headers: saved.headers };
2970
+ const fileName = fileNameFor(chosen, audioOnly);
2971
+ // A picture and a sound kept apart by the site are put together here
2972
+ // by ffmpeg, as they go: yt-dlp only merges into a file it can seek
2973
+ // in, which a pipe is not. Anything else yt-dlp hands over whole.
2974
+ const paired = !audioOnly && chosen.audio !== "";
2975
+ const [command, ...prefix] = (paired ? (options.ffmpeg ?? ["ffmpeg"]) : options.ytdlp) as [string, ...string[]];
2976
+ const child = spawn(
2977
+ command,
2978
+ [...prefix, ...(paired ? mergeDownloadArgs(chosen) : downloadArgs(link, audioOnly, options.cookies ?? ""))],
2979
+ { stdio: ["ignore", "pipe", "pipe"] },
2980
+ );
2916
2981
  response.writeHead(200, {
2917
2982
  "content-type": contentTypeFor(fileName),
2918
2983
  "content-disposition": `attachment; filename="${fileName.replace(/["\\]/g, "")}"; filename*=UTF-8''${encodeURIComponent(fileName)}`,
@@ -3043,13 +3108,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
3043
3108
  const stopped = channels.stop(id);
3044
3109
  // Taken off on purpose is forgotten on purpose: it must not come back
3045
3110
  // at the next restart.
3046
- if (stopped && options.rememberChannels) {
3047
- options.rememberChannels(
3048
- channels.list()
3049
- .filter((one) => one.via === "pull" && one.source)
3050
- .map((one) => ({ id: one.id, name: one.name, source: one.source as string })),
3051
- );
3052
- }
3111
+ if (stopped) options.rememberChannels?.(rememberedNow(channels));
3053
3112
  json(response, stopped ? 200 : 404, { ok: stopped });
3054
3113
  return;
3055
3114
  }
@@ -3093,13 +3152,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
3093
3152
  return;
3094
3153
  }
3095
3154
  channels.keep(id);
3096
- if (options.rememberChannels) {
3097
- options.rememberChannels(
3098
- channels.list()
3099
- .filter((one) => one.via === "pull" && one.source && !channels.isEphemeral(one.id))
3100
- .map((one) => ({ id: one.id, name: one.name, source: one.source as string })),
3101
- );
3102
- }
3155
+ options.rememberChannels?.(rememberedNow(channels));
3103
3156
  void options.live?.announce?.();
3104
3157
  json(response, 200, { ok: true });
3105
3158
  return;
@@ -3155,14 +3208,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
3155
3208
  return;
3156
3209
  }
3157
3210
  // Written down, so a restart puts it back on the air.
3158
- if (options.rememberChannels) {
3159
- options.rememberChannels([
3160
- ...channels.list()
3161
- .filter((one) => one.via === "pull" && one.source && one.id !== wanted)
3162
- .map((one) => ({ id: one.id, name: one.name, source: one.source as string })),
3163
- { id: wanted, name: called, source },
3164
- ]);
3165
- }
3211
+ options.rememberChannels?.(rememberedNow(channels));
3166
3212
  json(response, 200, { ok: true, channel: channel.info });
3167
3213
  return;
3168
3214
  }
@@ -4125,11 +4171,32 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
4125
4171
  // every restart used to take CNN off the air until somebody noticed.
4126
4172
  const remembering = (list: RememberedChannel[]): void => rememberChannels(stateDir(), options.port, list);
4127
4173
  for (const one of rememberedChannels(stateDir(), options.port)) {
4128
- console.log(` Putting "${one.id}" (${one.name}) back on the air.`);
4129
- void pullChannel(channels, tools.ffprobe, one.id, one.name, one.source).then((channel) => {
4174
+ const where = one.position && !one.live ? `, from ${Math.floor(one.position / 60)}m${Math.floor(one.position % 60)}s` : "";
4175
+ console.log(` Putting "${one.id}" (${one.name}) back on the air${where}.`);
4176
+ // With what was written down about it: what it holds, so the source is
4177
+ // not asked again, and where it had got to, so a film carries on.
4178
+ void pullChannel(channels, tools.ffprobe, one.id, one.name, one.source, [], "", {
4179
+ ...(one.kind ? { kind: one.kind } : {}),
4180
+ ...(one.codecs ? { codecs: one.codecs } : {}),
4181
+ ...(one.position !== undefined ? { position: one.position } : {}),
4182
+ ...(one.live !== undefined ? { live: one.live } : {}),
4183
+ }).then((channel) => {
4130
4184
  if (!channel) console.log(` "${one.id}" is already on.`);
4131
4185
  });
4132
4186
  }
4187
+ // Where each film has got to, written down every so often, so a restart
4188
+ // picks it up from about there rather than from the start. Only when it
4189
+ // has changed: a server carrying nothing but live television writes
4190
+ // nothing.
4191
+ let lastRemembered = "";
4192
+ setInterval(() => {
4193
+ const now = rememberedNow(channels);
4194
+ if (!now.some((one) => one.position !== undefined)) return;
4195
+ const text = JSON.stringify(now);
4196
+ if (text === lastRemembered) return;
4197
+ lastRemembered = text;
4198
+ remembering(now);
4199
+ }, REMEMBER_EVERY_MS).unref();
4133
4200
 
4134
4201
  // The m3u catalogs kept here: read from disk now, and any that were never
4135
4202
  // read are fetched in the background so browsing does not wait on a provider.
package/web/dist/sw.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /* nixamp service worker — generated, do not edit */
2
- const CACHE = "nixamp-1789114815909";
2
+ const CACHE = "nixamp-1789118956283";
3
3
  const PRECACHE = [
4
4
  "/",
5
5
  "/apple-touch-icon.png",