nixamp 0.13.1 → 0.14.1

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[], audio?: 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[], audio?: 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,12 +175,15 @@ 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 = [], audio = "") {
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);
177
187
  // What every remote input is told: dial again when the CDN drops it, and
178
188
  // give up on a socket that has gone quiet. Input options apply to the
179
189
  // input that follows them, so a second input is told again.
@@ -184,10 +194,21 @@ export class Channel {
184
194
  if (this.closing)
185
195
  return;
186
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)] : [];
187
203
  const child = spawn(command, [
188
204
  ...prefix,
189
205
  "-hide_banner",
190
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",
191
212
  // A dropped source is normal over hours, and a channel that dies
192
213
  // the first time a CDN hiccups is not a channel anybody can rely
193
214
  // on. ffmpeg redials on its own before we have to.
@@ -204,16 +225,35 @@ export class Channel {
204
225
  // referer, a cookie. A link resolved by yt-dlp comes with these,
205
226
  // and a CDN that got them from yt-dlp and not from us answers 403.
206
227
  ...input,
228
+ ...seek,
207
229
  "-i", source,
208
230
  // The sound, when the site keeps it apart from the picture: a
209
231
  // second input, dialled the same way, that the encode maps in.
210
- ...(audio ? [...remoteArgs, ...(paced ? ["-re"] : []), ...input, "-i", audio] : []),
232
+ ...(audio ? [...remoteArgs, ...(paced ? ["-re"] : []), ...input, ...seek, "-i", audio] : []),
211
233
  ...encode,
212
234
  "pipe:1",
213
- ], { stdio: ["ignore", "pipe", "pipe"] });
235
+ ], { stdio: ["ignore", "pipe", "pipe", "pipe"] });
214
236
  let sent = false;
215
237
  this.child = child;
216
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);
217
257
  child.stdout?.on("data", (chunk) => {
218
258
  // An ffmpeg that was replaced can still have a chunk in the pipe.
219
259
  if (this.child !== child)
@@ -568,7 +608,7 @@ export class Channels {
568
608
  * about it is the same too, which is the point -- a re-stream stops being a
569
609
  * special case and becomes one more thing that is on.
570
610
  */
571
- pull(id, name, source, encode, kind, paced = true, stall = STALL, input = [], audio = "") {
611
+ pull(id, name, source, encode, kind, paced = true, stall = STALL, input = [], audio = "", resume = { live: true, position: 0 }) {
572
612
  if (this.open.has(id))
573
613
  return null;
574
614
  const channel = new Channel({
@@ -583,7 +623,7 @@ export class Channels {
583
623
  source,
584
624
  }, this.options, (gone) => this.open.delete(gone));
585
625
  this.open.set(id, channel);
586
- channel.pull(source, encode, paced, stall, input, audio);
626
+ channel.pull(source, encode, paced, stall, input, audio, resume);
587
627
  return channel;
588
628
  }
589
629
  /**
@@ -679,15 +719,57 @@ export function rememberedChannels(dir, port) {
679
719
  const list = all[String(port)];
680
720
  if (!Array.isArray(list))
681
721
  return [];
682
- return list.filter((one) => typeof one === "object" && one !== null &&
722
+ return list
723
+ .filter((one) => typeof one === "object" && one !== null &&
683
724
  typeof one.id === "string" &&
684
725
  typeof one.name === "string" &&
685
- 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
+ });
686
746
  }
687
747
  catch {
688
748
  return [];
689
749
  }
690
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
+ }
691
773
  export function rememberChannels(dir, port, list) {
692
774
  let all = {};
693
775
  try {
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[], audio?: 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";
@@ -763,23 +763,51 @@ 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 = [], audio = "") {
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 = {}) {
773
774
  const tools = { ffmpeg: [], ffprobe, play: null };
775
+ const empty = (c) => c.video === "" && c.audio === "";
774
776
  // A pair is probed as a pair: the picture's file has no sound in it, and
775
777
  // asked alone it would read as a silent film. The sound's codec comes from
776
778
  // the sound's file; the picture's, and the container, from the picture's.
777
- const [picture, sound] = await Promise.all([
778
- codecsOf(tools, source, input),
779
- audio ? codecsOf(tools, audio, input) : Promise.resolve(null),
780
- ]);
781
- const codecs = sound ? { ...picture, audio: sound.audio } : picture;
782
- const kind = codecs.video === "" ? "audio" : "video";
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);
783
811
  const encode = kind === "video"
784
812
  ? [
785
813
  // Which streams from which input, when there are two: the picture
@@ -791,7 +819,10 @@ export async function pullChannel(channels, ffprobe, id, name, source, input = [
791
819
  // No picture in it, so none is invented: MP3 is the thing every browser
792
820
  // plays and the thing a listener can join halfway through.
793
821
  : ["-vn", "-c:a", "libmp3lame", "-b:a", "192k", "-f", "mp3"];
794
- return channels.pull(id, name, source, encode, kind, true, undefined, input, kind === "video" ? audio : "");
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;
795
826
  }
796
827
  /** What a link resolves to, kept so a download can be named without asking twice. */
797
828
  const links = new Map();
@@ -2298,11 +2329,7 @@ export function createHandler(engine, options) {
2298
2329
  }
2299
2330
  }
2300
2331
  options.channels.keep(channelId);
2301
- if (options.rememberChannels) {
2302
- options.rememberChannels(options.channels.list()
2303
- .filter((one) => one.via === "pull" && one.source && !options.channels?.isEphemeral(one.id))
2304
- .map((one) => ({ id: one.id, name: one.name, source: one.source })));
2305
- }
2332
+ options.rememberChannels?.(rememberedNow(options.channels));
2306
2333
  void options.live?.announce?.();
2307
2334
  json(response, 200, { channel: channelId, name: entry.title, kind: entry.live ? "live" : "vod" });
2308
2335
  return;
@@ -2580,11 +2607,8 @@ export function createHandler(engine, options) {
2580
2607
  const stopped = channels.stop(id);
2581
2608
  // Taken off on purpose is forgotten on purpose: it must not come back
2582
2609
  // at the next restart.
2583
- if (stopped && options.rememberChannels) {
2584
- options.rememberChannels(channels.list()
2585
- .filter((one) => one.via === "pull" && one.source)
2586
- .map((one) => ({ id: one.id, name: one.name, source: one.source })));
2587
- }
2610
+ if (stopped)
2611
+ options.rememberChannels?.(rememberedNow(channels));
2588
2612
  json(response, stopped ? 200 : 404, { ok: stopped });
2589
2613
  return;
2590
2614
  }
@@ -2625,11 +2649,7 @@ export function createHandler(engine, options) {
2625
2649
  return;
2626
2650
  }
2627
2651
  channels.keep(id);
2628
- if (options.rememberChannels) {
2629
- options.rememberChannels(channels.list()
2630
- .filter((one) => one.via === "pull" && one.source && !channels.isEphemeral(one.id))
2631
- .map((one) => ({ id: one.id, name: one.name, source: one.source })));
2632
- }
2652
+ options.rememberChannels?.(rememberedNow(channels));
2633
2653
  void options.live?.announce?.();
2634
2654
  json(response, 200, { ok: true });
2635
2655
  return;
@@ -2682,14 +2702,7 @@ export function createHandler(engine, options) {
2682
2702
  return;
2683
2703
  }
2684
2704
  // Written down, so a restart puts it back on the air.
2685
- if (options.rememberChannels) {
2686
- options.rememberChannels([
2687
- ...channels.list()
2688
- .filter((one) => one.via === "pull" && one.source && one.id !== wanted)
2689
- .map((one) => ({ id: one.id, name: one.name, source: one.source })),
2690
- { id: wanted, name: called, source },
2691
- ]);
2692
- }
2705
+ options.rememberChannels?.(rememberedNow(channels));
2693
2706
  json(response, 200, { ok: true, channel: channel.info });
2694
2707
  return;
2695
2708
  }
@@ -3590,12 +3603,35 @@ export async function serve(argv, version = "0.1.0") {
3590
3603
  // every restart used to take CNN off the air until somebody noticed.
3591
3604
  const remembering = (list) => rememberChannels(stateDir(), options.port, list);
3592
3605
  for (const one of rememberedChannels(stateDir(), options.port)) {
3593
- console.log(` Putting "${one.id}" (${one.name}) back on the air.`);
3594
- 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) => {
3595
3616
  if (!channel)
3596
3617
  console.log(` "${one.id}" is already on.`);
3597
3618
  });
3598
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();
3599
3635
  // The m3u catalogs kept here: read from disk now, and any that were never
3600
3636
  // read are fetched in the background so browsing does not wait on a provider.
3601
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.1",
3
+ "version": "0.14.1",
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);