nixamp 0.9.12 → 0.10.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 +3 -1
- package/dist/audio.js +11 -3
- package/dist/channels.d.ts +2 -2
- package/dist/channels.js +7 -3
- package/dist/directory.d.ts +30 -2
- package/dist/directory.js +55 -7
- package/dist/links.d.ts +62 -0
- package/dist/links.js +0 -0
- package/dist/server.d.ts +15 -1
- package/dist/server.js +159 -3
- package/dist/share.js +4 -2
- package/package.json +1 -1
- package/src/audio.ts +13 -3
- package/src/channels.ts +7 -2
- package/src/directory.ts +61 -8
- package/src/links.ts +0 -0
- package/src/server.ts +176 -3
- package/src/share.ts +4 -2
- package/web/dist/assets/{hls-3VKVEQE3-Dpc8MBeo.js → hls-3VKVEQE3-5T3M0hCY.js} +1 -1
- package/web/dist/assets/index-Bqujphpd.js +1 -0
- package/web/dist/assets/{index-dAV-kQL5.css → index-D9uHBJcW.css} +1 -1
- package/web/dist/assets/{mpegts-Coyqtirc.js → mpegts-DeEbMNtT.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-CiVqI2jo.js → mpegts-LO6RVLD6-DUwEL0l2.js} +1 -1
- package/web/dist/index.html +13 -2
- package/web/dist/install.sh +23 -0
- package/web/dist/sw.js +6 -6
- package/web/dist/assets/index-BeB8GvFX.js +0 -1
package/dist/audio.d.ts
CHANGED
|
@@ -13,6 +13,8 @@ export interface Tools {
|
|
|
13
13
|
ffprobe: string[];
|
|
14
14
|
/** Argv prefix for the player, or null when nothing can make sound here. */
|
|
15
15
|
play: string[] | null;
|
|
16
|
+
/** yt-dlp, which turns a pasted page into a media address; null when there is none. */
|
|
17
|
+
ytdlp?: string[] | null;
|
|
16
18
|
}
|
|
17
19
|
/**
|
|
18
20
|
* Find the tools. A bare `ffmpeg` on PATH is tried first; mise shims are common
|
|
@@ -98,7 +100,7 @@ export interface Codecs {
|
|
|
98
100
|
* answering other requests, and a synchronous probe per media request is how
|
|
99
101
|
* the whole library came to be tagged with the process wedged solid.
|
|
100
102
|
*/
|
|
101
|
-
export declare function codecsOf(tools: Tools, path: string): Promise<Codecs>;
|
|
103
|
+
export declare function codecsOf(tools: Tools, path: string, input?: string[]): Promise<Codecs>;
|
|
102
104
|
/**
|
|
103
105
|
* How to get this file into a browser, given what is inside it.
|
|
104
106
|
*
|
package/dist/audio.js
CHANGED
|
@@ -12,11 +12,11 @@ import { homedir } from "node:os";
|
|
|
12
12
|
import { join } from "node:path";
|
|
13
13
|
export const RATE = 44100;
|
|
14
14
|
export const CHANNELS = 2;
|
|
15
|
-
function works(argv) {
|
|
15
|
+
function works(argv, flag = "-version") {
|
|
16
16
|
const [cmd, ...rest] = argv;
|
|
17
17
|
if (!cmd)
|
|
18
18
|
return false;
|
|
19
|
-
const r = spawnSync(cmd, [...rest,
|
|
19
|
+
const r = spawnSync(cmd, [...rest, flag], { encoding: "utf8", timeout: 10_000 });
|
|
20
20
|
return !r.error && r.status === 0;
|
|
21
21
|
}
|
|
22
22
|
/**
|
|
@@ -73,10 +73,16 @@ export function detectTools() {
|
|
|
73
73
|
const ffmpeg = pick("ffmpeg");
|
|
74
74
|
const ffprobe = pick("ffprobe");
|
|
75
75
|
const play = pick("ffplay");
|
|
76
|
+
// yt-dlp is not an ffmpeg, so mise's ffmpeg tree is not where it lives;
|
|
77
|
+
// the installer puts it beside nixamp, and pip puts it in ~/.local/bin too.
|
|
78
|
+
// Asked with its own spelling: ffmpeg answers -version, yt-dlp only --version.
|
|
79
|
+
const ytdlp = [["yt-dlp"], [join(homedir(), ".local", "bin", "yt-dlp")], ["/usr/local/bin/yt-dlp"], ["/usr/bin/yt-dlp"], ["/opt/homebrew/bin/yt-dlp"]]
|
|
80
|
+
.find((argv) => works(argv, "--version")) ?? null;
|
|
76
81
|
return {
|
|
77
82
|
ffmpeg: ffmpeg ?? ["ffmpeg"],
|
|
78
83
|
ffprobe: ffprobe ?? ["ffprobe"],
|
|
79
84
|
play,
|
|
85
|
+
ytdlp,
|
|
80
86
|
};
|
|
81
87
|
}
|
|
82
88
|
export function probe(tools, path) {
|
|
@@ -341,7 +347,7 @@ function readTags(stdout, fallback) {
|
|
|
341
347
|
* answering other requests, and a synchronous probe per media request is how
|
|
342
348
|
* the whole library came to be tagged with the process wedged solid.
|
|
343
349
|
*/
|
|
344
|
-
export async function codecsOf(tools, path) {
|
|
350
|
+
export async function codecsOf(tools, path, input = []) {
|
|
345
351
|
const [cmd, ...rest] = tools.ffprobe;
|
|
346
352
|
const empty = { video: "", audio: "", container: "" };
|
|
347
353
|
if (!cmd)
|
|
@@ -352,6 +358,8 @@ export async function codecsOf(tools, path) {
|
|
|
352
358
|
"-v", "quiet",
|
|
353
359
|
"-print_format", "json",
|
|
354
360
|
"-show_entries", "format=format_name:stream=codec_type,codec_name",
|
|
361
|
+
// Headers the source's site expects, for a link resolved by yt-dlp.
|
|
362
|
+
...input,
|
|
355
363
|
path,
|
|
356
364
|
], { stdio: ["ignore", "pipe", "ignore"] });
|
|
357
365
|
let out = "";
|
package/dist/channels.d.ts
CHANGED
|
@@ -116,7 +116,7 @@ export declare class Channel {
|
|
|
116
116
|
* because you looked away, and a room where the picture depends on who is
|
|
117
117
|
* in it is not a room anybody can be invited to.
|
|
118
118
|
*/
|
|
119
|
-
pull(source: string, encode: string[], paced?: boolean, stall?: number): void;
|
|
119
|
+
pull(source: string, encode: string[], paced?: boolean, stall?: number, input?: string[]): void;
|
|
120
120
|
/**
|
|
121
121
|
* Start the source over, now.
|
|
122
122
|
*
|
|
@@ -209,7 +209,7 @@ export declare class Channels {
|
|
|
209
209
|
* about it is the same too, which is the point -- a re-stream stops being a
|
|
210
210
|
* special case and becomes one more thing that is on.
|
|
211
211
|
*/
|
|
212
|
-
pull(id: string, name: string, source: string, encode: string[], kind: "audio" | "video", paced?: boolean, stall?: number): Channel | null;
|
|
212
|
+
pull(id: string, name: string, source: string, encode: string[], kind: "audio" | "video", paced?: boolean, stall?: number, input?: string[]): Channel | null;
|
|
213
213
|
/**
|
|
214
214
|
* Dial a pulled channel's source again, now. False for a channel that is
|
|
215
215
|
* not there or is not ours to dial: a publisher's stream restarts at the
|
package/dist/channels.js
CHANGED
|
@@ -168,7 +168,7 @@ export class Channel {
|
|
|
168
168
|
* because you looked away, and a room where the picture depends on who is
|
|
169
169
|
* in it is not a room anybody can be invited to.
|
|
170
170
|
*/
|
|
171
|
-
pull(source, encode, paced = true, stall = STALL) {
|
|
171
|
+
pull(source, encode, paced = true, stall = STALL, input = []) {
|
|
172
172
|
this.stall = stall;
|
|
173
173
|
if (this.info.kind === "video")
|
|
174
174
|
this.fragments = new Fragments();
|
|
@@ -195,6 +195,10 @@ export class Channel {
|
|
|
195
195
|
// hour of film in ninety seconds and a room that cannot be in it
|
|
196
196
|
// together; a live source is already paced and loses nothing.
|
|
197
197
|
...(paced ? ["-re"] : []),
|
|
198
|
+
// What the source's site expects on the request: a user agent, a
|
|
199
|
+
// referer, a cookie. A link resolved by yt-dlp comes with these,
|
|
200
|
+
// and a CDN that got them from yt-dlp and not from us answers 403.
|
|
201
|
+
...input,
|
|
198
202
|
"-i", source,
|
|
199
203
|
...encode,
|
|
200
204
|
"pipe:1",
|
|
@@ -556,7 +560,7 @@ export class Channels {
|
|
|
556
560
|
* about it is the same too, which is the point -- a re-stream stops being a
|
|
557
561
|
* special case and becomes one more thing that is on.
|
|
558
562
|
*/
|
|
559
|
-
pull(id, name, source, encode, kind, paced = true, stall = STALL) {
|
|
563
|
+
pull(id, name, source, encode, kind, paced = true, stall = STALL, input = []) {
|
|
560
564
|
if (this.open.has(id))
|
|
561
565
|
return null;
|
|
562
566
|
const channel = new Channel({
|
|
@@ -571,7 +575,7 @@ export class Channels {
|
|
|
571
575
|
source,
|
|
572
576
|
}, this.options, (gone) => this.open.delete(gone));
|
|
573
577
|
this.open.set(id, channel);
|
|
574
|
-
channel.pull(source, encode, paced, stall);
|
|
578
|
+
channel.pull(source, encode, paced, stall, input);
|
|
575
579
|
return channel;
|
|
576
580
|
}
|
|
577
581
|
/**
|
package/dist/directory.d.ts
CHANGED
|
@@ -69,6 +69,12 @@ export interface Listing {
|
|
|
69
69
|
playing: boolean;
|
|
70
70
|
/** The live channels on it, by name: what a visitor could actually watch. */
|
|
71
71
|
channels: string[];
|
|
72
|
+
/**
|
|
73
|
+
* A phone code for each of those channels, by name: its own room, so the
|
|
74
|
+
* people calling about one live are not put in with the people calling
|
|
75
|
+
* about another on the same server.
|
|
76
|
+
*/
|
|
77
|
+
channelCodes: Record<string, string>;
|
|
72
78
|
/** Set by the directory from the request, never by the publisher. */
|
|
73
79
|
updatedAt: number;
|
|
74
80
|
/** When this stream first announced itself: the "started at" a caller hears. */
|
|
@@ -181,8 +187,30 @@ export declare class Directory {
|
|
|
181
187
|
announce(announcement: Announcement, ownerId?: string): Listing;
|
|
182
188
|
withdraw(id: string): void;
|
|
183
189
|
list(): Listing[];
|
|
184
|
-
/**
|
|
190
|
+
/**
|
|
191
|
+
* The live stream on this code, if there is one.
|
|
192
|
+
*
|
|
193
|
+
* A channel's code answers as the channel: the same listing, named for the
|
|
194
|
+
* channel and playing nothing else, so the phone line says "the live room
|
|
195
|
+
* for FIBA World Cup" rather than the server's name and whatever its own
|
|
196
|
+
* player has on.
|
|
197
|
+
*/
|
|
185
198
|
liveByCode(code: string): Listing | undefined;
|
|
199
|
+
/**
|
|
200
|
+
* A code for each channel, kept while the channel stays on.
|
|
201
|
+
*
|
|
202
|
+
* One code per server meant every live on it shared a room: somebody
|
|
203
|
+
* calling about the basketball landed with the people talking about the
|
|
204
|
+
* film. Each live is its own room now, and a channel that is still there at
|
|
205
|
+
* the next heartbeat keeps the code it was given.
|
|
206
|
+
*/
|
|
207
|
+
private codesFor;
|
|
208
|
+
/**
|
|
209
|
+
* Whether a code is somebody's already: a stream's own, a channel's on any
|
|
210
|
+
* stream, or a recently-ended stream's. A listing renewing itself is not
|
|
211
|
+
* "somebody else", or its channels would be re-coded every heartbeat.
|
|
212
|
+
*/
|
|
213
|
+
private taken;
|
|
186
214
|
/**
|
|
187
215
|
* Streams that stopped recently, most recent first.
|
|
188
216
|
*
|
|
@@ -201,7 +229,7 @@ export declare class Directory {
|
|
|
201
229
|
endedByCode(code: string): Ended | undefined;
|
|
202
230
|
private endedByUrl;
|
|
203
231
|
private remember;
|
|
204
|
-
/** A code no live and no recently-ended stream is using. */
|
|
232
|
+
/** A code no live stream, no channel on one, and no recently-ended stream is using. */
|
|
205
233
|
private freeCode;
|
|
206
234
|
/** Forget anything that stopped renewing, keeping a note of when it did. */
|
|
207
235
|
private sweep;
|
package/dist/directory.js
CHANGED
|
@@ -179,6 +179,7 @@ export class Directory {
|
|
|
179
179
|
// listing always meant, and no channels is the honest empty list.
|
|
180
180
|
playing: announcement.playing ?? true,
|
|
181
181
|
channels: announcement.channels ?? [],
|
|
182
|
+
channelCodes: this.codesFor(announcement.channels ?? [], existing?.channelCodes ?? (previously && "channelCodes" in previously ? previously.channelCodes : undefined), id),
|
|
182
183
|
updatedAt: this.now(),
|
|
183
184
|
// A stream that never stopped keeps its original start. One that did
|
|
184
185
|
// starts again now, because that is what a caller is being told about.
|
|
@@ -200,10 +201,59 @@ export class Directory {
|
|
|
200
201
|
this.sweep();
|
|
201
202
|
return [...this.items.values()].sort((a, b) => b.updatedAt - a.updatedAt || a.id.localeCompare(b.id));
|
|
202
203
|
}
|
|
203
|
-
/**
|
|
204
|
+
/**
|
|
205
|
+
* The live stream on this code, if there is one.
|
|
206
|
+
*
|
|
207
|
+
* A channel's code answers as the channel: the same listing, named for the
|
|
208
|
+
* channel and playing nothing else, so the phone line says "the live room
|
|
209
|
+
* for FIBA World Cup" rather than the server's name and whatever its own
|
|
210
|
+
* player has on.
|
|
211
|
+
*/
|
|
204
212
|
liveByCode(code) {
|
|
205
213
|
this.sweep();
|
|
206
|
-
|
|
214
|
+
const own = [...this.items.values()].find((item) => item.code === code);
|
|
215
|
+
if (own !== undefined)
|
|
216
|
+
return own;
|
|
217
|
+
for (const item of this.items.values()) {
|
|
218
|
+
const channel = Object.entries(item.channelCodes).find(([, one]) => one === code)?.[0];
|
|
219
|
+
if (channel !== undefined)
|
|
220
|
+
return { ...item, code, name: channel, nowPlaying: "" };
|
|
221
|
+
}
|
|
222
|
+
return undefined;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* A code for each channel, kept while the channel stays on.
|
|
226
|
+
*
|
|
227
|
+
* One code per server meant every live on it shared a room: somebody
|
|
228
|
+
* calling about the basketball landed with the people talking about the
|
|
229
|
+
* film. Each live is its own room now, and a channel that is still there at
|
|
230
|
+
* the next heartbeat keeps the code it was given.
|
|
231
|
+
*/
|
|
232
|
+
codesFor(channels, had, own = "") {
|
|
233
|
+
const codes = {};
|
|
234
|
+
for (const name of channels) {
|
|
235
|
+
if (codes[name] !== undefined)
|
|
236
|
+
continue;
|
|
237
|
+
const kept = had?.[name];
|
|
238
|
+
codes[name] = kept !== undefined && !this.taken(kept, codes, own) ? kept : this.freeCode(codes, own);
|
|
239
|
+
}
|
|
240
|
+
return codes;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Whether a code is somebody's already: a stream's own, a channel's on any
|
|
244
|
+
* stream, or a recently-ended stream's. A listing renewing itself is not
|
|
245
|
+
* "somebody else", or its channels would be re-coded every heartbeat.
|
|
246
|
+
*/
|
|
247
|
+
taken(code, besides = {}, own = "") {
|
|
248
|
+
if (Object.values(besides).includes(code))
|
|
249
|
+
return true;
|
|
250
|
+
for (const item of this.items.values()) {
|
|
251
|
+
if (item.code === code)
|
|
252
|
+
return true;
|
|
253
|
+
if (item.id !== own && Object.values(item.channelCodes).includes(code))
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
return [...this.ended.values()].some((i) => i.code === code);
|
|
207
257
|
}
|
|
208
258
|
/**
|
|
209
259
|
* Streams that stopped recently, most recent first.
|
|
@@ -263,15 +313,13 @@ export class Directory {
|
|
|
263
313
|
this.ended.set(item.id, record);
|
|
264
314
|
this.mirror?.save(record);
|
|
265
315
|
}
|
|
266
|
-
/** A code no live and no recently-ended stream is using. */
|
|
267
|
-
freeCode() {
|
|
316
|
+
/** A code no live stream, no channel on one, and no recently-ended stream is using. */
|
|
317
|
+
freeCode(besides = {}, own = "") {
|
|
268
318
|
for (let tries = 0; tries < 40; tries += 1) {
|
|
269
319
|
const code = this.randomCode();
|
|
270
320
|
if (code.length !== 6)
|
|
271
321
|
continue;
|
|
272
|
-
|
|
273
|
-
[...this.ended.values()].some((i) => i.code === code);
|
|
274
|
-
if (!taken)
|
|
322
|
+
if (!this.taken(code, besides, own))
|
|
275
323
|
return code;
|
|
276
324
|
}
|
|
277
325
|
return "";
|
package/dist/links.d.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export interface ResolvedLink {
|
|
2
|
+
/** What to call it: the page's title, or the file's name for a bare file. */
|
|
3
|
+
title: string;
|
|
4
|
+
/** Where the bytes are: what ffmpeg is pointed at. */
|
|
5
|
+
media: string;
|
|
6
|
+
/** Whether it is on now, which decides pacing and whether it can be saved. */
|
|
7
|
+
live: boolean;
|
|
8
|
+
/** Seconds, when known. */
|
|
9
|
+
duration: number;
|
|
10
|
+
/** Whether there is a picture. Unknown reads as yes; ffprobe settles it. */
|
|
11
|
+
video: boolean;
|
|
12
|
+
/** Headers the site expects on the media request, if any. */
|
|
13
|
+
headers: Record<string, string>;
|
|
14
|
+
/** Which site, as yt-dlp names it -- "soundcloud", "youtube", "generic". */
|
|
15
|
+
extractor: string;
|
|
16
|
+
/** The file type yt-dlp would save, for naming a download. */
|
|
17
|
+
ext: string;
|
|
18
|
+
/** The page it came from. */
|
|
19
|
+
page: string;
|
|
20
|
+
}
|
|
21
|
+
export declare function isDirectMedia(url: string): boolean;
|
|
22
|
+
/** A link somebody pasted, or "" when it is not one this can play. */
|
|
23
|
+
export declare function playableLink(entered: unknown): string;
|
|
24
|
+
/** A channel id for a link: stable, so two people pasting the same link share one decoder. */
|
|
25
|
+
export declare function linkChannelId(url: string): string;
|
|
26
|
+
/** The format a download would pick, so it can be named before it is fetched. */
|
|
27
|
+
export declare function saveFormat(audioOnly: boolean): string;
|
|
28
|
+
/** How yt-dlp is asked where a link's media is, for playing or for the format a download would take. */
|
|
29
|
+
export declare function resolveArgs(url: string, cookies?: string, format?: string): string[];
|
|
30
|
+
/** How yt-dlp is asked to hand over the whole thing, down a pipe. */
|
|
31
|
+
export declare function downloadArgs(url: string, audioOnly: boolean, cookies?: string): string[];
|
|
32
|
+
/** yt-dlp's answer, read into what the player needs. Null when it is not an answer. */
|
|
33
|
+
export declare function parseResolved(json: unknown, page: string): ResolvedLink | null;
|
|
34
|
+
/** A bare file link, described without asking anybody. */
|
|
35
|
+
export declare function directLink(url: string): ResolvedLink;
|
|
36
|
+
/**
|
|
37
|
+
* ffmpeg's input options for a site that wants headers on the media request.
|
|
38
|
+
*
|
|
39
|
+
* yt-dlp's answer came with the headers it would have sent; ffmpeg has to
|
|
40
|
+
* send the same ones or the CDN answers 403 to a request that worked a
|
|
41
|
+
* second ago. The user agent has its own flag; the rest go as one block.
|
|
42
|
+
*/
|
|
43
|
+
export declare function inputArgsFor(headers: Record<string, string>): string[];
|
|
44
|
+
/** A file name for a download: the title, made safe, with the right ending. */
|
|
45
|
+
export declare function fileNameFor(link: ResolvedLink, audioOnly: boolean): string;
|
|
46
|
+
/** What a browser should call the bytes, from the file name's ending. */
|
|
47
|
+
export declare function contentTypeFor(fileName: string): string;
|
|
48
|
+
/** The one line of yt-dlp's complaint worth repeating to a person. */
|
|
49
|
+
export declare function reasonFrom(stderr: string): string;
|
|
50
|
+
/** How long resolving may take before it is a link that is not going to answer. */
|
|
51
|
+
export declare const RESOLVE_TIMEOUT_MS = 60000;
|
|
52
|
+
/**
|
|
53
|
+
* Where a link's media is, by asking yt-dlp. A bare file is answered without
|
|
54
|
+
* asking. An error is a sentence for the person who pasted it.
|
|
55
|
+
*/
|
|
56
|
+
export declare function resolveLink(ytdlp: string[] | null, url: string, options?: {
|
|
57
|
+
cookies?: string;
|
|
58
|
+
timeoutMs?: number;
|
|
59
|
+
format?: string;
|
|
60
|
+
}): Promise<ResolvedLink | {
|
|
61
|
+
error: string;
|
|
62
|
+
}>;
|
package/dist/links.js
ADDED
|
Binary file
|
package/dist/server.d.ts
CHANGED
|
@@ -368,7 +368,15 @@ export declare const MAX_ON_DEMAND = 4;
|
|
|
368
368
|
* ones back, so that both agree on what a source is encoded as. Null when
|
|
369
369
|
* that channel id is already on.
|
|
370
370
|
*/
|
|
371
|
-
export declare function pullChannel(channels: Channels, ffprobe: string[], id: string, name: string, source: string): Promise<Channel | null>;
|
|
371
|
+
export declare function pullChannel(channels: Channels, ffprobe: string[], id: string, name: string, source: string, input?: string[]): Promise<Channel | null>;
|
|
372
|
+
/**
|
|
373
|
+
* A Netscape cookies file beside the state, if the operator has put one there.
|
|
374
|
+
*
|
|
375
|
+
* YouTube and Vimeo refuse a datacenter without a signed-in cookie; the
|
|
376
|
+
* person who runs the server can export one from their browser and drop it
|
|
377
|
+
* at ~/.local/state/nixamp/cookies.txt, and every link is asked for with it.
|
|
378
|
+
*/
|
|
379
|
+
export declare function cookiesFile(): string;
|
|
372
380
|
export declare function liveOnes(engine: Engine): {
|
|
373
381
|
name: string;
|
|
374
382
|
at: number;
|
|
@@ -412,6 +420,10 @@ export interface HandlerOptions {
|
|
|
412
420
|
ffmpeg?: string[];
|
|
413
421
|
/** Where ffprobe is, for asking what is inside a file before re-encoding it. */
|
|
414
422
|
ffprobe?: string[];
|
|
423
|
+
/** yt-dlp, which turns a pasted page into a media address. Null when there is none. */
|
|
424
|
+
ytdlp?: string[] | null;
|
|
425
|
+
/** A Netscape cookies file for sites that want a signed-in browser, when there is one. */
|
|
426
|
+
cookies?: string;
|
|
415
427
|
/** Who is listening, for the admin view. */
|
|
416
428
|
connections?: Connections;
|
|
417
429
|
/**
|
|
@@ -476,6 +488,8 @@ export interface HandlerOptions {
|
|
|
476
488
|
name: string;
|
|
477
489
|
url: string;
|
|
478
490
|
possible: boolean;
|
|
491
|
+
/** A phone code per live channel, by name, as the directory assigned them. */
|
|
492
|
+
channelCodes?: Record<string, string>;
|
|
479
493
|
};
|
|
480
494
|
start: () => Promise<{
|
|
481
495
|
live: boolean;
|
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 { contentTypeFor, downloadArgs, fileNameFor, inputArgsFor, linkChannelId, playableLink, resolveLink, saveFormat, } from "./links.js";
|
|
37
38
|
import { CALL_IN_NUMBER, OPT_IN_PATH, optInPage } from "./optin.js";
|
|
38
39
|
import pg from "pg";
|
|
39
40
|
import { Follows, phoneFrom } from "./follows.js";
|
|
@@ -766,15 +767,47 @@ export const MAX_ON_DEMAND = 4;
|
|
|
766
767
|
* ones back, so that both agree on what a source is encoded as. Null when
|
|
767
768
|
* that channel id is already on.
|
|
768
769
|
*/
|
|
769
|
-
export async function pullChannel(channels, ffprobe, id, name, source) {
|
|
770
|
-
const codecs = await codecsOf({ ffmpeg: [], ffprobe, play: null }, source);
|
|
770
|
+
export async function pullChannel(channels, ffprobe, id, name, source, input = []) {
|
|
771
|
+
const codecs = await codecsOf({ ffmpeg: [], ffprobe, play: null }, source, input);
|
|
771
772
|
const kind = codecs.video === "" ? "audio" : "video";
|
|
772
773
|
const encode = kind === "video"
|
|
773
774
|
? videoArgs(codecs)
|
|
774
775
|
// No picture in it, so none is invented: MP3 is the thing every browser
|
|
775
776
|
// plays and the thing a listener can join halfway through.
|
|
776
777
|
: ["-vn", "-c:a", "libmp3lame", "-b:a", "192k", "-f", "mp3"];
|
|
777
|
-
return channels.pull(id, name, source, encode, kind);
|
|
778
|
+
return channels.pull(id, name, source, encode, kind, true, undefined, input);
|
|
779
|
+
}
|
|
780
|
+
/** What a link resolves to, kept so a download can be named without asking twice. */
|
|
781
|
+
const links = new Map();
|
|
782
|
+
/**
|
|
783
|
+
* A Netscape cookies file beside the state, if the operator has put one there.
|
|
784
|
+
*
|
|
785
|
+
* YouTube and Vimeo refuse a datacenter without a signed-in cookie; the
|
|
786
|
+
* person who runs the server can export one from their browser and drop it
|
|
787
|
+
* at ~/.local/state/nixamp/cookies.txt, and every link is asked for with it.
|
|
788
|
+
*/
|
|
789
|
+
export function cookiesFile() {
|
|
790
|
+
const path = join(stateDir(), "cookies.txt");
|
|
791
|
+
try {
|
|
792
|
+
return statSync(path).isFile() ? path : "";
|
|
793
|
+
}
|
|
794
|
+
catch {
|
|
795
|
+
return "";
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
/** What the page is told about a link it asked to play. */
|
|
799
|
+
function shownLink(channelId, link) {
|
|
800
|
+
return {
|
|
801
|
+
kind: "live",
|
|
802
|
+
channel: channelId,
|
|
803
|
+
name: link.title,
|
|
804
|
+
live: link.live,
|
|
805
|
+
video: link.video,
|
|
806
|
+
duration: link.duration,
|
|
807
|
+
extractor: link.extractor,
|
|
808
|
+
// A live has no whole to keep; a bare file can be fetched by the browser itself.
|
|
809
|
+
download: !link.live && link.extractor !== "direct",
|
|
810
|
+
};
|
|
778
811
|
}
|
|
779
812
|
export function liveOnes(engine) {
|
|
780
813
|
const tracks = engine.snapshot().tracks ?? [];
|
|
@@ -1925,6 +1958,9 @@ export function createHandler(engine, options) {
|
|
|
1925
1958
|
const streams = options.directory.list().map(({ admin, ...stream }) => ({
|
|
1926
1959
|
...stream,
|
|
1927
1960
|
callers: onThePhone ? onThePhone.listenersOn(stream.code) : 0,
|
|
1961
|
+
// And on the phone for each live on it, by name: every live is its
|
|
1962
|
+
// own room, so each has its own count.
|
|
1963
|
+
channelCallers: Object.fromEntries(Object.entries(stream.channelCodes).map(([name, code]) => [name, onThePhone ? onThePhone.listenersOn(code) : 0])),
|
|
1928
1964
|
...(admin && me !== null && stream.ownerId === me.id ? { admin } : {}),
|
|
1929
1965
|
}));
|
|
1930
1966
|
// Recently ended too, because following exists to hear about
|
|
@@ -2089,6 +2125,9 @@ export function createHandler(engine, options) {
|
|
|
2089
2125
|
// Whether it has a picture, so the page puts it in the element
|
|
2090
2126
|
// that can show one. Never the source: that is the owner's.
|
|
2091
2127
|
kind: one.kind ?? "audio",
|
|
2128
|
+
// Its own room on the phone line, as the directory assigned it;
|
|
2129
|
+
// empty until the next heartbeat has told the directory it is on.
|
|
2130
|
+
code: state?.channelCodes?.[one.name] ?? "",
|
|
2092
2131
|
// How it has been going, for whoever may do something about it.
|
|
2093
2132
|
redials: one.redials ?? 0,
|
|
2094
2133
|
error: one.error ?? "",
|
|
@@ -2267,6 +2306,117 @@ export function createHandler(engine, options) {
|
|
|
2267
2306
|
json(response, 404, { error: "no such endpoint" });
|
|
2268
2307
|
return;
|
|
2269
2308
|
}
|
|
2309
|
+
// --- any link, played -------------------------------------------------
|
|
2310
|
+
//
|
|
2311
|
+
// Paste a page -- YouTube, a podcast, SoundCloud, a TikTok live -- and the
|
|
2312
|
+
// server works out where the media is and plays it as a channel of its
|
|
2313
|
+
// own, the way a catalog entry is played: started for whoever asked,
|
|
2314
|
+
// stopped a minute after the last viewer leaves. Open to anyone holding
|
|
2315
|
+
// the link, like picking something from a catalog.
|
|
2316
|
+
if (path === "/api/links/play" && request.method === "POST") {
|
|
2317
|
+
let body = {};
|
|
2318
|
+
try {
|
|
2319
|
+
body = JSON.parse(await readBody(request));
|
|
2320
|
+
}
|
|
2321
|
+
catch {
|
|
2322
|
+
json(response, 400, { error: "bad JSON" });
|
|
2323
|
+
return;
|
|
2324
|
+
}
|
|
2325
|
+
const link = playableLink(body.url);
|
|
2326
|
+
if (link === "") {
|
|
2327
|
+
json(response, 400, { error: "that is not a link this can play" });
|
|
2328
|
+
return;
|
|
2329
|
+
}
|
|
2330
|
+
if (!options.channels) {
|
|
2331
|
+
json(response, 503, { error: "this server cannot carry channels" });
|
|
2332
|
+
return;
|
|
2333
|
+
}
|
|
2334
|
+
const channelId = linkChannelId(link);
|
|
2335
|
+
const known = links.get(link);
|
|
2336
|
+
if (options.channels.has(channelId) && known) {
|
|
2337
|
+
json(response, 200, shownLink(channelId, known));
|
|
2338
|
+
return;
|
|
2339
|
+
}
|
|
2340
|
+
if (!options.channels.has(channelId) && options.channels.ephemeralCount >= MAX_ON_DEMAND) {
|
|
2341
|
+
json(response, 429, { error: `this server is already carrying ${MAX_ON_DEMAND} channels on demand; try again in a minute` });
|
|
2342
|
+
return;
|
|
2343
|
+
}
|
|
2344
|
+
const resolved = known ?? await resolveLink(options.ytdlp ?? null, link, { cookies: options.cookies ?? "" });
|
|
2345
|
+
if ("error" in resolved) {
|
|
2346
|
+
json(response, 422, { error: resolved.error });
|
|
2347
|
+
return;
|
|
2348
|
+
}
|
|
2349
|
+
links.set(link, resolved);
|
|
2350
|
+
if (!options.channels.has(channelId)) {
|
|
2351
|
+
const started = await pullChannel(options.channels, options.ffprobe ?? ["ffprobe"], channelId, resolved.title, resolved.media, inputArgsFor(resolved.headers));
|
|
2352
|
+
if (!started) {
|
|
2353
|
+
json(response, 409, { error: "that link is already starting" });
|
|
2354
|
+
return;
|
|
2355
|
+
}
|
|
2356
|
+
options.channels.ephemeral(channelId);
|
|
2357
|
+
}
|
|
2358
|
+
json(response, 200, shownLink(channelId, resolved));
|
|
2359
|
+
return;
|
|
2360
|
+
}
|
|
2361
|
+
// The whole thing, to keep. The server fetches it through yt-dlp and
|
|
2362
|
+
// hands the bytes straight on as a download, so the person's own machine
|
|
2363
|
+
// ends up with the file and the server keeps nothing. A live stream has
|
|
2364
|
+
// no whole to hand over.
|
|
2365
|
+
if (path === "/api/links/download") {
|
|
2366
|
+
const link = playableLink(url.searchParams.get("url"));
|
|
2367
|
+
if (link === "") {
|
|
2368
|
+
json(response, 400, { error: "that is not a link this can fetch" });
|
|
2369
|
+
return;
|
|
2370
|
+
}
|
|
2371
|
+
if (!options.ytdlp || options.ytdlp.length === 0) {
|
|
2372
|
+
json(response, 503, { error: "this server has no yt-dlp to fetch with" });
|
|
2373
|
+
return;
|
|
2374
|
+
}
|
|
2375
|
+
const resolved = links.get(link) ?? await resolveLink(options.ytdlp, link, { cookies: options.cookies ?? "" });
|
|
2376
|
+
if ("error" in resolved) {
|
|
2377
|
+
json(response, 422, { error: resolved.error });
|
|
2378
|
+
return;
|
|
2379
|
+
}
|
|
2380
|
+
links.set(link, resolved);
|
|
2381
|
+
if (resolved.live) {
|
|
2382
|
+
json(response, 409, { error: "that is live; there is no whole file to download yet" });
|
|
2383
|
+
return;
|
|
2384
|
+
}
|
|
2385
|
+
const audioOnly = url.searchParams.get("audio") === "1" || !resolved.video;
|
|
2386
|
+
// Named for the format the download will actually take, which is not
|
|
2387
|
+
// always the one played: a track played from an HLS playlist is saved
|
|
2388
|
+
// as the plain MP3 the site also offers, and ".m4a" on an MP3 is a
|
|
2389
|
+
// file nothing will open.
|
|
2390
|
+
const saved = await resolveLink(options.ytdlp, link, { cookies: options.cookies ?? "", format: saveFormat(audioOnly) });
|
|
2391
|
+
const fileName = fileNameFor("error" in saved ? resolved : { ...resolved, ext: saved.ext || resolved.ext }, audioOnly);
|
|
2392
|
+
const [command, ...prefix] = options.ytdlp;
|
|
2393
|
+
const child = spawn(command, [...prefix, ...downloadArgs(link, audioOnly, options.cookies ?? "")], {
|
|
2394
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
2395
|
+
});
|
|
2396
|
+
response.writeHead(200, {
|
|
2397
|
+
"content-type": contentTypeFor(fileName),
|
|
2398
|
+
"content-disposition": `attachment; filename="${fileName.replace(/["\\]/g, "")}"; filename*=UTF-8''${encodeURIComponent(fileName)}`,
|
|
2399
|
+
"cache-control": "no-store",
|
|
2400
|
+
});
|
|
2401
|
+
child.stdout?.pipe(response);
|
|
2402
|
+
let complaint = "";
|
|
2403
|
+
child.stderr?.on("data", (chunk) => {
|
|
2404
|
+
if (complaint.length < 20_000)
|
|
2405
|
+
complaint += chunk.toString("utf8");
|
|
2406
|
+
});
|
|
2407
|
+
child.on("error", () => response.end());
|
|
2408
|
+
child.on("close", (code) => {
|
|
2409
|
+
if (code !== 0)
|
|
2410
|
+
console.log(` a download of ${link} failed: ${complaint.split("\n").filter((one) => one.startsWith("ERROR")).pop() ?? code}`);
|
|
2411
|
+
response.end();
|
|
2412
|
+
});
|
|
2413
|
+
// The person closed the tab: stop fetching what nobody will keep.
|
|
2414
|
+
response.on("close", () => {
|
|
2415
|
+
if (child.exitCode === null)
|
|
2416
|
+
child.kill("SIGKILL");
|
|
2417
|
+
});
|
|
2418
|
+
return;
|
|
2419
|
+
}
|
|
2270
2420
|
// A channel is one publisher and everybody listening to them. Two or three
|
|
2271
2421
|
// devices can publish at once, each to their own channel, and a listener
|
|
2272
2422
|
// picks which to hear.
|
|
@@ -3613,6 +3763,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
3613
3763
|
status: () => ({
|
|
3614
3764
|
live: publisher !== null,
|
|
3615
3765
|
code: listing?.code ?? "",
|
|
3766
|
+
channelCodes: listing?.channelCodes ?? {},
|
|
3616
3767
|
name: listing?.name ?? (options.name || hostname()),
|
|
3617
3768
|
url: listing?.url ?? (publishable_ ? shareLink(publishable_.url, listenKey, false) : ""),
|
|
3618
3769
|
// Whether going live is even possible here. A laptop behind a router
|
|
@@ -3663,6 +3814,11 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
3663
3814
|
paywall,
|
|
3664
3815
|
ffmpeg: tools.ffmpeg,
|
|
3665
3816
|
ffprobe: tools.ffprobe,
|
|
3817
|
+
// For a pasted link: where its media is, and the whole of it to keep.
|
|
3818
|
+
// A cookie jar beside the state, when the operator has put one there,
|
|
3819
|
+
// for the sites that will not talk to a datacenter without one.
|
|
3820
|
+
ytdlp: tools.ytdlp ?? null,
|
|
3821
|
+
cookies: cookiesFile(),
|
|
3666
3822
|
...(tls ? { tls } : {}),
|
|
3667
3823
|
// Untagged, so a directory of five thousand files answers at once; the
|
|
3668
3824
|
// tags follow through `tag` below.
|
package/dist/share.js
CHANGED
|
@@ -327,8 +327,10 @@ export function allowedForListening(path) {
|
|
|
327
327
|
return false;
|
|
328
328
|
// Listing this machine in a public directory is not listening to it. The
|
|
329
329
|
// listen address itself, /api/live, stays open: that is the thing a listen
|
|
330
|
-
// key is for
|
|
331
|
-
|
|
330
|
+
// key is for -- and so is knowing the phone code, which /api/live/state
|
|
331
|
+
// answers: a joiner is told the number to call and the code to key, and
|
|
332
|
+
// the code is in the public directory anyway.
|
|
333
|
+
if (path === "/api/live/start" || path === "/api/live/stop")
|
|
332
334
|
return false;
|
|
333
335
|
return true;
|
|
334
336
|
}
|
package/package.json
CHANGED
package/src/audio.ts
CHANGED
|
@@ -28,12 +28,14 @@ export interface Tools {
|
|
|
28
28
|
ffprobe: string[];
|
|
29
29
|
/** Argv prefix for the player, or null when nothing can make sound here. */
|
|
30
30
|
play: string[] | null;
|
|
31
|
+
/** yt-dlp, which turns a pasted page into a media address; null when there is none. */
|
|
32
|
+
ytdlp?: string[] | null;
|
|
31
33
|
}
|
|
32
34
|
|
|
33
|
-
function works(argv: string[]): boolean {
|
|
35
|
+
function works(argv: string[], flag = "-version"): boolean {
|
|
34
36
|
const [cmd, ...rest] = argv;
|
|
35
37
|
if (!cmd) return false;
|
|
36
|
-
const r = spawnSync(cmd, [...rest,
|
|
38
|
+
const r = spawnSync(cmd, [...rest, flag], { encoding: "utf8", timeout: 10_000 });
|
|
37
39
|
return !r.error && r.status === 0;
|
|
38
40
|
}
|
|
39
41
|
|
|
@@ -92,10 +94,16 @@ export function detectTools(): Tools {
|
|
|
92
94
|
const ffmpeg = pick("ffmpeg");
|
|
93
95
|
const ffprobe = pick("ffprobe");
|
|
94
96
|
const play = pick("ffplay");
|
|
97
|
+
// yt-dlp is not an ffmpeg, so mise's ffmpeg tree is not where it lives;
|
|
98
|
+
// the installer puts it beside nixamp, and pip puts it in ~/.local/bin too.
|
|
99
|
+
// Asked with its own spelling: ffmpeg answers -version, yt-dlp only --version.
|
|
100
|
+
const ytdlp = [["yt-dlp"], [join(homedir(), ".local", "bin", "yt-dlp")], ["/usr/local/bin/yt-dlp"], ["/usr/bin/yt-dlp"], ["/opt/homebrew/bin/yt-dlp"]]
|
|
101
|
+
.find((argv) => works(argv, "--version")) ?? null;
|
|
95
102
|
return {
|
|
96
103
|
ffmpeg: ffmpeg ?? ["ffmpeg"],
|
|
97
104
|
ffprobe: ffprobe ?? ["ffprobe"],
|
|
98
105
|
play,
|
|
106
|
+
ytdlp,
|
|
99
107
|
};
|
|
100
108
|
}
|
|
101
109
|
|
|
@@ -392,7 +400,7 @@ export interface Codecs {
|
|
|
392
400
|
* answering other requests, and a synchronous probe per media request is how
|
|
393
401
|
* the whole library came to be tagged with the process wedged solid.
|
|
394
402
|
*/
|
|
395
|
-
export async function codecsOf(tools: Tools, path: string): Promise<Codecs> {
|
|
403
|
+
export async function codecsOf(tools: Tools, path: string, input: string[] = []): Promise<Codecs> {
|
|
396
404
|
const [cmd, ...rest] = tools.ffprobe;
|
|
397
405
|
const empty: Codecs = { video: "", audio: "", container: "" };
|
|
398
406
|
if (!cmd) return empty;
|
|
@@ -405,6 +413,8 @@ export async function codecsOf(tools: Tools, path: string): Promise<Codecs> {
|
|
|
405
413
|
"-v", "quiet",
|
|
406
414
|
"-print_format", "json",
|
|
407
415
|
"-show_entries", "format=format_name:stream=codec_type,codec_name",
|
|
416
|
+
// Headers the source's site expects, for a link resolved by yt-dlp.
|
|
417
|
+
...input,
|
|
408
418
|
path,
|
|
409
419
|
],
|
|
410
420
|
{ stdio: ["ignore", "pipe", "ignore"] },
|