nixamp 0.10.2 → 0.10.3
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.js +5 -1
- package/dist/hls.d.ts +55 -0
- package/dist/hls.js +254 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +55 -1
- package/package.json +1 -1
- package/src/audio.ts +5 -1
- package/src/hls.ts +265 -0
- package/src/server.ts +58 -1
- package/web/dist/assets/{hls-3VKVEQE3-5T3M0hCY.js → hls-3VKVEQE3-DR8q_kGX.js} +1 -1
- package/web/dist/assets/index-g9xRD0Ln.js +1 -0
- package/web/dist/assets/{mpegts-DeEbMNtT.js → mpegts-D-7xRRW6.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-DUwEL0l2.js → mpegts-LO6RVLD6-B0VjYPAC.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +5 -5
- package/web/dist/assets/index-Bqujphpd.js +0 -1
package/dist/audio.js
CHANGED
|
@@ -408,7 +408,11 @@ export function videoArgs(codecs, capKbps = 0) {
|
|
|
408
408
|
const keepAudio = !transportStream && (codecs.audio === "aac" || codecs.audio === "mp3");
|
|
409
409
|
return [
|
|
410
410
|
"-c:v", keepVideo ? "copy" : "libx264",
|
|
411
|
-
|
|
411
|
+
// A keyframe every two seconds when encoding. A fragment starts on a
|
|
412
|
+
// keyframe, so this is how soon a joiner sees a picture -- and an HLS
|
|
413
|
+
// segment, which is cut on keyframes too, was ten seconds long on
|
|
414
|
+
// x264's default and made a phone wait thirty before it played.
|
|
415
|
+
...(keepVideo ? [] : ["-preset", "veryfast", "-crf", "23", "-pix_fmt", "yuv420p", "-g", "48", "-keyint_min", "48", "-sc_threshold", "0"]),
|
|
412
416
|
"-c:a", keepAudio ? "copy" : "aac",
|
|
413
417
|
...(keepAudio ? [] : ["-b:a", "160k", "-ac", "2"]),
|
|
414
418
|
"-f", "mp4",
|
package/dist/hls.d.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** Seconds of video per segment. Short, so joining is quick; long enough that a playlist is not churn. */
|
|
2
|
+
export declare const SEGMENT_SECONDS = 2;
|
|
3
|
+
/** How many segments the playlist offers. About twelve seconds of catch-up. */
|
|
4
|
+
export declare const PLAYLIST_SEGMENTS = 6;
|
|
5
|
+
/** How long a packager runs with nobody asking for its playlist. */
|
|
6
|
+
export declare const IDLE_MS = 60000;
|
|
7
|
+
/** How long the first playlist may take to appear before it is a failure. */
|
|
8
|
+
export declare const FIRST_PLAYLIST_MS = 20000;
|
|
9
|
+
/** A segment file name, or "" for anything that is not one. Never a path. */
|
|
10
|
+
export declare function segmentName(requested: string): string;
|
|
11
|
+
/**
|
|
12
|
+
* The playlist with the key on every segment.
|
|
13
|
+
*
|
|
14
|
+
* A browser resolves segment names against the playlist's URL and drops its
|
|
15
|
+
* query, so the key that opened the playlist never reaches the segments and
|
|
16
|
+
* each one answers 401. The key travels on every line instead.
|
|
17
|
+
*/
|
|
18
|
+
export declare function withKey(playlist: string, key: string): string;
|
|
19
|
+
/** The ffmpeg arguments: copy what arrives on stdin into a rolling playlist. */
|
|
20
|
+
export declare function packagerArgs(dir: string): string[];
|
|
21
|
+
export interface Packaged {
|
|
22
|
+
/** Feed it the channel's bytes: the header first, then every fragment. */
|
|
23
|
+
write(chunk: Buffer): boolean;
|
|
24
|
+
/** The channel ended; finish and stop. */
|
|
25
|
+
end(): void;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The packagers, one per channel that somebody is watching this way.
|
|
29
|
+
*
|
|
30
|
+
* `listen` is how a packager becomes a listener on its channel: it is handed
|
|
31
|
+
* the header and then every fragment, exactly as a browser would be.
|
|
32
|
+
*/
|
|
33
|
+
export declare class HlsPackagers {
|
|
34
|
+
private readonly options;
|
|
35
|
+
private readonly running;
|
|
36
|
+
constructor(options: {
|
|
37
|
+
ffmpeg: string[];
|
|
38
|
+
listen: (id: string, listener: Packaged) => (() => void) | null;
|
|
39
|
+
onEvent?: (message: string) => void;
|
|
40
|
+
/** Injected for tests: how long to wait for the first playlist. */
|
|
41
|
+
firstPlaylistMs?: number;
|
|
42
|
+
});
|
|
43
|
+
/**
|
|
44
|
+
* The playlist for a channel, starting the packager if it is not running,
|
|
45
|
+
* and waiting for the first segments to exist. Null when the channel is not
|
|
46
|
+
* there or nothing could be packaged.
|
|
47
|
+
*/
|
|
48
|
+
playlist(id: string): Promise<string | null>;
|
|
49
|
+
/** A segment's path, or "" when there is no such segment. */
|
|
50
|
+
segment(id: string, name: string): string;
|
|
51
|
+
/** The channel went: stop packaging it. */
|
|
52
|
+
stop(id: string): void;
|
|
53
|
+
stopAll(): void;
|
|
54
|
+
get count(): number;
|
|
55
|
+
}
|
package/dist/hls.js
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A channel as HLS, for the browsers that will not take it any other way.
|
|
3
|
+
*
|
|
4
|
+
* A channel is sent as one endless fragmented MP4 down a chunked response,
|
|
5
|
+
* which Chrome and Firefox play as they would a file. Safari on an iPhone
|
|
6
|
+
* will not: it asks for a byte range, gets a stream with no length, and gives
|
|
7
|
+
* up before the first picture -- a spinner that flashes and a player that
|
|
8
|
+
* never starts. What Safari plays live is HLS, a playlist of short files.
|
|
9
|
+
*
|
|
10
|
+
* So this packages the same bytes into HLS on demand: one ffmpeg per channel,
|
|
11
|
+
* copying (never re-encoding) the fragments it is fed into two-second
|
|
12
|
+
* MPEG-TS segments in a temporary directory, started when the first playlist
|
|
13
|
+
* is asked for and stopped a minute after the last. The channel itself is
|
|
14
|
+
* untouched; this is one more listener on it.
|
|
15
|
+
*/
|
|
16
|
+
import { spawn } from "node:child_process";
|
|
17
|
+
import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
/** Seconds of video per segment. Short, so joining is quick; long enough that a playlist is not churn. */
|
|
21
|
+
export const SEGMENT_SECONDS = 2;
|
|
22
|
+
/** How many segments the playlist offers. About twelve seconds of catch-up. */
|
|
23
|
+
export const PLAYLIST_SEGMENTS = 6;
|
|
24
|
+
/** How long a packager runs with nobody asking for its playlist. */
|
|
25
|
+
export const IDLE_MS = 60_000;
|
|
26
|
+
/** How long the first playlist may take to appear before it is a failure. */
|
|
27
|
+
export const FIRST_PLAYLIST_MS = 20_000;
|
|
28
|
+
const SEGMENT = /^seg\d{5}\.ts$/;
|
|
29
|
+
/** A segment file name, or "" for anything that is not one. Never a path. */
|
|
30
|
+
export function segmentName(requested) {
|
|
31
|
+
return SEGMENT.test(requested) ? requested : "";
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The playlist with the key on every segment.
|
|
35
|
+
*
|
|
36
|
+
* A browser resolves segment names against the playlist's URL and drops its
|
|
37
|
+
* query, so the key that opened the playlist never reaches the segments and
|
|
38
|
+
* each one answers 401. The key travels on every line instead.
|
|
39
|
+
*/
|
|
40
|
+
export function withKey(playlist, key) {
|
|
41
|
+
if (key === "")
|
|
42
|
+
return playlist;
|
|
43
|
+
return playlist
|
|
44
|
+
.split("\n")
|
|
45
|
+
.map((line) => (line !== "" && !line.startsWith("#") ? `${line}?k=${encodeURIComponent(key)}` : line))
|
|
46
|
+
.join("\n");
|
|
47
|
+
}
|
|
48
|
+
/** The ffmpeg arguments: copy what arrives on stdin into a rolling playlist. */
|
|
49
|
+
export function packagerArgs(dir) {
|
|
50
|
+
return [
|
|
51
|
+
"-hide_banner",
|
|
52
|
+
"-loglevel", "error",
|
|
53
|
+
"-i", "pipe:0",
|
|
54
|
+
"-c", "copy",
|
|
55
|
+
"-f", "hls",
|
|
56
|
+
"-hls_time", String(SEGMENT_SECONDS),
|
|
57
|
+
"-hls_list_size", String(PLAYLIST_SEGMENTS),
|
|
58
|
+
// Old segments go; the playlist never ends; each segment starts on a
|
|
59
|
+
// keyframe so a joiner can begin anywhere; written whole then renamed so
|
|
60
|
+
// a request never reads half a file.
|
|
61
|
+
"-hls_flags", "delete_segments+omit_endlist+independent_segments+temp_file",
|
|
62
|
+
"-hls_segment_type", "mpegts",
|
|
63
|
+
"-hls_segment_filename", join(dir, "seg%05d.ts"),
|
|
64
|
+
join(dir, "index.m3u8"),
|
|
65
|
+
];
|
|
66
|
+
}
|
|
67
|
+
/** One channel's packager. */
|
|
68
|
+
class Packager {
|
|
69
|
+
id;
|
|
70
|
+
ffmpeg;
|
|
71
|
+
onStop;
|
|
72
|
+
onEvent;
|
|
73
|
+
dir;
|
|
74
|
+
child = null;
|
|
75
|
+
idle = null;
|
|
76
|
+
stopped = false;
|
|
77
|
+
detach = null;
|
|
78
|
+
constructor(id, ffmpeg, onStop, onEvent) {
|
|
79
|
+
this.id = id;
|
|
80
|
+
this.ffmpeg = ffmpeg;
|
|
81
|
+
this.onStop = onStop;
|
|
82
|
+
this.onEvent = onEvent;
|
|
83
|
+
this.dir = mkdtempSync(join(tmpdir(), `nixamp-hls-${id}-`));
|
|
84
|
+
}
|
|
85
|
+
start(listen) {
|
|
86
|
+
const [command, ...prefix] = this.ffmpeg;
|
|
87
|
+
try {
|
|
88
|
+
this.child = spawn(command, [...prefix, ...packagerArgs(this.dir)], { stdio: ["pipe", "ignore", "pipe"] });
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
this.onEvent(` HLS for "${this.id}" could not start: ${error.message}`);
|
|
92
|
+
this.stop();
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
let complaint = "";
|
|
96
|
+
this.child.stderr?.on("data", (chunk) => {
|
|
97
|
+
if (complaint.length < 4000)
|
|
98
|
+
complaint += chunk.toString("utf8");
|
|
99
|
+
});
|
|
100
|
+
this.child.stdin?.on("error", () => undefined);
|
|
101
|
+
this.child.on("error", (error) => {
|
|
102
|
+
this.onEvent(` HLS for "${this.id}" failed: ${error.message}`);
|
|
103
|
+
this.stop();
|
|
104
|
+
});
|
|
105
|
+
this.child.on("close", (code) => {
|
|
106
|
+
if (!this.stopped && code !== 0) {
|
|
107
|
+
this.onEvent(` HLS for "${this.id}" stopped: ${complaint.trim().split("\n").pop() ?? code}`);
|
|
108
|
+
}
|
|
109
|
+
this.stop();
|
|
110
|
+
});
|
|
111
|
+
this.detach = listen(this);
|
|
112
|
+
if (this.detach === null) {
|
|
113
|
+
this.stop();
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
this.touch();
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
write(chunk) {
|
|
120
|
+
const stdin = this.child?.stdin;
|
|
121
|
+
if (!stdin || stdin.destroyed)
|
|
122
|
+
return false;
|
|
123
|
+
try {
|
|
124
|
+
stdin.write(chunk);
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
end() {
|
|
132
|
+
try {
|
|
133
|
+
this.child?.stdin?.end();
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// Already gone.
|
|
137
|
+
}
|
|
138
|
+
this.stop();
|
|
139
|
+
}
|
|
140
|
+
/** Somebody asked for the playlist: it is wanted for another minute. */
|
|
141
|
+
touch() {
|
|
142
|
+
if (this.idle)
|
|
143
|
+
clearTimeout(this.idle);
|
|
144
|
+
this.idle = setTimeout(() => this.stop(), IDLE_MS);
|
|
145
|
+
this.idle.unref?.();
|
|
146
|
+
}
|
|
147
|
+
/** The playlist as it stands, or "" before the first segment is written. */
|
|
148
|
+
playlist() {
|
|
149
|
+
try {
|
|
150
|
+
const text = readFileSync(join(this.dir, "index.m3u8"), "utf8");
|
|
151
|
+
return text.includes("#EXTINF") ? text : "";
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return "";
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/** A segment's path, or "" when it is not there (any more). */
|
|
158
|
+
segment(name) {
|
|
159
|
+
const safe = segmentName(name);
|
|
160
|
+
if (safe === "")
|
|
161
|
+
return "";
|
|
162
|
+
const path = join(this.dir, safe);
|
|
163
|
+
try {
|
|
164
|
+
return statSync(path).isFile() ? path : "";
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return "";
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
stop() {
|
|
171
|
+
if (this.stopped)
|
|
172
|
+
return;
|
|
173
|
+
this.stopped = true;
|
|
174
|
+
if (this.idle)
|
|
175
|
+
clearTimeout(this.idle);
|
|
176
|
+
this.idle = null;
|
|
177
|
+
this.detach?.();
|
|
178
|
+
this.detach = null;
|
|
179
|
+
const child = this.child;
|
|
180
|
+
this.child = null;
|
|
181
|
+
if (child && child.exitCode === null) {
|
|
182
|
+
try {
|
|
183
|
+
child.stdin?.end();
|
|
184
|
+
child.kill("SIGKILL");
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
// Already gone.
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
rmSync(this.dir, { recursive: true, force: true });
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
// A directory that is already gone is fine.
|
|
195
|
+
}
|
|
196
|
+
this.onStop(this.id);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* The packagers, one per channel that somebody is watching this way.
|
|
201
|
+
*
|
|
202
|
+
* `listen` is how a packager becomes a listener on its channel: it is handed
|
|
203
|
+
* the header and then every fragment, exactly as a browser would be.
|
|
204
|
+
*/
|
|
205
|
+
export class HlsPackagers {
|
|
206
|
+
options;
|
|
207
|
+
running = new Map();
|
|
208
|
+
constructor(options) {
|
|
209
|
+
this.options = options;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* The playlist for a channel, starting the packager if it is not running,
|
|
213
|
+
* and waiting for the first segments to exist. Null when the channel is not
|
|
214
|
+
* there or nothing could be packaged.
|
|
215
|
+
*/
|
|
216
|
+
async playlist(id) {
|
|
217
|
+
let packager = this.running.get(id);
|
|
218
|
+
if (!packager) {
|
|
219
|
+
packager = new Packager(id, this.options.ffmpeg, (gone) => this.running.delete(gone), this.options.onEvent ?? (() => undefined));
|
|
220
|
+
this.running.set(id, packager);
|
|
221
|
+
if (!packager.start((listener) => this.options.listen(id, listener)))
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
packager.touch();
|
|
225
|
+
const deadline = Date.now() + (this.options.firstPlaylistMs ?? FIRST_PLAYLIST_MS);
|
|
226
|
+
for (;;) {
|
|
227
|
+
const text = packager.playlist();
|
|
228
|
+
if (text !== "")
|
|
229
|
+
return text;
|
|
230
|
+
if (Date.now() > deadline || !this.running.has(id))
|
|
231
|
+
return null;
|
|
232
|
+
await new Promise((done) => setTimeout(done, 250));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/** A segment's path, or "" when there is no such segment. */
|
|
236
|
+
segment(id, name) {
|
|
237
|
+
const packager = this.running.get(id);
|
|
238
|
+
if (!packager)
|
|
239
|
+
return "";
|
|
240
|
+
packager.touch();
|
|
241
|
+
return packager.segment(name);
|
|
242
|
+
}
|
|
243
|
+
/** The channel went: stop packaging it. */
|
|
244
|
+
stop(id) {
|
|
245
|
+
this.running.get(id)?.stop();
|
|
246
|
+
}
|
|
247
|
+
stopAll() {
|
|
248
|
+
for (const packager of [...this.running.values()])
|
|
249
|
+
packager.stop();
|
|
250
|
+
}
|
|
251
|
+
get count() {
|
|
252
|
+
return this.running.size;
|
|
253
|
+
}
|
|
254
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { SignIn } from "./oauth.ts";
|
|
|
11
11
|
import { Owner } from "./owner.ts";
|
|
12
12
|
import { Directory } from "./directory.ts";
|
|
13
13
|
import { PartyLine } from "./partyline.ts";
|
|
14
|
+
import { HlsPackagers } from "./hls.ts";
|
|
14
15
|
import { Follows } from "./follows.ts";
|
|
15
16
|
import { Favorites } from "./favorites.ts";
|
|
16
17
|
import { Catalogs } from "./catalogs.ts";
|
|
@@ -422,6 +423,8 @@ export interface HandlerOptions {
|
|
|
422
423
|
ffprobe?: string[];
|
|
423
424
|
/** yt-dlp, which turns a pasted page into a media address. Null when there is none. */
|
|
424
425
|
ytdlp?: string[] | null;
|
|
426
|
+
/** Channels as HLS, for Safari on a phone, which plays a live stream no other way. */
|
|
427
|
+
hls?: HlsPackagers;
|
|
425
428
|
/** A Netscape cookies file for sites that want a signed-in browser, when there is one. */
|
|
426
429
|
cookies?: string;
|
|
427
430
|
/** Who is listening, for the admin view. */
|
package/dist/server.js
CHANGED
|
@@ -34,6 +34,7 @@ import { stateDir } from "./daemon.js";
|
|
|
34
34
|
import { readSession } from "./session.js";
|
|
35
35
|
import { Directory, ENDED_TTL_MS, parseAnnouncement } from "./directory.js";
|
|
36
36
|
import { PartyLine, telnyxSms } from "./partyline.js";
|
|
37
|
+
import { HlsPackagers, withKey } from "./hls.js";
|
|
37
38
|
import { contentTypeFor, downloadArgs, fileNameFor, inputArgsFor, linkChannelId, playableLink, resolveLink, saveFormat, } from "./links.js";
|
|
38
39
|
import { CALL_IN_NUMBER, OPT_IN_PATH, optInPage } from "./optin.js";
|
|
39
40
|
import pg from "pg";
|
|
@@ -2441,8 +2442,52 @@ export function createHandler(engine, options) {
|
|
|
2441
2442
|
if (path.startsWith("/api/channels/") && options.channels) {
|
|
2442
2443
|
const channels = options.channels;
|
|
2443
2444
|
const rest = path.slice("/api/channels/".length);
|
|
2444
|
-
const [rawId, action] = rest.split("/");
|
|
2445
|
+
const [rawId, action, file] = rest.split("/");
|
|
2445
2446
|
const id = cleanId(rawId);
|
|
2447
|
+
// The same channel as HLS: a playlist of short files, which is what
|
|
2448
|
+
// Safari on an iPhone plays live -- it will not take the endless MP4
|
|
2449
|
+
// below, and spun on it a few times before giving up. Packaged on
|
|
2450
|
+
// demand, copying the fragments the browser would have got.
|
|
2451
|
+
if (action === "hls" && request.method === "GET") {
|
|
2452
|
+
if (!options.hls) {
|
|
2453
|
+
json(response, 503, { error: "this server cannot package HLS" });
|
|
2454
|
+
return;
|
|
2455
|
+
}
|
|
2456
|
+
if (!channels.has(id)) {
|
|
2457
|
+
json(response, 404, { error: "nothing is playing on that channel" });
|
|
2458
|
+
return;
|
|
2459
|
+
}
|
|
2460
|
+
if (file === "index.m3u8") {
|
|
2461
|
+
const playlist = await options.hls.playlist(id);
|
|
2462
|
+
if (playlist === null) {
|
|
2463
|
+
json(response, 503, { error: "that channel could not be packaged as HLS yet; try again in a moment" });
|
|
2464
|
+
return;
|
|
2465
|
+
}
|
|
2466
|
+
response.writeHead(200, {
|
|
2467
|
+
...CORS,
|
|
2468
|
+
"content-type": "application/vnd.apple.mpegurl",
|
|
2469
|
+
"cache-control": "no-store",
|
|
2470
|
+
});
|
|
2471
|
+
// The key rides on every segment line: a browser drops the query
|
|
2472
|
+
// when it resolves a segment against the playlist.
|
|
2473
|
+
response.end(withKey(playlist, url.searchParams.get("k") ?? ""));
|
|
2474
|
+
return;
|
|
2475
|
+
}
|
|
2476
|
+
const segment = options.hls.segment(id, file ?? "");
|
|
2477
|
+
if (segment === "") {
|
|
2478
|
+
json(response, 404, { error: "no such segment" });
|
|
2479
|
+
return;
|
|
2480
|
+
}
|
|
2481
|
+
watch(request, response, "stream", id);
|
|
2482
|
+
response.writeHead(200, {
|
|
2483
|
+
...CORS,
|
|
2484
|
+
"content-type": "video/mp2t",
|
|
2485
|
+
"cache-control": "no-store",
|
|
2486
|
+
"content-length": statSync(segment).size,
|
|
2487
|
+
});
|
|
2488
|
+
createReadStream(segment).pipe(response);
|
|
2489
|
+
return;
|
|
2490
|
+
}
|
|
2446
2491
|
if (action === undefined && request.method === "GET") {
|
|
2447
2492
|
// Listening. The response is the fan-out target: whatever ffmpeg
|
|
2448
2493
|
// produces for this channel is written to it until one end goes away.
|
|
@@ -3474,6 +3519,13 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
3474
3519
|
onStart: (info) => console.log(` ${info.name} is publishing to "${info.id}" (${info.format} over ${info.via}).`),
|
|
3475
3520
|
onEnd: (info) => console.log(` "${info.id}" stopped.`),
|
|
3476
3521
|
});
|
|
3522
|
+
// Channels as HLS, on demand, for Safari on a phone: one ffmpeg copying a
|
|
3523
|
+
// channel's fragments into short files while somebody is asking for them.
|
|
3524
|
+
const hls = new HlsPackagers({
|
|
3525
|
+
ffmpeg: tools.ffmpeg,
|
|
3526
|
+
listen: (id, listener) => channels.listen(id, listener),
|
|
3527
|
+
onEvent: (message) => console.log(message),
|
|
3528
|
+
});
|
|
3477
3529
|
// The channels this server was carrying when it was last stopped, put back
|
|
3478
3530
|
// on. A server is restarted to pick up a new version, which is often, and
|
|
3479
3531
|
// every restart used to take CNN off the air until somebody noticed.
|
|
@@ -3825,6 +3877,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
3825
3877
|
// for the sites that will not talk to a datacenter without one.
|
|
3826
3878
|
ytdlp: tools.ytdlp ?? null,
|
|
3827
3879
|
cookies: cookiesFile(),
|
|
3880
|
+
hls,
|
|
3828
3881
|
...(tls ? { tls } : {}),
|
|
3829
3882
|
// Untagged, so a directory of five thousand files answers at once; the
|
|
3830
3883
|
// tags follow through `tag` below.
|
|
@@ -4244,6 +4297,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
4244
4297
|
});
|
|
4245
4298
|
const shutdown = () => {
|
|
4246
4299
|
rtmp?.stop();
|
|
4300
|
+
hls.stopAll();
|
|
4247
4301
|
channels.stopAll();
|
|
4248
4302
|
ingest?.stopRtmp();
|
|
4249
4303
|
ingest?.close();
|
package/package.json
CHANGED
package/src/audio.ts
CHANGED
|
@@ -468,7 +468,11 @@ export function videoArgs(codecs: Codecs, capKbps = 0): string[] {
|
|
|
468
468
|
const keepAudio = !transportStream && (codecs.audio === "aac" || codecs.audio === "mp3");
|
|
469
469
|
return [
|
|
470
470
|
"-c:v", keepVideo ? "copy" : "libx264",
|
|
471
|
-
|
|
471
|
+
// A keyframe every two seconds when encoding. A fragment starts on a
|
|
472
|
+
// keyframe, so this is how soon a joiner sees a picture -- and an HLS
|
|
473
|
+
// segment, which is cut on keyframes too, was ten seconds long on
|
|
474
|
+
// x264's default and made a phone wait thirty before it played.
|
|
475
|
+
...(keepVideo ? [] : ["-preset", "veryfast", "-crf", "23", "-pix_fmt", "yuv420p", "-g", "48", "-keyint_min", "48", "-sc_threshold", "0"]),
|
|
472
476
|
"-c:a", keepAudio ? "copy" : "aac",
|
|
473
477
|
...(keepAudio ? [] : ["-b:a", "160k", "-ac", "2"]),
|
|
474
478
|
"-f", "mp4",
|