nixamp 0.45.4 → 0.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/break-silence.d.ts +27 -0
  2. package/dist/break-silence.js +57 -0
  3. package/dist/library.js +1 -0
  4. package/dist/listen-pass.d.ts +29 -0
  5. package/dist/listen-pass.js +93 -0
  6. package/dist/server.js +31 -0
  7. package/dist/station-page.js +2 -1
  8. package/dist/station-routes.d.ts +4 -0
  9. package/dist/station-routes.js +9 -1
  10. package/dist/station-usage.d.ts +1 -1
  11. package/dist/station-usage.js +1 -1
  12. package/dist/stations.d.ts +2 -0
  13. package/dist/stations.js +5 -1
  14. package/package.json +3 -3
  15. package/src/break-silence.ts +65 -0
  16. package/src/library.ts +1 -0
  17. package/src/listen-pass.ts +104 -0
  18. package/src/server.ts +33 -0
  19. package/src/station-page.ts +2 -1
  20. package/src/station-routes.ts +11 -1
  21. package/src/station-usage.ts +1 -1
  22. package/src/stations.ts +5 -1
  23. package/web/dist/assets/{ar-xvLZ3ofn.js → ar-BlY2yL5Y.js} +1 -1
  24. package/web/dist/assets/{cs-C7-uUQA9.js → cs-BimNH85J.js} +1 -1
  25. package/web/dist/assets/{da-cQhhi2Pg.js → da-B3IBhGVZ.js} +1 -1
  26. package/web/dist/assets/{de-8hoez3Qy.js → de-CH-pHqCQ.js} +1 -1
  27. package/web/dist/assets/{es-CTtqkNRz.js → es-BI5-s8Fs.js} +1 -1
  28. package/web/dist/assets/{fi-CS_j3H0z.js → fi-BQL91oQN.js} +1 -1
  29. package/web/dist/assets/{fr-Da2_xiCp.js → fr-CkzjINxM.js} +1 -1
  30. package/web/dist/assets/{hi-CwJVR1xQ.js → hi-DN8JNzbF.js} +1 -1
  31. package/web/dist/assets/{hu-Cj6afP-k.js → hu-Cgi1qJdP.js} +1 -1
  32. package/web/dist/assets/{id-C8y8jQm9.js → id-7NL6kLrr.js} +1 -1
  33. package/web/dist/assets/{it-COCm7P7w.js → it-jg9ppgTc.js} +1 -1
  34. package/web/dist/assets/main-BXsE3ZHI.js +22 -0
  35. package/web/dist/assets/{nl-aoLtknAt.js → nl-Cyz8HiXs.js} +1 -1
  36. package/web/dist/assets/{ru-B8WDQTnf.js → ru-BzkaVvIz.js} +1 -1
  37. package/web/dist/assets/{station-Djxdr3MD.js → station-BhCxDaWb.js} +1 -1
  38. package/web/dist/assets/{sv-DIy6tmT6.js → sv-CqTcD1de.js} +1 -1
  39. package/web/dist/assets/{uk-C_QEqSEn.js → uk-CvHTXcNv.js} +1 -1
  40. package/web/dist/assets/{vi-sShSpYrp.js → vi-BYQnyW2Q.js} +1 -1
  41. package/web/dist/assets/{zh-jrOPMKzL.js → zh-VSRcivxg.js} +1 -1
  42. package/web/dist/index.html +1 -1
  43. package/web/dist/station.html +1 -1
  44. package/web/dist/sw.js +20 -20
  45. package/web/dist/assets/main-srxxjfXp.js +0 -22
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Silence in place of a break, for one listener of a station.
3
+ *
4
+ * A station's breaks are in its one stream (playlist-input.ts), so a listener
5
+ * with a listen pass (listen-pass.ts) cannot be spared the time a spot takes,
6
+ * only the spot: while the channel says a break is on, what goes to them is
7
+ * MP3 silence lasting as long as the bytes it stands in for. 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.
3
+ *
4
+ * A station's breaks are in its one stream (playlist-input.ts), so a listener
5
+ * with a listen pass (listen-pass.ts) cannot be spared the time a spot takes,
6
+ * only the spot: while the channel says a break is on, what goes to them is
7
+ * MP3 silence lasting as long as the bytes it stands in for. 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
+ }
package/dist/library.js CHANGED
@@ -37,6 +37,7 @@ export function readConfig() {
37
37
  ...(Array.isArray(record["guideHosts"]) ? { guideHosts: record["guideHosts"].filter((one) => typeof one === "string") } : {}),
38
38
  ...(typeof record["guideAds"] === "object" && record["guideAds"] !== null ? { guideAds: record["guideAds"] } : {}),
39
39
  ...(Array.isArray(record["guidePeers"]) ? { guidePeers: record["guidePeers"].filter((one) => typeof one === "string") } : {}),
40
+ ...(typeof record["watchBase"] === "string" ? { watchBase: record["watchBase"] } : {}),
40
41
  };
41
42
  }
42
43
  catch {
@@ -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,9 @@ 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: the breaks go quiet. Radio only (break-silence.ts).
250
+ const pass = url.searchParams.get(PASS_PARAM) ?? "";
251
+ const spared = method === "GET" && !station.video && pass !== "" && ctx.passes ? await ctx.passes.good(pass).catch(() => false) : false;
247
252
  response.writeHead(200, { ...ctx.headers, "content-type": ctx.channels.contentType(channelId), "cache-control": "no-store" });
248
253
  if (method === "HEAD") {
249
254
  response.end();
@@ -251,7 +256,7 @@ export async function handleStations(request, response, path, url, ctx) {
251
256
  }
252
257
  // Every byte that goes out through the link is counted against the station.
253
258
  const usage = ctx.usage;
254
- const counted = usage
259
+ const metered = usage
255
260
  ? {
256
261
  write: (chunk) => {
257
262
  usage.delivered(station.id, chunk.length);
@@ -261,6 +266,9 @@ export async function handleStations(request, response, path, url, ctx) {
261
266
  pending: () => response.writableLength,
262
267
  }
263
268
  : response;
269
+ const counted = spared
270
+ ? quietDuringBreaks(metered, station.bitrate, () => ctx.channels.info(channelId)?.playlistAt === -1)
271
+ : metered;
264
272
  const detach = ctx.channels.listen(channelId, counted);
265
273
  if (detach === null) {
266
274
  response.end();
@@ -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.4",
3
+ "version": "0.46.0",
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
  }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Silence in place of a break, for one listener of a station.
3
+ *
4
+ * A station's breaks are in its one stream (playlist-input.ts), so a listener
5
+ * with a listen pass (listen-pass.ts) cannot be spared the time a spot takes,
6
+ * only the spot: while the channel says a break is on, what goes to them is
7
+ * MP3 silence lasting as long as the bytes it stands in for. 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
+
16
+ const SAMPLE_RATE = 48_000;
17
+ const FRAME_SECONDS = 1152 / SAMPLE_RATE;
18
+ /** MPEG-1 Layer III bitrate index by kbps. */
19
+ const BITRATE_INDEX: Record<number, number> = {
20
+ 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,
21
+ };
22
+
23
+ /** One silent frame at this bitrate (128 kbps when the bitrate is not a Layer III one). */
24
+ export function silentFrame(kbps: number): Buffer {
25
+ const rate = BITRATE_INDEX[kbps] ? kbps : 128;
26
+ const frame = Buffer.alloc((144 * rate * 1000) / SAMPLE_RATE);
27
+ // Sync, MPEG-1, Layer III, no CRC; bitrate, 48 kHz (01), no padding; stereo.
28
+ frame[0] = 0xff;
29
+ frame[1] = 0xfb;
30
+ frame[2] = (BITRATE_INDEX[rate]! << 4) | (0b01 << 2);
31
+ frame[3] = 0x00;
32
+ return frame;
33
+ }
34
+
35
+ export interface Writer {
36
+ write: (chunk: Buffer) => boolean;
37
+ end: () => void;
38
+ pending?: () => number;
39
+ }
40
+
41
+ /**
42
+ * A writer that sends silence instead of whatever is written while `inBreak()`
43
+ * says yes. `kbps` is the stream's bitrate, which is how a chunk's bytes become
44
+ * seconds.
45
+ */
46
+ export function quietDuringBreaks(out: Writer, kbps: number, inBreak: () => boolean): Writer {
47
+ const frame = silentFrame(kbps);
48
+ const bytesPerSecond = (kbps * 1000) / 8;
49
+ let owed = 0;
50
+ return {
51
+ write: (chunk: Buffer): boolean => {
52
+ if (!inBreak()) {
53
+ owed = 0;
54
+ return out.write(chunk);
55
+ }
56
+ owed += chunk.length / bytesPerSecond;
57
+ const frames = Math.floor(owed / FRAME_SECONDS);
58
+ if (frames === 0) return true;
59
+ owed -= frames * FRAME_SECONDS;
60
+ return out.write(Buffer.concat(Array.from({ length: frames }, () => frame)));
61
+ },
62
+ end: () => out.end(),
63
+ ...(out.pending ? { pending: out.pending } : {}),
64
+ };
65
+ }
package/src/library.ts CHANGED
@@ -59,6 +59,7 @@ export function readConfig(): Config {
59
59
  ...(Array.isArray(record["guideHosts"]) ? { guideHosts: (record["guideHosts"] as unknown[]).filter((one): one is string => typeof one === "string") } : {}),
60
60
  ...(typeof record["guideAds"] === "object" && record["guideAds"] !== null ? { guideAds: record["guideAds"] as Config["guideAds"] } : {}),
61
61
  ...(Array.isArray(record["guidePeers"]) ? { guidePeers: (record["guidePeers"] as unknown[]).filter((one): one is string => typeof one === "string") } : {}),
62
+ ...(typeof record["watchBase"] === "string" ? { watchBase: record["watchBase"] } : {}),
62
63
  };
63
64
  } catch {
64
65
  return {};
@@ -0,0 +1,104 @@
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
+
21
+ /** How long a pass is good for. The page asks again on every load. */
22
+ export const PASS_TTL_SECONDS = 24 * 60 * 60;
23
+
24
+ /** Where a pass rides on a stream link (an audio element sends no headers). */
25
+ export const PASS_PARAM = "pass";
26
+
27
+ /** The signing secret, or "" where passes cannot be signed (every server but nixamp.com). */
28
+ export function listenPassSecret(env: NodeJS.ProcessEnv = process.env): string {
29
+ if (env["NIXAMP_LISTEN_PASS_SECRET"]) return env["NIXAMP_LISTEN_PASS_SECRET"];
30
+ // Derived, so nixamp.com needs no new variable: a pass is not a session.
31
+ const jwt = env["NIXAMP_JWT_SECRET"] ?? "";
32
+ return jwt ? createHash("sha256").update(`listen-pass:${jwt}`).digest("hex") : "";
33
+ }
34
+
35
+ function sign(secret: string, body: string): string {
36
+ return createHmac("sha256", secret).update(body).digest("base64url").slice(0, 32);
37
+ }
38
+
39
+ export function mintListenPass(secret: string, nowMs = Date.now(), ttlSeconds = PASS_TTL_SECONDS): { pass: string; expires: number } {
40
+ const expires = Math.floor(nowMs / 1000) + ttlSeconds;
41
+ const body = `v1.${expires}`;
42
+ return { pass: `${body}.${sign(secret, body)}`, expires };
43
+ }
44
+
45
+ /** The expiry of a good pass in seconds, or 0. */
46
+ export function checkListenPass(secret: string, pass: string, nowMs = Date.now()): number {
47
+ const match = /^v1\.(\d{1,12})\.([A-Za-z0-9_-]{32})$/.exec(pass);
48
+ if (!secret || !match) return 0;
49
+ const expires = Number(match[1]);
50
+ if (expires * 1000 <= nowMs) return 0;
51
+ const want = Buffer.from(sign(secret, `v1.${expires}`));
52
+ const got = Buffer.from(match[2]!);
53
+ return want.length === got.length && timingSafeEqual(want, got) ? expires : 0;
54
+ }
55
+
56
+ export interface VerifierOptions {
57
+ /** nixamp.com, which signed the pass. */
58
+ site: string;
59
+ /** Set on nixamp.com itself, which checks its own passes without asking. */
60
+ secret?: string;
61
+ fetcher?: typeof fetch;
62
+ now?: () => number;
63
+ }
64
+
65
+ /** How long a refused pass is remembered, so a bad one cannot make us ask nixamp.com per request. */
66
+ const REFUSED_MS = 5 * 60 * 1000;
67
+ const MAX_REMEMBERED = 5_000;
68
+
69
+ /** Whether a pass is good, asked of nixamp.com once and then remembered until it expires. */
70
+ export class ListenPassVerifier {
71
+ /** pass -> whether it is good, and until when that answer stands. */
72
+ private readonly known = new Map<string, { good: boolean; until: number }>();
73
+
74
+ constructor(private readonly options: VerifierOptions) {}
75
+
76
+ private now(): number {
77
+ return this.options.now?.() ?? Date.now();
78
+ }
79
+
80
+ async good(pass: string): Promise<boolean> {
81
+ if (!/^v1\.\d{1,12}\.[A-Za-z0-9_-]{32}$/.test(pass)) return false;
82
+ if (this.options.secret) return checkListenPass(this.options.secret, pass, this.now()) > 0;
83
+ const seen = this.known.get(pass);
84
+ if (seen && seen.until > this.now()) return seen.good;
85
+ let expires = 0;
86
+ try {
87
+ const answer = await (this.options.fetcher ?? fetch)(
88
+ `${this.options.site.replace(/\/+$/, "")}/api/v1/listen-pass/check?${PASS_PARAM}=${encodeURIComponent(pass)}`,
89
+ { signal: AbortSignal.timeout(2_000) },
90
+ );
91
+ // nixamp.com down is not a refusal: nothing is remembered, and this
92
+ // listener hears the break this time.
93
+ if (answer.status >= 500) return false;
94
+ const body = (await answer.json().catch(() => ({}))) as { ok?: unknown; expires?: unknown };
95
+ expires = body.ok === true && typeof body.expires === "number" ? body.expires : 0;
96
+ } catch {
97
+ return false;
98
+ }
99
+ if (this.known.size >= MAX_REMEMBERED) this.known.clear();
100
+ const good = expires * 1000 > this.now();
101
+ this.known.set(pass, { good, until: good ? expires * 1000 : this.now() + REFUSED_MS });
102
+ return good;
103
+ }
104
+ }