nixamp 0.39.2 → 0.40.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai-radio.d.ts +2 -0
- package/dist/ai-radio.js +9 -0
- package/dist/ai-routes.d.ts +3 -0
- package/dist/ai-routes.js +26 -1
- package/dist/listener-shares.d.ts +2 -0
- package/dist/listener-shares.js +4 -0
- package/dist/server.d.ts +8 -1
- package/dist/server.js +78 -1
- package/dist/station-cli.js +31 -0
- package/dist/station-mcp.d.ts +144 -14
- package/dist/station-mcp.js +25 -0
- package/dist/station-routes.d.ts +3 -0
- package/dist/station-routes.js +26 -3
- package/dist/station-usage.d.ts +1 -1
- package/dist/station-usage.js +5 -0
- package/dist/support.d.ts +43 -0
- package/dist/support.js +203 -0
- package/dist/track-facts.d.ts +88 -0
- package/dist/track-facts.js +220 -0
- package/package.json +1 -1
- package/src/ai-radio.ts +9 -0
- package/src/ai-routes.ts +29 -1
- package/src/listener-shares.ts +5 -0
- package/src/server.ts +77 -1
- package/src/station-cli.ts +22 -0
- package/src/station-mcp.ts +24 -0
- package/src/station-routes.ts +25 -3
- package/src/station-usage.ts +5 -0
- package/src/support.ts +206 -0
- package/src/track-facts.ts +243 -0
- package/web/dist/assets/{ar-BSu384wE.js → ar-CK7bC2-m.js} +1 -1
- package/web/dist/assets/{cs-CycweUsX.js → cs-CIBFivGY.js} +1 -1
- package/web/dist/assets/{da-C7FbLd8g.js → da-ZtN_gUCV.js} +1 -1
- package/web/dist/assets/{de-DMcsxdff.js → de-BwKtwToR.js} +1 -1
- package/web/dist/assets/{es-VnyHTJVp.js → es-DLHAgBey.js} +1 -1
- package/web/dist/assets/{fi-B-oW_Ilc.js → fi-CbF3R__i.js} +1 -1
- package/web/dist/assets/{fr-CdEDQS5F.js → fr-vfEVNJ_p.js} +1 -1
- package/web/dist/assets/{hi-B_xiLY1e.js → hi-CVY6i8Qy.js} +1 -1
- package/web/dist/assets/{hu-kFISDmOM.js → hu-BZNMoEpy.js} +1 -1
- package/web/dist/assets/{id-DMel0ct7.js → id-RdnbJt1L.js} +1 -1
- package/web/dist/assets/{it-1LsKNR45.js → it-D0ddwTzz.js} +1 -1
- package/web/dist/assets/{main-CJLyq0BI.js → main-z66Lw26r.js} +2 -2
- package/web/dist/assets/{nl-bZUFxlcO.js → nl-CB3dlayZ.js} +1 -1
- package/web/dist/assets/{ru-BI9zEd6Z.js → ru-BmyeXLoz.js} +1 -1
- package/web/dist/assets/station-BZ2CtCcH.js +1 -0
- package/web/dist/assets/{station-Bq-LiY6G.css → station-CM31TuOq.css} +1 -1
- package/web/dist/assets/{sv-CD5WRIGc.js → sv-BGw-KdRk.js} +1 -1
- package/web/dist/assets/{uk-BVzlKtfs.js → uk-Cl4MytRM.js} +1 -1
- package/web/dist/assets/{vi-BV-dHawO.js → vi-CXee--Vk.js} +1 -1
- package/web/dist/assets/{zh-DiT7lmM0.js → zh-DRNK9Fdc.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/station.html +7 -2
- package/web/dist/sw.js +21 -21
- package/web/dist/assets/station-DDvu2lUI.js +0 -1
package/dist/station-routes.d.ts
CHANGED
|
@@ -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>;
|
package/dist/station-routes.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { alertsFor } from "./station-metering.js";
|
|
2
|
+
import { aiFromTags } from "./track-facts.js";
|
|
2
3
|
import { StationError, channelOf, tokensMatch, } from "./stations.js";
|
|
3
4
|
/** The three things a station's link opens. */
|
|
4
|
-
const LISTEN = /^\/api\/v1\/stations\/([a-z0-9]{10})\/(stream|state|listen\.m3u|events)$/;
|
|
5
|
+
const LISTEN = /^\/api\/v1\/stations\/([a-z0-9]{10})\/(stream|state|listen\.m3u|events|now)$/;
|
|
5
6
|
/** Whether this path is one a station's own link may open, without the server's key. */
|
|
6
7
|
export function isStationListenPath(path) {
|
|
7
8
|
return LISTEN.test(path) || path === "/api/v1/stations/guide";
|
|
@@ -56,6 +57,9 @@ export function publicView(ctx, station) {
|
|
|
56
57
|
export function guideGroup(station) {
|
|
57
58
|
if (station.auto.startsWith("ai:"))
|
|
58
59
|
return "ai";
|
|
60
|
+
// A station whose owner declared the music AI-made ("made with Suno") is AI radio too.
|
|
61
|
+
if (station.rights?.basis === "owned" && aiFromTags({ comment: station.rights.note }))
|
|
62
|
+
return "ai";
|
|
59
63
|
if (station.auto.startsWith("share:"))
|
|
60
64
|
return "shared";
|
|
61
65
|
return station.video ? "tv" : "radio";
|
|
@@ -137,7 +141,21 @@ export async function handleStations(request, response, path, url, ctx) {
|
|
|
137
141
|
const view = publicView(ctx, station);
|
|
138
142
|
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
143
|
};
|
|
140
|
-
|
|
144
|
+
// Each "now" carries what is known about the track: who made it, AI or
|
|
145
|
+
// not, whose copyright, how to support them.
|
|
146
|
+
const withFacts = async () => {
|
|
147
|
+
const payload = now();
|
|
148
|
+
const file = nowPlayingFile(ctx, station);
|
|
149
|
+
if (!ctx.facts || !file)
|
|
150
|
+
return payload;
|
|
151
|
+
try {
|
|
152
|
+
return { ...payload, facts: await ctx.facts(station, file) };
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return payload;
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
void withFacts().then((payload) => send("hello", payload));
|
|
141
159
|
const tick = setInterval(() => {
|
|
142
160
|
const current = ctx.store.get(station.id);
|
|
143
161
|
if (!current) {
|
|
@@ -149,7 +167,7 @@ export async function handleStations(request, response, path, url, ctx) {
|
|
|
149
167
|
if (file !== lastFile) {
|
|
150
168
|
lastFile = file;
|
|
151
169
|
instance += 1;
|
|
152
|
-
send("now",
|
|
170
|
+
void withFacts().then((payload) => send("now", payload));
|
|
153
171
|
}
|
|
154
172
|
else if (state !== lastState) {
|
|
155
173
|
send("state", now());
|
|
@@ -169,6 +187,11 @@ export async function handleStations(request, response, path, url, ctx) {
|
|
|
169
187
|
response.on("close", leave);
|
|
170
188
|
return true;
|
|
171
189
|
}
|
|
190
|
+
if (what === "now") {
|
|
191
|
+
const file = ctx.runner.observed(station.id) === "live" || ctx.runner.observed(station.id) === "recovering" ? nowPlayingFile(ctx, station) : "";
|
|
192
|
+
ctx.json(response, 200, { station: station.id, facts: file && ctx.facts ? await ctx.facts(station, file).catch(() => null) : null });
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
172
195
|
if (what === "state") {
|
|
173
196
|
ctx.json(response, 200, publicView(ctx, station));
|
|
174
197
|
return true;
|
package/dist/station-usage.d.ts
CHANGED
|
@@ -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.";
|
package/dist/station-usage.js
CHANGED
|
@@ -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;
|
package/dist/support.js
ADDED
|
@@ -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
|
+
}
|