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
package/src/ai-radio.ts CHANGED
@@ -302,6 +302,15 @@ export class AiRadio {
302
302
  return this.stations.get(key);
303
303
  }
304
304
 
305
+ /** The track AI radio composed at this path, if it did. */
306
+ trackAt(file: string): AiTrack | undefined {
307
+ for (const station of this.stations.values()) {
308
+ const hit = station.tracks.find((one) => one.file === file);
309
+ if (hit) return hit;
310
+ }
311
+ return undefined;
312
+ }
313
+
305
314
  /** What the owner sees: the stations, this month's seconds, and why nothing is being made. */
306
315
  status(): Record<string, unknown> {
307
316
  const month = monthOf(this.now());
package/src/ai-routes.ts CHANGED
@@ -13,6 +13,7 @@ import { AI_KEY_NAMES, aiKeysSet, saveAiKey, type AiKeyName } from "./ai-keys.ts
13
13
  import type { AiRadio } from "./ai-radio.ts";
14
14
  import { SHARE_BASES, SHARE_GENRES, ownerShareView, sharerView, type ListenerShares } from "./listener-shares.ts";
15
15
  import { StationError } from "./stations.ts";
16
+ import type { HolderStore } from "./track-facts.ts";
16
17
 
17
18
  const PUBLIC = /^\/api\/v1\/share(?:\/(upload|report|[a-f0-9]{12}))?$/;
18
19
 
@@ -24,6 +25,8 @@ export function isPublicSharePath(path: string): boolean {
24
25
  export interface AiRoutesContext {
25
26
  ai?: AiRadio;
26
27
  shares?: ListenerShares;
28
+ /** The copyright holders the owner registered, for the now-playing card. */
29
+ holders?: HolderStore;
27
30
  canControl: boolean;
28
31
  /** Who is asking, for per-sharer limits (an address; hashed before it is kept). */
29
32
  caller: string;
@@ -57,7 +60,8 @@ export async function handleAiRoutes(
57
60
  ): Promise<boolean> {
58
61
  const method = request.method ?? "GET";
59
62
  const publicPath = PUBLIC.exec(path);
60
- const ownerPath = path === "/api/v1/stations/ai" || path.startsWith("/api/v1/stations/ai/") || path === "/api/v1/stations/shares" || path.startsWith("/api/v1/stations/shares/");
63
+ const ownerPath = path === "/api/v1/stations/ai" || path.startsWith("/api/v1/stations/ai/") || path === "/api/v1/stations/shares" || path.startsWith("/api/v1/stations/shares/")
64
+ || path === "/api/v1/stations/holders" || path.startsWith("/api/v1/stations/holders/");
61
65
  if (!publicPath && !ownerPath) return false;
62
66
  try {
63
67
  if (publicPath) {
@@ -114,6 +118,30 @@ export async function handleAiRoutes(
114
118
  return true;
115
119
  }
116
120
 
121
+ if (path === "/api/v1/stations/holders" || path.startsWith("/api/v1/stations/holders/")) {
122
+ const holders = ctx.holders;
123
+ if (!holders) {
124
+ ctx.json(response, 404, { error: "copyright holders are not kept here", code: "not_found" });
125
+ return true;
126
+ }
127
+ if (path === "/api/v1/stations/holders" && method === "GET") {
128
+ ctx.json(response, 200, { holders: holders.list() });
129
+ return true;
130
+ }
131
+ if (path === "/api/v1/stations/holders" && method === "POST") {
132
+ ctx.json(response, 200, { holder: holders.set(await jsonBody(ctx, request)) });
133
+ return true;
134
+ }
135
+ const one = /^\/api\/v1\/stations\/holders\/([a-z0-9-]{1,40})$/.exec(path);
136
+ if (one && method === "DELETE") {
137
+ if (!holders.remove(one[1]!)) throw new StationError("not_found", "no such holder", 404);
138
+ ctx.json(response, 200, { deleted: one[1] });
139
+ return true;
140
+ }
141
+ ctx.json(response, 404, { error: "no such holder route", code: "not_found" });
142
+ return true;
143
+ }
144
+
117
145
  if (path === "/api/v1/stations/shares" || path.startsWith("/api/v1/stations/shares/")) {
118
146
  const shares = ctx.shares;
119
147
  if (!shares) {
@@ -215,6 +215,11 @@ export class ListenerShares {
215
215
  return this.shares.get(id);
216
216
  }
217
217
 
218
+ /** The share whose copy is this file, if it is one. */
219
+ atFile(file: string): Share | undefined {
220
+ return [...this.shares.values()].find((one) => one.file === file);
221
+ }
222
+
218
223
  /** The share, if this token is the one its sharer was given. */
219
224
  forSharer(id: string, token: string): Share | undefined {
220
225
  const share = this.shares.get(id);
package/src/server.ts CHANGED
@@ -80,6 +80,8 @@ import { handleHandoffs } from "./handoff-routes.ts";
80
80
  import { handleAiRoutes, isPublicSharePath } from "./ai-routes.ts";
81
81
  import { AiRadio } from "./ai-radio.ts";
82
82
  import { ListenerShares } from "./listener-shares.ts";
83
+ import { HolderStore, TagReader, factsFor } from "./track-facts.ts";
84
+ import { SUPPORT_CSP, SUPPORT_PAGE, Support, SupportError, supportHoldersFrom } from "./support.ts";
83
85
  import { STATION_APP_CSP, STATION_PAGE, STATION_PAGE_CSP, STATION_PAGE_HTML } from "./station-page.ts";
84
86
  import { GUIDE_CSP, guideHosts, guidePeers, peerBase, PeerGuides, renderGuide, type GuideEntry } from "./station-guide.ts";
85
87
  import { StationRunner } from "./station-runner.ts";
@@ -1736,6 +1738,10 @@ export interface HandlerOptions {
1736
1738
  ai?: AiRadio;
1737
1739
  /** Tracks anybody may share, sorted onto the air by AI. */
1738
1740
  shares?: ListenerShares;
1741
+ /** What a listener may know about the track on the air. */
1742
+ facts?: (station: Station, file: string) => Promise<import("./track-facts.ts").TrackFacts>;
1743
+ /** Copyright holders, matched to tracks by artist or station. */
1744
+ holders?: import("./track-facts.ts").HolderStore;
1739
1745
  /** Proxies (by address) whose X-Forwarded-For is believed, for per-sharer limits. */
1740
1746
  trustedProxies?: () => Set<string>;
1741
1747
  /** Other servers' public stations, for this guide to list beside its own. */
@@ -1914,6 +1920,8 @@ export interface HandlerOptions {
1914
1920
  speech?: Speech;
1915
1921
  liveVoice?: LiveVoice;
1916
1922
  translationPasses?: TranslationPasses;
1923
+ /** Payments to copyright holders nixamp.com collects for (support.ts). */
1924
+ support?: Support;
1917
1925
  /** Signing secret of nixamp's CoinPay business, for /api/v1/webhooks/coinpay. */
1918
1926
  coinpayWebhookSecret?: string;
1919
1927
  sharedTranslations?: SharedTranslations;
@@ -2687,6 +2695,8 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2687
2695
  !isPublicSharePath(path) &&
2688
2696
  // What is on, as MCP, for anybody.
2689
2697
  path !== "/mcp/public" &&
2698
+ // Supporting a copyright holder is for anybody listening.
2699
+ !/^\/support\/[a-z0-9-]{1,40}$/.test(path) && !/^\/api\/v1\/support\/[a-z0-9-]{1,40}$/.test(path) &&
2690
2700
  // A station's short link, which sends the browser to wherever it plays.
2691
2701
  !/^\/watch\/[a-z0-9]{10}$/.test(path) &&
2692
2702
  !STATION_PAGE.test(path) &&
@@ -2753,6 +2763,50 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2753
2763
  * in the directory). It sends the browser to the station's own page, on
2754
2764
  * the server that plays it. Nothing private or unlisted is ever found.
2755
2765
  */
2766
+ /* Support a copyright holder: the page, the coins, and a CoinPay checkout. */
2767
+ const supportPage = /^\/support\/([a-z0-9-]{1,40})$/.exec(path);
2768
+ if (supportPage && options.support && (request.method === "GET" || request.method === "HEAD")) {
2769
+ if (!options.support.holder(supportPage[1]!)) {
2770
+ json(response, 404, { error: "nobody by that name is supported here", code: "not_found" });
2771
+ return;
2772
+ }
2773
+ response.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-cache", "content-security-policy": SUPPORT_CSP, "referrer-policy": "no-referrer", "x-content-type-options": "nosniff" });
2774
+ response.end(request.method === "HEAD" ? undefined : SUPPORT_PAGE);
2775
+ return;
2776
+ }
2777
+ const supportApi = /^\/api\/v1\/support\/([a-z0-9-]{1,40})$/.exec(path);
2778
+ if (supportApi && options.support) {
2779
+ const support = options.support;
2780
+ const holder = support.holder(supportApi[1]!);
2781
+ if (!holder) {
2782
+ json(response, 404, { error: "nobody by that name is supported here", code: "not_found" });
2783
+ return;
2784
+ }
2785
+ try {
2786
+ if (request.method === "GET") {
2787
+ const { coins } = support.enabled ? await support.coins() : { coins: [] as string[] };
2788
+ json(response, 200, { id: holder.id, name: holder.name, coins, ...(support.enabled ? {} : { error: "Support payments are not set up here." }) });
2789
+ return;
2790
+ }
2791
+ if (request.method === "POST") {
2792
+ const verdict = guard.check(`support:${callerOf(request.headers, request.socket.remoteAddress, behindProxy)}`, { allowed: 10, windowMs: 60_000 });
2793
+ if (!verdict.ok) {
2794
+ json(response, 429, { error: "too many at once; wait a minute", code: "rate_limited", retryAfter: verdict.retryAfter });
2795
+ return;
2796
+ }
2797
+ let body: Record<string, unknown> = {};
2798
+ try { body = JSON.parse(await readBody(request)) as Record<string, unknown>; } catch { body = {}; }
2799
+ json(response, 200, await support.checkout({ ...body, holder: holder.id }));
2800
+ return;
2801
+ }
2802
+ json(response, 405, { error: "GET or POST", code: "method_not_allowed" });
2803
+ } catch (error) {
2804
+ if (error instanceof SupportError) json(response, error.status, { error: error.message, code: error.code });
2805
+ else json(response, 502, { error: "the payment service did not answer; nothing was charged", code: "provider" });
2806
+ }
2807
+ return;
2808
+ }
2809
+
2756
2810
  const stationShortLink = /^\/watch\/([a-z0-9]{10})$/.exec(path);
2757
2811
  if (stationShortLink && options.stations && (request.method === "GET" || request.method === "HEAD")) {
2758
2812
  const encrypted = (request.socket as { encrypted?: boolean }).encrypted === true;
@@ -2856,7 +2910,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2856
2910
  return;
2857
2911
  }
2858
2912
 
2859
- if (options.stations && (isPublicSharePath(path) || path.startsWith("/api/v1/stations/ai") || path.startsWith("/api/v1/stations/shares"))) {
2913
+ if (options.stations && (isPublicSharePath(path) || path.startsWith("/api/v1/stations/ai") || path.startsWith("/api/v1/stations/shares") || path.startsWith("/api/v1/stations/holders"))) {
2860
2914
  const control = await controlOf();
2861
2915
  // Behind a proxy we trust (dev2 for live.streaming), the sharer is the
2862
2916
  // first address it names; otherwise whoever connected.
@@ -2866,6 +2920,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2866
2920
  const handled = await handleAiRoutes(request, response, path, url, {
2867
2921
  ...(options.stations.ai ? { ai: options.stations.ai } : {}),
2868
2922
  ...(options.stations.shares ? { shares: options.stations.shares } : {}),
2923
+ ...(options.stations.holders ? { holders: options.stations.holders } : {}),
2869
2924
  canControl: control.allowed,
2870
2925
  caller,
2871
2926
  json,
@@ -2916,6 +2971,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2916
2971
  ...(options.stations.usage ? { usage: options.stations.usage } : {}),
2917
2972
  ...(options.stations.limits ? { limits: options.stations.limits } : {}),
2918
2973
  ...(options.stations.auto ? { auto: options.stations.auto } : {}),
2974
+ ...(options.stations.facts ? { facts: options.stations.facts } : {}),
2919
2975
  ...(options.stations.peers ? { peers: () => options.stations!.peers!.entries() } : {}),
2920
2976
  throttle: () => {
2921
2977
  const verdict = guard.check(`stations:${control.account || callerOf(request.headers, request.socket.remoteAddress, behindProxy)}`, STATION_CONTROL_LIMIT);
@@ -7006,6 +7062,23 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
7006
7062
  });
7007
7063
  stations.ai = aiRadio;
7008
7064
  stations.shares = listenerShares;
7065
+ // The now-playing card: the file's own tags, where it came from here, and
7066
+ // the copyright holders the owner registered.
7067
+ const tagReader = new TagReader(tools.ffprobe);
7068
+ const holderStore = new HolderStore(HolderStore.fileFor(stateDir(), options.port));
7069
+ stations.holders = holderStore;
7070
+ stations.facts = async (station, file) => {
7071
+ const share = listenerShares.atFile(file);
7072
+ const composed = aiRadio.trackAt(file);
7073
+ return factsFor({
7074
+ station, file,
7075
+ tags: await tagReader.read(file),
7076
+ known: trackFor(engine, file),
7077
+ holders: holderStore.list(),
7078
+ share: share ? { basis: share.rights.basis, note: share.rights.note, by: share.by } : null,
7079
+ aiTrack: composed ? { made: composed.made } : null,
7080
+ });
7081
+ };
7009
7082
  // Channels as HLS, on demand, for Safari on a phone: one ffmpeg copying a
7010
7083
  // channel's fragments into short files while somebody is asking for them.
7011
7084
  // What things are, from nichedb.dev, remembered beside the keys so a
@@ -7138,6 +7211,8 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
7138
7211
  // is temporarily unconfigured. Self-hosters can explicitly sponsor usage.
7139
7212
  const translationPasses = accounts && pool && process.env["NIXAMP_TRANSLATION_BILLING"] !== "off"
7140
7213
  ? new TranslationPasses({ db: pool, key: coinpayKeyFrom(), site: nixampSite }) : undefined;
7214
+ // Payments to the copyright holders nixamp.com collects for, on its own CoinPay business.
7215
+ const supportPayments = accounts ? new Support({ key: coinpayKeyFrom(), site: nixampSite, holders: supportHoldersFrom() }) : undefined;
7141
7216
  if (translationPasses) void translationPasses.access().catch(() => console.error("nixamp: translation checkout could not warm; it will retry on demand"));
7142
7217
  const liveVoice = accounts && process.env["NIXAMP_DUBBING"] !== "off" ? new LiveVoice({
7143
7218
  ...(pool ? { db: pool } : {}),
@@ -7690,6 +7765,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
7690
7765
  ...(speech ? { speech } : {}),
7691
7766
  ...(liveVoice ? { liveVoice } : {}),
7692
7767
  ...(translationPasses ? { translationPasses } : {}),
7768
+ ...(supportPayments ? { support: supportPayments } : {}),
7693
7769
  ...(process.env["COINPAY_WEBHOOK_SECRET"] ? { coinpayWebhookSecret: process.env["COINPAY_WEBHOOK_SECRET"] } : {}),
7694
7770
  ...(sharedTranslations ? { sharedTranslations } : {}),
7695
7771
  ...(translator ? { translator } : {}),
@@ -253,6 +253,28 @@ export async function station(argv: string[]): Promise<number> {
253
253
  return await aiCommand(argv.slice(1), call, show, json);
254
254
  case "shares":
255
255
  return await sharesCommand(argv.slice(1), call, show);
256
+ case "holder":
257
+ case "holders": {
258
+ const sub = argv[1] ?? "list";
259
+ const all = (flagName: string): string[] => argv.flatMap((one, at) => (one === flagName && argv[at + 1] ? [argv[at + 1]!] : []));
260
+ const line = (h: Record<string, unknown>): string =>
261
+ `${String(h["name"])} [${String(h["id"])}]\n artists ${((h["artists"] as string[]) ?? []).join(", ") || "-"}\n stations ${((h["stations"] as string[]) ?? []).join(", ") || "-"}\n support ${String(h["support"] || "-")}${h["license"] ? `\n licence ${String(h["license"])}` : ""}`;
262
+ if (sub === "list") return show(await call("GET", "/api/v1/stations/holders"), (body) => ((body["holders"] as Record<string, unknown>[]) ?? []).map(line).join("\n\n") || "No copyright holders yet. nixamp station holder set NAME --artist ARTIST --support URL");
263
+ if (sub === "remove") return show(await call("DELETE", `/api/v1/stations/holders/${argv[2] ?? ""}`), () => `Removed ${argv[2]}.`);
264
+ if (sub === "set") {
265
+ const name = argv[2];
266
+ if (!name || name.startsWith("--")) { console.error("nixamp station holder set needs a NAME"); return 2; }
267
+ const body: Record<string, unknown> = { name };
268
+ if (all("--artist").length) body["artists"] = all("--artist");
269
+ if (all("--station").length) body["stations"] = all("--station");
270
+ if (flag(argv, "--support")) body["support"] = flag(argv, "--support");
271
+ if (flag(argv, "--label")) body["label"] = flag(argv, "--label");
272
+ if (flag(argv, "--license")) body["license"] = flag(argv, "--license");
273
+ return show(await call("POST", "/api/v1/stations/holders", body), (answer) => line(answer["holder"] as Record<string, unknown>));
274
+ }
275
+ console.error(`nixamp station holder: no command "${sub}".\n\n${USAGE}`);
276
+ return 2;
277
+ }
256
278
  case "delete": {
257
279
  const station = needId();
258
280
  if (!station) return 2;
@@ -123,6 +123,21 @@ export const STATION_TOOLS = [
123
123
  },
124
124
  },
125
125
  },
126
+ {
127
+ name: "station_holders",
128
+ description: "Copyright holders for the now-playing card: who holds a track's copyright (matched by artist name or station) and the link that supports them. action list (default), set (name, artists, stations, support https URL, label, license), remove (id).",
129
+ inputSchema: {
130
+ type: "object",
131
+ properties: {
132
+ server: SERVER,
133
+ action: { type: "string", enum: ["list", "set", "remove"] },
134
+ id: STRING, name: STRING,
135
+ artists: { type: "array", items: STRING }, stations: { type: "array", items: STRING },
136
+ support: { ...STRING, description: "https link where listeners subscribe or donate, e.g. https://nixamp.com/support/profullstack" },
137
+ label: STRING, license: STRING,
138
+ },
139
+ },
140
+ },
126
141
  {
127
142
  name: "station_unpublish",
128
143
  description: "Take a station out of the directory, or make it private: its links stop working and listeners are disconnected.",
@@ -213,6 +228,15 @@ export async function callStationTool(name: string, args: Record<string, unknown
213
228
  if (answer === undefined) return say("action is status, create, generate, update, remove, budget or resume", true);
214
229
  return answer.ok ? say(JSON.stringify(answer.body, null, 2)) : refused(answer.body);
215
230
  }
231
+ if (name === "station_holders") {
232
+ const action = String(args["action"] ?? "list");
233
+ const answer = action === "list" ? await call("GET", "/api/v1/stations/holders")
234
+ : action === "set" ? await call("POST", "/api/v1/stations/holders", pick(["id", "name", "artists", "stations", "support", "label", "license"]))
235
+ : action === "remove" && typeof args["id"] === "string" ? await call("DELETE", `/api/v1/stations/holders/${encodeURIComponent(args["id"])}`)
236
+ : null;
237
+ if (!answer) return say("action is list, set or remove (remove needs id)", true);
238
+ return answer.ok ? say(JSON.stringify(answer.body, null, 2)) : refused(answer.body);
239
+ }
216
240
  if (name === "station_shares") {
217
241
  const action = String(args["action"] ?? "status");
218
242
  const share = typeof args["id"] === "string" && /^[a-f0-9]{12}$/.test(args["id"]) ? args["id"] : "";
@@ -16,12 +16,13 @@ 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 { alertsFor, type Limits, type StationUsage } from "./station-metering.ts";
19
+ import type { TrackFacts } from "./track-facts.ts";
19
20
  import {
20
21
  StationError, channelOf, tokensMatch, type OwnerStation, type PublicStation, type Station, type StationStore,
21
22
  } from "./stations.ts";
22
23
 
23
24
  /** The three things a station's link opens. */
24
- const LISTEN = /^\/api\/v1\/stations\/([a-z0-9]{10})\/(stream|state|listen\.m3u|events)$/;
25
+ const LISTEN = /^\/api\/v1\/stations\/([a-z0-9]{10})\/(stream|state|listen\.m3u|events|now)$/;
25
26
 
26
27
  /** Whether this path is one a station's own link may open, without the server's key. */
27
28
  export function isStationListenPath(path: string): boolean {
@@ -59,6 +60,8 @@ export interface StationContext {
59
60
  limits?: Limits;
60
61
  /** Null to go ahead with a change, or how long to wait (control rate limit). */
61
62
  throttle?: () => { retryAfter: number } | 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?: { sync: (input: Record<string, unknown>) => Promise<unknown>; setting: () => unknown };
64
67
  /** The public stations other servers list (guide peers); absent when there are none. */
@@ -210,7 +213,19 @@ export async function handleStations(
210
213
  const view = publicView(ctx, station);
211
214
  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 };
212
215
  };
213
- send("hello", now());
216
+ // Each "now" carries what is known about the track: who made it, AI or
217
+ // not, whose copyright, how to support them.
218
+ const withFacts = async (): Promise<Record<string, unknown>> => {
219
+ const payload = now();
220
+ const file = nowPlayingFile(ctx, station);
221
+ if (!ctx.facts || !file) return payload;
222
+ try {
223
+ return { ...payload, facts: await ctx.facts(station, file) };
224
+ } catch {
225
+ return payload;
226
+ }
227
+ };
228
+ void withFacts().then((payload) => send("hello", payload));
214
229
  const tick = setInterval(() => {
215
230
  const current = ctx.store.get(station.id);
216
231
  if (!current) {
@@ -222,7 +237,7 @@ export async function handleStations(
222
237
  if (file !== lastFile) {
223
238
  lastFile = file;
224
239
  instance += 1;
225
- send("now", now());
240
+ void withFacts().then((payload) => send("now", payload));
226
241
  } else if (state !== lastState) {
227
242
  send("state", now());
228
243
  }
@@ -241,6 +256,11 @@ export async function handleStations(
241
256
  response.on("close", leave);
242
257
  return true;
243
258
  }
259
+ if (what === "now") {
260
+ const file = ctx.runner.observed(station.id) === "live" || ctx.runner.observed(station.id) === "recovering" ? nowPlayingFile(ctx, station) : "";
261
+ ctx.json(response, 200, { station: station.id, facts: file && ctx.facts ? await ctx.facts(station, file).catch(() => null) : null });
262
+ return true;
263
+ }
244
264
  if (what === "state") {
245
265
  ctx.json(response, 200, publicView(ctx, station));
246
266
  return true;
@@ -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
 
package/src/support.ts ADDED
@@ -0,0 +1,206 @@
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
+
18
+ export interface SupportHolder { id: string; name: string }
19
+
20
+ export function supportHoldersFrom(env: Record<string, string | undefined> = process.env): SupportHolder[] {
21
+ const raw = env["NIXAMP_SUPPORT_HOLDERS"] ?? "profullstack=Profullstack";
22
+ return raw.split(",").map((one) => one.trim()).filter(Boolean).map((one) => {
23
+ const [id, ...name] = one.split("=");
24
+ return { id: (id ?? "").trim().toLowerCase(), name: name.join("=").trim() || (id ?? "").trim() };
25
+ }).filter((one) => /^[a-z0-9-]{1,40}$/.test(one.id));
26
+ }
27
+
28
+ export const AMOUNTS = [3, 5, 10, 25, 50];
29
+ const MIN_USD = 1;
30
+ const MAX_USD = 1000;
31
+
32
+ export class SupportError extends Error {
33
+ constructor(message: string, readonly status = 400, readonly code = "invalid") {
34
+ super(message);
35
+ }
36
+ }
37
+
38
+ export class Support {
39
+ private merchant: Promise<{ business: string; coins: string[] }> | null = null;
40
+ private merchantUntil = 0;
41
+
42
+ constructor(private readonly options: { key: string; site: string; holders: SupportHolder[]; fetcher?: typeof fetch; now?: () => number }) {}
43
+
44
+ holder(id: string): SupportHolder | undefined {
45
+ return this.options.holders.find((one) => one.id === id.toLowerCase());
46
+ }
47
+
48
+ get enabled(): boolean {
49
+ return Boolean(this.options.key);
50
+ }
51
+
52
+ private async provider(path: string, init: RequestInit = {}): Promise<Record<string, unknown>> {
53
+ if (!this.options.key) throw new SupportError("Support payments are not set up here.", 503, "unavailable");
54
+ const response = await (this.options.fetcher ?? fetch)(`https://coinpayportal.com${path}`, {
55
+ ...init,
56
+ signal: AbortSignal.timeout(15_000),
57
+ headers: { authorization: `Bearer ${this.options.key}`, "content-type": "application/json", ...(init.headers ?? {}) },
58
+ });
59
+ const body = (await response.json().catch(() => ({}))) as Record<string, unknown>;
60
+ if (!response.ok || body["success"] !== true) throw new SupportError("The payment service did not answer; nothing was charged. Try again.", 502, "provider");
61
+ return body;
62
+ }
63
+
64
+ /** The business and the coins it has a wallet for, asked at most every 10 minutes. */
65
+ async coins(): Promise<{ business: string; coins: string[] }> {
66
+ const now = this.options.now?.() ?? Date.now();
67
+ if (!this.merchant || now >= this.merchantUntil) {
68
+ this.merchantUntil = now + 600_000;
69
+ this.merchant = this.provider("/api/supported-coins").then((body) => {
70
+ const business = String(body["business_id"] ?? "");
71
+ if (!/^[0-9a-f-]{36}$/.test(business)) throw new SupportError("Support payments are not set up here.", 503, "unavailable");
72
+ const coins = (Array.isArray(body["coins"]) ? body["coins"] : [])
73
+ .filter((coin): coin is { symbol: string; is_active: boolean; has_wallet: boolean } => typeof coin === "object" && coin !== null)
74
+ .filter((coin) => coin.is_active && coin.has_wallet && typeof coin.symbol === "string")
75
+ .map((coin) => coin.symbol);
76
+ return { business, coins };
77
+ }).catch((error: unknown) => { this.merchant = null; throw error; });
78
+ }
79
+ return this.merchant;
80
+ }
81
+
82
+ /** A CoinPay checkout for this much, to this holder; the address to send the payer to. */
83
+ async checkout(input: { holder?: unknown; amountUsd?: unknown; coin?: unknown; station?: unknown; track?: unknown }): Promise<{ url: string }> {
84
+ const holder = typeof input.holder === "string" ? this.holder(input.holder) : undefined;
85
+ if (!holder) throw new SupportError("Nobody by that name is supported here.", 404, "not_found");
86
+ const amount = Number(input.amountUsd);
87
+ if (!Number.isFinite(amount) || amount < MIN_USD || amount > MAX_USD) 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)) throw new SupportError(`Pay with one of: ${coins.join(", ")}.`, 400, "invalid_coin");
91
+ const clean = (value: unknown): string => (typeof value === "string" ? value.replace(/[\u0000-\u001f]/g, " ").slice(0, 120) : "");
92
+ const order = randomUUID();
93
+ const created = await this.provider("/api/payments/create", {
94
+ method: "POST",
95
+ headers: { "idempotency-key": `nixamp-support-${order}` },
96
+ body: JSON.stringify({
97
+ business_id: business,
98
+ amount_usd: Math.round(amount * 100) / 100,
99
+ currency: coin.toLowerCase(),
100
+ payment_method: "crypto",
101
+ description: `Support ${holder.name} on nixamp`,
102
+ metadata: { app: "nixamp", product: "support", holder: holder.id, order, station: clean(input.station), track: clean(input.track) },
103
+ redirect_url: `${this.options.site}/support/${holder.id}?thanks=1`,
104
+ }),
105
+ });
106
+ const payment = created["payment"] as { id?: unknown } | undefined;
107
+ const id = typeof payment?.id === "string" ? payment.id : "";
108
+ if (!/^[A-Za-z0-9_-]{6,80}$/.test(id)) throw new SupportError("The checkout did not start; nothing was charged. Try again.", 502, "provider");
109
+ return { url: `https://coinpayportal.com/pay/${id}` };
110
+ }
111
+ }
112
+
113
+ const STYLE = `
114
+ :root{--bg:#080c09;--fg:#e8efe9;--muted:#9aa79d;--card:#0e1410;--line:#1f2a22;--accent:#3ddc84}
115
+ *{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}
116
+ main{max-width:520px;margin:0 auto;padding:48px 16px}
117
+ .card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:24px}
118
+ h1{margin:0 0 6px;font-size:1.5rem}p{color:var(--muted);margin:0 0 18px}
119
+ .amounts{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 14px}
120
+ .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}
121
+ .amounts button[aria-pressed=true]{border-color:var(--accent);color:var(--accent)}
122
+ label{display:block;color:var(--muted);font-size:.9rem;margin:10px 0 4px}
123
+ input,select{width:100%;padding:10px;border-radius:10px;border:1px solid var(--line);background:var(--bg);color:var(--fg);font:inherit}
124
+ 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}
125
+ button:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid var(--accent);outline-offset:2px}
126
+ #status{min-height:1.4em;margin-top:12px;color:var(--muted)}
127
+ .thanks{color:var(--accent);font-weight:700}
128
+ small{display:block;margin-top:18px;color:var(--muted)}small a{color:inherit}
129
+ `;
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
+
170
+ const hash = (text: string): string => `'sha256-${createHash("sha256").update(text).digest("base64")}'`;
171
+
172
+ export const SUPPORT_PAGE = `<!doctype html>
173
+ <html lang="en">
174
+ <head>
175
+ <meta charset="utf-8">
176
+ <meta name="viewport" content="width=device-width, initial-scale=1">
177
+ <title>Support · nixamp</title>
178
+ <link rel="icon" href="/favicon.png">
179
+ <style>${STYLE}</style>
180
+ </head>
181
+ <body>
182
+ <main>
183
+ <div class="card">
184
+ <h1>Support <span id="who">the artist</span></h1>
185
+ <p>Pay the copyright holder of the music you are listening to, directly. One payment, in crypto, through CoinPay.</p>
186
+ <form id="form">
187
+ <div class="amounts">${AMOUNTS.map((usd, at) => `<button type="button" data-usd="${usd}" aria-pressed="${at === 1}">$${usd}</button>`).join("")}</div>
188
+ <label for="other">Or another amount (US$)</label>
189
+ <input id="other" type="number" min="1" max="1000" step="1" inputmode="numeric">
190
+ <label for="coin">Pay with</label>
191
+ <select id="coin"></select>
192
+ <button id="go" class="go" type="submit">Continue to payment</button>
193
+ <div id="status" role="status" aria-live="polite"></div>
194
+ </form>
195
+ <small>Payments go through <a href="https://coinpayportal.com">CoinPay</a> to nixamp.com, which pays the holder. Nothing recurs.</small>
196
+ </div>
197
+ </main>
198
+ <script>${SCRIPT}</script>
199
+ </body>
200
+ </html>
201
+ `;
202
+
203
+ export const SUPPORT_CSP = [
204
+ "default-src 'none'", `script-src ${hash(SCRIPT)}`, `style-src ${hash(STYLE)}`, "connect-src 'self'",
205
+ "img-src 'self' data:", "base-uri 'none'", "form-action 'none'", "frame-ancestors 'none'",
206
+ ].join("; ");