nixamp 0.41.2 → 0.43.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.
- package/dist/ai-keys.d.ts +1 -1
- package/dist/ai-keys.js +2 -0
- package/dist/lyrics-publish.d.ts +56 -0
- package/dist/lyrics-publish.js +194 -0
- package/dist/lyrics.d.ts +67 -0
- package/dist/lyrics.js +145 -0
- package/dist/server.d.ts +7 -1
- package/dist/server.js +64 -3
- package/dist/station-cli.js +42 -0
- package/dist/station-page.d.ts +1 -1
- package/dist/station-page.js +39 -3
- package/dist/station-routes.d.ts +9 -0
- package/dist/station-routes.js +39 -1
- package/dist/station-usage.d.ts +1 -1
- package/dist/station-usage.js +5 -0
- package/dist/stations.d.ts +2 -0
- package/dist/stations.js +6 -0
- package/dist/support.d.ts +65 -0
- package/dist/support.js +187 -5
- package/dist/track-facts.d.ts +4 -0
- package/dist/track-facts.js +8 -4
- package/package.json +1 -1
- package/src/ai-keys.ts +2 -0
- package/src/lyrics-publish.ts +203 -0
- package/src/lyrics.ts +161 -0
- package/src/server.ts +68 -3
- package/src/station-cli.ts +40 -0
- package/src/station-page.ts +39 -3
- package/src/station-routes.ts +46 -1
- package/src/station-usage.ts +5 -0
- package/src/stations.ts +4 -0
- package/src/support.ts +201 -6
- package/src/track-facts.ts +11 -2
- package/web/dist/assets/{ar-DJ2gQPOo.js → ar-DJAQCP6v.js} +1 -1
- package/web/dist/assets/{cs-HEbqVNx5.js → cs-BYeHBx_F.js} +1 -1
- package/web/dist/assets/{da-C87QYmEx.js → da-CR89gWR7.js} +1 -1
- package/web/dist/assets/{de-BqR-HScZ.js → de-DPFsA0HC.js} +1 -1
- package/web/dist/assets/{es-DjzlzxAQ.js → es-Bg5Vx2bB.js} +1 -1
- package/web/dist/assets/{fi-2Is-qqeQ.js → fi-BVpY7Uww.js} +1 -1
- package/web/dist/assets/{fr-CTEDRgPI.js → fr-DsSofhGt.js} +1 -1
- package/web/dist/assets/{hi-CU5X7c5G.js → hi-DkSvHtix.js} +1 -1
- package/web/dist/assets/{hu-CQAs9Ye7.js → hu-D4Z2GpU0.js} +1 -1
- package/web/dist/assets/{id-5dd2TXs7.js → id-BzOPbgSu.js} +1 -1
- package/web/dist/assets/{it-B69_ME4E.js → it-BAT4YCiY.js} +1 -1
- package/web/dist/assets/{main-DRvUTBup.js → main-Dpj3oiHi.js} +2 -2
- package/web/dist/assets/{nl-Drb6oxnO.js → nl-CiTdg-si.js} +1 -1
- package/web/dist/assets/{ru-C4u8G5A0.js → ru-CJBuL5JA.js} +1 -1
- package/web/dist/assets/{station--cxhU1wr.css → station-BwDP2cuf.css} +1 -1
- package/web/dist/assets/station-DXRmjrqv.js +3 -0
- package/web/dist/assets/{sv-Bi-av_7u.js → sv-Dptz7Qud.js} +1 -1
- package/web/dist/assets/{uk-DZVF8mrT.js → uk-yL3tD6Kt.js} +1 -1
- package/web/dist/assets/{vi-B2UJxM5O.js → vi-CuVvOIut.js} +1 -1
- package/web/dist/assets/{zh-XRXZxCqU.js → zh-kHhOVHwJ.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/station.html +9 -2
- package/web/dist/sw.js +21 -21
- package/web/dist/assets/station-CdQ9LR6K.js +0 -1
package/dist/support.js
CHANGED
|
@@ -40,6 +40,10 @@ export class Support {
|
|
|
40
40
|
constructor(options) {
|
|
41
41
|
this.options = options;
|
|
42
42
|
}
|
|
43
|
+
/** For a subscription's renewals, which ask CoinPay about their payments. */
|
|
44
|
+
get key() {
|
|
45
|
+
return this.options.key;
|
|
46
|
+
}
|
|
43
47
|
holder(id) {
|
|
44
48
|
return this.options.holders.find((one) => one.id === id.toLowerCase());
|
|
45
49
|
}
|
|
@@ -100,7 +104,7 @@ export class Support {
|
|
|
100
104
|
currency: coin.toLowerCase(),
|
|
101
105
|
payment_method: "crypto",
|
|
102
106
|
description: `Support ${holder.name} on nixamp`,
|
|
103
|
-
metadata: { app: "nixamp", product: "support", holder: holder.id, order, station: clean(input.station), track: clean(input.track) },
|
|
107
|
+
metadata: { app: "nixamp", product: "support", holder: holder.id, order, station: clean(input.station), track: clean(input.track), ...(input.metadata ?? {}) },
|
|
104
108
|
redirect_url: `${this.options.site}/support/${holder.id}?thanks=1`,
|
|
105
109
|
}),
|
|
106
110
|
});
|
|
@@ -108,7 +112,7 @@ export class Support {
|
|
|
108
112
|
const id = typeof payment?.id === "string" ? payment.id : "";
|
|
109
113
|
if (!/^[A-Za-z0-9_-]{6,80}$/.test(id))
|
|
110
114
|
throw new SupportError("The checkout did not start; nothing was charged. Try again.", 502, "provider");
|
|
111
|
-
return { url: `https://coinpayportal.com/pay/${id}
|
|
115
|
+
return { url: `https://coinpayportal.com/pay/${id}`, paymentId: id };
|
|
112
116
|
}
|
|
113
117
|
}
|
|
114
118
|
const STYLE = `
|
|
@@ -126,6 +130,8 @@ button.go{margin-top:18px;width:100%;padding:12px;border:0;border-radius:999px;b
|
|
|
126
130
|
button:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid var(--accent);outline-offset:2px}
|
|
127
131
|
#status{min-height:1.4em;margin-top:12px;color:var(--muted)}
|
|
128
132
|
.thanks{color:var(--accent);font-weight:700}
|
|
133
|
+
.often{display:flex;gap:8px;margin:0 0 14px}.often label{flex:1;margin:0;display:flex;gap:8px;align-items:center;justify-content:center;padding:10px;border:1px solid var(--line);border-radius:10px;color:var(--fg);cursor:pointer}
|
|
134
|
+
.often input{width:auto}
|
|
129
135
|
small{display:block;margin-top:18px;color:var(--muted)}small a{color:inherit}
|
|
130
136
|
`;
|
|
131
137
|
const SCRIPT = `
|
|
@@ -141,6 +147,11 @@ const SCRIPT = `
|
|
|
141
147
|
for (const o of document.querySelectorAll(".amounts button")) o.setAttribute("aria-pressed", String(o === b));
|
|
142
148
|
});
|
|
143
149
|
}
|
|
150
|
+
const monthly = () => document.querySelector("input[name=often]:checked").value === "monthly";
|
|
151
|
+
for (const r of document.querySelectorAll("input[name=often]")) r.addEventListener("change", () => {
|
|
152
|
+
$("#email-box").hidden = !monthly();
|
|
153
|
+
$("#go").textContent = monthly() ? "Pay the first month" : "Continue to payment";
|
|
154
|
+
});
|
|
144
155
|
if (params.get("thanks")) { $("#status").className = "thanks"; $("#status").textContent = "Thank you. Your payment is on its way."; }
|
|
145
156
|
try {
|
|
146
157
|
const info = await (await fetch("/api/v1/support/" + encodeURIComponent(holder), { cache: "no-store" })).json();
|
|
@@ -157,7 +168,8 @@ const SCRIPT = `
|
|
|
157
168
|
$("#status").textContent = "Starting the checkout…";
|
|
158
169
|
try {
|
|
159
170
|
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") || ""
|
|
171
|
+
body: JSON.stringify({ amountUsd: usd, coin: $("#coin").value, station: params.get("station") || "", track: params.get("track") || "",
|
|
172
|
+
...(monthly() ? { monthly: true, email: $("#email").value } : {}) }) });
|
|
161
173
|
const body = await r.json();
|
|
162
174
|
if (r.ok && body.url) { location.href = body.url; return; }
|
|
163
175
|
$("#status").textContent = body.error || "That did not work.";
|
|
@@ -180,17 +192,22 @@ export const SUPPORT_PAGE = `<!doctype html>
|
|
|
180
192
|
<main>
|
|
181
193
|
<div class="card">
|
|
182
194
|
<h1>Support <span id="who">the artist</span></h1>
|
|
183
|
-
<p>Pay the copyright holder of the music you are listening to, directly
|
|
195
|
+
<p>Pay the copyright holder of the music you are listening to, directly, in crypto through CoinPay. Once, or every month.</p>
|
|
184
196
|
<form id="form">
|
|
197
|
+
<div class="often"><label><input type="radio" name="often" value="once" checked> One time</label><label><input type="radio" name="often" value="monthly"> Monthly</label></div>
|
|
185
198
|
<div class="amounts">${AMOUNTS.map((usd, at) => `<button type="button" data-usd="${usd}" aria-pressed="${at === 1}">$${usd}</button>`).join("")}</div>
|
|
186
199
|
<label for="other">Or another amount (US$)</label>
|
|
187
200
|
<input id="other" type="number" min="1" max="1000" step="1" inputmode="numeric">
|
|
201
|
+
<div id="email-box" hidden>
|
|
202
|
+
<label for="email">Email, for each month's payment link (and the cancel link)</label>
|
|
203
|
+
<input id="email" type="email" autocomplete="email" maxlength="254">
|
|
204
|
+
</div>
|
|
188
205
|
<label for="coin">Pay with</label>
|
|
189
206
|
<select id="coin"></select>
|
|
190
207
|
<button id="go" class="go" type="submit">Continue to payment</button>
|
|
191
208
|
<div id="status" role="status" aria-live="polite"></div>
|
|
192
209
|
</form>
|
|
193
|
-
<small>Payments go through <a href="https://coinpayportal.com">CoinPay</a> to nixamp.com, which pays the holder.
|
|
210
|
+
<small>Payments go through <a href="https://coinpayportal.com">CoinPay</a> to nixamp.com, which pays the holder. Monthly: each month we email a new payment link; nothing is taken until you pay it, and every email has a one-click cancel.</small>
|
|
194
211
|
</div>
|
|
195
212
|
</main>
|
|
196
213
|
<script>${SCRIPT}</script>
|
|
@@ -201,3 +218,168 @@ export const SUPPORT_CSP = [
|
|
|
201
218
|
"default-src 'none'", `script-src ${hash(SCRIPT)}`, `style-src ${hash(STYLE)}`, "connect-src 'self'",
|
|
202
219
|
"img-src 'self' data:", "base-uri 'none'", "form-action 'none'", "frame-ancestors 'none'",
|
|
203
220
|
].join("; ");
|
|
221
|
+
/** What CoinPay calls a payment that has been made. */
|
|
222
|
+
const PAID = new Set(["confirmed", "forwarding", "forwarded", "forwarding_failed", "completed"]);
|
|
223
|
+
const EMAIL = /^[^\s@<>"]{1,64}@[^\s@<>"]{1,190}\.[a-z]{2,24}$/i;
|
|
224
|
+
/** One month on from a time, on the same day where the month has it. */
|
|
225
|
+
export function nextMonth(at) {
|
|
226
|
+
const date = new Date(at);
|
|
227
|
+
const day = date.getUTCDate();
|
|
228
|
+
date.setUTCDate(1);
|
|
229
|
+
date.setUTCMonth(date.getUTCMonth() + 1);
|
|
230
|
+
const last = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0)).getUTCDate();
|
|
231
|
+
date.setUTCDate(Math.min(day, last));
|
|
232
|
+
return date.getTime();
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Monthly support, run here rather than by CoinPay: CoinPay's only merchant
|
|
236
|
+
* subscriptions are Stripe's, and its recurring invoices refuse a scoped key.
|
|
237
|
+
* A subscription is an email, an amount, a coin and a holder. Month one is
|
|
238
|
+
* paid at once on CoinPay's checkout; each month after, a new payment is made
|
|
239
|
+
* and its link emailed, with a one-click cancel. A month left unpaid ends it
|
|
240
|
+
* (lapsed) rather than emailing forever.
|
|
241
|
+
*/
|
|
242
|
+
export class SupportSubscriptions {
|
|
243
|
+
options;
|
|
244
|
+
schema = null;
|
|
245
|
+
timer = null;
|
|
246
|
+
constructor(options) {
|
|
247
|
+
this.options = options;
|
|
248
|
+
}
|
|
249
|
+
now() {
|
|
250
|
+
return this.options.now?.() ?? Date.now();
|
|
251
|
+
}
|
|
252
|
+
ready() {
|
|
253
|
+
this.schema ??= (async () => {
|
|
254
|
+
await this.options.db.query(`CREATE TABLE IF NOT EXISTS support_subscriptions (
|
|
255
|
+
id TEXT PRIMARY KEY, token TEXT NOT NULL, holder TEXT NOT NULL, email TEXT NOT NULL,
|
|
256
|
+
amount_cents INTEGER NOT NULL, coin TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active',
|
|
257
|
+
cycles INTEGER NOT NULL DEFAULT 0, last_payment_id TEXT, next_due TIMESTAMPTZ NOT NULL,
|
|
258
|
+
station TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL, canceled_at TIMESTAMPTZ)`);
|
|
259
|
+
await this.options.db.query("CREATE INDEX IF NOT EXISTS support_subscriptions_due ON support_subscriptions (status, next_due)");
|
|
260
|
+
})().catch((error) => { this.schema = null; throw error; });
|
|
261
|
+
return this.schema;
|
|
262
|
+
}
|
|
263
|
+
/** Month one: a subscription and its first checkout. */
|
|
264
|
+
async start(input) {
|
|
265
|
+
const email = typeof input.email === "string" ? input.email.trim().toLowerCase() : "";
|
|
266
|
+
if (!EMAIL.test(email))
|
|
267
|
+
throw new SupportError("Give an email for the monthly payment links.", 400, "invalid_email");
|
|
268
|
+
const holder = typeof input.holder === "string" ? this.options.support.holder(input.holder) : undefined;
|
|
269
|
+
if (!holder)
|
|
270
|
+
throw new SupportError("Nobody by that name is supported here.", 404, "not_found");
|
|
271
|
+
await this.ready();
|
|
272
|
+
const live = await this.options.db.query("SELECT count(*)::int AS n FROM support_subscriptions WHERE email = $1 AND status = 'active'", [email]);
|
|
273
|
+
if (Number(live.rows[0]?.["n"] ?? 0) >= 10)
|
|
274
|
+
throw new SupportError("That email already has ten monthly supports.", 409, "too_many");
|
|
275
|
+
const id = randomUUID();
|
|
276
|
+
const token = randomUUID().replace(/-/g, "");
|
|
277
|
+
const checkout = await this.options.support.checkout({ ...input, holder: holder.id, metadata: { subscription: id, cycle: 1 } });
|
|
278
|
+
const now = this.now();
|
|
279
|
+
await this.options.db.query(`INSERT INTO support_subscriptions (id, token, holder, email, amount_cents, coin, cycles, last_payment_id, next_due, station, created_at)
|
|
280
|
+
VALUES ($1,$2,$3,$4,$5,$6,1,$7,$8,$9,$10)`, [id, token, holder.id, email, Math.round(Number(input.amountUsd) * 100), String(input.coin).toUpperCase(), checkout.paymentId, new Date(nextMonth(now)), typeof input.station === "string" ? input.station.slice(0, 20) : "", new Date(now)]);
|
|
281
|
+
await this.mail(email, `Your monthly support for ${holder.name}`, [
|
|
282
|
+
`Thank you for supporting ${holder.name} on nixamp, $${Number(input.amountUsd).toFixed(2)} a month in ${String(input.coin).toUpperCase()}.`,
|
|
283
|
+
`This month: ${checkout.url}`,
|
|
284
|
+
`Each month we email you a new payment link like this one. Nothing is taken without you paying it.`,
|
|
285
|
+
`Stop at any time: ${this.cancelLink(id, token)}`,
|
|
286
|
+
]);
|
|
287
|
+
return { url: checkout.url, id };
|
|
288
|
+
}
|
|
289
|
+
cancelLink(id, token) {
|
|
290
|
+
return `${this.options.site}/support/cancel?s=${encodeURIComponent(id)}&t=${encodeURIComponent(token)}`;
|
|
291
|
+
}
|
|
292
|
+
async cancel(id, token) {
|
|
293
|
+
await this.ready();
|
|
294
|
+
const found = await this.options.db.query("UPDATE support_subscriptions SET status = 'canceled', canceled_at = $3 WHERE id = $1 AND token = $2 AND status <> 'canceled' RETURNING holder", [id, token, new Date(this.now())]);
|
|
295
|
+
const row = found.rows[0];
|
|
296
|
+
if (!row) {
|
|
297
|
+
const known = await this.options.db.query("SELECT holder FROM support_subscriptions WHERE id = $1 AND token = $2", [id, token]);
|
|
298
|
+
if (!known.rows[0])
|
|
299
|
+
throw new SupportError("That cancel link is not one of ours.", 404, "not_found");
|
|
300
|
+
return { canceled: true, holder: String(known.rows[0]["holder"]) };
|
|
301
|
+
}
|
|
302
|
+
return { canceled: true, holder: String(row["holder"]) };
|
|
303
|
+
}
|
|
304
|
+
async paid(paymentId) {
|
|
305
|
+
try {
|
|
306
|
+
const response = await (this.options.fetcher ?? fetch)(`https://coinpayportal.com/api/payments/${encodeURIComponent(paymentId)}`, {
|
|
307
|
+
signal: AbortSignal.timeout(15_000),
|
|
308
|
+
headers: { authorization: `Bearer ${this.options.support.key}` },
|
|
309
|
+
});
|
|
310
|
+
if (!response.ok)
|
|
311
|
+
return null;
|
|
312
|
+
const body = (await response.json());
|
|
313
|
+
return PAID.has(String(body.payment?.status ?? ""));
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
/** Every subscription now due: a new month's payment and its link, or lapsed when last month went unpaid. */
|
|
320
|
+
async renew() {
|
|
321
|
+
await this.ready();
|
|
322
|
+
const due = await this.options.db.query("SELECT * FROM support_subscriptions WHERE status = 'active' AND next_due <= $1 ORDER BY next_due LIMIT 200", [new Date(this.now())]);
|
|
323
|
+
let renewed = 0;
|
|
324
|
+
let lapsed = 0;
|
|
325
|
+
for (const row of due.rows) {
|
|
326
|
+
const id = String(row["id"]);
|
|
327
|
+
const last = String(row["last_payment_id"] ?? "");
|
|
328
|
+
const paid = last ? await this.paid(last) : false;
|
|
329
|
+
if (paid === null)
|
|
330
|
+
continue; // CoinPay did not answer: ask again next time.
|
|
331
|
+
const holder = this.options.support.holder(String(row["holder"]));
|
|
332
|
+
if (!paid || !holder) {
|
|
333
|
+
await this.options.db.query("UPDATE support_subscriptions SET status = 'lapsed' WHERE id = $1 AND status = 'active'", [id]);
|
|
334
|
+
lapsed += 1;
|
|
335
|
+
if (holder)
|
|
336
|
+
await this.mail(String(row["email"]), `Your monthly support for ${holder.name} has stopped`, [
|
|
337
|
+
`Last month's payment was not made, so we have stopped sending payment links. Nothing more will be asked.`,
|
|
338
|
+
`Start again any time: ${this.options.site}/support/${holder.id}`,
|
|
339
|
+
]);
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
const amountUsd = Number(row["amount_cents"]) / 100;
|
|
343
|
+
const cycle = Number(row["cycles"]) + 1;
|
|
344
|
+
let checkout;
|
|
345
|
+
try {
|
|
346
|
+
checkout = await this.options.support.checkout({ holder: holder.id, amountUsd, coin: String(row["coin"]), station: String(row["station"] ?? ""), metadata: { subscription: id, cycle } });
|
|
347
|
+
}
|
|
348
|
+
catch (error) {
|
|
349
|
+
this.options.log?.(` support: renewal ${id} could not start a payment: ${error.message}`);
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
await this.options.db.query("UPDATE support_subscriptions SET cycles = $2, last_payment_id = $3, next_due = $4 WHERE id = $1", [id, cycle, checkout.paymentId, new Date(nextMonth(new Date(String(row["next_due"])).getTime()))]);
|
|
353
|
+
await this.mail(String(row["email"]), `This month's support for ${holder.name}`, [
|
|
354
|
+
`Your monthly support for ${holder.name}: $${amountUsd.toFixed(2)} in ${String(row["coin"])}.`,
|
|
355
|
+
`Pay this month: ${checkout.url}`,
|
|
356
|
+
`Stop at any time: ${this.cancelLink(id, String(row["token"]))}`,
|
|
357
|
+
]);
|
|
358
|
+
renewed += 1;
|
|
359
|
+
}
|
|
360
|
+
return { renewed, lapsed };
|
|
361
|
+
}
|
|
362
|
+
startSchedule(everyMs = 3600_000) {
|
|
363
|
+
if (this.timer)
|
|
364
|
+
return;
|
|
365
|
+
this.timer = setInterval(() => void this.renew().catch((error) => this.options.log?.(` support: renewals failed: ${error.message}`)), everyMs);
|
|
366
|
+
this.timer.unref?.();
|
|
367
|
+
}
|
|
368
|
+
async mail(to, subject, lines) {
|
|
369
|
+
if (!this.options.mail) {
|
|
370
|
+
this.options.log?.(` support: no RESEND_API_KEY, so "${subject}" to ${to} was not sent`);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
try {
|
|
374
|
+
await (this.options.fetcher ?? fetch)("https://api.resend.com/emails", {
|
|
375
|
+
method: "POST",
|
|
376
|
+
signal: AbortSignal.timeout(15_000),
|
|
377
|
+
headers: { authorization: `Bearer ${this.options.mail.apiKey}`, "content-type": "application/json" },
|
|
378
|
+
body: JSON.stringify({ from: this.options.mail.from, to: [to], subject, text: `${lines.join("\n\n")}\n\nnixamp.com` }),
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
catch (error) {
|
|
382
|
+
this.options.log?.(` support: mail to ${to} failed: ${error.message}`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
package/dist/track-facts.d.ts
CHANGED
|
@@ -24,6 +24,8 @@ export interface TrackFacts {
|
|
|
24
24
|
} | null;
|
|
25
25
|
/** The track's own cover, on this station's link (it changes with the track); "" when it has none. */
|
|
26
26
|
art: string;
|
|
27
|
+
/** Where the track's words are, on this station's link; "" when none are known. */
|
|
28
|
+
lyrics: string;
|
|
27
29
|
}
|
|
28
30
|
export interface Holder {
|
|
29
31
|
id: string;
|
|
@@ -76,6 +78,8 @@ export interface FactSources {
|
|
|
76
78
|
} | null;
|
|
77
79
|
/** Where its cover is served, when it has one. */
|
|
78
80
|
art?: string;
|
|
81
|
+
/** Where its words are served, when it has them. */
|
|
82
|
+
lyrics?: string;
|
|
79
83
|
}
|
|
80
84
|
export declare function factsFor(input: FactSources): TrackFacts;
|
|
81
85
|
/** The copyright holders this server's owner registered. */
|
package/dist/track-facts.js
CHANGED
|
@@ -57,9 +57,13 @@ export class TagReader {
|
|
|
57
57
|
try {
|
|
58
58
|
const parsed = JSON.parse(out);
|
|
59
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
|
|
62
|
-
|
|
60
|
+
for (const [name, value] of Object.entries(source)) {
|
|
61
|
+
if (typeof value !== "string")
|
|
62
|
+
continue;
|
|
63
|
+
const lower = name.toLowerCase();
|
|
64
|
+
// A song's words run longer than any other tag (lyrics.ts).
|
|
65
|
+
tags[lower] = value.slice(0, /lyrics/.test(lower) ? 20_000 : 2000);
|
|
66
|
+
}
|
|
63
67
|
}
|
|
64
68
|
}
|
|
65
69
|
catch {
|
|
@@ -144,7 +148,7 @@ export function factsFor(input) {
|
|
|
144
148
|
rights = { holder: "", basis: "", note: "", license: "", source: "" };
|
|
145
149
|
}
|
|
146
150
|
const support = holder && holder.support ? { holder: holder.name, url: holder.support, label: holder.label || "Support" } : null;
|
|
147
|
-
return { title, artist, album, year, genre, ai, rights, support, art: input.art ?? "" };
|
|
151
|
+
return { title, artist, album, year, genre, ai, rights, support, art: input.art ?? "", lyrics: input.lyrics ?? "" };
|
|
148
152
|
}
|
|
149
153
|
function slug(text) {
|
|
150
154
|
return text.normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "holder";
|
package/package.json
CHANGED
package/src/ai-keys.ts
CHANGED
|
@@ -14,6 +14,8 @@ export const AI_KEY_NAMES = [
|
|
|
14
14
|
"ELEVENLABS_API_KEY", "GEMINI_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
|
|
15
15
|
// Any OpenAI-compatible endpoint in OpenAI's place (Cloudflare Workers AI, Ollama, ...) and its model.
|
|
16
16
|
"NIXAMP_OPENAI_URL", "NIXAMP_OPENAI_MODEL",
|
|
17
|
+
// nichedb.dev's key for publishing our own songs' words (lyrics-publish.ts).
|
|
18
|
+
"NIXAMP_LYRICS_KEY",
|
|
17
19
|
] as const;
|
|
18
20
|
export type AiKeyName = (typeof AI_KEY_NAMES)[number];
|
|
19
21
|
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Our own songs' words, published back to nichedb.dev.
|
|
3
|
+
*
|
|
4
|
+
* A station whose owner declared the music theirs (basis "owned") and put it
|
|
5
|
+
* on the public guide (public and listed) is music we may publish the words
|
|
6
|
+
* of. For each of its files whose own tags carry lyrics (lyrics.ts), the
|
|
7
|
+
* words go to nichedb's POST /api/v1/lyrics with the key the owner set
|
|
8
|
+
* (NIXAMP_LYRICS_KEY, or `nixamp station lyrics key` into ai.json), once per
|
|
9
|
+
* text: what was sent is remembered by a hash of the words, so a file is sent
|
|
10
|
+
* again only when its words change. One at a time, a second apart, and a
|
|
11
|
+
* refusal of the key (401/403) or a 429 stops the run.
|
|
12
|
+
*
|
|
13
|
+
* Nothing is published without a key, and nothing for a station that is not
|
|
14
|
+
* owned, whatever else is true of it.
|
|
15
|
+
*/
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
import { lyricsFromTags, NICHEDB_LYRICS } from "./lyrics.ts";
|
|
20
|
+
import type { Station } from "./stations.ts";
|
|
21
|
+
|
|
22
|
+
type Fetcher = (url: string, init?: RequestInit) => Promise<Response>;
|
|
23
|
+
|
|
24
|
+
/** Whether a station's lyrics may be published: owned, public and listed. */
|
|
25
|
+
export function mayPublishLyrics(station: Pick<Station, "visibility" | "listed" | "rights">): boolean {
|
|
26
|
+
return station.rights?.basis === "owned" && station.visibility === "public" && station.listed === true;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Why not, in words, or "" when it may. */
|
|
30
|
+
export function whyNotPublish(station: Pick<Station, "visibility" | "listed" | "rights">): string {
|
|
31
|
+
if (station.rights?.basis !== "owned") return "its music is not declared owned (nixamp station publish ID --basis owned)";
|
|
32
|
+
if (station.visibility !== "public") return "it is not public";
|
|
33
|
+
if (!station.listed) return "it is not listed on the guide";
|
|
34
|
+
return "";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface PublishReport {
|
|
38
|
+
station: string;
|
|
39
|
+
published: number;
|
|
40
|
+
unchanged: number;
|
|
41
|
+
/** Files with no words in their tags, or no artist or title to file them under. */
|
|
42
|
+
without: number;
|
|
43
|
+
failed: number;
|
|
44
|
+
stopped: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface LyricsPublisherOptions {
|
|
48
|
+
file: string;
|
|
49
|
+
/** The key nichedb takes, read each run (it may be set while the server runs); "" when unset. */
|
|
50
|
+
key: () => string;
|
|
51
|
+
stations: () => Station[];
|
|
52
|
+
tags: (file: string) => Promise<Record<string, string>>;
|
|
53
|
+
known?: (file: string) => { title: string; artist: string; album: string } | null;
|
|
54
|
+
base?: string;
|
|
55
|
+
fetcher?: Fetcher;
|
|
56
|
+
/** Between two posts. */
|
|
57
|
+
gapMs?: number;
|
|
58
|
+
/** After a change, before a run (changes come in bursts). */
|
|
59
|
+
debounceMs?: number;
|
|
60
|
+
log?: (line: string) => void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface Sent { hash: string; at: number; artist: string; title: string }
|
|
64
|
+
|
|
65
|
+
export class LyricsPublisher {
|
|
66
|
+
private sent: Record<string, Sent> = {};
|
|
67
|
+
private running: Promise<PublishReport[]> | null = null;
|
|
68
|
+
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
69
|
+
private last: { at: number; reports: PublishReport[] } | null = null;
|
|
70
|
+
private lastError = "";
|
|
71
|
+
private readonly base: string;
|
|
72
|
+
private readonly fetcher: Fetcher;
|
|
73
|
+
|
|
74
|
+
constructor(private readonly options: LyricsPublisherOptions) {
|
|
75
|
+
this.base = (options.base ?? process.env["NIXAMP_LYRICS_URL"] ?? NICHEDB_LYRICS).replace(/\/+$/, "");
|
|
76
|
+
this.fetcher = options.fetcher ?? ((url, init) => fetch(url, init));
|
|
77
|
+
try {
|
|
78
|
+
const parsed = JSON.parse(readFileSync(options.file, "utf8")) as { sent?: Record<string, Sent> };
|
|
79
|
+
this.sent = parsed.sent ?? {};
|
|
80
|
+
} catch {
|
|
81
|
+
this.sent = {};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
static fileFor(stateDir: string, port: number): string {
|
|
86
|
+
return join(stateDir, `lyrics-published-${port}.json`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private save(): void {
|
|
90
|
+
mkdirSync(dirname(this.options.file), { recursive: true });
|
|
91
|
+
const temporary = `${this.options.file}.${process.pid}.tmp`;
|
|
92
|
+
writeFileSync(temporary, `${JSON.stringify({ version: 1, sent: this.sent }, null, 2)}\n`, { mode: 0o600 });
|
|
93
|
+
renameSync(temporary, this.options.file);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Something changed (a station, the library): publish what is new, shortly. Nothing without a key. */
|
|
97
|
+
soon(): void {
|
|
98
|
+
// A run is already due: changes in a burst ride on it (a busy store saves often).
|
|
99
|
+
if (!this.options.key() || this.timer) return;
|
|
100
|
+
this.timer = setTimeout(() => {
|
|
101
|
+
this.timer = null;
|
|
102
|
+
void this.publishAll().catch(() => undefined);
|
|
103
|
+
}, this.options.debounceMs ?? 30_000);
|
|
104
|
+
this.timer.unref?.();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
status(): Record<string, unknown> {
|
|
108
|
+
const eligible = this.options.stations().filter(mayPublishLyrics);
|
|
109
|
+
return {
|
|
110
|
+
key: this.options.key() !== "",
|
|
111
|
+
target: this.base,
|
|
112
|
+
published: Object.keys(this.sent).length,
|
|
113
|
+
running: this.running !== null,
|
|
114
|
+
last: this.last,
|
|
115
|
+
lastError: this.lastError,
|
|
116
|
+
stations: this.options.stations().map((one) => ({
|
|
117
|
+
id: one.id,
|
|
118
|
+
name: one.name,
|
|
119
|
+
eligible: mayPublishLyrics(one),
|
|
120
|
+
why: whyNotPublish(one),
|
|
121
|
+
files: one.items.length,
|
|
122
|
+
published: one.items.filter((file) => this.sent[file]).length,
|
|
123
|
+
})),
|
|
124
|
+
eligible: eligible.length,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Every eligible station, one after another; a run already going is joined, not doubled. */
|
|
129
|
+
publishAll(): Promise<PublishReport[]> {
|
|
130
|
+
if (this.running) return this.running;
|
|
131
|
+
this.running = (async () => {
|
|
132
|
+
const reports: PublishReport[] = [];
|
|
133
|
+
for (const station of this.options.stations().filter(mayPublishLyrics)) {
|
|
134
|
+
const report = await this.publishOne(station);
|
|
135
|
+
reports.push(report);
|
|
136
|
+
if (report.stopped) break;
|
|
137
|
+
}
|
|
138
|
+
this.last = { at: Date.now(), reports };
|
|
139
|
+
const sent = reports.reduce((sum, one) => sum + one.published, 0);
|
|
140
|
+
if (sent > 0) this.options.log?.(` lyrics: published ${sent} to ${this.base}.`);
|
|
141
|
+
return reports;
|
|
142
|
+
})().finally(() => { this.running = null; });
|
|
143
|
+
return this.running;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** One station, now. Refused (stopped says why) when it may not be published or there is no key. */
|
|
147
|
+
async publish(station: Station): Promise<PublishReport> {
|
|
148
|
+
if (this.running) await this.running.catch(() => undefined);
|
|
149
|
+
return this.publishOne(station);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private async publishOne(station: Station): Promise<PublishReport> {
|
|
153
|
+
const report: PublishReport = { station: station.id, published: 0, unchanged: 0, without: 0, failed: 0, stopped: "" };
|
|
154
|
+
const why = whyNotPublish(station);
|
|
155
|
+
if (why) { report.stopped = `not published: ${why}`; return report; }
|
|
156
|
+
const key = this.options.key();
|
|
157
|
+
if (!key) { report.stopped = "no key: set NIXAMP_LYRICS_KEY or `nixamp station lyrics key`"; return report; }
|
|
158
|
+
if (!this.base) { report.stopped = "no target: NIXAMP_LYRICS_URL is empty"; return report; }
|
|
159
|
+
let first = true;
|
|
160
|
+
for (const file of station.items) {
|
|
161
|
+
let tags: Record<string, string> = {};
|
|
162
|
+
try { tags = await this.options.tags(file); } catch { tags = {}; }
|
|
163
|
+
const words = lyricsFromTags(tags);
|
|
164
|
+
const known = this.options.known?.(file) ?? null;
|
|
165
|
+
const title = tags["title"] || known?.title || "";
|
|
166
|
+
const artist = tags["artist"] || tags["album_artist"] || known?.artist || "";
|
|
167
|
+
const album = tags["album"] || known?.album || "";
|
|
168
|
+
if (!words || !title || !artist) { report.without += 1; continue; }
|
|
169
|
+
const hash = createHash("sha256").update(`${artist}\n${title}\n${words.text}`).digest("hex");
|
|
170
|
+
if (this.sent[file]?.hash === hash) { report.unchanged += 1; continue; }
|
|
171
|
+
if (!first) await new Promise((done) => setTimeout(done, this.options.gapMs ?? 1_000));
|
|
172
|
+
first = false;
|
|
173
|
+
let response: Response;
|
|
174
|
+
try {
|
|
175
|
+
response = await this.fetcher(`${this.base}/api/v1/lyrics`, {
|
|
176
|
+
method: "POST",
|
|
177
|
+
headers: { "content-type": "application/json", accept: "application/json", authorization: `Bearer ${key}` },
|
|
178
|
+
body: JSON.stringify({ artist, title, album, language: words.language, text: words.text, source: "embedded" }),
|
|
179
|
+
signal: AbortSignal.timeout(15_000),
|
|
180
|
+
});
|
|
181
|
+
} catch (error) {
|
|
182
|
+
report.failed += 1;
|
|
183
|
+
this.lastError = `could not reach ${this.base}: ${(error as Error).message}`;
|
|
184
|
+
report.stopped = this.lastError;
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
if (response.ok) {
|
|
188
|
+
this.sent[file] = { hash, at: Date.now(), artist, title };
|
|
189
|
+
this.save();
|
|
190
|
+
report.published += 1;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
report.failed += 1;
|
|
194
|
+
const detail = (await response.text().catch(() => "")).slice(0, 200);
|
|
195
|
+
this.lastError = `nichedb answered ${response.status} for "${title}"${detail ? `: ${detail}` : ""}`;
|
|
196
|
+
if (response.status === 401 || response.status === 403 || response.status === 429 || response.status >= 500) {
|
|
197
|
+
report.stopped = this.lastError;
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return report;
|
|
202
|
+
}
|
|
203
|
+
}
|