nixamp 0.7.4 → 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/README.md +8 -2
- package/dist/admin.d.ts +2 -0
- package/dist/admin.js +32 -8
- 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/protocol.d.ts +8 -0
- package/dist/server.d.ts +79 -5
- package/dist/server.js +215 -26
- package/dist/share.js +6 -1
- package/dist/sources.d.ts +9 -0
- package/dist/sources.js +24 -0
- package/package.json +1 -1
- package/src/admin.ts +35 -10
- 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/protocol.ts +8 -0
- package/src/server.ts +261 -29
- package/src/share.ts +5 -1
- package/src/sources.ts +21 -0
- package/web/dist/assets/{hls-3VKVEQE3-BirljKil.js → hls-3VKVEQE3-CKpUWwhK.js} +1 -1
- package/web/dist/assets/index-Bs7oVbTk.js +1 -0
- package/web/dist/assets/index-CTgfCs0m.css +1 -0
- package/web/dist/assets/{mpegts-Dd6YyA19.js → mpegts-6hEDu2OP.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-Cl9mowBJ.js → mpegts-LO6RVLD6-BAhx7mvs.js} +1 -1
- package/web/dist/index.html +35 -7
- package/web/dist/sw.js +6 -6
- package/web/dist/assets/index-DSIDSSPF.css +0 -1
- package/web/dist/assets/index-U2odRmpd.js +0 -1
package/src/invite.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
|
|
@@ -22,7 +24,7 @@ export interface Invite {
|
|
|
22
24
|
link: string;
|
|
23
25
|
/** The phone number, when this stream is one the line knows about. */
|
|
24
26
|
phone: string;
|
|
25
|
-
/** The six digits that reach this stream,
|
|
27
|
+
/** The six digits that reach this stream's room, once it has been published. */
|
|
26
28
|
code: string;
|
|
27
29
|
}
|
|
28
30
|
|
|
@@ -47,7 +49,10 @@ export function isEmail(value: string): boolean {
|
|
|
47
49
|
export function inviteText(invite: Invite): string {
|
|
48
50
|
const lines = [`${invite.name} is streaming.`, "", `Watch: ${invite.link}`];
|
|
49
51
|
if (invite.phone && invite.code) {
|
|
50
|
-
|
|
52
|
+
// "to talk about it", not "to listen": the line is a room full of the
|
|
53
|
+
// other people watching, and telling somebody they will hear the stream
|
|
54
|
+
// down the phone is telling them something that is not true.
|
|
55
|
+
lines.push("", `To talk about it: call ${invite.phone} and key ${invite.code}.`);
|
|
51
56
|
}
|
|
52
57
|
return lines.join("\n");
|
|
53
58
|
}
|
package/src/main.ts
CHANGED
|
@@ -69,7 +69,7 @@ const HELP = `nixamp — it really whips the terminal's ass.
|
|
|
69
69
|
|
|
70
70
|
nixamp [source] play it in the terminal
|
|
71
71
|
nixamp serve [source] [options] play here, and hand out a browser remote
|
|
72
|
-
nixamp daemon start|stop|status serve in the background, and let go of it
|
|
72
|
+
nixamp daemon start|restart|stop|status serve in the background, and let go of it
|
|
73
73
|
nixamp attach put the player back in front of the daemon
|
|
74
74
|
nixamp admin [--url U] [--key K] who is connected, and re-stream to them
|
|
75
75
|
nixamp login [--with github] sign in to nixamp.com, in a browser or here
|
|
@@ -188,9 +188,13 @@ Signing out does not touch it: that is what it is for.
|
|
|
188
188
|
daemon: `nixamp daemon — a nixamp that outlives the terminal that started it.
|
|
189
189
|
|
|
190
190
|
nixamp daemon start [source] [serve options] start it, detached
|
|
191
|
+
nixamp daemon restart [source] [serve options] stop it and start it again
|
|
191
192
|
nixamp daemon status where it is, and how long
|
|
192
193
|
nixamp daemon stop stop it
|
|
193
194
|
|
|
195
|
+
Restart with no arguments replays the ones it was started with, certificate
|
|
196
|
+
and public URL included, so picking up a new version costs one command.
|
|
197
|
+
|
|
194
198
|
It is \`nixamp serve\` with nobody holding its terminal, so it keeps playing and
|
|
195
199
|
keeps serving its browser remote. One per user.
|
|
196
200
|
|
|
@@ -257,6 +261,17 @@ async function runDaemon(argv: string[]): Promise<number> {
|
|
|
257
261
|
return 0;
|
|
258
262
|
}
|
|
259
263
|
|
|
264
|
+
if (action === "restart") {
|
|
265
|
+
try {
|
|
266
|
+
const state = await d.restart(rest, entry);
|
|
267
|
+
for (const line of d.daemonLines(state)) console.log(line);
|
|
268
|
+
return 0;
|
|
269
|
+
} catch (error) {
|
|
270
|
+
console.error((error as Error).message);
|
|
271
|
+
return 1;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
260
275
|
if (action === "status") {
|
|
261
276
|
const { running, state } = d.status();
|
|
262
277
|
if (!state) {
|
|
@@ -283,7 +298,7 @@ async function runDaemon(argv: string[]): Promise<number> {
|
|
|
283
298
|
return attach(rest);
|
|
284
299
|
}
|
|
285
300
|
|
|
286
|
-
console.error(`nixamp daemon: unknown action ${action}. Try start, stop, status or attach.`);
|
|
301
|
+
console.error(`nixamp daemon: unknown action ${action}. Try start, restart, stop, status or attach.`);
|
|
287
302
|
return 64;
|
|
288
303
|
}
|
|
289
304
|
|
package/src/partyline.ts
CHANGED
|
@@ -239,15 +239,6 @@ export class PartyLine {
|
|
|
239
239
|
this.reminders.set(code, set);
|
|
240
240
|
}
|
|
241
241
|
}
|
|
242
|
-
/**
|
|
243
|
-
* Legs listening to a stream, by its code.
|
|
244
|
-
*
|
|
245
|
-
* Separate from the rooms because a stream listener is not in a conference:
|
|
246
|
-
* they are a leg with an MP3 playing into it. Nothing else was counting
|
|
247
|
-
* them, so the directory had no way to say how many people were on the
|
|
248
|
-
* phone for a broadcast.
|
|
249
|
-
*/
|
|
250
|
-
private readonly streamLegs = new Map<string, Set<string>>();
|
|
251
242
|
private readonly key: ReturnType<typeof createPublicKey> | null;
|
|
252
243
|
private readonly fetch: typeof globalThis.fetch;
|
|
253
244
|
private readonly now: () => number;
|
|
@@ -414,6 +405,14 @@ export class PartyLine {
|
|
|
414
405
|
* room code still works: this line was a party line before it was a way into
|
|
415
406
|
* a broadcast, and a code that means nothing to the directory should still
|
|
416
407
|
* mean a room.
|
|
408
|
+
*
|
|
409
|
+
* Keying a stream's code puts you in a room with the other people watching
|
|
410
|
+
* it. It does not play the stream at you, which is what it used to do: this
|
|
411
|
+
* is the phone line beside a broadcast, the way a podcast has an 800 number
|
|
412
|
+
* -- the show is on your screen and the phone is where you talk about it.
|
|
413
|
+
* Playing the audio down the phone was both the worse half of the idea and
|
|
414
|
+
* the one that kept failing, because a share link answers a 302 and a cookie
|
|
415
|
+
* rather than an MP3.
|
|
417
416
|
*/
|
|
418
417
|
private async stream(leg: string, code: string): Promise<boolean> {
|
|
419
418
|
const streams = this.options.streams;
|
|
@@ -422,42 +421,13 @@ export class PartyLine {
|
|
|
422
421
|
const live = streams.liveByCode(code);
|
|
423
422
|
if (live !== undefined) {
|
|
424
423
|
const what = live.nowPlaying ? ` of ${live.nowPlaying}` : "";
|
|
425
|
-
|
|
426
|
-
// The share link is not playable. It answers 302 with a cookie and sends
|
|
427
|
-
// a browser to the player page; Telnyx fetches once with no cookie jar
|
|
428
|
-
// and gets a 401 in JSON. Playing it means a caller who is told "here it
|
|
429
|
-
// is" and then hears nothing at all, which is how this was found. Say
|
|
430
|
-
// what is true instead, and hang up rather than bill for silence.
|
|
431
|
-
if (!live.audio) {
|
|
432
|
-
await this.command(leg, "speak", {
|
|
433
|
-
payload:
|
|
434
|
-
`${live.name} is live right now${what}, but this stream cannot be played over the phone. ` +
|
|
435
|
-
"You can listen to it at nixamp dot com slash directory. Goodbye.",
|
|
436
|
-
voice: this.voice,
|
|
437
|
-
});
|
|
438
|
-
await this.command(leg, "hangup", {});
|
|
439
|
-
this.options.onEvent?.(` ${code} is live but announced no audio address; nothing to play.`);
|
|
440
|
-
return true;
|
|
441
|
-
}
|
|
442
|
-
|
|
443
424
|
await this.command(leg, "speak", {
|
|
444
|
-
payload:
|
|
425
|
+
payload:
|
|
426
|
+
`You're on the line for ${live.name}${what}. ` +
|
|
427
|
+
"Everyone here is watching it too. Say hello.",
|
|
445
428
|
voice: this.voice,
|
|
446
429
|
});
|
|
447
|
-
|
|
448
|
-
// call, so listening by phone costs no audio handling here at all.
|
|
449
|
-
const playing = await this.command(leg, "playback_start", {
|
|
450
|
-
audio_url: live.audio,
|
|
451
|
-
loop: "infinity",
|
|
452
|
-
});
|
|
453
|
-
// Counted only once the audio is actually going. A leg we failed to
|
|
454
|
-
// start is not somebody listening, and the directory would be saying so.
|
|
455
|
-
if (playing) {
|
|
456
|
-
const legs = this.streamLegs.get(code) ?? new Set<string>();
|
|
457
|
-
legs.add(leg);
|
|
458
|
-
this.streamLegs.set(code, legs);
|
|
459
|
-
this.options.onEvent?.(` a caller is listening to ${code} (${legs.size} on the phone).`);
|
|
460
|
-
}
|
|
430
|
+
await this.join(leg, code);
|
|
461
431
|
return true;
|
|
462
432
|
}
|
|
463
433
|
|
|
@@ -608,16 +578,19 @@ export class PartyLine {
|
|
|
608
578
|
this.options.onEvent?.(` a caller joined a room (${room.callers} on the line).`);
|
|
609
579
|
}
|
|
610
580
|
|
|
611
|
-
/**
|
|
581
|
+
/**
|
|
582
|
+
* How many people are on the phone for a stream.
|
|
583
|
+
*
|
|
584
|
+
* The room's own count, now that a stream's code is a room like any other.
|
|
585
|
+
* It used to count legs with an MP3 playing into them, which is a thing that
|
|
586
|
+
* no longer happens.
|
|
587
|
+
*/
|
|
612
588
|
listenersOn(code: string): number {
|
|
613
|
-
return this.
|
|
589
|
+
return this.rooms.get(code)?.callers ?? 0;
|
|
614
590
|
}
|
|
615
591
|
|
|
616
592
|
/** A leg that hung up or was dropped, wherever it was. */
|
|
617
593
|
private release(leg: string): void {
|
|
618
|
-
for (const [code, legs] of this.streamLegs) {
|
|
619
|
-
if (legs.delete(leg) && legs.size === 0) this.streamLegs.delete(code);
|
|
620
|
-
}
|
|
621
594
|
const code = this.legRoom.get(leg);
|
|
622
595
|
this.legRoom.delete(leg);
|
|
623
596
|
if (code === undefined) return;
|
package/src/protocol.ts
CHANGED
|
@@ -21,6 +21,14 @@ export interface RemoteTrack {
|
|
|
21
21
|
* a browser could show played its soundtrack over a blank panel.
|
|
22
22
|
*/
|
|
23
23
|
video?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* The source this track came in with, when it was not part of the library.
|
|
26
|
+
*
|
|
27
|
+
* Absent means it belongs to whatever this server was started on. Present
|
|
28
|
+
* means somebody added a folder or an album afterwards, and the name is what
|
|
29
|
+
* a client puts at the top of that block so the two are not one soup.
|
|
30
|
+
*/
|
|
31
|
+
group?: string;
|
|
24
32
|
}
|
|
25
33
|
|
|
26
34
|
/** Everything a remote needs to draw the player. */
|
package/src/server.ts
CHANGED
|
@@ -59,7 +59,7 @@ import {
|
|
|
59
59
|
type PaywallConfig,
|
|
60
60
|
paywallFromEnv,
|
|
61
61
|
} from "./paywall.ts";
|
|
62
|
-
import { isRemote, playsInBrowser } from "./sources.ts";
|
|
62
|
+
import { isRemote, playsInBrowser, sourceLabel } from "./sources.ts";
|
|
63
63
|
import { codecsOf, videoArgs } from "./audio.ts";
|
|
64
64
|
import {
|
|
65
65
|
allowedForListening,
|
|
@@ -389,6 +389,28 @@ export function safeJoin(rootDir: string, urlPath: string): string | null {
|
|
|
389
389
|
return full;
|
|
390
390
|
}
|
|
391
391
|
|
|
392
|
+
/**
|
|
393
|
+
* A track and the source it arrived with.
|
|
394
|
+
*
|
|
395
|
+
* The library a server was started on has no group: it is simply what this
|
|
396
|
+
* machine has. Anything added afterwards carries the name of the folder or
|
|
397
|
+
* album it came from, which is what lets a client draw the two apart instead
|
|
398
|
+
* of running them together.
|
|
399
|
+
*/
|
|
400
|
+
export type Loaded = Track & {
|
|
401
|
+
group?: string;
|
|
402
|
+
/**
|
|
403
|
+
* Whether this has a picture, when the name could not say.
|
|
404
|
+
*
|
|
405
|
+
* A file on disk is named `film.mkv` and that is answer enough. A live
|
|
406
|
+
* stream is `http://host/tipoffsport/KEY/301`, which says nothing at all --
|
|
407
|
+
* so it was treated as audio, transcoded with `-vn`, and arrived as a
|
|
408
|
+
* football match somebody could only listen to. Asked of ffprobe once, when
|
|
409
|
+
* the source is added, rather than guessed from a URL that has no opinion.
|
|
410
|
+
*/
|
|
411
|
+
picture?: boolean;
|
|
412
|
+
};
|
|
413
|
+
|
|
392
414
|
/** What the HTTP layer needs from a player. Tests hand it a fake. */
|
|
393
415
|
export interface Engine {
|
|
394
416
|
/** `withTracks` false leaves the library out, for a frame that is only motion. */
|
|
@@ -398,11 +420,29 @@ export interface Engine {
|
|
|
398
420
|
/** Absolute path of a track, or undefined when the index is not one. */
|
|
399
421
|
trackPath(index: number): string | undefined;
|
|
400
422
|
/**
|
|
401
|
-
* Play something else instead
|
|
402
|
-
*
|
|
403
|
-
*
|
|
423
|
+
* Play something else instead of everything here.
|
|
424
|
+
*
|
|
425
|
+
* The big hammer, and no longer what adding a folder does: this is "point
|
|
426
|
+
* this server somewhere else", which throws the library away on purpose.
|
|
404
427
|
*/
|
|
405
428
|
replace(tracks: Track[], root: string): void;
|
|
429
|
+
/**
|
|
430
|
+
* Play something as well as everything here.
|
|
431
|
+
*
|
|
432
|
+
* What somebody means by putting a folder in a box: the album shows up at
|
|
433
|
+
* the bottom of the playlist under its own name, and the music that was
|
|
434
|
+
* already there is still there. Answers how many tracks were new.
|
|
435
|
+
*/
|
|
436
|
+
add(tracks: Track[], from: string): number;
|
|
437
|
+
/**
|
|
438
|
+
* Take an added source back out again, by the name `add` gave it.
|
|
439
|
+
*
|
|
440
|
+
* Nothing that came with the library can be dropped this way; the library is
|
|
441
|
+
* what the server is, and there is a command line for changing that.
|
|
442
|
+
*/
|
|
443
|
+
drop(group: string): number;
|
|
444
|
+
/** Every added source, in the order they were added. */
|
|
445
|
+
groups(): string[];
|
|
406
446
|
/**
|
|
407
447
|
* The same tracks, now with their tags.
|
|
408
448
|
*
|
|
@@ -416,7 +456,7 @@ export interface Engine {
|
|
|
416
456
|
stop(): void;
|
|
417
457
|
}
|
|
418
458
|
|
|
419
|
-
export function toRemoteTracks(tracks:
|
|
459
|
+
export function toRemoteTracks(tracks: Loaded[]): RemoteTrack[] {
|
|
420
460
|
return tracks.map((t) => ({
|
|
421
461
|
title: t.title,
|
|
422
462
|
artist: t.artist,
|
|
@@ -425,7 +465,11 @@ export function toRemoteTracks(tracks: Track[]): RemoteTrack[] {
|
|
|
425
465
|
// Said out loud, because a remote cannot see the path and had been sending
|
|
426
466
|
// every track to the audio element -- a film's soundtrack over a blank
|
|
427
467
|
// panel, which is exactly what it looked like.
|
|
428
|
-
|
|
468
|
+
// The name when it says something, what ffprobe found when it does not.
|
|
469
|
+
...(t.picture ?? hasPicture(t.path) ? { video: true } : {}),
|
|
470
|
+
// Only for what was added; the library's own tracks say nothing, which is
|
|
471
|
+
// how a client knows they are the library.
|
|
472
|
+
...(t.group ? { group: t.group } : {}),
|
|
429
473
|
}));
|
|
430
474
|
}
|
|
431
475
|
|
|
@@ -437,6 +481,23 @@ export function hasPicture(path: string): boolean {
|
|
|
437
481
|
return dot > 0 && PICTURE.has(path.slice(dot).toLowerCase());
|
|
438
482
|
}
|
|
439
483
|
|
|
484
|
+
/**
|
|
485
|
+
* Whether the name of a source tells us anything about what is inside it.
|
|
486
|
+
*
|
|
487
|
+
* A remote address with no extension -- an IPTV channel, a stream key, a
|
|
488
|
+
* redirect -- is the case where it does not, and the only way to find out is
|
|
489
|
+
* to look.
|
|
490
|
+
*/
|
|
491
|
+
export function nameSaysNothing(path: string): boolean {
|
|
492
|
+
if (!isRemote(path)) return false;
|
|
493
|
+
try {
|
|
494
|
+
const last = new URL(path).pathname.split("/").pop() ?? "";
|
|
495
|
+
return !last.includes(".");
|
|
496
|
+
} catch {
|
|
497
|
+
return false;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
440
501
|
/**
|
|
441
502
|
* The headless player: the terminal app's engine without the terminal.
|
|
442
503
|
* One ffmpeg decodes, ffplay makes the sound, and every sample is measured on
|
|
@@ -463,7 +524,7 @@ export class PlayerEngine implements Engine {
|
|
|
463
524
|
};
|
|
464
525
|
|
|
465
526
|
constructor(
|
|
466
|
-
private tracks:
|
|
527
|
+
private tracks: Loaded[],
|
|
467
528
|
private root: string,
|
|
468
529
|
tools: Tools,
|
|
469
530
|
/** Frames a second pushed to remotes. */
|
|
@@ -637,13 +698,85 @@ export class PlayerEngine implements Engine {
|
|
|
637
698
|
this.push(true);
|
|
638
699
|
}
|
|
639
700
|
|
|
701
|
+
/**
|
|
702
|
+
* Load something as well as what is already here.
|
|
703
|
+
*
|
|
704
|
+
* Adding a folder used to be `replace`, so pointing a server at an album on
|
|
705
|
+
* the web threw away the music on its disk: the playlist you were looking at
|
|
706
|
+
* turned into somebody else's twenty-eight tracks, and clicking your own
|
|
707
|
+
* files played theirs. Nothing about playback changes here -- whatever was
|
|
708
|
+
* playing keeps playing, at the same index, because the new tracks go on the
|
|
709
|
+
* end.
|
|
710
|
+
*
|
|
711
|
+
* Paths already loaded are skipped, so adding the same album twice is not
|
|
712
|
+
* two copies of it.
|
|
713
|
+
*/
|
|
714
|
+
add(tracks: Track[], from: string): number {
|
|
715
|
+
const group = sourceLabel(from);
|
|
716
|
+
const known = new Set(this.tracks.map((track) => track.path));
|
|
717
|
+
const fresh = tracks.filter((track) => !known.has(track.path)).map((track) => ({ ...track, group }));
|
|
718
|
+
if (fresh.length === 0) return 0;
|
|
719
|
+
this.tracks = [...this.tracks, ...fresh];
|
|
720
|
+
// The list itself changed, so it has to ride this frame; a count nobody
|
|
721
|
+
// can index into is worse than no news at all.
|
|
722
|
+
this.push(true);
|
|
723
|
+
return fresh.length;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* Take an added source back out.
|
|
728
|
+
*
|
|
729
|
+
* The track that is playing is followed rather than an index: removing an
|
|
730
|
+
* album from above the current track would otherwise slide the playlist out
|
|
731
|
+
* from under a listener mid-song. If the playing track is itself in what is
|
|
732
|
+
* being removed, playback stops -- there is nothing to keep playing.
|
|
733
|
+
*/
|
|
734
|
+
drop(group: string): number {
|
|
735
|
+
if (group === "") return 0;
|
|
736
|
+
const playingPath = this.tracks[this.state.index]?.path;
|
|
737
|
+
const kept = this.tracks.filter((track) => track.group !== group);
|
|
738
|
+
const removed = this.tracks.length - kept.length;
|
|
739
|
+
if (removed === 0) return 0;
|
|
740
|
+
this.tracks = kept;
|
|
741
|
+
const stillThere = kept.findIndex((track) => track.path === playingPath);
|
|
742
|
+
if (stillThere === -1) {
|
|
743
|
+
this.halt();
|
|
744
|
+
this.state.index = this.clamp(this.state.index);
|
|
745
|
+
} else {
|
|
746
|
+
this.state.index = stillThere;
|
|
747
|
+
}
|
|
748
|
+
this.push(true);
|
|
749
|
+
return removed;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
groups(): string[] {
|
|
753
|
+
const seen: string[] = [];
|
|
754
|
+
for (const track of this.tracks) {
|
|
755
|
+
if (track.group && !seen.includes(track.group)) seen.push(track.group);
|
|
756
|
+
}
|
|
757
|
+
return seen;
|
|
758
|
+
}
|
|
759
|
+
|
|
640
760
|
retag(tracks: Track[], root: string): void {
|
|
641
|
-
//
|
|
642
|
-
//
|
|
643
|
-
//
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
761
|
+
// Matched by path rather than by position, because the list is no longer
|
|
762
|
+
// required to be the one that was sent for tagging: somebody can add an
|
|
763
|
+
// album while a library's tags are still being read, and an exact-shape
|
|
764
|
+
// check would throw away every tag for it. Tags that describe tracks which
|
|
765
|
+
// are no longer here simply match nothing, which is the same protection
|
|
766
|
+
// the shape check was giving.
|
|
767
|
+
void root;
|
|
768
|
+
const byPath = new Map(tracks.map((track) => [track.path, track]));
|
|
769
|
+
let changed = false;
|
|
770
|
+
const merged = this.tracks.map((track) => {
|
|
771
|
+
const tagged = byPath.get(track.path);
|
|
772
|
+
if (!tagged || tagged === track) return track;
|
|
773
|
+
changed = true;
|
|
774
|
+
// The group is ours, not the tagger's: it knows what a track is called,
|
|
775
|
+
// not which pile it is in.
|
|
776
|
+
return { ...tagged, ...(track.group ? { group: track.group } : {}) };
|
|
777
|
+
});
|
|
778
|
+
if (!changed) return;
|
|
779
|
+
this.tracks = merged;
|
|
647
780
|
// No stop, no index reset: the only thing that changes is what the titles
|
|
648
781
|
// say, and every remote finds out because a snapshot goes out -- carrying
|
|
649
782
|
// the list, since the titles are the whole point of this one.
|
|
@@ -667,6 +800,15 @@ export class EmptyEngine implements Engine {
|
|
|
667
800
|
return undefined;
|
|
668
801
|
}
|
|
669
802
|
replace(): void {}
|
|
803
|
+
add(): number {
|
|
804
|
+
return 0;
|
|
805
|
+
}
|
|
806
|
+
drop(): number {
|
|
807
|
+
return 0;
|
|
808
|
+
}
|
|
809
|
+
groups(): string[] {
|
|
810
|
+
return [];
|
|
811
|
+
}
|
|
670
812
|
retag(): void {}
|
|
671
813
|
stop(): void {}
|
|
672
814
|
}
|
|
@@ -681,6 +823,15 @@ const CORS: Record<string, string> = {
|
|
|
681
823
|
"access-control-max-age": "86400",
|
|
682
824
|
};
|
|
683
825
|
|
|
826
|
+
/**
|
|
827
|
+
* How many nameless addresses are worth an ffprobe when a source is added.
|
|
828
|
+
*
|
|
829
|
+
* One is the ordinary case -- somebody pasting a channel -- and a directory
|
|
830
|
+
* listing of thousands must not turn into thousands of probes for an answer
|
|
831
|
+
* that only changes which element a browser uses.
|
|
832
|
+
*/
|
|
833
|
+
const PROBE_BY_HAND = 8;
|
|
834
|
+
|
|
684
835
|
/** /api/v1/<provider>/oauth/start and .../callback, the house callback shape. */
|
|
685
836
|
const OAUTH_ROUTE = /^\/api\/v1\/([a-z0-9-]+)\/oauth\/(start|callback)$/;
|
|
686
837
|
|
|
@@ -1098,7 +1249,17 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
1098
1249
|
path !== "/api/directory" &&
|
|
1099
1250
|
!isSignInPath(path)
|
|
1100
1251
|
) {
|
|
1101
|
-
|
|
1252
|
+
let scope = scopeOf(keyFrom(request, url), key, listenKey);
|
|
1253
|
+
// A key is how somebody who was invited proves it. It is not the only way
|
|
1254
|
+
// to be allowed in: the person who owns this server is allowed in whether
|
|
1255
|
+
// or not they still have the link, and their nixamp.com session says who
|
|
1256
|
+
// they are. Without this, signing in as yourself and opening your own
|
|
1257
|
+
// server was refused, and the address of a machine you administer was
|
|
1258
|
+
// useless without a link you had to go and find.
|
|
1259
|
+
if (scope === null && options.owner) {
|
|
1260
|
+
const check = await options.owner.check(false, tokenFrom(request.headers));
|
|
1261
|
+
if (check.allowed) scope = "control";
|
|
1262
|
+
}
|
|
1102
1263
|
if (scope === null) {
|
|
1103
1264
|
// Counted, not because a 128-bit key falls to guessing, but because
|
|
1104
1265
|
// somebody hammering one should stop costing this server anything.
|
|
@@ -1109,7 +1270,9 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
1109
1270
|
response.end(JSON.stringify({ error: "too many attempts; wait a moment" }));
|
|
1110
1271
|
return;
|
|
1111
1272
|
}
|
|
1112
|
-
json(response, 401, {
|
|
1273
|
+
json(response, 401, {
|
|
1274
|
+
error: "this nixamp needs the key from its share link, or sign in as its owner",
|
|
1275
|
+
});
|
|
1113
1276
|
return;
|
|
1114
1277
|
}
|
|
1115
1278
|
if (scope === "listen" && !allowedForListening(path)) {
|
|
@@ -2030,16 +2193,50 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2030
2193
|
return;
|
|
2031
2194
|
}
|
|
2032
2195
|
|
|
2033
|
-
//
|
|
2034
|
-
//
|
|
2196
|
+
// Take an added source back out of the playlist. The library it was added
|
|
2197
|
+
// to is untouched -- there is no group name that names it.
|
|
2198
|
+
if (path === "/api/source/remove") {
|
|
2199
|
+
if (request.method !== "POST") {
|
|
2200
|
+
json(response, 405, { error: "POST only" });
|
|
2201
|
+
return;
|
|
2202
|
+
}
|
|
2203
|
+
let group = "";
|
|
2204
|
+
try {
|
|
2205
|
+
group = String((JSON.parse(await readBody(request)) as { group?: unknown }).group ?? "");
|
|
2206
|
+
} catch {
|
|
2207
|
+
json(response, 400, { error: "bad JSON" });
|
|
2208
|
+
return;
|
|
2209
|
+
}
|
|
2210
|
+
if (!group) {
|
|
2211
|
+
json(response, 400, { error: "no group given" });
|
|
2212
|
+
return;
|
|
2213
|
+
}
|
|
2214
|
+
const removed = engine.drop(group);
|
|
2215
|
+
if (removed === 0) {
|
|
2216
|
+
json(response, 404, { error: `nothing here came from ${group}` });
|
|
2217
|
+
return;
|
|
2218
|
+
}
|
|
2219
|
+
json(response, 200, { ...engine.snapshot(), removed, groups: engine.groups() });
|
|
2220
|
+
return;
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
// Hand the running server another source. The listeners stay connected;
|
|
2224
|
+
// by default they get more to listen to, and only an explicit `replace`
|
|
2225
|
+
// swaps what this server is for something else.
|
|
2035
2226
|
if (path === "/api/source") {
|
|
2036
2227
|
if (request.method !== "POST") {
|
|
2037
2228
|
json(response, 405, { error: "POST only" });
|
|
2038
2229
|
return;
|
|
2039
2230
|
}
|
|
2040
2231
|
let source = "";
|
|
2232
|
+
let replacing = false;
|
|
2041
2233
|
try {
|
|
2042
|
-
|
|
2234
|
+
const body = JSON.parse(await readBody(request)) as { source?: unknown; replace?: unknown };
|
|
2235
|
+
source = String(body.source ?? "");
|
|
2236
|
+
// Adding is what somebody means by putting a folder in a box, so it is
|
|
2237
|
+
// the default. Replacing is the much larger claim that this server now
|
|
2238
|
+
// serves that instead, so it is the one you have to ask for.
|
|
2239
|
+
replacing = body.replace === true;
|
|
2043
2240
|
} catch {
|
|
2044
2241
|
json(response, 400, { error: "bad JSON" });
|
|
2045
2242
|
return;
|
|
@@ -2049,13 +2246,42 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2049
2246
|
return;
|
|
2050
2247
|
}
|
|
2051
2248
|
try {
|
|
2052
|
-
|
|
2249
|
+
let tracks: Loaded[] = await options.load(source);
|
|
2053
2250
|
if (tracks.length === 0) {
|
|
2054
2251
|
json(response, 422, { error: `nothing to play at ${source}` });
|
|
2055
2252
|
return;
|
|
2056
2253
|
}
|
|
2057
|
-
|
|
2058
|
-
//
|
|
2254
|
+
// A handful of addresses whose names say nothing get asked what they
|
|
2255
|
+
// are, so a live channel arrives as a picture rather than as its own
|
|
2256
|
+
// soundtrack. Capped, because a playlist of five thousand of them is
|
|
2257
|
+
// five thousand ffprobes and the answer only matters for the few a
|
|
2258
|
+
// person adds by hand.
|
|
2259
|
+
const looked = await Promise.all(
|
|
2260
|
+
tracks.map(async (track, at) => {
|
|
2261
|
+
if (at >= PROBE_BY_HAND || !nameSaysNothing(track.path)) return track;
|
|
2262
|
+
const codecs = await codecsOf(
|
|
2263
|
+
{ ffmpeg: [], ffprobe: options.ffprobe ?? ["ffprobe"], play: null },
|
|
2264
|
+
track.path,
|
|
2265
|
+
);
|
|
2266
|
+
return codecs.video === "" ? track : { ...track, picture: true };
|
|
2267
|
+
}),
|
|
2268
|
+
);
|
|
2269
|
+
tracks = looked;
|
|
2270
|
+
|
|
2271
|
+
let added = tracks.length;
|
|
2272
|
+
if (replacing) {
|
|
2273
|
+
engine.replace(tracks, source);
|
|
2274
|
+
} else {
|
|
2275
|
+
added = engine.add(tracks, source);
|
|
2276
|
+
if (added === 0) {
|
|
2277
|
+
// Everything there was already here. Not an error -- the playlist
|
|
2278
|
+
// is exactly what the caller asked for -- but worth saying, so a
|
|
2279
|
+
// client can tell that apart from having added an album.
|
|
2280
|
+
json(response, 200, { ...engine.snapshot(), added: 0, groups: engine.groups() });
|
|
2281
|
+
return;
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
// Names now, tags later, here as much as at startup: loading a
|
|
2059
2285
|
// directory of five thousand files used to read every tag before it
|
|
2060
2286
|
// answered, with the event loop held the whole time.
|
|
2061
2287
|
if (options.tag) {
|
|
@@ -2064,7 +2290,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2064
2290
|
.then((tagged) => engine.retag(tagged, source))
|
|
2065
2291
|
.catch(() => {});
|
|
2066
2292
|
}
|
|
2067
|
-
json(response, 200, engine.snapshot());
|
|
2293
|
+
json(response, 200, { ...engine.snapshot(), added, replaced: replacing, groups: engine.groups() });
|
|
2068
2294
|
} catch (error) {
|
|
2069
2295
|
json(response, 422, { error: (error as Error).message.replace(/^nixamp: /, "") });
|
|
2070
2296
|
}
|
|
@@ -2095,15 +2321,21 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2095
2321
|
|
|
2096
2322
|
if (playsInBrowser(file) && capKbps === 0) {
|
|
2097
2323
|
sendFile(request, response, file);
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2324
|
+
return;
|
|
2325
|
+
}
|
|
2326
|
+
// A film, or something whose name refuses to say. A live channel at
|
|
2327
|
+
// .../301 used to fall through to the audio branch and arrive as MP3
|
|
2328
|
+
// with `-vn` -- a match you could only listen to.
|
|
2329
|
+
if (hasPicture(file) || nameSaysNothing(file)) {
|
|
2330
|
+
// What ffprobe finds inside decides how little work it takes to keep
|
|
2331
|
+
// the picture, and whether there is a picture to keep at all.
|
|
2102
2332
|
const codecs = await codecsOf({ ffmpeg: [], ffprobe: options.ffprobe ?? ["ffprobe"], play: null }, file);
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2333
|
+
if (codecs.video !== "") {
|
|
2334
|
+
pipeFfmpeg(request, response, file, options.ffmpeg ?? ["ffmpeg"], videoArgs(codecs, capKbps), "video/mp4");
|
|
2335
|
+
return;
|
|
2336
|
+
}
|
|
2106
2337
|
}
|
|
2338
|
+
transcode(request, response, file, options.ffmpeg ?? ["ffmpeg"]);
|
|
2107
2339
|
return;
|
|
2108
2340
|
}
|
|
2109
2341
|
|
package/src/share.ts
CHANGED
|
@@ -199,7 +199,11 @@ export function scopeOf(offered: string | null, control: string | null, listen:
|
|
|
199
199
|
|
|
200
200
|
/** Paths a listen key may have. Everything else needs the control key. */
|
|
201
201
|
export function allowedForListening(path: string): boolean {
|
|
202
|
-
if (path === "/api/command"
|
|
202
|
+
if (path === "/api/command") return false;
|
|
203
|
+
// Prefix, not equality: everything under this changes what the server plays,
|
|
204
|
+
// and an exact check let a listen key reach /api/source/remove and delete an
|
|
205
|
+
// album out of somebody else's playlist.
|
|
206
|
+
if (path === "/api/source" || path.startsWith("/api/source/")) return false;
|
|
203
207
|
return true;
|
|
204
208
|
}
|
|
205
209
|
|
package/src/sources.ts
CHANGED
|
@@ -106,6 +106,27 @@ export function parsePls(text: string, base: string): Entry[] {
|
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
/** The last useful part of a path or URL, for when nothing named the track. */
|
|
109
|
+
/**
|
|
110
|
+
* What to call a whole source, as a heading over the tracks it brought.
|
|
111
|
+
*
|
|
112
|
+
* `nameOf` answers for a file; this answers for the thing a person added --
|
|
113
|
+
* usually the last segment either way, but a URL that is only a host has no
|
|
114
|
+
* segment to take, and "the album at that address" reads better as the host
|
|
115
|
+
* than as the whole URL repeated over every row.
|
|
116
|
+
*/
|
|
117
|
+
export function sourceLabel(source: string): string {
|
|
118
|
+
const trimmed = source.replace(/\/+$/, "");
|
|
119
|
+
if (trimmed === "") return source;
|
|
120
|
+
const named = nameOf(trimmed);
|
|
121
|
+
if (named !== trimmed && named !== "") return named;
|
|
122
|
+
if (!isRemote(trimmed)) return trimmed;
|
|
123
|
+
try {
|
|
124
|
+
return new URL(trimmed).host;
|
|
125
|
+
} catch {
|
|
126
|
+
return trimmed;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
109
130
|
export function nameOf(source: string): string {
|
|
110
131
|
const remote = isRemote(source);
|
|
111
132
|
const path = remote ? new URL(source).pathname : source;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{t as e}from"./index-
|
|
1
|
+
import{t as e}from"./index-Bs7oVbTk.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};
|