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 +6 -0
- package/dist/audio.js +5 -1
- package/dist/channels.d.ts +55 -2
- package/dist/channels.js +100 -10
- package/dist/links.d.ts +17 -0
- package/dist/links.js +0 -0
- package/dist/server.d.ts +17 -1
- package/dist/server.js +96 -43
- package/package.json +1 -1
- package/src/audio.ts +12 -2
- package/src/channels.ts +135 -14
- package/src/links.ts +0 -0
- package/src/server.ts +111 -44
- package/web/dist/sw.js +1 -1
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 {
|
package/dist/channels.d.ts
CHANGED
|
@@ -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
|
|
191
|
-
//
|
|
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
|
-
...
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
773
|
-
|
|
774
|
-
|
|
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
|
-
?
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2434
|
-
const
|
|
2435
|
-
|
|
2436
|
-
|
|
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
|
|
2567
|
-
options.rememberChannels(channels
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3577
|
-
|
|
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
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(
|
|
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
|
|
243
|
-
//
|
|
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
|
-
...
|
|
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
|
|
762
|
-
(
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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,
|
|
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
|
|
990
|
-
const
|
|
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
|
-
?
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2912
|
-
const
|
|
2913
|
-
|
|
2914
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4129
|
-
|
|
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