nixamp 0.10.1 → 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/directory.d.ts +6 -0
- package/dist/directory.js +17 -4
- package/dist/durable.d.ts +2 -0
- package/dist/durable.js +27 -3
- 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/directory.ts +24 -4
- package/src/durable.ts +27 -3
- 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/directory.d.ts
CHANGED
|
@@ -100,6 +100,12 @@ export interface Ended {
|
|
|
100
100
|
startedAt: number;
|
|
101
101
|
/** The last heartbeat we saw, which is as close to "ended" as we can know. */
|
|
102
102
|
endedAt: number;
|
|
103
|
+
/**
|
|
104
|
+
* The codes its channels had, so a server that stops and comes back -- an
|
|
105
|
+
* update restarts the daemon, and the daemon withdraws its listing on the
|
|
106
|
+
* way down -- puts each channel back under the code people were given.
|
|
107
|
+
*/
|
|
108
|
+
channelCodes?: Record<string, string>;
|
|
103
109
|
}
|
|
104
110
|
/** How long an ended stream is still worth telling a caller about. */
|
|
105
111
|
export declare const ENDED_TTL_MS: number;
|
package/dist/directory.js
CHANGED
|
@@ -129,8 +129,14 @@ export class Directory {
|
|
|
129
129
|
*/
|
|
130
130
|
seedEnded(items) {
|
|
131
131
|
for (const item of items) {
|
|
132
|
-
if (!this.ended.has(item.id) && !this.items.has(item.id))
|
|
132
|
+
if (!this.ended.has(item.id) && !this.items.has(item.id)) {
|
|
133
133
|
this.ended.set(item.id, item);
|
|
134
|
+
// Its channels' codes are reserved too, so a server coming back after
|
|
135
|
+
// the directory restarted still finds them its own.
|
|
136
|
+
if (item.channelCodes && Object.keys(item.channelCodes).length > 0) {
|
|
137
|
+
this.channelHistory.set(item.id, { ...(this.channelHistory.get(item.id) ?? {}), ...item.channelCodes });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
134
140
|
}
|
|
135
141
|
}
|
|
136
142
|
constructor(ttl = TTL_MS, now = Date.now,
|
|
@@ -186,7 +192,11 @@ export class Directory {
|
|
|
186
192
|
// channel that was gone for a heartbeat -- a server restarting puts
|
|
187
193
|
// its channels back a moment after it announces -- comes back to the
|
|
188
194
|
// code people were given, not a new one.
|
|
189
|
-
{
|
|
195
|
+
{
|
|
196
|
+
...(previously && "channelCodes" in previously ? previously.channelCodes ?? {} : {}),
|
|
197
|
+
...(this.channelHistory.get(id) ?? {}),
|
|
198
|
+
...(existing?.channelCodes ?? {}),
|
|
199
|
+
}, id),
|
|
190
200
|
updatedAt: this.now(),
|
|
191
201
|
// A stream that never stopped keeps its original start. One that did
|
|
192
202
|
// starts again now, because that is what a caller is being told about.
|
|
@@ -204,7 +214,7 @@ export class Directory {
|
|
|
204
214
|
if (item !== undefined)
|
|
205
215
|
this.remember(item);
|
|
206
216
|
this.items.delete(id);
|
|
207
|
-
|
|
217
|
+
// The channel history stays with the ended record, and goes when it goes.
|
|
208
218
|
}
|
|
209
219
|
list() {
|
|
210
220
|
this.sweep();
|
|
@@ -322,6 +332,9 @@ export class Directory {
|
|
|
322
332
|
nowPlaying: item.nowPlaying,
|
|
323
333
|
startedAt: item.startedAt,
|
|
324
334
|
endedAt: item.updatedAt,
|
|
335
|
+
// Every code its channels have had, not only the ones on at the end:
|
|
336
|
+
// a restart announces before its channels are back.
|
|
337
|
+
channelCodes: { ...(this.channelHistory.get(item.id) ?? {}), ...item.channelCodes },
|
|
325
338
|
};
|
|
326
339
|
this.ended.set(item.id, record);
|
|
327
340
|
this.mirror?.save(record);
|
|
@@ -344,13 +357,13 @@ export class Directory {
|
|
|
344
357
|
if (item.updatedAt < cutoff) {
|
|
345
358
|
this.remember(item);
|
|
346
359
|
this.items.delete(id);
|
|
347
|
-
this.channelHistory.delete(id);
|
|
348
360
|
}
|
|
349
361
|
}
|
|
350
362
|
const forget = this.now() - ENDED_TTL_MS;
|
|
351
363
|
for (const [id, item] of this.ended) {
|
|
352
364
|
if (item.endedAt < forget) {
|
|
353
365
|
this.ended.delete(id);
|
|
366
|
+
this.channelHistory.delete(id);
|
|
354
367
|
this.mirror?.drop(id);
|
|
355
368
|
}
|
|
356
369
|
}
|
package/dist/durable.d.ts
CHANGED
|
@@ -34,6 +34,8 @@ export interface StoredEnded {
|
|
|
34
34
|
nowPlaying: string;
|
|
35
35
|
startedAt: number;
|
|
36
36
|
endedAt: number;
|
|
37
|
+
/** Each channel's phone code, by name, so a server coming back keeps them. */
|
|
38
|
+
channelCodes?: Record<string, string>;
|
|
37
39
|
}
|
|
38
40
|
export declare class Durable {
|
|
39
41
|
private readonly db;
|
package/dist/durable.js
CHANGED
|
@@ -9,6 +9,7 @@ const SCHEMA = `
|
|
|
9
9
|
started_at BIGINT NOT NULL,
|
|
10
10
|
ended_at BIGINT NOT NULL
|
|
11
11
|
);
|
|
12
|
+
ALTER TABLE ended_streams ADD COLUMN IF NOT EXISTS channel_codes JSONB NOT NULL DEFAULT '{}'::jsonb;
|
|
12
13
|
CREATE INDEX IF NOT EXISTS ended_streams_code ON ended_streams (code);
|
|
13
14
|
|
|
14
15
|
CREATE TABLE IF NOT EXISTS stream_reminders (
|
|
@@ -18,6 +19,26 @@ const SCHEMA = `
|
|
|
18
19
|
PRIMARY KEY (code, phone)
|
|
19
20
|
);
|
|
20
21
|
`;
|
|
22
|
+
/** A JSONB column, as pg hands it back: an object already, or a string on an odd driver. */
|
|
23
|
+
function codesFrom(value) {
|
|
24
|
+
let parsed = value;
|
|
25
|
+
if (typeof value === "string") {
|
|
26
|
+
try {
|
|
27
|
+
parsed = JSON.parse(value);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
34
|
+
return {};
|
|
35
|
+
const codes = {};
|
|
36
|
+
for (const [name, code] of Object.entries(parsed)) {
|
|
37
|
+
if (typeof code === "string" && /^\d{6}$/.test(code))
|
|
38
|
+
codes[name] = code;
|
|
39
|
+
}
|
|
40
|
+
return codes;
|
|
41
|
+
}
|
|
21
42
|
export class Durable {
|
|
22
43
|
db;
|
|
23
44
|
onEvent;
|
|
@@ -48,8 +69,8 @@ export class Durable {
|
|
|
48
69
|
}
|
|
49
70
|
async saveEnded(stream) {
|
|
50
71
|
await this.quietly("an ended stream", () => this.db.query(`INSERT INTO ended_streams
|
|
51
|
-
(id, code, name, owner_id, url, now_playing, started_at, ended_at)
|
|
52
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
72
|
+
(id, code, name, owner_id, url, now_playing, started_at, ended_at, channel_codes)
|
|
73
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)
|
|
53
74
|
ON CONFLICT (id) DO UPDATE
|
|
54
75
|
SET code = EXCLUDED.code,
|
|
55
76
|
name = EXCLUDED.name,
|
|
@@ -57,7 +78,8 @@ export class Durable {
|
|
|
57
78
|
url = EXCLUDED.url,
|
|
58
79
|
now_playing = EXCLUDED.now_playing,
|
|
59
80
|
started_at = EXCLUDED.started_at,
|
|
60
|
-
ended_at = EXCLUDED.ended_at
|
|
81
|
+
ended_at = EXCLUDED.ended_at,
|
|
82
|
+
channel_codes = EXCLUDED.channel_codes`, [
|
|
61
83
|
stream.id,
|
|
62
84
|
stream.code,
|
|
63
85
|
stream.name,
|
|
@@ -66,6 +88,7 @@ export class Durable {
|
|
|
66
88
|
stream.nowPlaying,
|
|
67
89
|
stream.startedAt,
|
|
68
90
|
stream.endedAt,
|
|
91
|
+
JSON.stringify(stream.channelCodes ?? {}),
|
|
69
92
|
]));
|
|
70
93
|
}
|
|
71
94
|
/** A stream that came back, or one old enough to forget. */
|
|
@@ -88,6 +111,7 @@ export class Durable {
|
|
|
88
111
|
// nothing like a number.
|
|
89
112
|
startedAt: Number(r["started_at"] ?? 0),
|
|
90
113
|
endedAt: Number(r["ended_at"] ?? 0),
|
|
114
|
+
channelCodes: codesFrom(r["channel_codes"]),
|
|
91
115
|
}));
|
|
92
116
|
}
|
|
93
117
|
catch (error) {
|
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",
|