nixamp 0.45.5 → 0.46.1

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.
Files changed (46) hide show
  1. package/dist/break-silence.d.ts +27 -0
  2. package/dist/break-silence.js +57 -0
  3. package/dist/channels.d.ts +21 -0
  4. package/dist/channels.js +82 -0
  5. package/dist/listen-pass.d.ts +29 -0
  6. package/dist/listen-pass.js +93 -0
  7. package/dist/server.js +31 -0
  8. package/dist/station-page.js +2 -1
  9. package/dist/station-routes.d.ts +4 -0
  10. package/dist/station-routes.js +10 -2
  11. package/dist/station-usage.d.ts +1 -1
  12. package/dist/station-usage.js +1 -1
  13. package/dist/stations.d.ts +2 -0
  14. package/dist/stations.js +5 -1
  15. package/package.json +3 -3
  16. package/src/break-silence.ts +65 -0
  17. package/src/channels.ts +78 -0
  18. package/src/listen-pass.ts +104 -0
  19. package/src/server.ts +33 -0
  20. package/src/station-page.ts +2 -1
  21. package/src/station-routes.ts +12 -2
  22. package/src/station-usage.ts +1 -1
  23. package/src/stations.ts +5 -1
  24. package/web/dist/assets/{ar-CebVvHAw.js → ar-Db_tDh8O.js} +1 -1
  25. package/web/dist/assets/{cs-CWbKpezF.js → cs-BlaJwVKZ.js} +1 -1
  26. package/web/dist/assets/{da-DDeqVz72.js → da-rqJgHbtd.js} +1 -1
  27. package/web/dist/assets/{de-BIcO1iEz.js → de-D3jfQ5X1.js} +1 -1
  28. package/web/dist/assets/{es-rha6a6uY.js → es-DL3ceO0F.js} +1 -1
  29. package/web/dist/assets/{fi-DQVrR7HD.js → fi-C9uYTqZj.js} +1 -1
  30. package/web/dist/assets/{fr-7GBVEDb9.js → fr-BIMC-Rho.js} +1 -1
  31. package/web/dist/assets/{hi-DgWvU_Lm.js → hi-B6Wzcc1h.js} +1 -1
  32. package/web/dist/assets/{hu-ev2AI3vJ.js → hu-B_1agTpY.js} +1 -1
  33. package/web/dist/assets/{id-CEpAesOA.js → id-Cqvy9k6T.js} +1 -1
  34. package/web/dist/assets/{it-XGRhkpyV.js → it-D7dik-D0.js} +1 -1
  35. package/web/dist/assets/main-BGqOUW4j.js +22 -0
  36. package/web/dist/assets/{nl-C3AxtpK7.js → nl-DMbfbT8D.js} +1 -1
  37. package/web/dist/assets/{ru-DK2Ja4_w.js → ru-BZj1t0h7.js} +1 -1
  38. package/web/dist/assets/{station-Djxdr3MD.js → station-BhCxDaWb.js} +1 -1
  39. package/web/dist/assets/{sv-BYUU-a1y.js → sv-B8a1GD58.js} +1 -1
  40. package/web/dist/assets/{uk-TxpZ1viH.js → uk-BpEXCXvC.js} +1 -1
  41. package/web/dist/assets/{vi-Di4kvWLk.js → vi-B2ch2kpO.js} +1 -1
  42. package/web/dist/assets/{zh-BjOk7rqG.js → zh-B6GcwVnN.js} +1 -1
  43. package/web/dist/index.html +1 -1
  44. package/web/dist/station.html +1 -1
  45. package/web/dist/sw.js +20 -20
  46. package/web/dist/assets/main-B6IHgkWQ.js +0 -22
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Silence in place of a break, for one listener of a station: the last resort.
3
+ *
4
+ * A listener with a listen pass (listen-pass.ts) skips breaks out of a head
5
+ * start of music (Channel.listenWithoutBreaks). Only when that is spent is a
6
+ * break replaced, by MP3 silence lasting as long as the bytes it stands in
7
+ * for, rather than a gap that stalls the player. Audio only; a
8
+ * fragmented MP4 cannot have fragments swapped, so a TV station's breaks are
9
+ * everybody's.
10
+ *
11
+ * The silent frame is MPEG-1 Layer III at 48 kHz (the rate every station is
12
+ * resampled to) with empty side information: four header bytes and zeros,
13
+ * which every decoder plays as nothing.
14
+ */
15
+ /** One silent frame at this bitrate (128 kbps when the bitrate is not a Layer III one). */
16
+ export declare function silentFrame(kbps: number): Buffer;
17
+ export interface Writer {
18
+ write: (chunk: Buffer) => boolean;
19
+ end: () => void;
20
+ pending?: () => number;
21
+ }
22
+ /**
23
+ * A writer that sends silence instead of whatever is written while `inBreak()`
24
+ * says yes. `kbps` is the stream's bitrate, which is how a chunk's bytes become
25
+ * seconds.
26
+ */
27
+ export declare function quietDuringBreaks(out: Writer, kbps: number, inBreak: () => boolean): Writer;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Silence in place of a break, for one listener of a station: the last resort.
3
+ *
4
+ * A listener with a listen pass (listen-pass.ts) skips breaks out of a head
5
+ * start of music (Channel.listenWithoutBreaks). Only when that is spent is a
6
+ * break replaced, by MP3 silence lasting as long as the bytes it stands in
7
+ * for, rather than a gap that stalls the player. Audio only; a
8
+ * fragmented MP4 cannot have fragments swapped, so a TV station's breaks are
9
+ * everybody's.
10
+ *
11
+ * The silent frame is MPEG-1 Layer III at 48 kHz (the rate every station is
12
+ * resampled to) with empty side information: four header bytes and zeros,
13
+ * which every decoder plays as nothing.
14
+ */
15
+ const SAMPLE_RATE = 48_000;
16
+ const FRAME_SECONDS = 1152 / SAMPLE_RATE;
17
+ /** MPEG-1 Layer III bitrate index by kbps. */
18
+ const BITRATE_INDEX = {
19
+ 32: 1, 40: 2, 48: 3, 56: 4, 64: 5, 80: 6, 96: 7, 112: 8, 128: 9, 160: 10, 192: 11, 224: 12, 256: 13, 320: 14,
20
+ };
21
+ /** One silent frame at this bitrate (128 kbps when the bitrate is not a Layer III one). */
22
+ export function silentFrame(kbps) {
23
+ const rate = BITRATE_INDEX[kbps] ? kbps : 128;
24
+ const frame = Buffer.alloc((144 * rate * 1000) / SAMPLE_RATE);
25
+ // Sync, MPEG-1, Layer III, no CRC; bitrate, 48 kHz (01), no padding; stereo.
26
+ frame[0] = 0xff;
27
+ frame[1] = 0xfb;
28
+ frame[2] = (BITRATE_INDEX[rate] << 4) | (0b01 << 2);
29
+ frame[3] = 0x00;
30
+ return frame;
31
+ }
32
+ /**
33
+ * A writer that sends silence instead of whatever is written while `inBreak()`
34
+ * says yes. `kbps` is the stream's bitrate, which is how a chunk's bytes become
35
+ * seconds.
36
+ */
37
+ export function quietDuringBreaks(out, kbps, inBreak) {
38
+ const frame = silentFrame(kbps);
39
+ const bytesPerSecond = (kbps * 1000) / 8;
40
+ let owed = 0;
41
+ return {
42
+ write: (chunk) => {
43
+ if (!inBreak()) {
44
+ owed = 0;
45
+ return out.write(chunk);
46
+ }
47
+ owed += chunk.length / bytesPerSecond;
48
+ const frames = Math.floor(owed / FRAME_SECONDS);
49
+ if (frames === 0)
50
+ return true;
51
+ owed -= frames * FRAME_SECONDS;
52
+ return out.write(Buffer.concat(Array.from({ length: frames }, () => frame)));
53
+ },
54
+ end: () => out.end(),
55
+ ...(out.pending ? { pending: out.pending } : {}),
56
+ };
57
+ }
@@ -168,6 +168,12 @@ export declare const BACKLOG_AUDIO: number;
168
168
  * until the channel ended, however long that was.
169
169
  */
170
170
  export declare const LISTENER_QUEUE: number;
171
+ /**
172
+ * How much break-free radio a listener who skips breaks is handed on arrival:
173
+ * two minutes at 192k. Their player holds it, and each break they skip spends
174
+ * the length of the spot from it, so they never hear a gap.
175
+ */
176
+ export declare const MUSIC_BACKLOG: number;
171
177
  /** Media-time window for complete MP4 fragments. Byte limits are a separate
172
178
  * memory ceiling; the measured byte rate is only a fallback for inputs with
173
179
  * no usable fragment timestamps. It must never make low-bitrate video old. */
@@ -270,6 +276,11 @@ export declare class Channel {
270
276
  * can begin at; for MP3 any point will do, a frame announces itself.
271
277
  */
272
278
  private recent;
279
+ /** Whether what the encoder is writing now is a break (a radio list's spot). */
280
+ private breakOn;
281
+ /** The last couple of minutes of a radio list without its breaks, for listenWithoutBreaks. */
282
+ private music;
283
+ private musicBytes;
273
284
  private fragmentClock;
274
285
  private fragmentTimes;
275
286
  private recentBytes;
@@ -420,6 +431,14 @@ export declare class Channel {
420
431
  */
421
432
  private feedThrough;
422
433
  listen(listener: Listener): () => void;
434
+ /**
435
+ * A radio list's listener who hears no breaks: handed the last couple of
436
+ * minutes of music at once, which their player holds, then everything but
437
+ * the breaks. Each skipped break spends its length of that head start, so
438
+ * there is no gap; only once it is all spent does a break become silence of
439
+ * the same length (`silence`), never the spot. Joining again refills it.
440
+ */
441
+ listenWithoutBreaks(listener: Listener, kbps: number, silence: (out: Listener) => Listener): () => void;
423
442
  /** Stay up with nobody watching: no longer on demand. */
424
443
  keep(): void;
425
444
  /** Nobody is watching an on-demand channel: give it a minute, then stop. */
@@ -485,6 +504,8 @@ export declare class Channels {
485
504
  contentType(id: string): string;
486
505
  /** Attach a listener, or null when nothing is playing on that channel. */
487
506
  listen(id: string, listener: Listener): (() => void) | null;
507
+ /** Attach a listener who skips a radio list's breaks (Channel.listenWithoutBreaks). */
508
+ listenWithoutBreaks(id: string, listener: Listener, kbps: number, silence: (out: Listener) => Listener): (() => void) | null;
488
509
  /** Feed a channel that already exists, for a publisher sending chunks. */
489
510
  writeTo(id: string, chunk: Buffer): boolean;
490
511
  /**
package/dist/channels.js CHANGED
@@ -66,6 +66,12 @@ export const BACKLOG_AUDIO = 64 * 1024;
66
66
  * until the channel ended, however long that was.
67
67
  */
68
68
  export const LISTENER_QUEUE = 16 * 1024 * 1024;
69
+ /**
70
+ * How much break-free radio a listener who skips breaks is handed on arrival:
71
+ * two minutes at 192k. Their player holds it, and each break they skip spends
72
+ * the length of the spot from it, so they never hear a gap.
73
+ */
74
+ export const MUSIC_BACKLOG = 3 * 1024 * 1024;
69
75
  /** Media-time window for complete MP4 fragments. Byte limits are a separate
70
76
  * memory ceiling; the measured byte rate is only a fallback for inputs with
71
77
  * no usable fragment timestamps. It must never make low-bitrate video old. */
@@ -190,6 +196,11 @@ export class Channel {
190
196
  * can begin at; for MP3 any point will do, a frame announces itself.
191
197
  */
192
198
  recent = [];
199
+ /** Whether what the encoder is writing now is a break (a radio list's spot). */
200
+ breakOn = false;
201
+ /** The last couple of minutes of a radio list without its breaks, for listenWithoutBreaks. */
202
+ music = [];
203
+ musicBytes = 0;
193
204
  fragmentClock = new FragmentClock();
194
205
  fragmentTimes = new WeakMap();
195
206
  recentBytes = 0;
@@ -412,6 +423,13 @@ export class Channel {
412
423
  }
413
424
  });
414
425
  child.stdio[3]?.on("error", () => undefined);
426
+ // A radio list's output is constant-bitrate MP3, so how many bytes it
427
+ // has written is exactly how far it has played: whether this chunk is
428
+ // a break, to the frame, rather than to progress's once a second.
429
+ const kbps = playlistInput && this.info.kind !== "video"
430
+ ? Number(/^(\d+)k$/.exec((playlistEncode ?? [])[playlistEncode.indexOf("-b:a") + 1] ?? "")?.[1] ?? 0)
431
+ : 0;
432
+ let written = 0;
415
433
  child.stdout?.on("data", (chunk) => {
416
434
  // An ffmpeg that was replaced can still have a chunk in the pipe.
417
435
  if (this.child !== child)
@@ -421,6 +439,12 @@ export class Channel {
421
439
  sent = true;
422
440
  this.info.bytes += chunk.byteLength;
423
441
  this.rearm(child);
442
+ if (kbps > 0 && playlistInput) {
443
+ this.breakOn = playlistInput.place((written * 8) / (kbps * 1000)).at === -1;
444
+ written += chunk.byteLength;
445
+ }
446
+ else
447
+ this.breakOn = this.info.playlistAt === -1;
424
448
  this.emit(chunk);
425
449
  });
426
450
  child.stdout?.on("error", () => undefined);
@@ -669,6 +693,13 @@ export class Channel {
669
693
  this.measure(chunk.byteLength);
670
694
  if (!this.fragments) {
671
695
  this.remember(chunk, BACKLOG_AUDIO, false);
696
+ if (this.info.playlist && !this.breakOn) {
697
+ this.music.push(chunk);
698
+ this.musicBytes += chunk.byteLength;
699
+ while (this.music.length > 1 && this.musicBytes - this.music[0].byteLength > MUSIC_BACKLOG) {
700
+ this.musicBytes -= this.music.shift().byteLength;
701
+ }
702
+ }
672
703
  this.send(chunk);
673
704
  return;
674
705
  }
@@ -934,6 +965,52 @@ export class Channel {
934
965
  this.idleOut();
935
966
  };
936
967
  }
968
+ /**
969
+ * A radio list's listener who hears no breaks: handed the last couple of
970
+ * minutes of music at once, which their player holds, then everything but
971
+ * the breaks. Each skipped break spends its length of that head start, so
972
+ * there is no gap; only once it is all spent does a break become silence of
973
+ * the same length (`silence`), never the spot. Joining again refills it.
974
+ */
975
+ listenWithoutBreaks(listener, kbps, silence) {
976
+ const bytesPerSecond = (kbps * 1000) / 8;
977
+ let ahead = 0;
978
+ for (const piece of this.music) {
979
+ try {
980
+ listener.write(piece);
981
+ }
982
+ catch {
983
+ break;
984
+ }
985
+ ahead += piece.byteLength / bytesPerSecond;
986
+ }
987
+ const quiet = silence(listener);
988
+ const skipping = {
989
+ write: (chunk) => {
990
+ if (!this.breakOn)
991
+ return listener.write(chunk);
992
+ const seconds = chunk.byteLength / bytesPerSecond;
993
+ if (ahead >= seconds) {
994
+ ahead -= seconds;
995
+ return true;
996
+ }
997
+ return quiet.write(chunk);
998
+ },
999
+ end: () => listener.end(),
1000
+ ...(listener.pending ? { pending: () => listener.pending() } : {}),
1001
+ };
1002
+ this.listeners.add(skipping);
1003
+ this.info.listeners = this.listeners.size;
1004
+ if (this.idle)
1005
+ clearTimeout(this.idle);
1006
+ this.idle = null;
1007
+ return () => {
1008
+ this.listeners.delete(skipping);
1009
+ this.info.listeners = this.listeners.size;
1010
+ if (this.ephemeral && this.listeners.size === 0)
1011
+ this.idleOut();
1012
+ };
1013
+ }
937
1014
  /** Stay up with nobody watching: no longer on demand. */
938
1015
  keep() {
939
1016
  this.ephemeral = false;
@@ -1140,6 +1217,11 @@ export class Channels {
1140
1217
  const channel = this.open.get(id);
1141
1218
  return channel ? channel.listen(listener) : null;
1142
1219
  }
1220
+ /** Attach a listener who skips a radio list's breaks (Channel.listenWithoutBreaks). */
1221
+ listenWithoutBreaks(id, listener, kbps, silence) {
1222
+ const channel = this.open.get(id);
1223
+ return channel ? channel.listenWithoutBreaks(listener, kbps, silence) : null;
1224
+ }
1143
1225
  /** Feed a channel that already exists, for a publisher sending chunks. */
1144
1226
  writeTo(id, chunk) {
1145
1227
  return this.open.get(id)?.write(chunk) ?? false;
@@ -0,0 +1,29 @@
1
+ /** How long a pass is good for. The page asks again on every load. */
2
+ export declare const PASS_TTL_SECONDS: number;
3
+ /** Where a pass rides on a stream link (an audio element sends no headers). */
4
+ export declare const PASS_PARAM = "pass";
5
+ /** The signing secret, or "" where passes cannot be signed (every server but nixamp.com). */
6
+ export declare function listenPassSecret(env?: NodeJS.ProcessEnv): string;
7
+ export declare function mintListenPass(secret: string, nowMs?: number, ttlSeconds?: number): {
8
+ pass: string;
9
+ expires: number;
10
+ };
11
+ /** The expiry of a good pass in seconds, or 0. */
12
+ export declare function checkListenPass(secret: string, pass: string, nowMs?: number): number;
13
+ export interface VerifierOptions {
14
+ /** nixamp.com, which signed the pass. */
15
+ site: string;
16
+ /** Set on nixamp.com itself, which checks its own passes without asking. */
17
+ secret?: string;
18
+ fetcher?: typeof fetch;
19
+ now?: () => number;
20
+ }
21
+ /** Whether a pass is good, asked of nixamp.com once and then remembered until it expires. */
22
+ export declare class ListenPassVerifier {
23
+ private readonly options;
24
+ /** pass -> whether it is good, and until when that answer stands. */
25
+ private readonly known;
26
+ constructor(options: VerifierOptions);
27
+ private now;
28
+ good(pass: string): Promise<boolean>;
29
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The listen pass: what a signed-in nixamp.com listener carries to a station
3
+ * on another server, so its breaks go quiet for them.
4
+ *
5
+ * Breaks are in the stream (playlist-input.ts), one stream for every
6
+ * listener, so a station cannot leave a spot out for one person: it can only
7
+ * send that person silence while everybody else hears it. To do even that it
8
+ * has to know who is signed in, and a station on server1 holds no accounts.
9
+ * nixamp.com does, so nixamp.com signs a short pass for a signed-in account
10
+ * (GET /api/v1/me/pass, credentialed from any *.nixamp.com page) and the
11
+ * station asks nixamp.com whether a pass is good (GET
12
+ * /api/v1/listen-pass/check), remembering the answer.
13
+ *
14
+ * A pass is `v1.<expiry seconds>.<signature>` and nothing else: no account
15
+ * id, because a station has no use for one and a link gets pasted around.
16
+ * Anybody holding a fresh pass hears no breaks until it expires, which is the
17
+ * same thing a signed-in listener sharing their screen would give away.
18
+ */
19
+ import { createHash, createHmac, timingSafeEqual } from "node:crypto";
20
+ /** How long a pass is good for. The page asks again on every load. */
21
+ export const PASS_TTL_SECONDS = 24 * 60 * 60;
22
+ /** Where a pass rides on a stream link (an audio element sends no headers). */
23
+ export const PASS_PARAM = "pass";
24
+ /** The signing secret, or "" where passes cannot be signed (every server but nixamp.com). */
25
+ export function listenPassSecret(env = process.env) {
26
+ if (env["NIXAMP_LISTEN_PASS_SECRET"])
27
+ return env["NIXAMP_LISTEN_PASS_SECRET"];
28
+ // Derived, so nixamp.com needs no new variable: a pass is not a session.
29
+ const jwt = env["NIXAMP_JWT_SECRET"] ?? "";
30
+ return jwt ? createHash("sha256").update(`listen-pass:${jwt}`).digest("hex") : "";
31
+ }
32
+ function sign(secret, body) {
33
+ return createHmac("sha256", secret).update(body).digest("base64url").slice(0, 32);
34
+ }
35
+ export function mintListenPass(secret, nowMs = Date.now(), ttlSeconds = PASS_TTL_SECONDS) {
36
+ const expires = Math.floor(nowMs / 1000) + ttlSeconds;
37
+ const body = `v1.${expires}`;
38
+ return { pass: `${body}.${sign(secret, body)}`, expires };
39
+ }
40
+ /** The expiry of a good pass in seconds, or 0. */
41
+ export function checkListenPass(secret, pass, nowMs = Date.now()) {
42
+ const match = /^v1\.(\d{1,12})\.([A-Za-z0-9_-]{32})$/.exec(pass);
43
+ if (!secret || !match)
44
+ return 0;
45
+ const expires = Number(match[1]);
46
+ if (expires * 1000 <= nowMs)
47
+ return 0;
48
+ const want = Buffer.from(sign(secret, `v1.${expires}`));
49
+ const got = Buffer.from(match[2]);
50
+ return want.length === got.length && timingSafeEqual(want, got) ? expires : 0;
51
+ }
52
+ /** How long a refused pass is remembered, so a bad one cannot make us ask nixamp.com per request. */
53
+ const REFUSED_MS = 5 * 60 * 1000;
54
+ const MAX_REMEMBERED = 5_000;
55
+ /** Whether a pass is good, asked of nixamp.com once and then remembered until it expires. */
56
+ export class ListenPassVerifier {
57
+ options;
58
+ /** pass -> whether it is good, and until when that answer stands. */
59
+ known = new Map();
60
+ constructor(options) {
61
+ this.options = options;
62
+ }
63
+ now() {
64
+ return this.options.now?.() ?? Date.now();
65
+ }
66
+ async good(pass) {
67
+ if (!/^v1\.\d{1,12}\.[A-Za-z0-9_-]{32}$/.test(pass))
68
+ return false;
69
+ if (this.options.secret)
70
+ return checkListenPass(this.options.secret, pass, this.now()) > 0;
71
+ const seen = this.known.get(pass);
72
+ if (seen && seen.until > this.now())
73
+ return seen.good;
74
+ let expires = 0;
75
+ try {
76
+ const answer = await (this.options.fetcher ?? fetch)(`${this.options.site.replace(/\/+$/, "")}/api/v1/listen-pass/check?${PASS_PARAM}=${encodeURIComponent(pass)}`, { signal: AbortSignal.timeout(2_000) });
77
+ // nixamp.com down is not a refusal: nothing is remembered, and this
78
+ // listener hears the break this time.
79
+ if (answer.status >= 500)
80
+ return false;
81
+ const body = (await answer.json().catch(() => ({})));
82
+ expires = body.ok === true && typeof body.expires === "number" ? body.expires : 0;
83
+ }
84
+ catch {
85
+ return false;
86
+ }
87
+ if (this.known.size >= MAX_REMEMBERED)
88
+ this.known.clear();
89
+ const good = expires * 1000 > this.now();
90
+ this.known.set(pass, { good, until: good ? expires * 1000 : this.now() + REFUSED_MS });
91
+ return good;
92
+ }
93
+ }
package/dist/server.js CHANGED
@@ -101,6 +101,7 @@ import { EventWriter } from "./event-writer.js";
101
101
  import { handleLiveApi } from "./live-api.js";
102
102
  import { LiveEvents, eventStructuredData, isTicketed } from "./live-events.js";
103
103
  import { Tickets, needsTicket, ticketFrom, ticketsFromEnv } from "./tickets.js";
104
+ import { ListenPassVerifier, PASS_PARAM, checkListenPass, listenPassSecret, mintListenPass } from "./listen-pass.js";
104
105
  import { Layouts } from "./layouts.js";
105
106
  import { Rooms } from "./rooms.js";
106
107
  import { Trollbox, TrollboxError, fallbackHandle, roomFor } from "./trollbox.js";
@@ -1246,6 +1247,9 @@ export function isSignInPath(path) {
1246
1247
  path === "/api/v1/servers" ||
1247
1248
  path.startsWith("/api/v1/servers/") ||
1248
1249
  path === "/api/v1/me/handle" ||
1250
+ // A listen pass is the session's to ask for and anybody's to check.
1251
+ path === "/api/v1/me/pass" ||
1252
+ path === "/api/v1/listen-pass/check" ||
1249
1253
  path === "/api/v1/me/profile" ||
1250
1254
  path.startsWith("/api/v1/me/profile/") ||
1251
1255
  // A stored photo is public: it is on every class the host runs.
@@ -1478,6 +1482,12 @@ export function createHandler(engine, options) {
1478
1482
  // away, which are the same thing as far as "is it still going" goes.
1479
1483
  response.once("close", () => tracker.close(id));
1480
1484
  };
1485
+ // Signs listen passes where accounts live (nixamp.com); "" everywhere else.
1486
+ const listenPassKey = listenPassSecret();
1487
+ const listenPasses = new ListenPassVerifier({
1488
+ site: process.env["NIXAMP_SITE"] || "https://nixamp.com",
1489
+ ...(listenPassKey ? { secret: listenPassKey } : {}),
1490
+ });
1481
1491
  return async function handle(request, response) {
1482
1492
  const url = new URL(request.url ?? "/", "http://localhost");
1483
1493
  const path = url.pathname;
@@ -2521,6 +2531,7 @@ export function createHandler(engine, options) {
2521
2531
  ...(options.stations.cover ? { cover: options.stations.cover } : {}),
2522
2532
  ...(options.stations.lyrics ? { lyrics: options.stations.lyrics } : {}),
2523
2533
  ...(options.stations.lyricsPublisher ? { lyricsPublisher: options.stations.lyricsPublisher } : {}),
2534
+ passes: listenPasses,
2524
2535
  ...(options.stations.peers ? { peers: () => options.stations.peers.entries() } : {}),
2525
2536
  ...(options.stations.rooms ? {
2526
2537
  rooms: () => roomEntries(options.stations.rooms.store, options.stations.rooms.probe, watchBase(options.stations.publicBase?.() || `${scheme}://${request.headers.host ?? "localhost"}`)),
@@ -3758,6 +3769,26 @@ export function createHandler(engine, options) {
3758
3769
  });
3759
3770
  return;
3760
3771
  }
3772
+ // A listen pass (listen-pass.ts): a station page on any *.nixamp.com server
3773
+ // asks for one with this site's cookie, which is same-site there, so a
3774
+ // signed-in listener hears no breaks. Credentialed, so never "*".
3775
+ if (path === "/api/v1/me/pass" && request.method === "GET" && options.accounts && listenPassKey) {
3776
+ const origin = String(request.headers.origin ?? "");
3777
+ const cors = /^https:\/\/([a-z0-9-]+\.)*nixamp\.com(:\d+)?$/.test(origin)
3778
+ ? { "access-control-allow-origin": origin, "access-control-allow-credentials": "true", vary: "origin" }
3779
+ : {};
3780
+ const who = await options.accounts.whoIs(tokenFrom(request.headers));
3781
+ const body = who === null ? { error: "not signed in" } : mintListenPass(listenPassKey);
3782
+ response.writeHead(who === null ? 401 : 200, { ...cors, "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
3783
+ response.end(JSON.stringify(body));
3784
+ return;
3785
+ }
3786
+ // Asked by a station on another server about a pass it was handed.
3787
+ if (path === "/api/v1/listen-pass/check" && request.method === "GET" && listenPassKey) {
3788
+ const expires = checkListenPass(listenPassKey, url.searchParams.get(PASS_PARAM) ?? "");
3789
+ json(response, 200, expires > 0 ? { ok: true, expires } : { ok: false });
3790
+ return;
3791
+ }
3761
3792
  if (path === "/api/v1/me/handle" && options.handles && options.accounts) {
3762
3793
  const handles = options.handles;
3763
3794
  const who = await options.accounts.whoIs(tokenFrom(request.headers));
@@ -188,7 +188,8 @@ export const STATION_APP_CSP = [
188
188
  "media-src 'self' blob:",
189
189
  "worker-src 'self' blob:",
190
190
  "img-src 'self' data: blob:",
191
- "connect-src 'self'",
191
+ // nixamp.com for a listen pass, so a signed-in listener's breaks go quiet.
192
+ "connect-src 'self' https://nixamp.com",
192
193
  "base-uri 'none'",
193
194
  "form-action 'none'",
194
195
  "frame-ancestors 'none'",
@@ -76,6 +76,10 @@ export interface StationContext {
76
76
  status: () => unknown;
77
77
  publish: (station: Station) => Promise<PublishReport>;
78
78
  };
79
+ /** Whether a listen pass is good: its holder hears silence in place of a radio station's breaks. */
80
+ passes?: {
81
+ good: (pass: string) => Promise<boolean>;
82
+ };
79
83
  /** Auto stations: run a sync with these settings (and keep them), or read them. */
80
84
  auto?: {
81
85
  sync: (input: Record<string, unknown>) => Promise<unknown>;
@@ -1,5 +1,7 @@
1
1
  import { alertsFor } from "./station-metering.js";
2
2
  import { aiFromTags } from "./track-facts.js";
3
+ import { quietDuringBreaks } from "./break-silence.js";
4
+ import { PASS_PARAM } from "./listen-pass.js";
3
5
  import { StationError, channelOf, tokensMatch, } from "./stations.js";
4
6
  /** The three things a station's link opens. */
5
7
  const LISTEN = /^\/api\/v1\/stations\/([a-z0-9]{10})\/(stream|state|listen\.m3u|events|now|art|lyrics)$/;
@@ -244,6 +246,10 @@ export async function handleStations(request, response, path, url, ctx) {
244
246
  response.end(JSON.stringify({ error: refused.error, code: refused.code, retryAfter: refused.retryAfter }));
245
247
  return true;
246
248
  }
249
+ // Signed in at nixamp.com: no breaks, starting a little behind live so
250
+ // they can be left out without a gap. Radio only (Channel.listenWithoutBreaks).
251
+ const pass = url.searchParams.get(PASS_PARAM) ?? "";
252
+ const spared = method === "GET" && !station.video && pass !== "" && ctx.passes ? await ctx.passes.good(pass).catch(() => false) : false;
247
253
  response.writeHead(200, { ...ctx.headers, "content-type": ctx.channels.contentType(channelId), "cache-control": "no-store" });
248
254
  if (method === "HEAD") {
249
255
  response.end();
@@ -251,7 +257,7 @@ export async function handleStations(request, response, path, url, ctx) {
251
257
  }
252
258
  // Every byte that goes out through the link is counted against the station.
253
259
  const usage = ctx.usage;
254
- const counted = usage
260
+ const metered = usage
255
261
  ? {
256
262
  write: (chunk) => {
257
263
  usage.delivered(station.id, chunk.length);
@@ -261,7 +267,9 @@ export async function handleStations(request, response, path, url, ctx) {
261
267
  pending: () => response.writableLength,
262
268
  }
263
269
  : response;
264
- const detach = ctx.channels.listen(channelId, counted);
270
+ const detach = spared
271
+ ? ctx.channels.listenWithoutBreaks(channelId, metered, station.bitrate, (out) => quietDuringBreaks(out, station.bitrate, () => true))
272
+ : ctx.channels.listen(channelId, metered);
265
273
  if (detach === null) {
266
274
  response.end();
267
275
  return true;
@@ -1,2 +1,2 @@
1
1
  /** `nixamp station` usage, apart so `nixamp help station` does not load the client. */
2
- export declare const USAGE = "nixamp station \u2014 make a collection into a station\n\n nixamp station list\n nixamp station create --name NAME (--folder PATH | --playlist ID | --items 0,1,2) [--shuffle] [--no-repeat] [--bitrate 128] [--breaks 600]\n nixamp station show ID\n nixamp station status ID\n nixamp station start ID\n nixamp station stop ID\n nixamp station skip ID\n nixamp station breaks ID SECONDS a spot between entries every SECONDS (120..86400; --off for none)\n nixamp station retry ID try every left-out file again\n nixamp station publish ID --basis owned|permission|licensed|public-domain [--note TEXT] [--unlisted] [--no-list]\n nixamp station unpublish ID [--private]\n nixamp station link ID [--rotate]\n nixamp station delete ID\n nixamp station guide-host NAME [--remove]\n NAME's front page is the public station guide (e.g. a Moshpit name)\n nixamp station guide-ads [--site ID] [--slot ID] [--format banner_300x250|text_link] [--off]\n crawlproof analytics (project ID) and an ad unit (slot ID) on the guide\n nixamp station guide-peer URL [--remove]\n the guide also lists the public, listed stations of the server at URL\n nixamp station auto [--no-video] [--min 10] [--films 3] [--tv 2] [--breaks 600] [--no-start]\n a station per genre, kept in step with the library (--off stops it)\n\n AI radio (music composed for a station, a few tracks a day):\n nixamp station ai what is composing, this month's seconds, the stations\n nixamp station ai create (--preset lofi|synthwave|ambient|chiptune|punk|jazz | --name NAME --style TEXT)\n [--vocals] [--seconds 120] [--per-day 2] [--unlisted] public on the guide unless --unlisted\n nixamp station ai generate KEY compose one now\n nixamp station ai set KEY [--style TEXT] [--per-day N] [--seconds N] [--off|--on]\n nixamp station ai remove KEY [--files]\n nixamp station ai budget SECONDS seconds of music a month across AI stations (0: no cap)\n nixamp station ai resume after the provider ran out of credit\n nixamp station ai key NAME ELEVENLABS_API_KEY | GEMINI_API_KEY | OPENAI_API_KEY | ANTHROPIC_API_KEY,\n NIXAMP_OPENAI_URL | NIXAMP_OPENAI_MODEL (an OpenAI-compatible stand-in), from stdin\n\n Listener shares (anybody shares an MP3 link or file from the guide; AI files and checks it):\n nixamp station shares the setting and what is waiting\n nixamp station shares on [--owner-moderation] [--unlisted] [--max-mb 60] [--per-hour 6]\n nixamp station shares off\n nixamp station shares list [--state held|approved|reported|rejected]\n nixamp station shares approve|reject|delete SHARE\n nixamp station shares trust ADDRESS [--remove] believe this proxy's X-Forwarded-For\n\n Lyrics (shown on the station page from each file's own tag, else nichedb.dev; ours published back):\n nixamp station lyrics the key, what was published, which stations may publish\n nixamp station lyrics publish ID publish a station's words now (public, listed, basis owned only)\n nixamp station lyrics key nichedb's key (NIXAMP_LYRICS_KEY), from stdin (--clear forgets it)\n\n Copyright holders (the now-playing card: who holds a track's copyright, and how to support them):\n nixamp station holder list\n nixamp station holder set NAME [--artist ARTIST]... [--station ID]... [--support https://...] [--label Subscribe] [--license TEXT]\n nixamp station holder remove ID\n\n --json machine-readable output\n --url, --key another server, as with `nixamp admin`\n\nA new station is private and stopped. Starting it does not publish it.";
2
+ export declare const USAGE = "nixamp station \u2014 make a collection into a station\n\n nixamp station list\n nixamp station create --name NAME (--folder PATH | --playlist ID | --items 0,1,2) [--shuffle] [--no-repeat] [--bitrate 128] [--breaks 600 (the default; 0 for none)]\n nixamp station show ID\n nixamp station status ID\n nixamp station start ID\n nixamp station stop ID\n nixamp station skip ID\n nixamp station breaks ID SECONDS a spot between entries every SECONDS (120..86400; --off for none)\n nixamp station retry ID try every left-out file again\n nixamp station publish ID --basis owned|permission|licensed|public-domain [--note TEXT] [--unlisted] [--no-list]\n nixamp station unpublish ID [--private]\n nixamp station link ID [--rotate]\n nixamp station delete ID\n nixamp station guide-host NAME [--remove]\n NAME's front page is the public station guide (e.g. a Moshpit name)\n nixamp station guide-ads [--site ID] [--slot ID] [--format banner_300x250|text_link] [--off]\n crawlproof analytics (project ID) and an ad unit (slot ID) on the guide\n nixamp station guide-peer URL [--remove]\n the guide also lists the public, listed stations of the server at URL\n nixamp station auto [--no-video] [--min 10] [--films 3] [--tv 2] [--breaks 600] [--no-start]\n a station per genre, kept in step with the library (--off stops it)\n\n AI radio (music composed for a station, a few tracks a day):\n nixamp station ai what is composing, this month's seconds, the stations\n nixamp station ai create (--preset lofi|synthwave|ambient|chiptune|punk|jazz | --name NAME --style TEXT)\n [--vocals] [--seconds 120] [--per-day 2] [--unlisted] public on the guide unless --unlisted\n nixamp station ai generate KEY compose one now\n nixamp station ai set KEY [--style TEXT] [--per-day N] [--seconds N] [--off|--on]\n nixamp station ai remove KEY [--files]\n nixamp station ai budget SECONDS seconds of music a month across AI stations (0: no cap)\n nixamp station ai resume after the provider ran out of credit\n nixamp station ai key NAME ELEVENLABS_API_KEY | GEMINI_API_KEY | OPENAI_API_KEY | ANTHROPIC_API_KEY,\n NIXAMP_OPENAI_URL | NIXAMP_OPENAI_MODEL (an OpenAI-compatible stand-in), from stdin\n\n Listener shares (anybody shares an MP3 link or file from the guide; AI files and checks it):\n nixamp station shares the setting and what is waiting\n nixamp station shares on [--owner-moderation] [--unlisted] [--max-mb 60] [--per-hour 6]\n nixamp station shares off\n nixamp station shares list [--state held|approved|reported|rejected]\n nixamp station shares approve|reject|delete SHARE\n nixamp station shares trust ADDRESS [--remove] believe this proxy's X-Forwarded-For\n\n Lyrics (shown on the station page from each file's own tag, else nichedb.dev; ours published back):\n nixamp station lyrics the key, what was published, which stations may publish\n nixamp station lyrics publish ID publish a station's words now (public, listed, basis owned only)\n nixamp station lyrics key nichedb's key (NIXAMP_LYRICS_KEY), from stdin (--clear forgets it)\n\n Copyright holders (the now-playing card: who holds a track's copyright, and how to support them):\n nixamp station holder list\n nixamp station holder set NAME [--artist ARTIST]... [--station ID]... [--support https://...] [--label Subscribe] [--license TEXT]\n nixamp station holder remove ID\n\n --json machine-readable output\n --url, --key another server, as with `nixamp admin`\n\nA new station is private and stopped. Starting it does not publish it.";
@@ -2,7 +2,7 @@
2
2
  export const USAGE = `nixamp station — make a collection into a station
3
3
 
4
4
  nixamp station list
5
- nixamp station create --name NAME (--folder PATH | --playlist ID | --items 0,1,2) [--shuffle] [--no-repeat] [--bitrate 128] [--breaks 600]
5
+ nixamp station create --name NAME (--folder PATH | --playlist ID | --items 0,1,2) [--shuffle] [--no-repeat] [--bitrate 128] [--breaks 600 (the default; 0 for none)]
6
6
  nixamp station show ID
7
7
  nixamp station status ID
8
8
  nixamp station start ID
@@ -45,6 +45,8 @@ export interface Station {
45
45
  /** Times an item may fail in a row before rotation leaves it out. */
46
46
  export declare const EXCLUDE_AFTER = 3;
47
47
  export declare const DEFAULT_BITRATE = 128;
48
+ /** Seconds between breaks on a new station that did not say. */
49
+ export declare const DEFAULT_BREAKS_EVERY = 600;
48
50
  export declare const BITRATES: number[];
49
51
  /** A station's channel id. */
50
52
  export declare function channelOf(id: string): string;
package/dist/stations.js CHANGED
@@ -26,6 +26,8 @@ export const RIGHTS_BASES = ["owned", "permission", "licensed", "public-domain"]
26
26
  /** Times an item may fail in a row before rotation leaves it out. */
27
27
  export const EXCLUDE_AFTER = 3;
28
28
  export const DEFAULT_BITRATE = 128;
29
+ /** Seconds between breaks on a new station that did not say. */
30
+ export const DEFAULT_BREAKS_EVERY = 600;
29
31
  export const BITRATES = [64, 96, 128, 160, 192, 256, 320];
30
32
  const MAX_ITEMS = 20_000;
31
33
  const MAX_NAME = 120;
@@ -179,7 +181,9 @@ export class StationStore {
179
181
  listenToken: newToken(),
180
182
  bitrate: this.bitrateFrom(input.bitrate),
181
183
  video: input.video === true,
182
- breaksEvery: this.breaksFrom(input.breaksEvery),
184
+ // On unless asked for none: a station is free to listen to, and the
185
+ // spot is what pays for it. Signed-in listeners hear silence instead.
186
+ breaksEvery: input.breaksEvery === undefined ? DEFAULT_BREAKS_EVERY : this.breaksFrom(input.breaksEvery),
183
187
  auto: "",
184
188
  rights: null,
185
189
  owner,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.45.5",
3
+ "version": "0.46.1",
4
4
  "description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -51,7 +51,7 @@
51
51
  "@profullstack/notifications": "^0.1.0",
52
52
  "@profullstack/synconfig": "^0.1.2",
53
53
  "@profullstack/throttle": "0.2.2",
54
- "@profullstack/x402-gateway": "^0.7.0",
54
+ "@profullstack/x402-gateway": "^0.10.1",
55
55
  "acme-client": "5.4.0",
56
56
  "pg": "^8.23.0"
57
57
  },
@@ -84,6 +84,6 @@
84
84
  "overrides": {
85
85
  "https-proxy-agent": "7.0.6",
86
86
  "tslib": "2.8.1",
87
- "@profullstack/x402-gateway": "^0.7.0"
87
+ "@profullstack/x402-gateway": "^0.10.1"
88
88
  }
89
89
  }