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/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 the
6
- * three ways in, written as a sentence: a link that opens a player, a phone
7
- * number, and the code to key once it answers.
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, when it has been published. */
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
- lines.push("", `Or call ${invite.phone} and key ${invite.code} to listen.`);
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: `Welcome to ${live.name}'s live stream${what}. It started at ${pacificTime(live.startedAt)}. Here it is.`,
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
- // A nixamp stream is an MP3 over HTTP and Telnyx will play a URL into a
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
- /** How many people are listening to a stream by phone. */
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.streamLegs.get(code)?.size ?? 0;
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. Re-streaming is the whole reason the admin
402
- * view exists: point a running server at a URL without restarting it and
403
- * dropping every listener.
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: Track[]): RemoteTrack[] {
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
- ...(hasPicture(t.path) ? { video: true } : {}),
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: Track[],
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
- // Dropped rather than applied if the library moved underneath: somebody
642
- // re-streamed while the tagging was still running, and these tags describe
643
- // something nobody is playing any more.
644
- if (root !== this.root || tracks.length !== this.tracks.length) return;
645
- if (tracks.some((track, at) => track.path !== this.tracks[at]?.path)) return;
646
- this.tracks = tracks;
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
- const scope = scopeOf(keyFrom(request, url), key, listenKey);
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, { error: "this nixamp needs the key from its share link" });
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
- // Re-stream: hand the running server a different source. The listeners
2034
- // stay connected; what they are listening to changes under them.
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
- source = String((JSON.parse(await readBody(request)) as { source?: unknown }).source ?? "");
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
- const tracks = await options.load(source);
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
- engine.replace(tracks, source);
2058
- // Names now, tags later, here as much as at startup: re-streaming a
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
- } else if (hasPicture(file)) {
2099
- // A film. It used to arrive as MP3 with `-vn`, which is to say as a
2100
- // soundtrack over a blank panel; what ffprobe finds inside decides how
2101
- // little work it takes to keep the picture.
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
- pipeFfmpeg(request, response, file, options.ffmpeg ?? ["ffmpeg"], videoArgs(codecs, capKbps), "video/mp4");
2104
- } else {
2105
- transcode(request, response, file, options.ffmpeg ?? ["ffmpeg"]);
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" || path === "/api/source") return false;
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-U2odRmpd.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};
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};