nixamp 0.39.2 → 0.40.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 (54) hide show
  1. package/dist/ai-radio.d.ts +2 -0
  2. package/dist/ai-radio.js +9 -0
  3. package/dist/ai-routes.d.ts +3 -0
  4. package/dist/ai-routes.js +26 -1
  5. package/dist/listener-shares.d.ts +2 -0
  6. package/dist/listener-shares.js +4 -0
  7. package/dist/server.d.ts +8 -1
  8. package/dist/server.js +78 -1
  9. package/dist/station-cli.js +31 -0
  10. package/dist/station-mcp.d.ts +144 -14
  11. package/dist/station-mcp.js +25 -0
  12. package/dist/station-routes.d.ts +3 -0
  13. package/dist/station-routes.js +22 -3
  14. package/dist/station-usage.d.ts +1 -1
  15. package/dist/station-usage.js +5 -0
  16. package/dist/support.d.ts +43 -0
  17. package/dist/support.js +203 -0
  18. package/dist/track-facts.d.ts +88 -0
  19. package/dist/track-facts.js +220 -0
  20. package/package.json +1 -1
  21. package/src/ai-radio.ts +9 -0
  22. package/src/ai-routes.ts +29 -1
  23. package/src/listener-shares.ts +5 -0
  24. package/src/server.ts +77 -1
  25. package/src/station-cli.ts +22 -0
  26. package/src/station-mcp.ts +24 -0
  27. package/src/station-routes.ts +23 -3
  28. package/src/station-usage.ts +5 -0
  29. package/src/support.ts +206 -0
  30. package/src/track-facts.ts +243 -0
  31. package/web/dist/assets/{ar-BSu384wE.js → ar-BY-kuED6.js} +1 -1
  32. package/web/dist/assets/{cs-CycweUsX.js → cs-C6mMbdyp.js} +1 -1
  33. package/web/dist/assets/{da-C7FbLd8g.js → da-DqV68WRi.js} +1 -1
  34. package/web/dist/assets/{de-DMcsxdff.js → de-C4JKBX1z.js} +1 -1
  35. package/web/dist/assets/{es-VnyHTJVp.js → es-B_mF5h1R.js} +1 -1
  36. package/web/dist/assets/{fi-B-oW_Ilc.js → fi-CDbSPL76.js} +1 -1
  37. package/web/dist/assets/{fr-CdEDQS5F.js → fr-B_573mv-.js} +1 -1
  38. package/web/dist/assets/{hi-B_xiLY1e.js → hi--YR7-iSh.js} +1 -1
  39. package/web/dist/assets/{hu-kFISDmOM.js → hu-kotmcF8p.js} +1 -1
  40. package/web/dist/assets/{id-DMel0ct7.js → id-CY1mU0Fh.js} +1 -1
  41. package/web/dist/assets/{it-1LsKNR45.js → it-Dj4xHmAf.js} +1 -1
  42. package/web/dist/assets/{main-CJLyq0BI.js → main-CwBTD5i3.js} +2 -2
  43. package/web/dist/assets/{nl-bZUFxlcO.js → nl-DVvshFko.js} +1 -1
  44. package/web/dist/assets/{ru-BI9zEd6Z.js → ru-BMwolR6_.js} +1 -1
  45. package/web/dist/assets/station-BZ2CtCcH.js +1 -0
  46. package/web/dist/assets/{station-Bq-LiY6G.css → station-CM31TuOq.css} +1 -1
  47. package/web/dist/assets/{sv-CD5WRIGc.js → sv-B_VngG1H.js} +1 -1
  48. package/web/dist/assets/{uk-BVzlKtfs.js → uk-D9pKHqQ7.js} +1 -1
  49. package/web/dist/assets/{vi-BV-dHawO.js → vi-BezqX85w.js} +1 -1
  50. package/web/dist/assets/{zh-DiT7lmM0.js → zh-CjTmbtGB.js} +1 -1
  51. package/web/dist/index.html +1 -1
  52. package/web/dist/station.html +7 -2
  53. package/web/dist/sw.js +21 -21
  54. package/web/dist/assets/station-DDvu2lUI.js +0 -1
@@ -16,6 +16,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
16
16
  import type { Channels } from "./channels.ts";
17
17
  import type { StationRunner } from "./station-runner.ts";
18
18
  import { type Limits, type StationUsage } from "./station-metering.ts";
19
+ import type { TrackFacts } from "./track-facts.ts";
19
20
  import { type OwnerStation, type PublicStation, type Station, type StationStore } from "./stations.ts";
20
21
  /** Whether this path is one a station's own link may open, without the server's key. */
21
22
  export declare function isStationListenPath(path: string): boolean;
@@ -59,6 +60,8 @@ export interface StationContext {
59
60
  throttle?: () => {
60
61
  retryAfter: number;
61
62
  } | null;
63
+ /** What a listener may know about the track on the air (track-facts.ts). */
64
+ facts?: (station: Station, file: string) => Promise<TrackFacts>;
62
65
  /** Auto stations: run a sync with these settings (and keep them), or read them. */
63
66
  auto?: {
64
67
  sync: (input: Record<string, unknown>) => Promise<unknown>;
@@ -1,7 +1,7 @@
1
1
  import { alertsFor } from "./station-metering.js";
2
2
  import { StationError, channelOf, tokensMatch, } from "./stations.js";
3
3
  /** The three things a station's link opens. */
4
- const LISTEN = /^\/api\/v1\/stations\/([a-z0-9]{10})\/(stream|state|listen\.m3u|events)$/;
4
+ const LISTEN = /^\/api\/v1\/stations\/([a-z0-9]{10})\/(stream|state|listen\.m3u|events|now)$/;
5
5
  /** Whether this path is one a station's own link may open, without the server's key. */
6
6
  export function isStationListenPath(path) {
7
7
  return LISTEN.test(path) || path === "/api/v1/stations/guide";
@@ -137,7 +137,21 @@ export async function handleStations(request, response, path, url, ctx) {
137
137
  const view = publicView(ctx, station);
138
138
  return { station: station.id, playback: `${station.id}:${ctx.channels.info(channelOf(station.id))?.startedAt ?? 0}`, track: `${station.id}:${instance}`, at: Date.now(), state: view.state, nowPlaying: view.nowPlaying };
139
139
  };
140
- send("hello", now());
140
+ // Each "now" carries what is known about the track: who made it, AI or
141
+ // not, whose copyright, how to support them.
142
+ const withFacts = async () => {
143
+ const payload = now();
144
+ const file = nowPlayingFile(ctx, station);
145
+ if (!ctx.facts || !file)
146
+ return payload;
147
+ try {
148
+ return { ...payload, facts: await ctx.facts(station, file) };
149
+ }
150
+ catch {
151
+ return payload;
152
+ }
153
+ };
154
+ void withFacts().then((payload) => send("hello", payload));
141
155
  const tick = setInterval(() => {
142
156
  const current = ctx.store.get(station.id);
143
157
  if (!current) {
@@ -149,7 +163,7 @@ export async function handleStations(request, response, path, url, ctx) {
149
163
  if (file !== lastFile) {
150
164
  lastFile = file;
151
165
  instance += 1;
152
- send("now", now());
166
+ void withFacts().then((payload) => send("now", payload));
153
167
  }
154
168
  else if (state !== lastState) {
155
169
  send("state", now());
@@ -169,6 +183,11 @@ export async function handleStations(request, response, path, url, ctx) {
169
183
  response.on("close", leave);
170
184
  return true;
171
185
  }
186
+ if (what === "now") {
187
+ const file = ctx.runner.observed(station.id) === "live" || ctx.runner.observed(station.id) === "recovering" ? nowPlayingFile(ctx, station) : "";
188
+ ctx.json(response, 200, { station: station.id, facts: file && ctx.facts ? await ctx.facts(station, file).catch(() => null) : null });
189
+ return true;
190
+ }
172
191
  if (what === "state") {
173
192
  ctx.json(response, 200, publicView(ctx, station));
174
193
  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-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 --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]\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-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 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.";
@@ -41,6 +41,11 @@ export const USAGE = `nixamp station — make a collection into a station
41
41
  nixamp station shares approve|reject|delete SHARE
42
42
  nixamp station shares trust ADDRESS [--remove] believe this proxy's X-Forwarded-For
43
43
 
44
+ Copyright holders (the now-playing card: who holds a track's copyright, and how to support them):
45
+ nixamp station holder list
46
+ nixamp station holder set NAME [--artist ARTIST]... [--station ID]... [--support https://...] [--label Subscribe] [--license TEXT]
47
+ nixamp station holder remove ID
48
+
44
49
  --json machine-readable output
45
50
  --url, --key another server, as with \`nixamp admin\`
46
51
 
@@ -0,0 +1,43 @@
1
+ export interface SupportHolder {
2
+ id: string;
3
+ name: string;
4
+ }
5
+ export declare function supportHoldersFrom(env?: Record<string, string | undefined>): SupportHolder[];
6
+ export declare const AMOUNTS: number[];
7
+ export declare class SupportError extends Error {
8
+ readonly status: number;
9
+ readonly code: string;
10
+ constructor(message: string, status?: number, code?: string);
11
+ }
12
+ export declare class Support {
13
+ private readonly options;
14
+ private merchant;
15
+ private merchantUntil;
16
+ constructor(options: {
17
+ key: string;
18
+ site: string;
19
+ holders: SupportHolder[];
20
+ fetcher?: typeof fetch;
21
+ now?: () => number;
22
+ });
23
+ holder(id: string): SupportHolder | undefined;
24
+ get enabled(): boolean;
25
+ private provider;
26
+ /** The business and the coins it has a wallet for, asked at most every 10 minutes. */
27
+ coins(): Promise<{
28
+ business: string;
29
+ coins: string[];
30
+ }>;
31
+ /** A CoinPay checkout for this much, to this holder; the address to send the payer to. */
32
+ checkout(input: {
33
+ holder?: unknown;
34
+ amountUsd?: unknown;
35
+ coin?: unknown;
36
+ station?: unknown;
37
+ track?: unknown;
38
+ }): Promise<{
39
+ url: string;
40
+ }>;
41
+ }
42
+ export declare const SUPPORT_PAGE: string;
43
+ export declare const SUPPORT_CSP: string;
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Support a copyright holder: a payment through nixamp.com's own CoinPay
3
+ * business, for the holders whose money that business may take.
4
+ *
5
+ * A station's now-playing card links a track's holder to their support page.
6
+ * Holders who collect for themselves register their own link on the station
7
+ * (`nixamp station holder set NAME --support URL`); this page is for the
8
+ * holders nixamp.com collects for -- today Profullstack's own music (the
9
+ * band Þrøngva, AI radio) -- named in NIXAMP_SUPPORT_HOLDERS ("id=Name,...").
10
+ *
11
+ * One payment, in crypto, on CoinPay's hosted checkout. CoinPay offers no
12
+ * crypto subscription a business key can start (its plan/subscribe API is
13
+ * Stripe's, which we do not use), so "monthly" is not offered rather than
14
+ * pretended.
15
+ */
16
+ import { createHash, randomUUID } from "node:crypto";
17
+ export function supportHoldersFrom(env = process.env) {
18
+ const raw = env["NIXAMP_SUPPORT_HOLDERS"] ?? "profullstack=Profullstack";
19
+ return raw.split(",").map((one) => one.trim()).filter(Boolean).map((one) => {
20
+ const [id, ...name] = one.split("=");
21
+ return { id: (id ?? "").trim().toLowerCase(), name: name.join("=").trim() || (id ?? "").trim() };
22
+ }).filter((one) => /^[a-z0-9-]{1,40}$/.test(one.id));
23
+ }
24
+ export const AMOUNTS = [3, 5, 10, 25, 50];
25
+ const MIN_USD = 1;
26
+ const MAX_USD = 1000;
27
+ export class SupportError extends Error {
28
+ status;
29
+ code;
30
+ constructor(message, status = 400, code = "invalid") {
31
+ super(message);
32
+ this.status = status;
33
+ this.code = code;
34
+ }
35
+ }
36
+ export class Support {
37
+ options;
38
+ merchant = null;
39
+ merchantUntil = 0;
40
+ constructor(options) {
41
+ this.options = options;
42
+ }
43
+ holder(id) {
44
+ return this.options.holders.find((one) => one.id === id.toLowerCase());
45
+ }
46
+ get enabled() {
47
+ return Boolean(this.options.key);
48
+ }
49
+ async provider(path, init = {}) {
50
+ if (!this.options.key)
51
+ throw new SupportError("Support payments are not set up here.", 503, "unavailable");
52
+ const response = await (this.options.fetcher ?? fetch)(`https://coinpayportal.com${path}`, {
53
+ ...init,
54
+ signal: AbortSignal.timeout(15_000),
55
+ headers: { authorization: `Bearer ${this.options.key}`, "content-type": "application/json", ...(init.headers ?? {}) },
56
+ });
57
+ const body = (await response.json().catch(() => ({})));
58
+ if (!response.ok || body["success"] !== true)
59
+ throw new SupportError("The payment service did not answer; nothing was charged. Try again.", 502, "provider");
60
+ return body;
61
+ }
62
+ /** The business and the coins it has a wallet for, asked at most every 10 minutes. */
63
+ async coins() {
64
+ const now = this.options.now?.() ?? Date.now();
65
+ if (!this.merchant || now >= this.merchantUntil) {
66
+ this.merchantUntil = now + 600_000;
67
+ this.merchant = this.provider("/api/supported-coins").then((body) => {
68
+ const business = String(body["business_id"] ?? "");
69
+ if (!/^[0-9a-f-]{36}$/.test(business))
70
+ throw new SupportError("Support payments are not set up here.", 503, "unavailable");
71
+ const coins = (Array.isArray(body["coins"]) ? body["coins"] : [])
72
+ .filter((coin) => typeof coin === "object" && coin !== null)
73
+ .filter((coin) => coin.is_active && coin.has_wallet && typeof coin.symbol === "string")
74
+ .map((coin) => coin.symbol);
75
+ return { business, coins };
76
+ }).catch((error) => { this.merchant = null; throw error; });
77
+ }
78
+ return this.merchant;
79
+ }
80
+ /** A CoinPay checkout for this much, to this holder; the address to send the payer to. */
81
+ async checkout(input) {
82
+ const holder = typeof input.holder === "string" ? this.holder(input.holder) : undefined;
83
+ if (!holder)
84
+ throw new SupportError("Nobody by that name is supported here.", 404, "not_found");
85
+ const amount = Number(input.amountUsd);
86
+ if (!Number.isFinite(amount) || amount < MIN_USD || amount > MAX_USD)
87
+ throw new SupportError(`Choose an amount from $${MIN_USD} to $${MAX_USD}.`, 400, "invalid_amount");
88
+ const { business, coins } = await this.coins();
89
+ const coin = typeof input.coin === "string" ? input.coin.toUpperCase() : "";
90
+ if (!coins.includes(coin))
91
+ throw new SupportError(`Pay with one of: ${coins.join(", ")}.`, 400, "invalid_coin");
92
+ const clean = (value) => (typeof value === "string" ? value.replace(/[\u0000-\u001f]/g, " ").slice(0, 120) : "");
93
+ const order = randomUUID();
94
+ const created = await this.provider("/api/payments/create", {
95
+ method: "POST",
96
+ headers: { "idempotency-key": `nixamp-support-${order}` },
97
+ body: JSON.stringify({
98
+ business_id: business,
99
+ amount_usd: Math.round(amount * 100) / 100,
100
+ currency: coin.toLowerCase(),
101
+ payment_method: "crypto",
102
+ description: `Support ${holder.name} on nixamp`,
103
+ metadata: { app: "nixamp", product: "support", holder: holder.id, order, station: clean(input.station), track: clean(input.track) },
104
+ redirect_url: `${this.options.site}/support/${holder.id}?thanks=1`,
105
+ }),
106
+ });
107
+ const payment = created["payment"];
108
+ const id = typeof payment?.id === "string" ? payment.id : "";
109
+ if (!/^[A-Za-z0-9_-]{6,80}$/.test(id))
110
+ throw new SupportError("The checkout did not start; nothing was charged. Try again.", 502, "provider");
111
+ return { url: `https://coinpayportal.com/pay/${id}` };
112
+ }
113
+ }
114
+ const STYLE = `
115
+ :root{--bg:#080c09;--fg:#e8efe9;--muted:#9aa79d;--card:#0e1410;--line:#1f2a22;--accent:#3ddc84}
116
+ *{box-sizing:border-box}html,body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif}
117
+ main{max-width:520px;margin:0 auto;padding:48px 16px}
118
+ .card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:24px}
119
+ h1{margin:0 0 6px;font-size:1.5rem}p{color:var(--muted);margin:0 0 18px}
120
+ .amounts{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 14px}
121
+ .amounts button{flex:1 1 64px;padding:10px;border-radius:10px;border:1px solid var(--line);background:transparent;color:var(--fg);font:inherit;font-weight:700;cursor:pointer}
122
+ .amounts button[aria-pressed=true]{border-color:var(--accent);color:var(--accent)}
123
+ label{display:block;color:var(--muted);font-size:.9rem;margin:10px 0 4px}
124
+ input,select{width:100%;padding:10px;border-radius:10px;border:1px solid var(--line);background:var(--bg);color:var(--fg);font:inherit}
125
+ button.go{margin-top:18px;width:100%;padding:12px;border:0;border-radius:999px;background:var(--accent);color:#06120a;font:inherit;font-weight:800;cursor:pointer}
126
+ button:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid var(--accent);outline-offset:2px}
127
+ #status{min-height:1.4em;margin-top:12px;color:var(--muted)}
128
+ .thanks{color:var(--accent);font-weight:700}
129
+ small{display:block;margin-top:18px;color:var(--muted)}small a{color:inherit}
130
+ `;
131
+ const SCRIPT = `
132
+ (async () => {
133
+ const holder = location.pathname.split("/").filter(Boolean)[1] || "";
134
+ const params = new URLSearchParams(location.search);
135
+ const $ = (s) => document.querySelector(s);
136
+ let amount = 5;
137
+ for (const b of document.querySelectorAll(".amounts button")) {
138
+ b.addEventListener("click", () => {
139
+ amount = Number(b.dataset.usd);
140
+ $("#other").value = "";
141
+ for (const o of document.querySelectorAll(".amounts button")) o.setAttribute("aria-pressed", String(o === b));
142
+ });
143
+ }
144
+ if (params.get("thanks")) { $("#status").className = "thanks"; $("#status").textContent = "Thank you. Your payment is on its way."; }
145
+ try {
146
+ const info = await (await fetch("/api/v1/support/" + encodeURIComponent(holder), { cache: "no-store" })).json();
147
+ if (info.name) { $("#who").textContent = info.name; document.title = "Support " + info.name + " · nixamp"; }
148
+ const coins = info.coins || [];
149
+ $("#coin").replaceChildren(...coins.map((c) => { const o = document.createElement("option"); o.value = c; o.textContent = c.replace(/_/g, " "); return o; }));
150
+ if (!coins.length) { $("#go").disabled = true; $("#status").textContent = info.error || "Payments are not available right now."; }
151
+ } catch { $("#status").textContent = "Payments are not available right now."; }
152
+ $("#form").addEventListener("submit", async (event) => {
153
+ event.preventDefault();
154
+ const other = Number($("#other").value);
155
+ const usd = other > 0 ? other : amount;
156
+ $("#go").disabled = true;
157
+ $("#status").textContent = "Starting the checkout…";
158
+ try {
159
+ const r = await fetch("/api/v1/support/" + encodeURIComponent(holder), { method: "POST", headers: { "content-type": "application/json" },
160
+ body: JSON.stringify({ amountUsd: usd, coin: $("#coin").value, station: params.get("station") || "", track: params.get("track") || "" }) });
161
+ const body = await r.json();
162
+ if (r.ok && body.url) { location.href = body.url; return; }
163
+ $("#status").textContent = body.error || "That did not work.";
164
+ } catch { $("#status").textContent = "Could not reach the payment service."; }
165
+ $("#go").disabled = false;
166
+ });
167
+ })();
168
+ `;
169
+ const hash = (text) => `'sha256-${createHash("sha256").update(text).digest("base64")}'`;
170
+ export const SUPPORT_PAGE = `<!doctype html>
171
+ <html lang="en">
172
+ <head>
173
+ <meta charset="utf-8">
174
+ <meta name="viewport" content="width=device-width, initial-scale=1">
175
+ <title>Support · nixamp</title>
176
+ <link rel="icon" href="/favicon.png">
177
+ <style>${STYLE}</style>
178
+ </head>
179
+ <body>
180
+ <main>
181
+ <div class="card">
182
+ <h1>Support <span id="who">the artist</span></h1>
183
+ <p>Pay the copyright holder of the music you are listening to, directly. One payment, in crypto, through CoinPay.</p>
184
+ <form id="form">
185
+ <div class="amounts">${AMOUNTS.map((usd, at) => `<button type="button" data-usd="${usd}" aria-pressed="${at === 1}">$${usd}</button>`).join("")}</div>
186
+ <label for="other">Or another amount (US$)</label>
187
+ <input id="other" type="number" min="1" max="1000" step="1" inputmode="numeric">
188
+ <label for="coin">Pay with</label>
189
+ <select id="coin"></select>
190
+ <button id="go" class="go" type="submit">Continue to payment</button>
191
+ <div id="status" role="status" aria-live="polite"></div>
192
+ </form>
193
+ <small>Payments go through <a href="https://coinpayportal.com">CoinPay</a> to nixamp.com, which pays the holder. Nothing recurs.</small>
194
+ </div>
195
+ </main>
196
+ <script>${SCRIPT}</script>
197
+ </body>
198
+ </html>
199
+ `;
200
+ export const SUPPORT_CSP = [
201
+ "default-src 'none'", `script-src ${hash(SCRIPT)}`, `style-src ${hash(STYLE)}`, "connect-src 'self'",
202
+ "img-src 'self' data:", "base-uri 'none'", "form-action 'none'", "frame-ancestors 'none'",
203
+ ].join("; ");
@@ -0,0 +1,88 @@
1
+ import type { Station } from "./stations.ts";
2
+ export interface TrackFacts {
3
+ title: string;
4
+ artist: string;
5
+ album: string;
6
+ year: string;
7
+ genre: string;
8
+ ai: {
9
+ generated: boolean | null;
10
+ by: string;
11
+ evidence: string;
12
+ };
13
+ rights: {
14
+ holder: string;
15
+ basis: string;
16
+ note: string;
17
+ license: string;
18
+ source: "tag" | "holder" | "share" | "ai-radio" | "station" | "";
19
+ };
20
+ support: {
21
+ holder: string;
22
+ url: string;
23
+ label: string;
24
+ } | null;
25
+ }
26
+ export interface Holder {
27
+ id: string;
28
+ name: string;
29
+ /** Artist names (as tagged) whose tracks are theirs; case does not matter. */
30
+ artists: string[];
31
+ /** Stations whose every track is theirs. */
32
+ stations: string[];
33
+ /** Where a listener supports them: a subscription or donation page (https). */
34
+ support: string;
35
+ /** The button's words, e.g. "Subscribe" or "Donate". */
36
+ label: string;
37
+ /** e.g. "All rights reserved", "CC BY 4.0". */
38
+ license: string;
39
+ }
40
+ /** The tags ffprobe reads from a file, lower-cased, cached by size and time. */
41
+ export declare class TagReader {
42
+ private readonly ffprobe;
43
+ private cache;
44
+ constructor(ffprobe: string[]);
45
+ /** Known now, or {} while it is read in the background. */
46
+ known(path: string): Record<string, string> | null;
47
+ read(path: string): Promise<Record<string, string>>;
48
+ }
49
+ /** Whether these tags say the track was made by AI, and with what. */
50
+ export declare function aiFromTags(tags: Record<string, string>): {
51
+ by: string;
52
+ evidence: string;
53
+ } | null;
54
+ export interface FactSources {
55
+ station: Station;
56
+ file: string;
57
+ tags: Record<string, string>;
58
+ /** What the library knows when the file has no tag for it. */
59
+ known?: {
60
+ title: string;
61
+ artist: string;
62
+ album: string;
63
+ } | null;
64
+ holders: Holder[];
65
+ /** A listener's share of this file, when it is one. */
66
+ share?: {
67
+ basis: string;
68
+ note: string;
69
+ by: string;
70
+ } | null;
71
+ /** AI radio's record of this file, when it composed it. */
72
+ aiTrack?: {
73
+ made: string;
74
+ } | null;
75
+ }
76
+ export declare function factsFor(input: FactSources): TrackFacts;
77
+ /** The copyright holders this server's owner registered. */
78
+ export declare class HolderStore {
79
+ private readonly file;
80
+ private holders;
81
+ constructor(file: string);
82
+ static fileFor(stateDir: string, port: number): string;
83
+ private save;
84
+ list(): Holder[];
85
+ /** Make or change a holder, found by id or by name. */
86
+ set(input: Record<string, unknown>): Holder;
87
+ remove(id: string): boolean;
88
+ }
@@ -0,0 +1,220 @@
1
+ /**
2
+ * What a listener may know about the track on the air: who made it, whether
3
+ * it was made by AI, who holds its copyright, and how to support them.
4
+ *
5
+ * Gathered from what is already known, never guessed into a fact:
6
+ * - the file's own tags (title, artist, album, year, genre, and the
7
+ * copyright, publisher, comment and encoder tags ffprobe reads);
8
+ * - where it came from on this server: composed by AI radio, shared by a
9
+ * listener (with their declaration), or the station's own declared basis;
10
+ * - the copyright holders the owner registered (`nixamp station holder`),
11
+ * matched by artist or station, with the link that supports them.
12
+ * "AI-generated" is true only on evidence (a tag naming Suno, Udio,
13
+ * ElevenLabs...; AI radio's own files), and null -- not stated -- otherwise.
14
+ */
15
+ import { spawn } from "node:child_process";
16
+ import { mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
17
+ import { dirname, join } from "node:path";
18
+ import { StationError } from "./stations.js";
19
+ /** The tags ffprobe reads from a file, lower-cased, cached by size and time. */
20
+ export class TagReader {
21
+ ffprobe;
22
+ cache = new Map();
23
+ constructor(ffprobe) {
24
+ this.ffprobe = ffprobe;
25
+ }
26
+ /** Known now, or {} while it is read in the background. */
27
+ known(path) {
28
+ const hit = this.cache.get(path);
29
+ if (!hit)
30
+ return null;
31
+ try {
32
+ const info = statSync(path);
33
+ return hit.key === `${info.size}:${info.mtimeMs}` ? hit.tags : null;
34
+ }
35
+ catch {
36
+ return hit.tags;
37
+ }
38
+ }
39
+ read(path) {
40
+ const known = this.known(path);
41
+ if (known)
42
+ return Promise.resolve(known);
43
+ const [command, ...prefix] = this.ffprobe;
44
+ if (!command)
45
+ return Promise.resolve({});
46
+ return new Promise((done) => {
47
+ const child = spawn(command, [...prefix, "-v", "error", "-print_format", "json", "-show_entries", "format_tags:stream_tags", path], { stdio: ["ignore", "pipe", "ignore"] });
48
+ let out = "";
49
+ const timer = setTimeout(() => child.kill("SIGKILL"), 10_000);
50
+ timer.unref?.();
51
+ child.stdout.on("data", (chunk) => { if (out.length < 1024 * 1024)
52
+ out += chunk.toString("utf8"); });
53
+ child.on("error", () => { clearTimeout(timer); done({}); });
54
+ child.on("close", () => {
55
+ clearTimeout(timer);
56
+ const tags = {};
57
+ try {
58
+ const parsed = JSON.parse(out);
59
+ for (const source of [...(parsed.streams ?? []).map((one) => one.tags ?? {}), parsed.format?.tags ?? {}]) {
60
+ for (const [name, value] of Object.entries(source))
61
+ if (typeof value === "string")
62
+ tags[name.toLowerCase()] = value.slice(0, 2000);
63
+ }
64
+ }
65
+ catch {
66
+ // No tags is an answer too.
67
+ }
68
+ try {
69
+ const info = statSync(path);
70
+ if (this.cache.size > 5000)
71
+ this.cache.clear();
72
+ this.cache.set(path, { key: `${info.size}:${info.mtimeMs}`, tags });
73
+ }
74
+ catch {
75
+ // Gone already.
76
+ }
77
+ done(tags);
78
+ });
79
+ });
80
+ }
81
+ }
82
+ const AI_MARKS = [
83
+ [/\bsuno\b/i, "Suno"],
84
+ [/\budio\b/i, "Udio"],
85
+ [/elevenlabs|eleven labs/i, "ElevenLabs Music"],
86
+ [/musicgen|audiocraft/i, "MusicGen"],
87
+ [/stable audio/i, "Stable Audio"],
88
+ [/riffusion/i, "Riffusion"],
89
+ [/ace-?step/i, "ACE-Step"],
90
+ [/mubert/i, "Mubert"],
91
+ [/\b(ai[- ]generated|generated (by|with) ai|made (by|with) ai)\b/i, "AI"],
92
+ ];
93
+ /** Whether these tags say the track was made by AI, and with what. */
94
+ export function aiFromTags(tags) {
95
+ for (const field of ["comment", "description", "encoded_by", "encoder", "software", "tsse", "publisher", "copyright", "genre"]) {
96
+ const value = tags[field];
97
+ if (!value)
98
+ continue;
99
+ // The encoder field is usually just "Lavf..." -- only a named model counts.
100
+ for (const [pattern, by] of AI_MARKS) {
101
+ if (pattern.test(value))
102
+ return { by, evidence: `${field} tag: "${value.slice(0, 120)}"` };
103
+ }
104
+ }
105
+ if (/^ai$/i.test(tags["genre"] ?? ""))
106
+ return { by: "AI", evidence: 'genre tag: "AI"' };
107
+ return null;
108
+ }
109
+ export function factsFor(input) {
110
+ const { station, tags } = input;
111
+ const title = tags["title"] || input.known?.title || "";
112
+ const artist = tags["artist"] || tags["album_artist"] || input.known?.artist || "";
113
+ const album = tags["album"] || input.known?.album || "";
114
+ const year = (tags["date"] || tags["year"] || tags["tdrc"] || "").slice(0, 4);
115
+ const genre = tags["genre"] || "";
116
+ const tagged = aiFromTags(tags);
117
+ const ai = input.aiTrack
118
+ ? { generated: true, by: input.aiTrack.made.split("+").pop()?.trim() || "AI", evidence: "composed for this station by AI radio" }
119
+ : tagged
120
+ ? { generated: true, by: tagged.by, evidence: tagged.evidence }
121
+ : { generated: null, by: "", evidence: "" };
122
+ const lower = artist.toLowerCase();
123
+ const holder = input.holders.find((one) => one.stations.includes(station.id))
124
+ ?? (lower ? input.holders.find((one) => one.artists.some((name) => name.toLowerCase() === lower)) : undefined);
125
+ const copyright = tags["copyright"] || tags["tcop"] || "";
126
+ let rights;
127
+ if (copyright) {
128
+ rights = { holder: copyright.replace(/^(\(c\)|©|copyright)\s*/i, "").slice(0, 200), basis: "", note: "", license: holder?.license ?? "", source: "tag" };
129
+ }
130
+ else if (holder) {
131
+ rights = { holder: holder.name, basis: station.rights?.basis ?? "", note: "", license: holder.license, source: "holder" };
132
+ }
133
+ else if (input.share) {
134
+ rights = { holder: input.share.by || "the person who shared it", basis: input.share.basis, note: input.share.note, license: input.share.basis === "creative-commons" || input.share.basis === "public-domain" ? input.share.note : "", source: "share" };
135
+ }
136
+ else if (input.aiTrack) {
137
+ rights = { holder: "this station's owner", basis: "owned", note: station.rights?.note ?? "", license: "", source: "ai-radio" };
138
+ }
139
+ else if (station.rights) {
140
+ rights = { holder: "", basis: station.rights.basis, note: station.rights.note, license: "", source: "station" };
141
+ }
142
+ else {
143
+ rights = { holder: "", basis: "", note: "", license: "", source: "" };
144
+ }
145
+ const support = holder && holder.support ? { holder: holder.name, url: holder.support, label: holder.label || "Support" } : null;
146
+ return { title, artist, album, year, genre, ai, rights, support };
147
+ }
148
+ function slug(text) {
149
+ return text.normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "holder";
150
+ }
151
+ /** The copyright holders this server's owner registered. */
152
+ export class HolderStore {
153
+ file;
154
+ holders = new Map();
155
+ constructor(file) {
156
+ this.file = file;
157
+ try {
158
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
159
+ for (const one of parsed.holders ?? [])
160
+ if (one?.id)
161
+ this.holders.set(one.id, one);
162
+ }
163
+ catch {
164
+ // None yet.
165
+ }
166
+ }
167
+ static fileFor(stateDir, port) {
168
+ return join(stateDir, `holders-${port}.json`);
169
+ }
170
+ save() {
171
+ mkdirSync(dirname(this.file), { recursive: true });
172
+ const temporary = `${this.file}.${process.pid}.tmp`;
173
+ writeFileSync(temporary, `${JSON.stringify({ version: 1, holders: [...this.holders.values()] }, null, 2)}\n`, { mode: 0o600 });
174
+ renameSync(temporary, this.file);
175
+ }
176
+ list() {
177
+ return [...this.holders.values()];
178
+ }
179
+ /** Make or change a holder, found by id or by name. */
180
+ set(input) {
181
+ const name = typeof input["name"] === "string" ? input["name"].replace(/[\u0000-\u001f]/g, " ").trim().slice(0, 120) : "";
182
+ const id = typeof input["id"] === "string" && input["id"] ? input["id"] : slug(name);
183
+ const existing = this.holders.get(id);
184
+ if (!existing && !name)
185
+ throw new StationError("name_required", "a copyright holder needs a name");
186
+ const words = (value) => Array.isArray(value)
187
+ ? value.filter((one) => typeof one === "string").map((one) => one.trim().slice(0, 120)).filter(Boolean).slice(0, 100)
188
+ : undefined;
189
+ const support = typeof input["support"] === "string" ? input["support"].trim() : undefined;
190
+ if (support) {
191
+ let url;
192
+ try {
193
+ url = new URL(support);
194
+ }
195
+ catch {
196
+ throw new StationError("invalid_support", "the support link is a web address (https://...)");
197
+ }
198
+ if (url.protocol !== "https:")
199
+ throw new StationError("invalid_support", "the support link must be https");
200
+ }
201
+ const holder = {
202
+ id,
203
+ name: name || existing.name,
204
+ artists: words(input["artists"]) ?? existing?.artists ?? [],
205
+ stations: (words(input["stations"]) ?? existing?.stations ?? []).filter((one) => /^[a-z0-9]{10}$/.test(one)),
206
+ support: support ?? existing?.support ?? "",
207
+ label: typeof input["label"] === "string" ? input["label"].trim().slice(0, 30) : existing?.label ?? "Support",
208
+ license: typeof input["license"] === "string" ? input["license"].trim().slice(0, 120) : existing?.license ?? "",
209
+ };
210
+ this.holders.set(id, holder);
211
+ this.save();
212
+ return holder;
213
+ }
214
+ remove(id) {
215
+ const had = this.holders.delete(id);
216
+ if (had)
217
+ this.save();
218
+ return had;
219
+ }
220
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.39.2",
3
+ "version": "0.40.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",