nixamp 0.18.1 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/audio.d.ts +12 -0
- package/dist/audio.js +22 -2
- package/dist/channels.d.ts +15 -0
- package/dist/channels.js +50 -10
- package/dist/links.d.ts +32 -0
- package/dist/links.js +82 -0
- package/dist/server.d.ts +9 -0
- package/dist/server.js +29 -5
- package/package.json +1 -1
- package/src/audio.ts +32 -3
- package/src/channels.ts +59 -10
- package/src/links.ts +88 -0
- package/src/server.ts +37 -4
- package/web/dist/assets/{hls-3VKVEQE3-DLpqiiG9.js → hls-3VKVEQE3-wkRCzISl.js} +1 -1
- package/web/dist/assets/index-BuR7VEMY.js +1 -0
- package/web/dist/assets/{mpegts-LO6RVLD6-CdlaJXcD.js → mpegts-LO6RVLD6-CDElw-VW.js} +1 -1
- package/web/dist/assets/{mpegts-LSi2IZbO.js → mpegts-mq9xdn-u.js} +1 -1
- package/web/dist/index.html +8 -12
- package/web/dist/sw.js +5 -5
- package/web/dist/assets/index-BafZs9FO.js +0 -1
package/dist/audio.d.ts
CHANGED
|
@@ -15,6 +15,12 @@ export interface Tools {
|
|
|
15
15
|
play: string[] | null;
|
|
16
16
|
/** yt-dlp, which turns a pasted page into a media address; null when there is none. */
|
|
17
17
|
ytdlp?: string[] | null;
|
|
18
|
+
/**
|
|
19
|
+
* Whether an ffmpeg was actually found, as opposed to guessed at. A server
|
|
20
|
+
* without one cannot carry a channel, and should say so before a link is
|
|
21
|
+
* resolved and probed and blamed for it.
|
|
22
|
+
*/
|
|
23
|
+
carries?: boolean;
|
|
18
24
|
}
|
|
19
25
|
/**
|
|
20
26
|
* Find the tools. A bare `ffmpeg` on PATH is tried first; mise shims are common
|
|
@@ -120,6 +126,12 @@ export interface Codecs {
|
|
|
120
126
|
* the whole library came to be tagged with the process wedged solid.
|
|
121
127
|
*/
|
|
122
128
|
export declare function codecsOf(tools: Tools, path: string, input?: string[]): Promise<Codecs>;
|
|
129
|
+
export declare function isCoverArt(stream: {
|
|
130
|
+
codec_name?: string;
|
|
131
|
+
disposition?: {
|
|
132
|
+
attached_pic?: number;
|
|
133
|
+
};
|
|
134
|
+
}): boolean;
|
|
123
135
|
/** How much of a transport stream is read before deciding what is in it. */
|
|
124
136
|
export declare const TRANSPORT_PROBE_BYTES: number;
|
|
125
137
|
export declare const TRANSPORT_ANALYSE_US = 10000000;
|
package/dist/audio.js
CHANGED
|
@@ -91,6 +91,7 @@ export function detectTools() {
|
|
|
91
91
|
ffprobe: ffprobe ?? ["ffprobe"],
|
|
92
92
|
play,
|
|
93
93
|
ytdlp,
|
|
94
|
+
carries: ffmpeg !== null,
|
|
94
95
|
};
|
|
95
96
|
}
|
|
96
97
|
export function probe(tools, path) {
|
|
@@ -365,7 +366,10 @@ export async function codecsOf(tools, path, input = []) {
|
|
|
365
366
|
...rest,
|
|
366
367
|
"-v", "quiet",
|
|
367
368
|
"-print_format", "json",
|
|
368
|
-
|
|
369
|
+
// The disposition too: an MP3 with its cover art in it carries that
|
|
370
|
+
// art as a video stream of one JPEG, and a probe that took it for a
|
|
371
|
+
// picture put a podcast on the air as a film with no frames.
|
|
372
|
+
"-show_entries", "format=format_name,duration:stream=codec_type,codec_name,width,height:stream_disposition=attached_pic",
|
|
369
373
|
// A transport stream needs looking further into than a file with an
|
|
370
374
|
// index does: there is no header listing the tracks, only packets, and
|
|
371
375
|
// a 4K recording can carry a second of null padding and a long gap to
|
|
@@ -389,7 +393,7 @@ export async function codecsOf(tools, path, input = []) {
|
|
|
389
393
|
// ffprobe prints seconds as a string, and "N/A" for a stream with no
|
|
390
394
|
// end; both of those read as 0.
|
|
391
395
|
const seconds = Number(parsed.format?.duration ?? 0);
|
|
392
|
-
const picture = streams.find((s) => s.codec_type === "video");
|
|
396
|
+
const picture = streams.find((s) => s.codec_type === "video" && !isCoverArt(s));
|
|
393
397
|
return done({
|
|
394
398
|
video: picture?.codec_name ?? "",
|
|
395
399
|
audio: streams.find((s) => s.codec_type === "audio")?.codec_name ?? "",
|
|
@@ -405,6 +409,22 @@ export async function codecsOf(tools, path, input = []) {
|
|
|
405
409
|
});
|
|
406
410
|
});
|
|
407
411
|
}
|
|
412
|
+
/**
|
|
413
|
+
* Cover art is not a picture.
|
|
414
|
+
*
|
|
415
|
+
* An MP3, FLAC or M4A with its sleeve embedded shows ffprobe a video stream:
|
|
416
|
+
* one JPEG or PNG, flagged as an attached picture. Carried as video it is a
|
|
417
|
+
* channel whose picture never gets a second frame -- ffmpeg writes nothing
|
|
418
|
+
* and the source is dialled again and again -- so a sleeve counts for
|
|
419
|
+
* nothing and the sound decides. A still-image codec with no such flag is
|
|
420
|
+
* the same thing from a container that does not flag it.
|
|
421
|
+
*/
|
|
422
|
+
const STILL_IMAGE = new Set(["png", "bmp", "gif", "tiff", "webp"]);
|
|
423
|
+
export function isCoverArt(stream) {
|
|
424
|
+
if (stream.disposition?.attached_pic === 1)
|
|
425
|
+
return true;
|
|
426
|
+
return STILL_IMAGE.has((stream.codec_name ?? "").toLowerCase());
|
|
427
|
+
}
|
|
408
428
|
/** How much of a transport stream is read before deciding what is in it. */
|
|
409
429
|
export const TRANSPORT_PROBE_BYTES = 20 * 1024 * 1024;
|
|
410
430
|
export const TRANSPORT_ANALYSE_US = 10_000_000;
|
package/dist/channels.d.ts
CHANGED
|
@@ -33,6 +33,14 @@ export interface ChannelInfo {
|
|
|
33
33
|
error?: string;
|
|
34
34
|
/** How many times the source has been dialled again since it started. */
|
|
35
35
|
redials?: number;
|
|
36
|
+
/**
|
|
37
|
+
* For a channel that plays a list: the entries, in order, and which one is
|
|
38
|
+
* on. When one ends the next is dialled at once, and the last is followed
|
|
39
|
+
* by the first: a station, not a file. A restart picks up at the entry
|
|
40
|
+
* that was on.
|
|
41
|
+
*/
|
|
42
|
+
playlist?: string[];
|
|
43
|
+
playlistAt?: number;
|
|
36
44
|
/**
|
|
37
45
|
* For a pulled film: how far into it we are, in seconds, read off ffmpeg
|
|
38
46
|
* as it goes. This is what a restart and a redial go back to. A live
|
|
@@ -78,6 +86,8 @@ export interface PullResume {
|
|
|
78
86
|
live: boolean;
|
|
79
87
|
/** Seconds into a film to start from. */
|
|
80
88
|
position: number;
|
|
89
|
+
/** For a list of things rather than one: every entry, in order. */
|
|
90
|
+
playlist?: string[];
|
|
81
91
|
}
|
|
82
92
|
/**
|
|
83
93
|
* A source read by us rather than by ffmpeg: what to tell ffmpeg it is,
|
|
@@ -187,6 +197,8 @@ export declare class Channel {
|
|
|
187
197
|
private fragments;
|
|
188
198
|
/** For a pulled channel: what to run, and how many times it has failed. */
|
|
189
199
|
private redial;
|
|
200
|
+
/** For a channel playing a list: move to the next entry. Null when there is no list. */
|
|
201
|
+
private advance;
|
|
190
202
|
private failures;
|
|
191
203
|
private timer;
|
|
192
204
|
/** Fires when a pulled source has said nothing for STALL. */
|
|
@@ -478,6 +490,9 @@ export interface RememberedChannel {
|
|
|
478
490
|
live?: boolean;
|
|
479
491
|
/** The member who put it on, so it is still theirs after a restart. */
|
|
480
492
|
startedBy?: string;
|
|
493
|
+
/** For a list: every entry, and which was on, so it carries on from there. */
|
|
494
|
+
playlist?: string[];
|
|
495
|
+
playlistAt?: number;
|
|
481
496
|
}
|
|
482
497
|
export declare function rememberedChannels(dir: string, port: number): RememberedChannel[];
|
|
483
498
|
/**
|
package/dist/channels.js
CHANGED
|
@@ -133,6 +133,8 @@ export class Channel {
|
|
|
133
133
|
fragments = null;
|
|
134
134
|
/** For a pulled channel: what to run, and how many times it has failed. */
|
|
135
135
|
redial = null;
|
|
136
|
+
/** For a channel playing a list: move to the next entry. Null when there is no list. */
|
|
137
|
+
advance = null;
|
|
136
138
|
failures = 0;
|
|
137
139
|
timer = null;
|
|
138
140
|
/** Fires when a pulled source has said nothing for STALL. */
|
|
@@ -220,20 +222,38 @@ export class Channel {
|
|
|
220
222
|
if (this.info.kind === "video")
|
|
221
223
|
this.fragments = new Fragments();
|
|
222
224
|
const [command, ...prefix] = this.options.ffmpeg;
|
|
223
|
-
const remote = /^https?:\/\//i.test(source);
|
|
224
225
|
this.info.live = resume.live;
|
|
225
226
|
if (!resume.live)
|
|
226
227
|
this.info.position = Math.max(0, resume.position);
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
228
|
+
// A list plays entry by entry: the one that is on is what gets dialled,
|
|
229
|
+
// and an entry that ended is followed by the next, at once.
|
|
230
|
+
const list = resume.playlist && resume.playlist.length > 0 ? resume.playlist : null;
|
|
231
|
+
let at = 0;
|
|
232
|
+
if (list) {
|
|
233
|
+
this.info.playlist = list;
|
|
234
|
+
at = Math.min(Math.max(0, this.info.playlistAt ?? 0), list.length - 1);
|
|
235
|
+
this.info.playlistAt = at;
|
|
236
|
+
}
|
|
237
|
+
let current = list ? list[at] : source;
|
|
238
|
+
this.advance = list && list.length > 1
|
|
239
|
+
? () => {
|
|
240
|
+
at = (at + 1) % list.length;
|
|
241
|
+
this.info.playlistAt = at;
|
|
242
|
+
current = list[at];
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
: null;
|
|
233
246
|
const dial = () => {
|
|
234
247
|
if (this.closing)
|
|
235
248
|
return;
|
|
236
249
|
this.stderr = "";
|
|
250
|
+
const remote = /^https?:\/\//i.test(current);
|
|
251
|
+
// What every remote input is told: dial again when the CDN drops it, and
|
|
252
|
+
// give up on a socket that has gone quiet. Input options apply to the
|
|
253
|
+
// input that follows them, so a second input is told again.
|
|
254
|
+
const remoteArgs = remote
|
|
255
|
+
? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5", "-rw_timeout", String(stall * 1000)]
|
|
256
|
+
: [];
|
|
237
257
|
// Where to pick a film up from: a little before where it was, since
|
|
238
258
|
// the place was written down a moment ago and a moment of it twice is
|
|
239
259
|
// better than a moment of it missing. A live source is joined as is,
|
|
@@ -291,7 +311,7 @@ export class Channel {
|
|
|
291
311
|
// and a CDN that got them from yt-dlp and not from us answers 403.
|
|
292
312
|
...input,
|
|
293
313
|
...seek,
|
|
294
|
-
"-i",
|
|
314
|
+
"-i", current,
|
|
295
315
|
// The sound, when the site keeps it apart from the picture: a
|
|
296
316
|
// second input, dialled the same way, that the encode maps in.
|
|
297
317
|
...(audio ? [...remoteArgs, ...(paced ? ["-re"] : []), ...input, ...seek, "-i", audio] : []),
|
|
@@ -461,13 +481,22 @@ export class Channel {
|
|
|
461
481
|
this.close();
|
|
462
482
|
return;
|
|
463
483
|
}
|
|
464
|
-
|
|
484
|
+
// A list moves on. An entry that played to its end is not a source that
|
|
485
|
+
// dropped: the next is dialled now, and it is not a redial. One that
|
|
486
|
+
// gave nothing is skipped the same way, counted as the failure it was,
|
|
487
|
+
// so a list of dead links gives up rather than cycling for ever.
|
|
488
|
+
const moved = this.advance?.() ?? false;
|
|
489
|
+
const ended = moved && sent;
|
|
490
|
+
if (ended)
|
|
491
|
+
this.info.error = undefined;
|
|
492
|
+
else
|
|
493
|
+
this.info.redials = (this.info.redials ?? 0) + 1;
|
|
465
494
|
this.startOver();
|
|
466
495
|
const dial = this.redial;
|
|
467
496
|
this.timer = setTimeout(() => {
|
|
468
497
|
this.timer = null;
|
|
469
498
|
dial();
|
|
470
|
-
}, REDIAL);
|
|
499
|
+
}, ended ? 0 : REDIAL);
|
|
471
500
|
// A redial is not a reason to keep the process alive at exit.
|
|
472
501
|
this.timer.unref?.();
|
|
473
502
|
}
|
|
@@ -1032,6 +1061,13 @@ export function rememberedChannels(dir, port) {
|
|
|
1032
1061
|
kept.live = one["live"];
|
|
1033
1062
|
if (typeof one["startedBy"] === "string" && one["startedBy"] !== "")
|
|
1034
1063
|
kept.startedBy = one["startedBy"];
|
|
1064
|
+
if (Array.isArray(one["playlist"])) {
|
|
1065
|
+
const entries = one["playlist"].filter((entry) => typeof entry === "string" && entry !== "");
|
|
1066
|
+
if (entries.length > 0)
|
|
1067
|
+
kept.playlist = entries;
|
|
1068
|
+
if (typeof one["playlistAt"] === "number" && Number.isInteger(one["playlistAt"]) && one["playlistAt"] >= 0)
|
|
1069
|
+
kept.playlistAt = one["playlistAt"];
|
|
1070
|
+
}
|
|
1035
1071
|
return kept;
|
|
1036
1072
|
});
|
|
1037
1073
|
}
|
|
@@ -1060,6 +1096,10 @@ export function rememberedNow(channels) {
|
|
|
1060
1096
|
kept.position = Math.floor(one.position);
|
|
1061
1097
|
if (one.startedBy)
|
|
1062
1098
|
kept.startedBy = one.startedBy;
|
|
1099
|
+
if (one.playlist && one.playlist.length > 0) {
|
|
1100
|
+
kept.playlist = one.playlist;
|
|
1101
|
+
kept.playlistAt = one.playlistAt ?? 0;
|
|
1102
|
+
}
|
|
1063
1103
|
return kept;
|
|
1064
1104
|
});
|
|
1065
1105
|
}
|
package/dist/links.d.ts
CHANGED
|
@@ -26,8 +26,40 @@ export interface ResolvedLink {
|
|
|
26
26
|
ext: string;
|
|
27
27
|
/** The page it came from. */
|
|
28
28
|
page: string;
|
|
29
|
+
/**
|
|
30
|
+
* For a pasted .m3u: every entry in it, in order. The channel plays them
|
|
31
|
+
* one after another and starts over at the end, a station rather than a
|
|
32
|
+
* file; `media` is the first, for the probe that decides what it holds.
|
|
33
|
+
*/
|
|
34
|
+
playlist?: string[];
|
|
29
35
|
}
|
|
30
36
|
export declare function isDirectMedia(url: string): boolean;
|
|
37
|
+
export declare function isPlaylistLink(url: string): boolean;
|
|
38
|
+
/** How much of a playlist is worth reading: a list, not a library dump. */
|
|
39
|
+
export declare const PLAYLIST_MAX_BYTES = 2000000;
|
|
40
|
+
export declare const PLAYLIST_MAX_ENTRIES = 1000;
|
|
41
|
+
export declare const PLAYLIST_FETCH_TIMEOUT_MS = 15000;
|
|
42
|
+
/**
|
|
43
|
+
* What a playlist's text holds: the entries, in order, resolved against
|
|
44
|
+
* where the list was fetched from. Some things called .m3u are HLS after
|
|
45
|
+
* all -- a segment list with #EXT-X- tags -- and those are one stream for
|
|
46
|
+
* ffmpeg to read as it is, not a list of streams.
|
|
47
|
+
*/
|
|
48
|
+
export declare function playlistFrom(text: string, base: string): {
|
|
49
|
+
hls: boolean;
|
|
50
|
+
sources: string[];
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* A pasted .m3u, read and turned into a channel's worth of entries. Fetched
|
|
54
|
+
* here rather than by yt-dlp, which would take the first entry and stop, or
|
|
55
|
+
* by ffmpeg, which does not read a plain list at all.
|
|
56
|
+
*/
|
|
57
|
+
export declare function resolvePlaylist(url: string, options?: {
|
|
58
|
+
timeoutMs?: number;
|
|
59
|
+
fetcher?: typeof fetch;
|
|
60
|
+
}): Promise<ResolvedLink | {
|
|
61
|
+
error: string;
|
|
62
|
+
}>;
|
|
31
63
|
/** A link somebody pasted, or "" when it is not one this can play. */
|
|
32
64
|
export declare function playableLink(entered: unknown): string;
|
|
33
65
|
/** A channel id for a link: stable, so two people pasting the same link share one decoder. */
|
package/dist/links.js
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import { createHash } from "node:crypto";
|
|
23
23
|
import { spawn } from "node:child_process";
|
|
24
|
+
import { parseCatalog } from "./catalogs.js";
|
|
24
25
|
/**
|
|
25
26
|
* Formats that are a file, not a page.
|
|
26
27
|
*
|
|
@@ -40,6 +41,85 @@ export function isDirectMedia(url) {
|
|
|
40
41
|
return false;
|
|
41
42
|
}
|
|
42
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* A plain m3u: a list of things to play, one per line, which is not a thing
|
|
46
|
+
* ffmpeg reads. The .m3u8 of HLS is a list of segments of one thing, which
|
|
47
|
+
* it does, and stays in DIRECT above.
|
|
48
|
+
*/
|
|
49
|
+
const PLAYLIST = /\.m3u(\?.*)?$/i;
|
|
50
|
+
export function isPlaylistLink(url) {
|
|
51
|
+
try {
|
|
52
|
+
const parsed = new URL(url);
|
|
53
|
+
return PLAYLIST.test(parsed.pathname + parsed.search);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** How much of a playlist is worth reading: a list, not a library dump. */
|
|
60
|
+
export const PLAYLIST_MAX_BYTES = 2_000_000;
|
|
61
|
+
export const PLAYLIST_MAX_ENTRIES = 1000;
|
|
62
|
+
export const PLAYLIST_FETCH_TIMEOUT_MS = 15_000;
|
|
63
|
+
/**
|
|
64
|
+
* What a playlist's text holds: the entries, in order, resolved against
|
|
65
|
+
* where the list was fetched from. Some things called .m3u are HLS after
|
|
66
|
+
* all -- a segment list with #EXT-X- tags -- and those are one stream for
|
|
67
|
+
* ffmpeg to read as it is, not a list of streams.
|
|
68
|
+
*/
|
|
69
|
+
export function playlistFrom(text, base) {
|
|
70
|
+
if (/^#EXT-X-/m.test(text))
|
|
71
|
+
return { hls: true, sources: [] };
|
|
72
|
+
const sources = parseCatalog(text, base)
|
|
73
|
+
.map((entry) => entry.source)
|
|
74
|
+
.filter((source) => /^(https?|rtmps?):\/\//i.test(source))
|
|
75
|
+
.slice(0, PLAYLIST_MAX_ENTRIES);
|
|
76
|
+
return { hls: false, sources };
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* A pasted .m3u, read and turned into a channel's worth of entries. Fetched
|
|
80
|
+
* here rather than by yt-dlp, which would take the first entry and stop, or
|
|
81
|
+
* by ffmpeg, which does not read a plain list at all.
|
|
82
|
+
*/
|
|
83
|
+
export async function resolvePlaylist(url, options = {}) {
|
|
84
|
+
const get = options.fetcher ?? fetch;
|
|
85
|
+
const abort = new AbortController();
|
|
86
|
+
const timer = setTimeout(() => abort.abort(), options.timeoutMs ?? PLAYLIST_FETCH_TIMEOUT_MS);
|
|
87
|
+
let text = "";
|
|
88
|
+
try {
|
|
89
|
+
const answer = await get(url, { signal: abort.signal, headers: { "user-agent": "nixamp" }, redirect: "follow" });
|
|
90
|
+
if (!answer.ok)
|
|
91
|
+
return { error: `that playlist could not be fetched (HTTP ${answer.status})` };
|
|
92
|
+
text = (await answer.text()).slice(0, PLAYLIST_MAX_BYTES);
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
const why = abort.signal.aborted ? "took too long to answer" : (error.message || "could not be fetched");
|
|
96
|
+
return { error: `that playlist ${why}` };
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
clearTimeout(timer);
|
|
100
|
+
}
|
|
101
|
+
const list = playlistFrom(text, url);
|
|
102
|
+
if (list.hls)
|
|
103
|
+
return directLink(url);
|
|
104
|
+
const [first] = list.sources;
|
|
105
|
+
if (!first)
|
|
106
|
+
return { error: "that playlist has nothing in it this can play" };
|
|
107
|
+
return {
|
|
108
|
+
title: fileNameOf(url).replace(/\.m3u$/i, "") || "Playlist",
|
|
109
|
+
media: first,
|
|
110
|
+
audio: "",
|
|
111
|
+
// A station: joined where it is, never seeked, and with no end to save.
|
|
112
|
+
live: true,
|
|
113
|
+
duration: 0,
|
|
114
|
+
// Whether there is a picture is the first entry's to say; ffprobe settles it.
|
|
115
|
+
video: true,
|
|
116
|
+
headers: {},
|
|
117
|
+
extractor: "playlist",
|
|
118
|
+
ext: "",
|
|
119
|
+
page: url,
|
|
120
|
+
playlist: list.sources,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
43
123
|
/** A link somebody pasted, or "" when it is not one this can play. */
|
|
44
124
|
export function playableLink(entered) {
|
|
45
125
|
if (typeof entered !== "string")
|
|
@@ -277,6 +357,8 @@ export const RESOLVE_TIMEOUT_MS = 60_000;
|
|
|
277
357
|
export async function resolveLink(ytdlp, url, options = {}) {
|
|
278
358
|
if (isDirectMedia(url))
|
|
279
359
|
return directLink(url);
|
|
360
|
+
if (isPlaylistLink(url))
|
|
361
|
+
return resolvePlaylist(url, { timeoutMs: options.timeoutMs });
|
|
280
362
|
// Without yt-dlp, a link is taken as the media it may well be: an IPTV
|
|
281
363
|
// feed has no extension and no page behind it, and ffprobe -- asked next,
|
|
282
364
|
// before anything goes on the air -- tells a stream from a web page in a
|
package/dist/server.d.ts
CHANGED
|
@@ -400,6 +400,9 @@ export interface KnownSource {
|
|
|
400
400
|
codecs?: Codecs;
|
|
401
401
|
position?: number;
|
|
402
402
|
live?: boolean;
|
|
403
|
+
/** A list of entries to play in turn, and which one was on. */
|
|
404
|
+
playlist?: string[];
|
|
405
|
+
playlistAt?: number;
|
|
403
406
|
}
|
|
404
407
|
/** How many times a source that answers nothing is asked, and how far apart. */
|
|
405
408
|
export declare const PROBE_TRIES = 3;
|
|
@@ -497,6 +500,12 @@ export interface HandlerOptions {
|
|
|
497
500
|
ffmpeg?: string[];
|
|
498
501
|
/** Where ffprobe is, for asking what is inside a file before re-encoding it. */
|
|
499
502
|
ffprobe?: string[];
|
|
503
|
+
/**
|
|
504
|
+
* Whether this server can carry a channel at all: false where no ffmpeg
|
|
505
|
+
* was found, as on the hosted directory, which then says so instead of
|
|
506
|
+
* resolving a link, probing it, and blaming the site.
|
|
507
|
+
*/
|
|
508
|
+
carries?: boolean;
|
|
500
509
|
/** yt-dlp, which turns a pasted page into a media address. Null when there is none. */
|
|
501
510
|
ytdlp?: string[] | null;
|
|
502
511
|
/** Channels as HLS, for Safari on a phone, which plays a live stream no other way. */
|
package/dist/server.js
CHANGED
|
@@ -880,7 +880,9 @@ export async function pullChannel(channels, ffprobe, id, name, source, input = [
|
|
|
880
880
|
if (assumed)
|
|
881
881
|
console.log(` "${id}": the source would not say what it holds; carrying it as ${kind}.`);
|
|
882
882
|
// A film has a length and a place to go back to; a live source has neither.
|
|
883
|
-
|
|
883
|
+
// A list is a station: joined where it is, and never seeked.
|
|
884
|
+
const playlist = known.playlist && known.playlist.length > 0 ? known.playlist : null;
|
|
885
|
+
const live = playlist ? true : (known.live ?? !((codecs.duration ?? 0) > 0));
|
|
884
886
|
const encode = kind === "video"
|
|
885
887
|
? [
|
|
886
888
|
// Which streams from which input, when there are two: the picture
|
|
@@ -896,12 +898,14 @@ export async function pullChannel(channels, ffprobe, id, name, source, input = [
|
|
|
896
898
|
// the timestamps a recording cut mid-stream does not carry. Ahead of the
|
|
897
899
|
// caller's own input arguments, which are headers for the address itself.
|
|
898
900
|
const opening = [...transportInputArgs(source, codecs.container), ...input];
|
|
899
|
-
const channel = channels.pull(id, name, source, encode, kind, true, undefined, opening, kind === "video" ? audio : "", { live, position: known.position ?? 0 },
|
|
901
|
+
const channel = channels.pull(id, name, source, encode, kind, true, undefined, opening, kind === "video" ? audio : "", { live, position: known.position ?? 0, ...(playlist ? { playlist } : {}) },
|
|
900
902
|
// Known before the first dial: whether the source is a transport stream
|
|
901
903
|
// decides whether it can be read here for a source-boundary relay.
|
|
902
904
|
assumed ? undefined : codecs);
|
|
903
905
|
if (channel && !assumed)
|
|
904
906
|
channel.info.codecs = codecs;
|
|
907
|
+
if (channel && playlist && known.playlistAt !== undefined)
|
|
908
|
+
channel.info.playlistAt = known.playlistAt;
|
|
905
909
|
// What comes out, as opposed to what went in. An H.265 source copied
|
|
906
910
|
// through stays H.265; one re-encoded arrives as H.264, and a packager
|
|
907
911
|
// told otherwise would cut fMP4 segments for a stream that did not need
|
|
@@ -943,11 +947,16 @@ function shownLink(channelId, link, channel) {
|
|
|
943
947
|
channel: channelId,
|
|
944
948
|
name: channel?.name ?? link.title,
|
|
945
949
|
live,
|
|
946
|
-
|
|
950
|
+
// Whether there is a picture is the channel's answer where there is one:
|
|
951
|
+
// a list is guessed at as video until its first entry has been probed,
|
|
952
|
+
// and a station of podcasts opened a video element for nothing.
|
|
953
|
+
video: channel?.kind ? channel.kind !== "audio" : link.video,
|
|
947
954
|
duration: link.duration,
|
|
948
955
|
extractor: link.extractor,
|
|
949
956
|
// A live has no whole to keep; a bare file can be fetched by the browser itself.
|
|
950
957
|
download: !live && link.extractor !== "direct",
|
|
958
|
+
// How many things a list holds, for the page to say.
|
|
959
|
+
entries: link.playlist?.length ?? 0,
|
|
951
960
|
};
|
|
952
961
|
}
|
|
953
962
|
export function liveOnes(engine) {
|
|
@@ -2469,6 +2478,10 @@ export function createHandler(engine, options) {
|
|
|
2469
2478
|
// one nobody can dial is not worth showing.
|
|
2470
2479
|
code: state?.code ?? "",
|
|
2471
2480
|
url: state?.url ?? "",
|
|
2481
|
+
// Whether a link can go live here at all. The hosted directory
|
|
2482
|
+
// has no ffmpeg, and a page that offered to go live on it was
|
|
2483
|
+
// offering something that always failed.
|
|
2484
|
+
carries: options.carries !== false && Boolean(options.channels),
|
|
2472
2485
|
},
|
|
2473
2486
|
channels: (options.channels?.list() ?? []).map((one) => ({
|
|
2474
2487
|
id: one.id,
|
|
@@ -2485,6 +2498,8 @@ export function createHandler(engine, options) {
|
|
|
2485
2498
|
// How it has been going, for whoever may do something about it.
|
|
2486
2499
|
redials: one.redials ?? 0,
|
|
2487
2500
|
error: one.error ?? "",
|
|
2501
|
+
// For a list: how long it is, and which entry is on, from 0.
|
|
2502
|
+
...(one.playlist ? { entries: one.playlist.length, entry: one.playlistAt ?? 0 } : {}),
|
|
2488
2503
|
// The member who put it on, when one did: theirs to take off.
|
|
2489
2504
|
startedBy: one.startedBy ?? "",
|
|
2490
2505
|
})),
|
|
@@ -2743,6 +2758,12 @@ export function createHandler(engine, options) {
|
|
|
2743
2758
|
json(response, 503, { error: "this server cannot carry channels" });
|
|
2744
2759
|
return;
|
|
2745
2760
|
}
|
|
2761
|
+
if (options.carries === false) {
|
|
2762
|
+
json(response, 503, {
|
|
2763
|
+
error: `${options.serverName ?? "this server"} has no ffmpeg, so it cannot carry a link. Pick a server to go live on.`,
|
|
2764
|
+
});
|
|
2765
|
+
return;
|
|
2766
|
+
}
|
|
2746
2767
|
const channelId = linkChannelId(link);
|
|
2747
2768
|
const known = links.get(link);
|
|
2748
2769
|
// A member's is always a live: a decoder started on somebody else's
|
|
@@ -2816,7 +2837,7 @@ export function createHandler(engine, options) {
|
|
|
2816
2837
|
});
|
|
2817
2838
|
return;
|
|
2818
2839
|
}
|
|
2819
|
-
const started = await pullChannel(options.channels, options.ffprobe ?? ["ffprobe"], channelId, resolved.title, resolved.media, inputArgsFor(resolved.headers), resolved.audio);
|
|
2840
|
+
const started = await pullChannel(options.channels, options.ffprobe ?? ["ffprobe"], channelId, resolved.title, resolved.media, inputArgsFor(resolved.headers), resolved.audio, resolved.playlist ? { playlist: resolved.playlist } : {});
|
|
2820
2841
|
if (!started) {
|
|
2821
2842
|
json(response, 409, { error: "that link is already starting" });
|
|
2822
2843
|
return;
|
|
@@ -4257,6 +4278,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
4257
4278
|
...(one.codecs ? { codecs: one.codecs } : {}),
|
|
4258
4279
|
...(one.position !== undefined ? { position: one.position } : {}),
|
|
4259
4280
|
...(one.live !== undefined ? { live: one.live } : {}),
|
|
4281
|
+
...(one.playlist ? { playlist: one.playlist, playlistAt: one.playlistAt ?? 0 } : {}),
|
|
4260
4282
|
}).then((channel) => {
|
|
4261
4283
|
if (!channel)
|
|
4262
4284
|
console.log(` "${one.id}" is already on.`);
|
|
@@ -4269,7 +4291,8 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
4269
4291
|
let lastRemembered = "";
|
|
4270
4292
|
setInterval(() => {
|
|
4271
4293
|
const now = rememberedNow(channels);
|
|
4272
|
-
|
|
4294
|
+
// A list moves on without a position: which entry is on is the thing to keep.
|
|
4295
|
+
if (!now.some((one) => one.position !== undefined || one.playlist))
|
|
4273
4296
|
return;
|
|
4274
4297
|
const text = JSON.stringify(now);
|
|
4275
4298
|
if (text === lastRemembered)
|
|
@@ -4680,6 +4703,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
4680
4703
|
// A cookie jar beside the state, when the operator has put one there,
|
|
4681
4704
|
// for the sites that will not talk to a datacenter without one.
|
|
4682
4705
|
ytdlp: tools.ytdlp ?? null,
|
|
4706
|
+
carries: tools.carries !== false,
|
|
4683
4707
|
cookies: cookiesFile(),
|
|
4684
4708
|
hls,
|
|
4685
4709
|
compression,
|
package/package.json
CHANGED
package/src/audio.ts
CHANGED
|
@@ -31,6 +31,12 @@ export interface Tools {
|
|
|
31
31
|
play: string[] | null;
|
|
32
32
|
/** yt-dlp, which turns a pasted page into a media address; null when there is none. */
|
|
33
33
|
ytdlp?: string[] | null;
|
|
34
|
+
/**
|
|
35
|
+
* Whether an ffmpeg was actually found, as opposed to guessed at. A server
|
|
36
|
+
* without one cannot carry a channel, and should say so before a link is
|
|
37
|
+
* resolved and probed and blamed for it.
|
|
38
|
+
*/
|
|
39
|
+
carries?: boolean;
|
|
34
40
|
}
|
|
35
41
|
|
|
36
42
|
function works(argv: string[], flag = "-version"): boolean {
|
|
@@ -112,6 +118,7 @@ export function detectTools(): Tools {
|
|
|
112
118
|
ffprobe: ffprobe ?? ["ffprobe"],
|
|
113
119
|
play,
|
|
114
120
|
ytdlp,
|
|
121
|
+
carries: ffmpeg !== null,
|
|
115
122
|
};
|
|
116
123
|
}
|
|
117
124
|
|
|
@@ -439,7 +446,10 @@ export async function codecsOf(tools: Tools, path: string, input: string[] = [])
|
|
|
439
446
|
...rest,
|
|
440
447
|
"-v", "quiet",
|
|
441
448
|
"-print_format", "json",
|
|
442
|
-
|
|
449
|
+
// The disposition too: an MP3 with its cover art in it carries that
|
|
450
|
+
// art as a video stream of one JPEG, and a probe that took it for a
|
|
451
|
+
// picture put a podcast on the air as a film with no frames.
|
|
452
|
+
"-show_entries", "format=format_name,duration:stream=codec_type,codec_name,width,height:stream_disposition=attached_pic",
|
|
443
453
|
// A transport stream needs looking further into than a file with an
|
|
444
454
|
// index does: there is no header listing the tracks, only packets, and
|
|
445
455
|
// a 4K recording can carry a second of null padding and a long gap to
|
|
@@ -461,14 +471,17 @@ export async function codecsOf(tools: Tools, path: string, input: string[] = [])
|
|
|
461
471
|
child.on("close", () => {
|
|
462
472
|
try {
|
|
463
473
|
const parsed = JSON.parse(out) as {
|
|
464
|
-
streams?: {
|
|
474
|
+
streams?: {
|
|
475
|
+
codec_type?: string; codec_name?: string; width?: number; height?: number;
|
|
476
|
+
disposition?: { attached_pic?: number };
|
|
477
|
+
}[];
|
|
465
478
|
format?: { format_name?: string; duration?: string };
|
|
466
479
|
};
|
|
467
480
|
const streams = parsed.streams ?? [];
|
|
468
481
|
// ffprobe prints seconds as a string, and "N/A" for a stream with no
|
|
469
482
|
// end; both of those read as 0.
|
|
470
483
|
const seconds = Number(parsed.format?.duration ?? 0);
|
|
471
|
-
const picture = streams.find((s) => s.codec_type === "video");
|
|
484
|
+
const picture = streams.find((s) => s.codec_type === "video" && !isCoverArt(s));
|
|
472
485
|
return done({
|
|
473
486
|
video: picture?.codec_name ?? "",
|
|
474
487
|
audio: streams.find((s) => s.codec_type === "audio")?.codec_name ?? "",
|
|
@@ -484,6 +497,22 @@ export async function codecsOf(tools: Tools, path: string, input: string[] = [])
|
|
|
484
497
|
});
|
|
485
498
|
}
|
|
486
499
|
|
|
500
|
+
/**
|
|
501
|
+
* Cover art is not a picture.
|
|
502
|
+
*
|
|
503
|
+
* An MP3, FLAC or M4A with its sleeve embedded shows ffprobe a video stream:
|
|
504
|
+
* one JPEG or PNG, flagged as an attached picture. Carried as video it is a
|
|
505
|
+
* channel whose picture never gets a second frame -- ffmpeg writes nothing
|
|
506
|
+
* and the source is dialled again and again -- so a sleeve counts for
|
|
507
|
+
* nothing and the sound decides. A still-image codec with no such flag is
|
|
508
|
+
* the same thing from a container that does not flag it.
|
|
509
|
+
*/
|
|
510
|
+
const STILL_IMAGE = new Set(["png", "bmp", "gif", "tiff", "webp"]);
|
|
511
|
+
export function isCoverArt(stream: { codec_name?: string; disposition?: { attached_pic?: number } }): boolean {
|
|
512
|
+
if (stream.disposition?.attached_pic === 1) return true;
|
|
513
|
+
return STILL_IMAGE.has((stream.codec_name ?? "").toLowerCase());
|
|
514
|
+
}
|
|
515
|
+
|
|
487
516
|
/** How much of a transport stream is read before deciding what is in it. */
|
|
488
517
|
export const TRANSPORT_PROBE_BYTES = 20 * 1024 * 1024;
|
|
489
518
|
export const TRANSPORT_ANALYSE_US = 10_000_000;
|