nixamp 0.9.11 → 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/owner.js +1 -1
- package/dist/publish.d.ts +10 -0
- package/dist/publish.js +1 -0
- package/dist/server.d.ts +15 -1
- package/dist/server.js +167 -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/owner.ts +1 -1
- package/src/publish.ts +11 -0
- package/src/server.ts +184 -3
- package/src/share.ts +4 -2
- package/web/dist/assets/{hls-3VKVEQE3-Djy-zI1_.js → hls-3VKVEQE3-5T3M0hCY.js} +1 -1
- package/web/dist/assets/index-Bqujphpd.js +1 -0
- package/web/dist/assets/{index-BH1MBOSf.css → index-D9uHBJcW.css} +1 -1
- package/web/dist/assets/{mpegts-D-XCO7zz.js → mpegts-DeEbMNtT.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-RrDOxMgi.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-Dnk9CNfO.js +0 -1
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"] },
|
package/src/channels.ts
CHANGED
|
@@ -219,7 +219,7 @@ export class Channel {
|
|
|
219
219
|
* because you looked away, and a room where the picture depends on who is
|
|
220
220
|
* in it is not a room anybody can be invited to.
|
|
221
221
|
*/
|
|
222
|
-
pull(source: string, encode: string[], paced = true, stall = STALL): void {
|
|
222
|
+
pull(source: string, encode: string[], paced = true, stall = STALL, input: string[] = []): void {
|
|
223
223
|
this.stall = stall;
|
|
224
224
|
if (this.info.kind === "video") this.fragments = new Fragments();
|
|
225
225
|
const [command, ...prefix] = this.options.ffmpeg as [string, ...string[]];
|
|
@@ -247,6 +247,10 @@ export class Channel {
|
|
|
247
247
|
// hour of film in ninety seconds and a room that cannot be in it
|
|
248
248
|
// together; a live source is already paced and loses nothing.
|
|
249
249
|
...(paced ? ["-re"] : []),
|
|
250
|
+
// What the source's site expects on the request: a user agent, a
|
|
251
|
+
// referer, a cookie. A link resolved by yt-dlp comes with these,
|
|
252
|
+
// and a CDN that got them from yt-dlp and not from us answers 403.
|
|
253
|
+
...input,
|
|
250
254
|
"-i", source,
|
|
251
255
|
...encode,
|
|
252
256
|
"pipe:1",
|
|
@@ -612,6 +616,7 @@ export class Channels {
|
|
|
612
616
|
kind: "audio" | "video",
|
|
613
617
|
paced = true,
|
|
614
618
|
stall = STALL,
|
|
619
|
+
input: string[] = [],
|
|
615
620
|
): Channel | null {
|
|
616
621
|
if (this.open.has(id)) return null;
|
|
617
622
|
const channel = new Channel(
|
|
@@ -630,7 +635,7 @@ export class Channels {
|
|
|
630
635
|
(gone) => this.open.delete(gone),
|
|
631
636
|
);
|
|
632
637
|
this.open.set(id, channel);
|
|
633
|
-
channel.pull(source, encode, paced, stall);
|
|
638
|
+
channel.pull(source, encode, paced, stall, input);
|
|
634
639
|
return channel;
|
|
635
640
|
}
|
|
636
641
|
|
package/src/directory.ts
CHANGED
|
@@ -73,6 +73,12 @@ export interface Listing {
|
|
|
73
73
|
playing: boolean;
|
|
74
74
|
/** The live channels on it, by name: what a visitor could actually watch. */
|
|
75
75
|
channels: string[];
|
|
76
|
+
/**
|
|
77
|
+
* A phone code for each of those channels, by name: its own room, so the
|
|
78
|
+
* people calling about one live are not put in with the people calling
|
|
79
|
+
* about another on the same server.
|
|
80
|
+
*/
|
|
81
|
+
channelCodes: Record<string, string>;
|
|
76
82
|
/** Set by the directory from the request, never by the publisher. */
|
|
77
83
|
updatedAt: number;
|
|
78
84
|
/** When this stream first announced itself: the "started at" a caller hears. */
|
|
@@ -283,6 +289,11 @@ export class Directory {
|
|
|
283
289
|
// listing always meant, and no channels is the honest empty list.
|
|
284
290
|
playing: announcement.playing ?? true,
|
|
285
291
|
channels: announcement.channels ?? [],
|
|
292
|
+
channelCodes: this.codesFor(
|
|
293
|
+
announcement.channels ?? [],
|
|
294
|
+
existing?.channelCodes ?? (previously && "channelCodes" in previously ? previously.channelCodes : undefined),
|
|
295
|
+
id,
|
|
296
|
+
),
|
|
286
297
|
updatedAt: this.now(),
|
|
287
298
|
// A stream that never stopped keeps its original start. One that did
|
|
288
299
|
// starts again now, because that is what a caller is being told about.
|
|
@@ -305,10 +316,55 @@ export class Directory {
|
|
|
305
316
|
return [...this.items.values()].sort((a, b) => b.updatedAt - a.updatedAt || a.id.localeCompare(b.id));
|
|
306
317
|
}
|
|
307
318
|
|
|
308
|
-
/**
|
|
319
|
+
/**
|
|
320
|
+
* The live stream on this code, if there is one.
|
|
321
|
+
*
|
|
322
|
+
* A channel's code answers as the channel: the same listing, named for the
|
|
323
|
+
* channel and playing nothing else, so the phone line says "the live room
|
|
324
|
+
* for FIBA World Cup" rather than the server's name and whatever its own
|
|
325
|
+
* player has on.
|
|
326
|
+
*/
|
|
309
327
|
liveByCode(code: string): Listing | undefined {
|
|
310
328
|
this.sweep();
|
|
311
|
-
|
|
329
|
+
const own = [...this.items.values()].find((item) => item.code === code);
|
|
330
|
+
if (own !== undefined) return own;
|
|
331
|
+
for (const item of this.items.values()) {
|
|
332
|
+
const channel = Object.entries(item.channelCodes).find(([, one]) => one === code)?.[0];
|
|
333
|
+
if (channel !== undefined) return { ...item, code, name: channel, nowPlaying: "" };
|
|
334
|
+
}
|
|
335
|
+
return undefined;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* A code for each channel, kept while the channel stays on.
|
|
340
|
+
*
|
|
341
|
+
* One code per server meant every live on it shared a room: somebody
|
|
342
|
+
* calling about the basketball landed with the people talking about the
|
|
343
|
+
* film. Each live is its own room now, and a channel that is still there at
|
|
344
|
+
* the next heartbeat keeps the code it was given.
|
|
345
|
+
*/
|
|
346
|
+
private codesFor(channels: string[], had: Record<string, string> | undefined, own = ""): Record<string, string> {
|
|
347
|
+
const codes: Record<string, string> = {};
|
|
348
|
+
for (const name of channels) {
|
|
349
|
+
if (codes[name] !== undefined) continue;
|
|
350
|
+
const kept = had?.[name];
|
|
351
|
+
codes[name] = kept !== undefined && !this.taken(kept, codes, own) ? kept : this.freeCode(codes, own);
|
|
352
|
+
}
|
|
353
|
+
return codes;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Whether a code is somebody's already: a stream's own, a channel's on any
|
|
358
|
+
* stream, or a recently-ended stream's. A listing renewing itself is not
|
|
359
|
+
* "somebody else", or its channels would be re-coded every heartbeat.
|
|
360
|
+
*/
|
|
361
|
+
private taken(code: string, besides: Record<string, string> = {}, own = ""): boolean {
|
|
362
|
+
if (Object.values(besides).includes(code)) return true;
|
|
363
|
+
for (const item of this.items.values()) {
|
|
364
|
+
if (item.code === code) return true;
|
|
365
|
+
if (item.id !== own && Object.values(item.channelCodes).includes(code)) return true;
|
|
366
|
+
}
|
|
367
|
+
return [...this.ended.values()].some((i) => i.code === code);
|
|
312
368
|
}
|
|
313
369
|
|
|
314
370
|
/**
|
|
@@ -372,15 +428,12 @@ export class Directory {
|
|
|
372
428
|
this.mirror?.save(record);
|
|
373
429
|
}
|
|
374
430
|
|
|
375
|
-
/** A code no live and no recently-ended stream is using. */
|
|
376
|
-
private freeCode(): string {
|
|
431
|
+
/** A code no live stream, no channel on one, and no recently-ended stream is using. */
|
|
432
|
+
private freeCode(besides: Record<string, string> = {}, own = ""): string {
|
|
377
433
|
for (let tries = 0; tries < 40; tries += 1) {
|
|
378
434
|
const code = this.randomCode();
|
|
379
435
|
if (code.length !== 6) continue;
|
|
380
|
-
|
|
381
|
-
[...this.items.values()].some((i) => i.code === code) ||
|
|
382
|
-
[...this.ended.values()].some((i) => i.code === code);
|
|
383
|
-
if (!taken) return code;
|
|
436
|
+
if (!this.taken(code, besides, own)) return code;
|
|
384
437
|
}
|
|
385
438
|
return "";
|
|
386
439
|
}
|
package/src/links.ts
ADDED
|
Binary file
|
package/src/owner.ts
CHANGED
|
@@ -111,7 +111,7 @@ export const ADMIN_PATHS = [
|
|
|
111
111
|
* `/api/live` itself is the public listen address -- the one the phone line is
|
|
112
112
|
* handed. Gating it by prefix would shut the front door to lock the office.
|
|
113
113
|
*/
|
|
114
|
-
const LIVE_CONTROL = ["/api/live/
|
|
114
|
+
const LIVE_CONTROL = ["/api/live/start", "/api/live/stop"];
|
|
115
115
|
|
|
116
116
|
export function needsAdmin(path: string, method = "GET"): boolean {
|
|
117
117
|
if (LIVE_CONTROL.includes(path)) return true;
|
package/src/publish.ts
CHANGED
|
@@ -52,6 +52,16 @@ export interface PublishTarget {
|
|
|
52
52
|
onConfig?: (config: unknown) => void;
|
|
53
53
|
/** Called when the directory refused us for want of an account. */
|
|
54
54
|
onRefused?: () => void;
|
|
55
|
+
/**
|
|
56
|
+
* Called with the listing every heartbeat, not only the first.
|
|
57
|
+
*
|
|
58
|
+
* The directory forgets everything when it restarts, and the next heartbeat
|
|
59
|
+
* lists this server again under a new phone code. A server that only kept
|
|
60
|
+
* the first answer went on showing the old code -- to the Share panel, the
|
|
61
|
+
* player, and anybody sent the link -- while the phone line knew only the
|
|
62
|
+
* new one.
|
|
63
|
+
*/
|
|
64
|
+
onListed?: (listing: Listing) => void;
|
|
55
65
|
}
|
|
56
66
|
|
|
57
67
|
/**
|
|
@@ -128,6 +138,7 @@ export class Publisher {
|
|
|
128
138
|
const listing = (await response.json()) as Listing & { config?: unknown };
|
|
129
139
|
this.id = listing.id;
|
|
130
140
|
if (listing.config !== undefined) this.target.onConfig?.(listing.config);
|
|
141
|
+
this.target.onListed?.(listing);
|
|
131
142
|
return listing;
|
|
132
143
|
} catch {
|
|
133
144
|
// The directory being down is not a reason for a player to stop playing.
|
package/src/server.ts
CHANGED
|
@@ -51,6 +51,10 @@ import { stateDir } from "./daemon.ts";
|
|
|
51
51
|
import { readSession } from "./session.ts";
|
|
52
52
|
import { Directory, ENDED_TTL_MS, parseAnnouncement, type Listing } from "./directory.ts";
|
|
53
53
|
import { PartyLine, telnyxSms } from "./partyline.ts";
|
|
54
|
+
import {
|
|
55
|
+
contentTypeFor, downloadArgs, fileNameFor, inputArgsFor, linkChannelId, playableLink, resolveLink, saveFormat,
|
|
56
|
+
type ResolvedLink,
|
|
57
|
+
} from "./links.ts";
|
|
54
58
|
import { CALL_IN_NUMBER, OPT_IN_PATH, optInPage } from "./optin.ts";
|
|
55
59
|
import pg from "pg";
|
|
56
60
|
import { Follows, phoneFrom } from "./follows.ts";
|
|
@@ -978,15 +982,50 @@ export async function pullChannel(
|
|
|
978
982
|
id: string,
|
|
979
983
|
name: string,
|
|
980
984
|
source: string,
|
|
985
|
+
input: string[] = [],
|
|
981
986
|
): Promise<Channel | null> {
|
|
982
|
-
const codecs = await codecsOf({ ffmpeg: [], ffprobe, play: null }, source);
|
|
987
|
+
const codecs = await codecsOf({ ffmpeg: [], ffprobe, play: null }, source, input);
|
|
983
988
|
const kind = codecs.video === "" ? "audio" : "video";
|
|
984
989
|
const encode = kind === "video"
|
|
985
990
|
? videoArgs(codecs)
|
|
986
991
|
// No picture in it, so none is invented: MP3 is the thing every browser
|
|
987
992
|
// plays and the thing a listener can join halfway through.
|
|
988
993
|
: ["-vn", "-c:a", "libmp3lame", "-b:a", "192k", "-f", "mp3"];
|
|
989
|
-
return channels.pull(id, name, source, encode, kind);
|
|
994
|
+
return channels.pull(id, name, source, encode, kind, true, undefined, input);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/** What a link resolves to, kept so a download can be named without asking twice. */
|
|
998
|
+
const links = new Map<string, ResolvedLink>();
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* A Netscape cookies file beside the state, if the operator has put one there.
|
|
1002
|
+
*
|
|
1003
|
+
* YouTube and Vimeo refuse a datacenter without a signed-in cookie; the
|
|
1004
|
+
* person who runs the server can export one from their browser and drop it
|
|
1005
|
+
* at ~/.local/state/nixamp/cookies.txt, and every link is asked for with it.
|
|
1006
|
+
*/
|
|
1007
|
+
export function cookiesFile(): string {
|
|
1008
|
+
const path = join(stateDir(), "cookies.txt");
|
|
1009
|
+
try {
|
|
1010
|
+
return statSync(path).isFile() ? path : "";
|
|
1011
|
+
} catch {
|
|
1012
|
+
return "";
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/** What the page is told about a link it asked to play. */
|
|
1017
|
+
function shownLink(channelId: string, link: ResolvedLink) {
|
|
1018
|
+
return {
|
|
1019
|
+
kind: "live",
|
|
1020
|
+
channel: channelId,
|
|
1021
|
+
name: link.title,
|
|
1022
|
+
live: link.live,
|
|
1023
|
+
video: link.video,
|
|
1024
|
+
duration: link.duration,
|
|
1025
|
+
extractor: link.extractor,
|
|
1026
|
+
// A live has no whole to keep; a bare file can be fetched by the browser itself.
|
|
1027
|
+
download: !link.live && link.extractor !== "direct",
|
|
1028
|
+
};
|
|
990
1029
|
}
|
|
991
1030
|
|
|
992
1031
|
export function liveOnes(engine: Engine): { name: string; at: number; tracks: number }[] {
|
|
@@ -1122,6 +1161,10 @@ export interface HandlerOptions {
|
|
|
1122
1161
|
ffmpeg?: string[];
|
|
1123
1162
|
/** Where ffprobe is, for asking what is inside a file before re-encoding it. */
|
|
1124
1163
|
ffprobe?: string[];
|
|
1164
|
+
/** yt-dlp, which turns a pasted page into a media address. Null when there is none. */
|
|
1165
|
+
ytdlp?: string[] | null;
|
|
1166
|
+
/** A Netscape cookies file for sites that want a signed-in browser, when there is one. */
|
|
1167
|
+
cookies?: string;
|
|
1125
1168
|
/** Who is listening, for the admin view. */
|
|
1126
1169
|
connections?: Connections;
|
|
1127
1170
|
/**
|
|
@@ -1177,7 +1220,11 @@ export interface HandlerOptions {
|
|
|
1177
1220
|
* and starting it. It is an action now, because that is what it is.
|
|
1178
1221
|
*/
|
|
1179
1222
|
live?: {
|
|
1180
|
-
status: () => {
|
|
1223
|
+
status: () => {
|
|
1224
|
+
live: boolean; code: string; name: string; url: string; possible: boolean;
|
|
1225
|
+
/** A phone code per live channel, by name, as the directory assigned them. */
|
|
1226
|
+
channelCodes?: Record<string, string>;
|
|
1227
|
+
};
|
|
1181
1228
|
start: () => Promise<{ live: boolean; code: string; name: string; url: string; error?: string }>;
|
|
1182
1229
|
stop: () => Promise<void>;
|
|
1183
1230
|
/**
|
|
@@ -2363,6 +2410,11 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2363
2410
|
const streams = options.directory.list().map(({ admin, ...stream }) => ({
|
|
2364
2411
|
...stream,
|
|
2365
2412
|
callers: onThePhone ? onThePhone.listenersOn(stream.code) : 0,
|
|
2413
|
+
// And on the phone for each live on it, by name: every live is its
|
|
2414
|
+
// own room, so each has its own count.
|
|
2415
|
+
channelCallers: Object.fromEntries(
|
|
2416
|
+
Object.entries(stream.channelCodes).map(([name, code]) => [name, onThePhone ? onThePhone.listenersOn(code) : 0]),
|
|
2417
|
+
),
|
|
2366
2418
|
...(admin && me !== null && stream.ownerId === me.id ? { admin } : {}),
|
|
2367
2419
|
}));
|
|
2368
2420
|
// Recently ended too, because following exists to hear about
|
|
@@ -2533,6 +2585,9 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2533
2585
|
// Whether it has a picture, so the page puts it in the element
|
|
2534
2586
|
// that can show one. Never the source: that is the owner's.
|
|
2535
2587
|
kind: one.kind ?? "audio",
|
|
2588
|
+
// Its own room on the phone line, as the directory assigned it;
|
|
2589
|
+
// empty until the next heartbeat has told the directory it is on.
|
|
2590
|
+
code: state?.channelCodes?.[one.name] ?? "",
|
|
2536
2591
|
// How it has been going, for whoever may do something about it.
|
|
2537
2592
|
redials: one.redials ?? 0,
|
|
2538
2593
|
error: one.error ?? "",
|
|
@@ -2721,6 +2776,118 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2721
2776
|
return;
|
|
2722
2777
|
}
|
|
2723
2778
|
|
|
2779
|
+
// --- any link, played -------------------------------------------------
|
|
2780
|
+
//
|
|
2781
|
+
// Paste a page -- YouTube, a podcast, SoundCloud, a TikTok live -- and the
|
|
2782
|
+
// server works out where the media is and plays it as a channel of its
|
|
2783
|
+
// own, the way a catalog entry is played: started for whoever asked,
|
|
2784
|
+
// stopped a minute after the last viewer leaves. Open to anyone holding
|
|
2785
|
+
// the link, like picking something from a catalog.
|
|
2786
|
+
if (path === "/api/links/play" && request.method === "POST") {
|
|
2787
|
+
let body: { url?: unknown } = {};
|
|
2788
|
+
try {
|
|
2789
|
+
body = JSON.parse(await readBody(request)) as typeof body;
|
|
2790
|
+
} catch {
|
|
2791
|
+
json(response, 400, { error: "bad JSON" });
|
|
2792
|
+
return;
|
|
2793
|
+
}
|
|
2794
|
+
const link = playableLink(body.url);
|
|
2795
|
+
if (link === "") {
|
|
2796
|
+
json(response, 400, { error: "that is not a link this can play" });
|
|
2797
|
+
return;
|
|
2798
|
+
}
|
|
2799
|
+
if (!options.channels) {
|
|
2800
|
+
json(response, 503, { error: "this server cannot carry channels" });
|
|
2801
|
+
return;
|
|
2802
|
+
}
|
|
2803
|
+
const channelId = linkChannelId(link);
|
|
2804
|
+
const known = links.get(link);
|
|
2805
|
+
if (options.channels.has(channelId) && known) {
|
|
2806
|
+
json(response, 200, shownLink(channelId, known));
|
|
2807
|
+
return;
|
|
2808
|
+
}
|
|
2809
|
+
if (!options.channels.has(channelId) && options.channels.ephemeralCount >= MAX_ON_DEMAND) {
|
|
2810
|
+
json(response, 429, { error: `this server is already carrying ${MAX_ON_DEMAND} channels on demand; try again in a minute` });
|
|
2811
|
+
return;
|
|
2812
|
+
}
|
|
2813
|
+
const resolved = known ?? await resolveLink(options.ytdlp ?? null, link, { cookies: options.cookies ?? "" });
|
|
2814
|
+
if ("error" in resolved) {
|
|
2815
|
+
json(response, 422, { error: resolved.error });
|
|
2816
|
+
return;
|
|
2817
|
+
}
|
|
2818
|
+
links.set(link, resolved);
|
|
2819
|
+
if (!options.channels.has(channelId)) {
|
|
2820
|
+
const started = await pullChannel(
|
|
2821
|
+
options.channels, options.ffprobe ?? ["ffprobe"], channelId, resolved.title, resolved.media,
|
|
2822
|
+
inputArgsFor(resolved.headers),
|
|
2823
|
+
);
|
|
2824
|
+
if (!started) {
|
|
2825
|
+
json(response, 409, { error: "that link is already starting" });
|
|
2826
|
+
return;
|
|
2827
|
+
}
|
|
2828
|
+
options.channels.ephemeral(channelId);
|
|
2829
|
+
}
|
|
2830
|
+
json(response, 200, shownLink(channelId, resolved));
|
|
2831
|
+
return;
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
// The whole thing, to keep. The server fetches it through yt-dlp and
|
|
2835
|
+
// hands the bytes straight on as a download, so the person's own machine
|
|
2836
|
+
// ends up with the file and the server keeps nothing. A live stream has
|
|
2837
|
+
// no whole to hand over.
|
|
2838
|
+
if (path === "/api/links/download") {
|
|
2839
|
+
const link = playableLink(url.searchParams.get("url"));
|
|
2840
|
+
if (link === "") {
|
|
2841
|
+
json(response, 400, { error: "that is not a link this can fetch" });
|
|
2842
|
+
return;
|
|
2843
|
+
}
|
|
2844
|
+
if (!options.ytdlp || options.ytdlp.length === 0) {
|
|
2845
|
+
json(response, 503, { error: "this server has no yt-dlp to fetch with" });
|
|
2846
|
+
return;
|
|
2847
|
+
}
|
|
2848
|
+
const resolved = links.get(link) ?? await resolveLink(options.ytdlp, link, { cookies: options.cookies ?? "" });
|
|
2849
|
+
if ("error" in resolved) {
|
|
2850
|
+
json(response, 422, { error: resolved.error });
|
|
2851
|
+
return;
|
|
2852
|
+
}
|
|
2853
|
+
links.set(link, resolved);
|
|
2854
|
+
if (resolved.live) {
|
|
2855
|
+
json(response, 409, { error: "that is live; there is no whole file to download yet" });
|
|
2856
|
+
return;
|
|
2857
|
+
}
|
|
2858
|
+
const audioOnly = url.searchParams.get("audio") === "1" || !resolved.video;
|
|
2859
|
+
// Named for the format the download will actually take, which is not
|
|
2860
|
+
// always the one played: a track played from an HLS playlist is saved
|
|
2861
|
+
// as the plain MP3 the site also offers, and ".m4a" on an MP3 is a
|
|
2862
|
+
// file nothing will open.
|
|
2863
|
+
const saved = await resolveLink(options.ytdlp, link, { cookies: options.cookies ?? "", format: saveFormat(audioOnly) });
|
|
2864
|
+
const fileName = fileNameFor("error" in saved ? resolved : { ...resolved, ext: saved.ext || resolved.ext }, audioOnly);
|
|
2865
|
+
const [command, ...prefix] = options.ytdlp as [string, ...string[]];
|
|
2866
|
+
const child = spawn(command, [...prefix, ...downloadArgs(link, audioOnly, options.cookies ?? "")], {
|
|
2867
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
2868
|
+
});
|
|
2869
|
+
response.writeHead(200, {
|
|
2870
|
+
"content-type": contentTypeFor(fileName),
|
|
2871
|
+
"content-disposition": `attachment; filename="${fileName.replace(/["\\]/g, "")}"; filename*=UTF-8''${encodeURIComponent(fileName)}`,
|
|
2872
|
+
"cache-control": "no-store",
|
|
2873
|
+
});
|
|
2874
|
+
child.stdout?.pipe(response);
|
|
2875
|
+
let complaint = "";
|
|
2876
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
2877
|
+
if (complaint.length < 20_000) complaint += chunk.toString("utf8");
|
|
2878
|
+
});
|
|
2879
|
+
child.on("error", () => response.end());
|
|
2880
|
+
child.on("close", (code) => {
|
|
2881
|
+
if (code !== 0) console.log(` a download of ${link} failed: ${complaint.split("\n").filter((one) => one.startsWith("ERROR")).pop() ?? code}`);
|
|
2882
|
+
response.end();
|
|
2883
|
+
});
|
|
2884
|
+
// The person closed the tab: stop fetching what nobody will keep.
|
|
2885
|
+
response.on("close", () => {
|
|
2886
|
+
if (child.exitCode === null) child.kill("SIGKILL");
|
|
2887
|
+
});
|
|
2888
|
+
return;
|
|
2889
|
+
}
|
|
2890
|
+
|
|
2724
2891
|
// A channel is one publisher and everybody listening to them. Two or three
|
|
2725
2892
|
// devices can publish at once, each to their own channel, and a listener
|
|
2726
2893
|
// picks which to hear.
|
|
@@ -4163,6 +4330,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
4163
4330
|
status: () => ({
|
|
4164
4331
|
live: publisher !== null,
|
|
4165
4332
|
code: listing?.code ?? "",
|
|
4333
|
+
channelCodes: listing?.channelCodes ?? {},
|
|
4166
4334
|
name: listing?.name ?? (options.name || hostname()),
|
|
4167
4335
|
url: listing?.url ?? (publishable_ ? shareLink(publishable_.url, listenKey, false) : ""),
|
|
4168
4336
|
// Whether going live is even possible here. A laptop behind a router
|
|
@@ -4210,6 +4378,11 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
4210
4378
|
paywall,
|
|
4211
4379
|
ffmpeg: tools.ffmpeg,
|
|
4212
4380
|
ffprobe: tools.ffprobe,
|
|
4381
|
+
// For a pasted link: where its media is, and the whole of it to keep.
|
|
4382
|
+
// A cookie jar beside the state, when the operator has put one there,
|
|
4383
|
+
// for the sites that will not talk to a datacenter without one.
|
|
4384
|
+
ytdlp: tools.ytdlp ?? null,
|
|
4385
|
+
cookies: cookiesFile(),
|
|
4213
4386
|
...(tls ? { tls } : {}),
|
|
4214
4387
|
// Untagged, so a directory of five thousand files answers at once; the
|
|
4215
4388
|
// tags follow through `tag` below.
|
|
@@ -4557,6 +4730,14 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
4557
4730
|
console.log(" nixamp.com would not list this stream: it needs an account.");
|
|
4558
4731
|
console.log(" Run `nixamp login` (or `nixamp signup`) and start again.");
|
|
4559
4732
|
},
|
|
4733
|
+
// Every heartbeat, so the code this server shows is the code the
|
|
4734
|
+
// phone line knows, whatever the directory has forgotten meanwhile.
|
|
4735
|
+
onListed: (fresh) => {
|
|
4736
|
+
if (listing && listing.code !== fresh.code) {
|
|
4737
|
+
console.log(` nixamp.com listed this stream again; the phone code is now ${fresh.code}.`);
|
|
4738
|
+
}
|
|
4739
|
+
listing = fresh;
|
|
4740
|
+
},
|
|
4560
4741
|
nowPlaying: () => {
|
|
4561
4742
|
const snapshot = engine.snapshot();
|
|
4562
4743
|
return snapshot.tracks?.[snapshot.index]?.title ?? "";
|
package/src/share.ts
CHANGED
|
@@ -354,8 +354,10 @@ export function allowedForListening(path: string): boolean {
|
|
|
354
354
|
if (path === "/api/source" || path.startsWith("/api/source/")) return false;
|
|
355
355
|
// Listing this machine in a public directory is not listening to it. The
|
|
356
356
|
// listen address itself, /api/live, stays open: that is the thing a listen
|
|
357
|
-
// key is for
|
|
358
|
-
|
|
357
|
+
// key is for -- and so is knowing the phone code, which /api/live/state
|
|
358
|
+
// answers: a joiner is told the number to call and the code to key, and
|
|
359
|
+
// the code is in the public directory anyway.
|
|
360
|
+
if (path === "/api/live/start" || path === "/api/live/stop") return false;
|
|
359
361
|
return true;
|
|
360
362
|
}
|
|
361
363
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{t as e}from"./index-
|
|
1
|
+
import{t as e}from"./index-Bqujphpd.js";var t=3;async function n(n){let{media:r,src:i,isTv:a}=n,{default:o}=await e(async()=>{let{default:e}=await import(`./hls-n74Cnh8A.js`);return{default:e}},[]);if(!o.isSupported())return n.onError(`This browser cannot play HLS streams.`),{destroy:()=>void 0,levels:()=>[]};let s=new o({...a?{maxBufferLength:60,maxMaxBufferLength:120,backBufferLength:30,liveSyncDurationCount:4}:{backBufferLength:90},enableWorker:!0}),c=0,l=!1;s.on(o.Events.ERROR,(e,r)=>{if(!l&&r.fatal){if(c>=t){n.onError(`This stream kept failing and has been stopped.`),s.destroy();return}switch(c+=1,r.type){case o.ErrorTypes.NETWORK_ERROR:n.onNotice(`Reconnecting…`),s.startLoad();break;case o.ErrorTypes.MEDIA_ERROR:n.onNotice(`Recovering…`),s.recoverMediaError();break;default:n.onError(`This stream could not be played.`),s.destroy()}}}),s.on(o.Events.MANIFEST_PARSED,()=>{l||(n.onNotice(null),n.onReady?.({live:s.levels.length>0&&!Number.isFinite(r.duration),levels:u()}))}),s.on(o.Events.LEVEL_LOADED,(e,t)=>{l||n.onReady?.({live:t.details.live,levels:u()})}),s.on(o.Events.FRAG_BUFFERED,()=>{l||n.onNotice(null)});function u(){return s.levels.map((e,t)=>({index:t,height:e.height||null,bitrate:e.bitrate||null,label:e.height?`${String(e.height)}p`:`${String(Math.round((e.bitrate||0)/1e3))}k`}))}return s.loadSource(i),s.attachMedia(r),{destroy(){l=!0,s.destroy()},levels:u,setLevel(e){s.currentLevel=e},currentLevel:()=>s.autoLevelEnabled?-1:s.currentLevel}}export{n as createHlsEngine};
|