nixamp 0.7.5 → 0.7.6
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 +11 -0
- package/dist/audio.js +10 -3
- package/dist/daemon.d.ts +20 -0
- package/dist/daemon.js +21 -1
- package/dist/invite.d.ts +6 -4
- package/dist/invite.js +9 -4
- package/dist/main.js +18 -2
- package/dist/partyline.d.ts +15 -10
- package/dist/partyline.js +19 -45
- package/dist/server.d.ts +18 -0
- package/dist/server.js +53 -10
- package/package.json +1 -1
- package/src/audio.ts +25 -4
- package/src/daemon.ts +36 -1
- package/src/invite.ts +10 -5
- package/src/main.ts +17 -2
- package/src/partyline.ts +20 -47
- package/src/server.ts +72 -10
- package/web/dist/assets/{hls-3VKVEQE3-BsLZl7PK.js → hls-3VKVEQE3-CKpUWwhK.js} +1 -1
- package/web/dist/assets/index-Bs7oVbTk.js +1 -0
- package/web/dist/assets/{index-8D8K4vqw.css → index-CTgfCs0m.css} +1 -1
- package/web/dist/assets/{mpegts-DWMPccwQ.js → mpegts-6hEDu2OP.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-CviHHfb1.js → mpegts-LO6RVLD6-BAhx7mvs.js} +1 -1
- package/web/dist/index.html +20 -2
- package/web/dist/sw.js +6 -6
- package/web/dist/assets/index-a1aekKkP.js +0 -1
package/dist/audio.d.ts
CHANGED
|
@@ -78,6 +78,17 @@ export interface Codecs {
|
|
|
78
78
|
video: string;
|
|
79
79
|
/** e.g. "aac", "ac3", "dts". Empty when there is no audio stream. */
|
|
80
80
|
audio: string;
|
|
81
|
+
/**
|
|
82
|
+
* What is wrapped around them: "mpegts", "matroska,webm", "mov,mp4,...".
|
|
83
|
+
*
|
|
84
|
+
* It matters for one reason. A transport stream -- which is what every IPTV
|
|
85
|
+
* channel is -- frames its AAC as ADTS, and copying that into MP4 needs a
|
|
86
|
+
* bitstream filter or ffmpeg refuses the whole muxing and writes nothing.
|
|
87
|
+
* They also tend to carry several audio tracks, so the one ffmpeg picks can
|
|
88
|
+
* be AC-3 on the same URL that offered AAC a minute earlier, and AC-3 in MP4
|
|
89
|
+
* is a track no browser will play.
|
|
90
|
+
*/
|
|
91
|
+
container: string;
|
|
81
92
|
}
|
|
82
93
|
/**
|
|
83
94
|
* Ask ffprobe what the streams are, without holding the event loop.
|
package/dist/audio.js
CHANGED
|
@@ -284,7 +284,7 @@ function readTags(stdout, fallback) {
|
|
|
284
284
|
*/
|
|
285
285
|
export async function codecsOf(tools, path) {
|
|
286
286
|
const [cmd, ...rest] = tools.ffprobe;
|
|
287
|
-
const empty = { video: "", audio: "" };
|
|
287
|
+
const empty = { video: "", audio: "", container: "" };
|
|
288
288
|
if (!cmd)
|
|
289
289
|
return empty;
|
|
290
290
|
return new Promise((done) => {
|
|
@@ -292,7 +292,7 @@ export async function codecsOf(tools, path) {
|
|
|
292
292
|
...rest,
|
|
293
293
|
"-v", "quiet",
|
|
294
294
|
"-print_format", "json",
|
|
295
|
-
"-show_entries", "stream=codec_type,codec_name",
|
|
295
|
+
"-show_entries", "format=format_name:stream=codec_type,codec_name",
|
|
296
296
|
path,
|
|
297
297
|
], { stdio: ["ignore", "pipe", "ignore"] });
|
|
298
298
|
let out = "";
|
|
@@ -307,6 +307,7 @@ export async function codecsOf(tools, path) {
|
|
|
307
307
|
return done({
|
|
308
308
|
video: streams.find((s) => s.codec_type === "video")?.codec_name ?? "",
|
|
309
309
|
audio: streams.find((s) => s.codec_type === "audio")?.codec_name ?? "",
|
|
310
|
+
container: parsed.format?.format_name ?? "",
|
|
310
311
|
});
|
|
311
312
|
}
|
|
312
313
|
catch {
|
|
@@ -331,7 +332,13 @@ export function videoArgs(codecs, capKbps = 0) {
|
|
|
331
332
|
return cappedArgs(capKbps);
|
|
332
333
|
// What a browser can play inside MP4 without help.
|
|
333
334
|
const keepVideo = codecs.video === "h264";
|
|
334
|
-
|
|
335
|
+
// A transport stream's audio is never copied. Its AAC is ADTS-framed, which
|
|
336
|
+
// MP4 refuses without a bitstream filter -- ffmpeg writes nothing at all and
|
|
337
|
+
// says "Malformed AAC bitstream detected" -- and the track ffmpeg picks off
|
|
338
|
+
// a channel with several of them can be AC-3, which that filter rejects and
|
|
339
|
+
// no browser plays. Re-encoding audio is cheap; this failing is total.
|
|
340
|
+
const transportStream = codecs.container.includes("mpegts");
|
|
341
|
+
const keepAudio = !transportStream && (codecs.audio === "aac" || codecs.audio === "mp3");
|
|
335
342
|
return [
|
|
336
343
|
"-c:v", keepVideo ? "copy" : "libx264",
|
|
337
344
|
...(keepVideo ? [] : ["-preset", "veryfast", "-crf", "23", "-pix_fmt", "yuv420p"]),
|
package/dist/daemon.d.ts
CHANGED
|
@@ -27,6 +27,15 @@ export interface DaemonState {
|
|
|
27
27
|
* interface, so it names the router and not this port.
|
|
28
28
|
*/
|
|
29
29
|
guessedPublic?: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* What it was started with, so it can be started that way again.
|
|
32
|
+
*
|
|
33
|
+
* A daemon serving TLS on a public name is six flags, and restarting it
|
|
34
|
+
* meant finding them again -- from shell history, or from `ps`, or not at
|
|
35
|
+
* all. Absent on a state file written by an older nixamp, which is why
|
|
36
|
+
* `restart` says so rather than starting something different.
|
|
37
|
+
*/
|
|
38
|
+
argv?: string[];
|
|
30
39
|
}
|
|
31
40
|
/** XDG, with the usual fallback. One daemon per user, which is one too few for nobody. */
|
|
32
41
|
export declare function stateDir(): string;
|
|
@@ -75,5 +84,16 @@ export declare function daemonLines(state: DaemonState, uptimeMs?: number): stri
|
|
|
75
84
|
* log file is the thing this is meant to avoid.
|
|
76
85
|
*/
|
|
77
86
|
export declare function start(argv: string[], entry: string): Promise<DaemonState>;
|
|
87
|
+
/**
|
|
88
|
+
* Stop it and start it again, the way it was started.
|
|
89
|
+
*
|
|
90
|
+
* The flags are replayed from the state file rather than retyped, because the
|
|
91
|
+
* interesting daemons are the ones with the most flags: a certificate, a key,
|
|
92
|
+
* a public URL. Given arguments of its own it uses those instead, which is how
|
|
93
|
+
* you change one thing without stopping and starting by hand.
|
|
94
|
+
*/
|
|
95
|
+
export declare function restart(argv: string[], entry: string,
|
|
96
|
+
/** Injected so a test can see which arguments would be replayed. */
|
|
97
|
+
starter?: typeof start): Promise<DaemonState>;
|
|
78
98
|
/** Stop it, and wait for it to actually be gone. */
|
|
79
99
|
export declare function stop(timeoutMs?: number): Promise<boolean>;
|
package/dist/daemon.js
CHANGED
|
@@ -178,10 +178,30 @@ export async function start(argv, entry) {
|
|
|
178
178
|
}
|
|
179
179
|
throw new Error(`nixamp: the daemon did not start. See ${log}`);
|
|
180
180
|
}
|
|
181
|
-
const state = { ...announced, pid: child.pid, startedAt: Date.now(), log };
|
|
181
|
+
const state = { ...announced, pid: child.pid, startedAt: Date.now(), log, argv };
|
|
182
182
|
writeState(state);
|
|
183
183
|
return state;
|
|
184
184
|
}
|
|
185
|
+
/**
|
|
186
|
+
* Stop it and start it again, the way it was started.
|
|
187
|
+
*
|
|
188
|
+
* The flags are replayed from the state file rather than retyped, because the
|
|
189
|
+
* interesting daemons are the ones with the most flags: a certificate, a key,
|
|
190
|
+
* a public URL. Given arguments of its own it uses those instead, which is how
|
|
191
|
+
* you change one thing without stopping and starting by hand.
|
|
192
|
+
*/
|
|
193
|
+
export async function restart(argv, entry,
|
|
194
|
+
/** Injected so a test can see which arguments would be replayed. */
|
|
195
|
+
starter = start) {
|
|
196
|
+
const { state } = status();
|
|
197
|
+
const before = state?.argv;
|
|
198
|
+
if (argv.length === 0 && before === undefined && state !== null) {
|
|
199
|
+
throw new Error("nixamp: this daemon was started by an older nixamp, which did not record its flags. " +
|
|
200
|
+
"Stop it and start it again with the flags you want.");
|
|
201
|
+
}
|
|
202
|
+
await stop();
|
|
203
|
+
return starter(argv.length > 0 ? argv : (before ?? []), entry);
|
|
204
|
+
}
|
|
185
205
|
/** Poll the log for the announce line. */
|
|
186
206
|
async function waitForAnnounce(log, timeoutMs) {
|
|
187
207
|
const deadline = Date.now() + timeoutMs;
|
package/dist/invite.d.ts
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
* Asking somebody to watch, when that somebody is not technical.
|
|
3
3
|
*
|
|
4
4
|
* A share link is a URL with a key in it, which is fine for the person who
|
|
5
|
-
* runs the server and useless as a thing to text your mother. An invite is
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* runs the server and useless as a thing to text your mother. An invite is two
|
|
6
|
+
* things written as a sentence: a link that opens a player, and a phone number
|
|
7
|
+
* with a code, which is the line where everyone watching talks to each other.
|
|
8
|
+
* The phone is not another way to hear the stream -- it is the 800 number
|
|
9
|
+
* beside a podcast. The show is on the screen; the call is the company.
|
|
8
10
|
*
|
|
9
11
|
* The sender is signed in, because sending is an action with a cost: a text
|
|
10
12
|
* message is money and somebody's phone. The recipient signs in too, but only
|
|
@@ -21,7 +23,7 @@ export interface Invite {
|
|
|
21
23
|
link: string;
|
|
22
24
|
/** The phone number, when this stream is one the line knows about. */
|
|
23
25
|
phone: string;
|
|
24
|
-
/** The six digits that reach this stream,
|
|
26
|
+
/** The six digits that reach this stream's room, once it has been published. */
|
|
25
27
|
code: string;
|
|
26
28
|
}
|
|
27
29
|
/** Looks like a phone number rather than an address. */
|
package/dist/invite.js
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
* Asking somebody to watch, when that somebody is not technical.
|
|
3
3
|
*
|
|
4
4
|
* A share link is a URL with a key in it, which is fine for the person who
|
|
5
|
-
* runs the server and useless as a thing to text your mother. An invite is
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* runs the server and useless as a thing to text your mother. An invite is two
|
|
6
|
+
* things written as a sentence: a link that opens a player, and a phone number
|
|
7
|
+
* with a code, which is the line where everyone watching talks to each other.
|
|
8
|
+
* The phone is not another way to hear the stream -- it is the 800 number
|
|
9
|
+
* beside a podcast. The show is on the screen; the call is the company.
|
|
8
10
|
*
|
|
9
11
|
* The sender is signed in, because sending is an action with a cost: a text
|
|
10
12
|
* message is money and somebody's phone. The recipient signs in too, but only
|
|
@@ -32,7 +34,10 @@ export function isEmail(value) {
|
|
|
32
34
|
export function inviteText(invite) {
|
|
33
35
|
const lines = [`${invite.name} is streaming.`, "", `Watch: ${invite.link}`];
|
|
34
36
|
if (invite.phone && invite.code) {
|
|
35
|
-
|
|
37
|
+
// "to talk about it", not "to listen": the line is a room full of the
|
|
38
|
+
// other people watching, and telling somebody they will hear the stream
|
|
39
|
+
// down the phone is telling them something that is not true.
|
|
40
|
+
lines.push("", `To talk about it: call ${invite.phone} and key ${invite.code}.`);
|
|
36
41
|
}
|
|
37
42
|
return lines.join("\n");
|
|
38
43
|
}
|
package/dist/main.js
CHANGED
|
@@ -45,7 +45,7 @@ const HELP = `nixamp — it really whips the terminal's ass.
|
|
|
45
45
|
|
|
46
46
|
nixamp [source] play it in the terminal
|
|
47
47
|
nixamp serve [source] [options] play here, and hand out a browser remote
|
|
48
|
-
nixamp daemon start|stop|status serve in the background, and let go of it
|
|
48
|
+
nixamp daemon start|restart|stop|status serve in the background, and let go of it
|
|
49
49
|
nixamp attach put the player back in front of the daemon
|
|
50
50
|
nixamp admin [--url U] [--key K] who is connected, and re-stream to them
|
|
51
51
|
nixamp login [--with github] sign in to nixamp.com, in a browser or here
|
|
@@ -162,9 +162,13 @@ Signing out does not touch it: that is what it is for.
|
|
|
162
162
|
daemon: `nixamp daemon — a nixamp that outlives the terminal that started it.
|
|
163
163
|
|
|
164
164
|
nixamp daemon start [source] [serve options] start it, detached
|
|
165
|
+
nixamp daemon restart [source] [serve options] stop it and start it again
|
|
165
166
|
nixamp daemon status where it is, and how long
|
|
166
167
|
nixamp daemon stop stop it
|
|
167
168
|
|
|
169
|
+
Restart with no arguments replays the ones it was started with, certificate
|
|
170
|
+
and public URL included, so picking up a new version costs one command.
|
|
171
|
+
|
|
168
172
|
It is \`nixamp serve\` with nobody holding its terminal, so it keeps playing and
|
|
169
173
|
keeps serving its browser remote. One per user.
|
|
170
174
|
|
|
@@ -228,6 +232,18 @@ async function runDaemon(argv) {
|
|
|
228
232
|
console.log(stopped ? "nixamp daemon stopped" : "nixamp: no daemon was running");
|
|
229
233
|
return 0;
|
|
230
234
|
}
|
|
235
|
+
if (action === "restart") {
|
|
236
|
+
try {
|
|
237
|
+
const state = await d.restart(rest, entry);
|
|
238
|
+
for (const line of d.daemonLines(state))
|
|
239
|
+
console.log(line);
|
|
240
|
+
return 0;
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
console.error(error.message);
|
|
244
|
+
return 1;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
231
247
|
if (action === "status") {
|
|
232
248
|
const { running, state } = d.status();
|
|
233
249
|
if (!state) {
|
|
@@ -253,7 +269,7 @@ async function runDaemon(argv) {
|
|
|
253
269
|
const { attach } = await import("./attach.js");
|
|
254
270
|
return attach(rest);
|
|
255
271
|
}
|
|
256
|
-
console.error(`nixamp daemon: unknown action ${action}. Try start, stop, status or attach.`);
|
|
272
|
+
console.error(`nixamp daemon: unknown action ${action}. Try start, restart, stop, status or attach.`);
|
|
257
273
|
return 64;
|
|
258
274
|
}
|
|
259
275
|
/**
|
package/dist/partyline.d.ts
CHANGED
|
@@ -134,15 +134,6 @@ export declare class PartyLine {
|
|
|
134
134
|
add: (code: string, phone: string) => void;
|
|
135
135
|
take: (code: string) => Promise<string[]>;
|
|
136
136
|
}, waiting?: ReadonlyMap<string, ReadonlySet<string>>): void;
|
|
137
|
-
/**
|
|
138
|
-
* Legs listening to a stream, by its code.
|
|
139
|
-
*
|
|
140
|
-
* Separate from the rooms because a stream listener is not in a conference:
|
|
141
|
-
* they are a leg with an MP3 playing into it. Nothing else was counting
|
|
142
|
-
* them, so the directory had no way to say how many people were on the
|
|
143
|
-
* phone for a broadcast.
|
|
144
|
-
*/
|
|
145
|
-
private readonly streamLegs;
|
|
146
137
|
private readonly key;
|
|
147
138
|
private readonly fetch;
|
|
148
139
|
private readonly now;
|
|
@@ -199,6 +190,14 @@ export declare class PartyLine {
|
|
|
199
190
|
* room code still works: this line was a party line before it was a way into
|
|
200
191
|
* a broadcast, and a code that means nothing to the directory should still
|
|
201
192
|
* mean a room.
|
|
193
|
+
*
|
|
194
|
+
* Keying a stream's code puts you in a room with the other people watching
|
|
195
|
+
* it. It does not play the stream at you, which is what it used to do: this
|
|
196
|
+
* is the phone line beside a broadcast, the way a podcast has an 800 number
|
|
197
|
+
* -- the show is on your screen and the phone is where you talk about it.
|
|
198
|
+
* Playing the audio down the phone was both the worse half of the idea and
|
|
199
|
+
* the one that kept failing, because a share link answers a 302 and a cookie
|
|
200
|
+
* rather than an MP3.
|
|
202
201
|
*/
|
|
203
202
|
private stream;
|
|
204
203
|
/** Whether the caller took the reminder that was offered. */
|
|
@@ -220,7 +219,13 @@ export declare class PartyLine {
|
|
|
220
219
|
/** Put a leg into a room, making the conference if it is the first one there. */
|
|
221
220
|
private join;
|
|
222
221
|
private enter;
|
|
223
|
-
/**
|
|
222
|
+
/**
|
|
223
|
+
* How many people are on the phone for a stream.
|
|
224
|
+
*
|
|
225
|
+
* The room's own count, now that a stream's code is a room like any other.
|
|
226
|
+
* It used to count legs with an MP3 playing into them, which is a thing that
|
|
227
|
+
* no longer happens.
|
|
228
|
+
*/
|
|
224
229
|
listenersOn(code: string): number;
|
|
225
230
|
/** A leg that hung up or was dropped, wherever it was. */
|
|
226
231
|
private release;
|
package/dist/partyline.js
CHANGED
|
@@ -155,15 +155,6 @@ export class PartyLine {
|
|
|
155
155
|
this.reminders.set(code, set);
|
|
156
156
|
}
|
|
157
157
|
}
|
|
158
|
-
/**
|
|
159
|
-
* Legs listening to a stream, by its code.
|
|
160
|
-
*
|
|
161
|
-
* Separate from the rooms because a stream listener is not in a conference:
|
|
162
|
-
* they are a leg with an MP3 playing into it. Nothing else was counting
|
|
163
|
-
* them, so the directory had no way to say how many people were on the
|
|
164
|
-
* phone for a broadcast.
|
|
165
|
-
*/
|
|
166
|
-
streamLegs = new Map();
|
|
167
158
|
key;
|
|
168
159
|
fetch;
|
|
169
160
|
now;
|
|
@@ -318,6 +309,14 @@ export class PartyLine {
|
|
|
318
309
|
* room code still works: this line was a party line before it was a way into
|
|
319
310
|
* a broadcast, and a code that means nothing to the directory should still
|
|
320
311
|
* mean a room.
|
|
312
|
+
*
|
|
313
|
+
* Keying a stream's code puts you in a room with the other people watching
|
|
314
|
+
* it. It does not play the stream at you, which is what it used to do: this
|
|
315
|
+
* is the phone line beside a broadcast, the way a podcast has an 800 number
|
|
316
|
+
* -- the show is on your screen and the phone is where you talk about it.
|
|
317
|
+
* Playing the audio down the phone was both the worse half of the idea and
|
|
318
|
+
* the one that kept failing, because a share link answers a 302 and a cookie
|
|
319
|
+
* rather than an MP3.
|
|
321
320
|
*/
|
|
322
321
|
async stream(leg, code) {
|
|
323
322
|
const streams = this.options.streams;
|
|
@@ -326,39 +325,12 @@ export class PartyLine {
|
|
|
326
325
|
const live = streams.liveByCode(code);
|
|
327
326
|
if (live !== undefined) {
|
|
328
327
|
const what = live.nowPlaying ? ` of ${live.nowPlaying}` : "";
|
|
329
|
-
// The share link is not playable. It answers 302 with a cookie and sends
|
|
330
|
-
// a browser to the player page; Telnyx fetches once with no cookie jar
|
|
331
|
-
// and gets a 401 in JSON. Playing it means a caller who is told "here it
|
|
332
|
-
// is" and then hears nothing at all, which is how this was found. Say
|
|
333
|
-
// what is true instead, and hang up rather than bill for silence.
|
|
334
|
-
if (!live.audio) {
|
|
335
|
-
await this.command(leg, "speak", {
|
|
336
|
-
payload: `${live.name} is live right now${what}, but this stream cannot be played over the phone. ` +
|
|
337
|
-
"You can listen to it at nixamp dot com slash directory. Goodbye.",
|
|
338
|
-
voice: this.voice,
|
|
339
|
-
});
|
|
340
|
-
await this.command(leg, "hangup", {});
|
|
341
|
-
this.options.onEvent?.(` ${code} is live but announced no audio address; nothing to play.`);
|
|
342
|
-
return true;
|
|
343
|
-
}
|
|
344
328
|
await this.command(leg, "speak", {
|
|
345
|
-
payload: `
|
|
329
|
+
payload: `You're on the line for ${live.name}${what}. ` +
|
|
330
|
+
"Everyone here is watching it too. Say hello.",
|
|
346
331
|
voice: this.voice,
|
|
347
332
|
});
|
|
348
|
-
|
|
349
|
-
// call, so listening by phone costs no audio handling here at all.
|
|
350
|
-
const playing = await this.command(leg, "playback_start", {
|
|
351
|
-
audio_url: live.audio,
|
|
352
|
-
loop: "infinity",
|
|
353
|
-
});
|
|
354
|
-
// Counted only once the audio is actually going. A leg we failed to
|
|
355
|
-
// start is not somebody listening, and the directory would be saying so.
|
|
356
|
-
if (playing) {
|
|
357
|
-
const legs = this.streamLegs.get(code) ?? new Set();
|
|
358
|
-
legs.add(leg);
|
|
359
|
-
this.streamLegs.set(code, legs);
|
|
360
|
-
this.options.onEvent?.(` a caller is listening to ${code} (${legs.size} on the phone).`);
|
|
361
|
-
}
|
|
333
|
+
await this.join(leg, code);
|
|
362
334
|
return true;
|
|
363
335
|
}
|
|
364
336
|
const ended = streams.endedByCode(code);
|
|
@@ -490,16 +462,18 @@ export class PartyLine {
|
|
|
490
462
|
room.callers = room.legs.size;
|
|
491
463
|
this.options.onEvent?.(` a caller joined a room (${room.callers} on the line).`);
|
|
492
464
|
}
|
|
493
|
-
/**
|
|
465
|
+
/**
|
|
466
|
+
* How many people are on the phone for a stream.
|
|
467
|
+
*
|
|
468
|
+
* The room's own count, now that a stream's code is a room like any other.
|
|
469
|
+
* It used to count legs with an MP3 playing into them, which is a thing that
|
|
470
|
+
* no longer happens.
|
|
471
|
+
*/
|
|
494
472
|
listenersOn(code) {
|
|
495
|
-
return this.
|
|
473
|
+
return this.rooms.get(code)?.callers ?? 0;
|
|
496
474
|
}
|
|
497
475
|
/** A leg that hung up or was dropped, wherever it was. */
|
|
498
476
|
release(leg) {
|
|
499
|
-
for (const [code, legs] of this.streamLegs) {
|
|
500
|
-
if (legs.delete(leg) && legs.size === 0)
|
|
501
|
-
this.streamLegs.delete(code);
|
|
502
|
-
}
|
|
503
477
|
const code = this.legRoom.get(leg);
|
|
504
478
|
this.legRoom.delete(leg);
|
|
505
479
|
if (code === undefined)
|
package/dist/server.d.ts
CHANGED
|
@@ -134,6 +134,16 @@ export declare function safeJoin(rootDir: string, urlPath: string): string | nul
|
|
|
134
134
|
*/
|
|
135
135
|
export type Loaded = Track & {
|
|
136
136
|
group?: string;
|
|
137
|
+
/**
|
|
138
|
+
* Whether this has a picture, when the name could not say.
|
|
139
|
+
*
|
|
140
|
+
* A file on disk is named `film.mkv` and that is answer enough. A live
|
|
141
|
+
* stream is `http://host/tipoffsport/KEY/301`, which says nothing at all --
|
|
142
|
+
* so it was treated as audio, transcoded with `-vn`, and arrived as a
|
|
143
|
+
* football match somebody could only listen to. Asked of ffprobe once, when
|
|
144
|
+
* the source is added, rather than guessed from a URL that has no opinion.
|
|
145
|
+
*/
|
|
146
|
+
picture?: boolean;
|
|
137
147
|
};
|
|
138
148
|
/** What the HTTP layer needs from a player. Tests hand it a fake. */
|
|
139
149
|
export interface Engine {
|
|
@@ -181,6 +191,14 @@ export interface Engine {
|
|
|
181
191
|
}
|
|
182
192
|
export declare function toRemoteTracks(tracks: Loaded[]): RemoteTrack[];
|
|
183
193
|
export declare function hasPicture(path: string): boolean;
|
|
194
|
+
/**
|
|
195
|
+
* Whether the name of a source tells us anything about what is inside it.
|
|
196
|
+
*
|
|
197
|
+
* A remote address with no extension -- an IPTV channel, a stream key, a
|
|
198
|
+
* redirect -- is the case where it does not, and the only way to find out is
|
|
199
|
+
* to look.
|
|
200
|
+
*/
|
|
201
|
+
export declare function nameSaysNothing(path: string): boolean;
|
|
184
202
|
/**
|
|
185
203
|
* The headless player: the terminal app's engine without the terminal.
|
|
186
204
|
* One ffmpeg decodes, ffplay makes the sound, and every sample is measured on
|
package/dist/server.js
CHANGED
|
@@ -292,7 +292,8 @@ export function toRemoteTracks(tracks) {
|
|
|
292
292
|
// Said out loud, because a remote cannot see the path and had been sending
|
|
293
293
|
// every track to the audio element -- a film's soundtrack over a blank
|
|
294
294
|
// panel, which is exactly what it looked like.
|
|
295
|
-
|
|
295
|
+
// The name when it says something, what ffprobe found when it does not.
|
|
296
|
+
...(t.picture ?? hasPicture(t.path) ? { video: true } : {}),
|
|
296
297
|
// Only for what was added; the library's own tracks say nothing, which is
|
|
297
298
|
// how a client knows they are the library.
|
|
298
299
|
...(t.group ? { group: t.group } : {}),
|
|
@@ -304,6 +305,24 @@ export function hasPicture(path) {
|
|
|
304
305
|
const dot = path.lastIndexOf(".");
|
|
305
306
|
return dot > 0 && PICTURE.has(path.slice(dot).toLowerCase());
|
|
306
307
|
}
|
|
308
|
+
/**
|
|
309
|
+
* Whether the name of a source tells us anything about what is inside it.
|
|
310
|
+
*
|
|
311
|
+
* A remote address with no extension -- an IPTV channel, a stream key, a
|
|
312
|
+
* redirect -- is the case where it does not, and the only way to find out is
|
|
313
|
+
* to look.
|
|
314
|
+
*/
|
|
315
|
+
export function nameSaysNothing(path) {
|
|
316
|
+
if (!isRemote(path))
|
|
317
|
+
return false;
|
|
318
|
+
try {
|
|
319
|
+
const last = new URL(path).pathname.split("/").pop() ?? "";
|
|
320
|
+
return !last.includes(".");
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
307
326
|
/**
|
|
308
327
|
* The headless player: the terminal app's engine without the terminal.
|
|
309
328
|
* One ffmpeg decodes, ffplay makes the sound, and every sample is measured on
|
|
@@ -632,6 +651,14 @@ const CORS = {
|
|
|
632
651
|
"access-control-allow-headers": "content-type",
|
|
633
652
|
"access-control-max-age": "86400",
|
|
634
653
|
};
|
|
654
|
+
/**
|
|
655
|
+
* How many nameless addresses are worth an ffprobe when a source is added.
|
|
656
|
+
*
|
|
657
|
+
* One is the ordinary case -- somebody pasting a channel -- and a directory
|
|
658
|
+
* listing of thousands must not turn into thousands of probes for an answer
|
|
659
|
+
* that only changes which element a browser uses.
|
|
660
|
+
*/
|
|
661
|
+
const PROBE_BY_HAND = 8;
|
|
635
662
|
/** /api/v1/<provider>/oauth/start and .../callback, the house callback shape. */
|
|
636
663
|
const OAUTH_ROUTE = /^\/api\/v1\/([a-z0-9-]+)\/oauth\/(start|callback)$/;
|
|
637
664
|
/**
|
|
@@ -1872,11 +1899,23 @@ export function createHandler(engine, options) {
|
|
|
1872
1899
|
return;
|
|
1873
1900
|
}
|
|
1874
1901
|
try {
|
|
1875
|
-
|
|
1902
|
+
let tracks = await options.load(source);
|
|
1876
1903
|
if (tracks.length === 0) {
|
|
1877
1904
|
json(response, 422, { error: `nothing to play at ${source}` });
|
|
1878
1905
|
return;
|
|
1879
1906
|
}
|
|
1907
|
+
// A handful of addresses whose names say nothing get asked what they
|
|
1908
|
+
// are, so a live channel arrives as a picture rather than as its own
|
|
1909
|
+
// soundtrack. Capped, because a playlist of five thousand of them is
|
|
1910
|
+
// five thousand ffprobes and the answer only matters for the few a
|
|
1911
|
+
// person adds by hand.
|
|
1912
|
+
const looked = await Promise.all(tracks.map(async (track, at) => {
|
|
1913
|
+
if (at >= PROBE_BY_HAND || !nameSaysNothing(track.path))
|
|
1914
|
+
return track;
|
|
1915
|
+
const codecs = await codecsOf({ ffmpeg: [], ffprobe: options.ffprobe ?? ["ffprobe"], play: null }, track.path);
|
|
1916
|
+
return codecs.video === "" ? track : { ...track, picture: true };
|
|
1917
|
+
}));
|
|
1918
|
+
tracks = looked;
|
|
1880
1919
|
let added = tracks.length;
|
|
1881
1920
|
if (replacing) {
|
|
1882
1921
|
engine.replace(tracks, source);
|
|
@@ -1930,17 +1969,21 @@ export function createHandler(engine, options) {
|
|
|
1930
1969
|
const capKbps = Number.isFinite(asked) && asked > 0 ? Math.min(20_000, Math.max(200, asked)) : 0;
|
|
1931
1970
|
if (playsInBrowser(file) && capKbps === 0) {
|
|
1932
1971
|
sendFile(request, response, file);
|
|
1972
|
+
return;
|
|
1933
1973
|
}
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1974
|
+
// A film, or something whose name refuses to say. A live channel at
|
|
1975
|
+
// .../301 used to fall through to the audio branch and arrive as MP3
|
|
1976
|
+
// with `-vn` -- a match you could only listen to.
|
|
1977
|
+
if (hasPicture(file) || nameSaysNothing(file)) {
|
|
1978
|
+
// What ffprobe finds inside decides how little work it takes to keep
|
|
1979
|
+
// the picture, and whether there is a picture to keep at all.
|
|
1938
1980
|
const codecs = await codecsOf({ ffmpeg: [], ffprobe: options.ffprobe ?? ["ffprobe"], play: null }, file);
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1981
|
+
if (codecs.video !== "") {
|
|
1982
|
+
pipeFfmpeg(request, response, file, options.ffmpeg ?? ["ffmpeg"], videoArgs(codecs, capKbps), "video/mp4");
|
|
1983
|
+
return;
|
|
1984
|
+
}
|
|
1943
1985
|
}
|
|
1986
|
+
transcode(request, response, file, options.ffmpeg ?? ["ffmpeg"]);
|
|
1944
1987
|
return;
|
|
1945
1988
|
}
|
|
1946
1989
|
// Whatever the source is, this comes back as MP3 a browser will play:
|
package/package.json
CHANGED
package/src/audio.ts
CHANGED
|
@@ -314,6 +314,17 @@ export interface Codecs {
|
|
|
314
314
|
video: string;
|
|
315
315
|
/** e.g. "aac", "ac3", "dts". Empty when there is no audio stream. */
|
|
316
316
|
audio: string;
|
|
317
|
+
/**
|
|
318
|
+
* What is wrapped around them: "mpegts", "matroska,webm", "mov,mp4,...".
|
|
319
|
+
*
|
|
320
|
+
* It matters for one reason. A transport stream -- which is what every IPTV
|
|
321
|
+
* channel is -- frames its AAC as ADTS, and copying that into MP4 needs a
|
|
322
|
+
* bitstream filter or ffmpeg refuses the whole muxing and writes nothing.
|
|
323
|
+
* They also tend to carry several audio tracks, so the one ffmpeg picks can
|
|
324
|
+
* be AC-3 on the same URL that offered AAC a minute earlier, and AC-3 in MP4
|
|
325
|
+
* is a track no browser will play.
|
|
326
|
+
*/
|
|
327
|
+
container: string;
|
|
317
328
|
}
|
|
318
329
|
|
|
319
330
|
/**
|
|
@@ -325,7 +336,7 @@ export interface Codecs {
|
|
|
325
336
|
*/
|
|
326
337
|
export async function codecsOf(tools: Tools, path: string): Promise<Codecs> {
|
|
327
338
|
const [cmd, ...rest] = tools.ffprobe;
|
|
328
|
-
const empty: Codecs = { video: "", audio: "" };
|
|
339
|
+
const empty: Codecs = { video: "", audio: "", container: "" };
|
|
329
340
|
if (!cmd) return empty;
|
|
330
341
|
|
|
331
342
|
return new Promise<Codecs>((done) => {
|
|
@@ -335,7 +346,7 @@ export async function codecsOf(tools: Tools, path: string): Promise<Codecs> {
|
|
|
335
346
|
...rest,
|
|
336
347
|
"-v", "quiet",
|
|
337
348
|
"-print_format", "json",
|
|
338
|
-
"-show_entries", "stream=codec_type,codec_name",
|
|
349
|
+
"-show_entries", "format=format_name:stream=codec_type,codec_name",
|
|
339
350
|
path,
|
|
340
351
|
],
|
|
341
352
|
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
@@ -347,11 +358,15 @@ export async function codecsOf(tools: Tools, path: string): Promise<Codecs> {
|
|
|
347
358
|
child.on("error", () => done(empty));
|
|
348
359
|
child.on("close", () => {
|
|
349
360
|
try {
|
|
350
|
-
const parsed = JSON.parse(out) as {
|
|
361
|
+
const parsed = JSON.parse(out) as {
|
|
362
|
+
streams?: { codec_type?: string; codec_name?: string }[];
|
|
363
|
+
format?: { format_name?: string };
|
|
364
|
+
};
|
|
351
365
|
const streams = parsed.streams ?? [];
|
|
352
366
|
return done({
|
|
353
367
|
video: streams.find((s) => s.codec_type === "video")?.codec_name ?? "",
|
|
354
368
|
audio: streams.find((s) => s.codec_type === "audio")?.codec_name ?? "",
|
|
369
|
+
container: parsed.format?.format_name ?? "",
|
|
355
370
|
});
|
|
356
371
|
} catch {
|
|
357
372
|
return done(empty);
|
|
@@ -376,7 +391,13 @@ export function videoArgs(codecs: Codecs, capKbps = 0): string[] {
|
|
|
376
391
|
|
|
377
392
|
// What a browser can play inside MP4 without help.
|
|
378
393
|
const keepVideo = codecs.video === "h264";
|
|
379
|
-
|
|
394
|
+
// A transport stream's audio is never copied. Its AAC is ADTS-framed, which
|
|
395
|
+
// MP4 refuses without a bitstream filter -- ffmpeg writes nothing at all and
|
|
396
|
+
// says "Malformed AAC bitstream detected" -- and the track ffmpeg picks off
|
|
397
|
+
// a channel with several of them can be AC-3, which that filter rejects and
|
|
398
|
+
// no browser plays. Re-encoding audio is cheap; this failing is total.
|
|
399
|
+
const transportStream = codecs.container.includes("mpegts");
|
|
400
|
+
const keepAudio = !transportStream && (codecs.audio === "aac" || codecs.audio === "mp3");
|
|
380
401
|
return [
|
|
381
402
|
"-c:v", keepVideo ? "copy" : "libx264",
|
|
382
403
|
...(keepVideo ? [] : ["-preset", "veryfast", "-crf", "23", "-pix_fmt", "yuv420p"]),
|
package/src/daemon.ts
CHANGED
|
@@ -38,6 +38,15 @@ export interface DaemonState {
|
|
|
38
38
|
* interface, so it names the router and not this port.
|
|
39
39
|
*/
|
|
40
40
|
guessedPublic?: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* What it was started with, so it can be started that way again.
|
|
43
|
+
*
|
|
44
|
+
* A daemon serving TLS on a public name is six flags, and restarting it
|
|
45
|
+
* meant finding them again -- from shell history, or from `ps`, or not at
|
|
46
|
+
* all. Absent on a state file written by an older nixamp, which is why
|
|
47
|
+
* `restart` says so rather than starting something different.
|
|
48
|
+
*/
|
|
49
|
+
argv?: string[];
|
|
41
50
|
}
|
|
42
51
|
|
|
43
52
|
/** XDG, with the usual fallback. One daemon per user, which is one too few for nobody. */
|
|
@@ -234,11 +243,37 @@ export async function start(argv: string[], entry: string): Promise<DaemonState>
|
|
|
234
243
|
throw new Error(`nixamp: the daemon did not start. See ${log}`);
|
|
235
244
|
}
|
|
236
245
|
|
|
237
|
-
const state: DaemonState = { ...announced, pid: child.pid, startedAt: Date.now(), log };
|
|
246
|
+
const state: DaemonState = { ...announced, pid: child.pid, startedAt: Date.now(), log, argv };
|
|
238
247
|
writeState(state);
|
|
239
248
|
return state;
|
|
240
249
|
}
|
|
241
250
|
|
|
251
|
+
/**
|
|
252
|
+
* Stop it and start it again, the way it was started.
|
|
253
|
+
*
|
|
254
|
+
* The flags are replayed from the state file rather than retyped, because the
|
|
255
|
+
* interesting daemons are the ones with the most flags: a certificate, a key,
|
|
256
|
+
* a public URL. Given arguments of its own it uses those instead, which is how
|
|
257
|
+
* you change one thing without stopping and starting by hand.
|
|
258
|
+
*/
|
|
259
|
+
export async function restart(
|
|
260
|
+
argv: string[],
|
|
261
|
+
entry: string,
|
|
262
|
+
/** Injected so a test can see which arguments would be replayed. */
|
|
263
|
+
starter: typeof start = start,
|
|
264
|
+
): Promise<DaemonState> {
|
|
265
|
+
const { state } = status();
|
|
266
|
+
const before = state?.argv;
|
|
267
|
+
if (argv.length === 0 && before === undefined && state !== null) {
|
|
268
|
+
throw new Error(
|
|
269
|
+
"nixamp: this daemon was started by an older nixamp, which did not record its flags. " +
|
|
270
|
+
"Stop it and start it again with the flags you want.",
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
await stop();
|
|
274
|
+
return starter(argv.length > 0 ? argv : (before ?? []), entry);
|
|
275
|
+
}
|
|
276
|
+
|
|
242
277
|
/** Poll the log for the announce line. */
|
|
243
278
|
async function waitForAnnounce(
|
|
244
279
|
log: string,
|