nixamp 0.67.0 → 0.67.3

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 (38) hide show
  1. package/dist/ai-label.d.ts +17 -4
  2. package/dist/ai-label.js +39 -9
  3. package/dist/license-routes.js +6 -13
  4. package/dist/music-license.js +4 -3
  5. package/dist/music-store.js +4 -3
  6. package/dist/server.js +76 -15
  7. package/dist/store-routes.js +6 -14
  8. package/dist/support.d.ts +77 -8
  9. package/dist/support.js +229 -93
  10. package/package.json +2 -2
  11. package/src/ai-label.ts +45 -10
  12. package/src/license-routes.ts +6 -13
  13. package/src/music-license.ts +4 -3
  14. package/src/music-store.ts +4 -3
  15. package/src/server.ts +59 -9
  16. package/src/store-routes.ts +6 -14
  17. package/src/support.ts +227 -90
  18. package/web/dist/assets/{ar-Dw3s9ADu.js → ar-DxGw3Rs5.js} +1 -1
  19. package/web/dist/assets/{cs-CzDFyr4i.js → cs-CvhFUJ-M.js} +1 -1
  20. package/web/dist/assets/{da-DCTQgBaR.js → da-DNNaWmG1.js} +1 -1
  21. package/web/dist/assets/{de-bRuy-6md.js → de-Bd73nJDj.js} +1 -1
  22. package/web/dist/assets/{es-iHd4cFjq.js → es-kql7koI9.js} +1 -1
  23. package/web/dist/assets/{fi-BQgHm0iD.js → fi-DOSVk45m.js} +1 -1
  24. package/web/dist/assets/{fr-Bx2XC-ON.js → fr-C-PWAVGY.js} +1 -1
  25. package/web/dist/assets/{hi-Cq8IDrah.js → hi-DSccyns6.js} +1 -1
  26. package/web/dist/assets/{hu-C3mzcLs_.js → hu-nhOx8KN8.js} +1 -1
  27. package/web/dist/assets/{id-DddaqO3C.js → id-B_7XcFMO.js} +1 -1
  28. package/web/dist/assets/{it-C8TvaS5w.js → it-DS_wAQLZ.js} +1 -1
  29. package/web/dist/assets/main-DBqNZR5i.js +22 -0
  30. package/web/dist/assets/{nl-C3LqPPhu.js → nl-E4kRa4hT.js} +1 -1
  31. package/web/dist/assets/{ru-DzAMilh-.js → ru-BBTDvHvj.js} +1 -1
  32. package/web/dist/assets/{sv-Ca1BMi0N.js → sv-eb2QkbPB.js} +1 -1
  33. package/web/dist/assets/{uk-D-CADfyO.js → uk-D8ewz76c.js} +1 -1
  34. package/web/dist/assets/{vi-CWeZdHAD.js → vi-DjxETiKX.js} +1 -1
  35. package/web/dist/assets/{zh-BvS0BRgy.js → zh-pjVIcKxb.js} +1 -1
  36. package/web/dist/index.html +2 -3
  37. package/web/dist/sw.js +19 -19
  38. package/web/dist/assets/main-BSOJX0Of.js +0 -22
package/dist/support.js CHANGED
@@ -8,7 +8,9 @@
8
8
  * holders nixamp.com collects for -- today Profullstack's own music (the
9
9
  * band Þrøngva, AI radio) -- named in NIXAMP_SUPPORT_HOLDERS ("id=Name,...").
10
10
  *
11
- * One payment, in crypto, on CoinPay's hosted checkout. CoinPay offers no
11
+ * One payment on CoinPay's hosted checkout session: the payer picks any coin
12
+ * nixamp's business takes, or PayPal, and the sale is filed under nixamp.com
13
+ * with the payer's account id (their email when signed out) and email. CoinPay offers no
12
14
  * crypto subscription a business key can start (its plan/subscribe API is
13
15
  * Stripe's, which we do not use), so "monthly" is not offered rather than
14
16
  * pretended.
@@ -24,6 +26,33 @@ export function supportHoldersFrom(env = process.env) {
24
26
  export const AMOUNTS = [3, 5, 10, 25, 50];
25
27
  const MIN_USD = 1;
26
28
  const MAX_USD = 1000;
29
+ const SESSION = /^cs_[A-Za-z0-9]{16,64}$/;
30
+ const COINPAY = "https://coinpayportal.com";
31
+ /** A CoinPay hosted checkout session id (cs_...), as stored where a payment id used to be. */
32
+ export function isSessionRef(ref) {
33
+ return SESSION.test(ref);
34
+ }
35
+ /** Where a payer finishes a stored payment: its hosted session, or an older one-coin invoice. */
36
+ export function payUrl(ref) {
37
+ return isSessionRef(ref) ? `${COINPAY}/checkout/${ref}` : `${COINPAY}/pay/${encodeURIComponent(ref)}`;
38
+ }
39
+ /** The site hostname every nixamp sale is filed under at CoinPay. */
40
+ export function propertyOf(site) {
41
+ try {
42
+ return new URL(site).hostname.replace(/^www\./, "").toLowerCase() || "nixamp.com";
43
+ }
44
+ catch {
45
+ return "nixamp.com";
46
+ }
47
+ }
48
+ /** The coin a stored payment names: "ANY" for a hosted session, where the payer picks (or PayPal). */
49
+ export function coinOf(value) {
50
+ return typeof value === "string" && /^[A-Za-z0-9_]{2,20}$/.test(value.trim()) ? value.trim().toUpperCase() : "ANY";
51
+ }
52
+ /** "in USDC_POL" for an older one-coin payment; nothing for a session. */
53
+ export function paidIn(coin) {
54
+ return coin && coin !== "ANY" ? ` in ${coin}` : "";
55
+ }
27
56
  export class SupportError extends Error {
28
57
  status;
29
58
  code;
@@ -81,33 +110,66 @@ export class Support {
81
110
  }
82
111
  return this.merchant;
83
112
  }
84
- /** Any CoinPay checkout on nixamp's business (a purchase in the store): no holder, no floor but a cent. */
85
- async pay(input) {
86
- const { business, coins } = await this.coins();
87
- const coin = typeof input.coin === "string" ? input.coin.toUpperCase() : "";
88
- if (!coins.includes(coin))
89
- throw new SupportError(`Pay with one of: ${coins.join(", ")}.`, 400, "invalid_coin");
90
- const created = await this.provider("/api/payments/create", {
113
+ /** The POST /api/checkout/sessions body: what CoinPay files the sale under. */
114
+ sessionBody(input) {
115
+ return {
116
+ amount: Math.max(0.01, Math.round(input.amountUsd * 100) / 100),
117
+ currency: "USD",
118
+ description: input.description.slice(0, 200),
119
+ customer_email: input.email,
120
+ account_id: input.accountId,
121
+ property: propertyOf(this.options.site),
122
+ metadata: { app: "nixamp", ...input.metadata },
123
+ success_url: input.redirect,
124
+ cancel_url: input.cancel ?? input.redirect,
125
+ expires_in_minutes: 60,
126
+ };
127
+ }
128
+ /** Open a CoinPay hosted checkout session; its id stands where a payment id used to. */
129
+ async session(input) {
130
+ const email = typeof input.email === "string" ? input.email.trim().toLowerCase() : "";
131
+ if (!EMAIL.test(email))
132
+ throw new SupportError("Give an email for the receipt.", 400, "invalid_email");
133
+ const accountId = (typeof input.accountId === "string" && input.accountId.trim()) || email;
134
+ const created = await this.provider("/api/checkout/sessions", {
91
135
  method: "POST",
92
- headers: { "idempotency-key": `nixamp-${input.order}` },
93
- body: JSON.stringify({
94
- business_id: business,
95
- amount_usd: Math.max(0.01, Math.round(input.amountUsd * 100) / 100),
96
- currency: coin.toLowerCase(),
97
- payment_method: "crypto",
98
- description: input.description.slice(0, 200),
99
- metadata: { app: "nixamp", ...input.metadata },
100
- redirect_url: input.redirect,
101
- }),
136
+ body: JSON.stringify(this.sessionBody({ ...input, email, accountId })),
102
137
  });
103
- const payment = created["payment"];
104
- const id = typeof payment?.id === "string" ? payment.id : "";
105
- if (!/^[A-Za-z0-9_-]{6,80}$/.test(id))
138
+ const id = typeof created["id"] === "string" ? created["id"] : "";
139
+ if (!SESSION.test(id))
106
140
  throw new SupportError("The checkout did not start; nothing was charged. Try again.", 502, "provider");
107
- return { url: `https://coinpayportal.com/pay/${id}`, paymentId: id };
141
+ return { url: payUrl(id), paymentId: id };
108
142
  }
109
- /** Whether CoinPay counts this payment as paid; null when it did not answer. */
143
+ /**
144
+ * Any CoinPay checkout on nixamp's business (a purchase in the store): no
145
+ * holder, no floor but a cent. A hosted session, so the payer picks any coin
146
+ * or PayPal there; `coin` is what an older page still sends, and is ignored.
147
+ */
148
+ async pay(input) {
149
+ return this.session({ ...input, metadata: { order: input.order, ...input.metadata } });
150
+ }
151
+ /**
152
+ * Whether CoinPay counts this payment as paid; null when it did not answer.
153
+ * A session (cs_...) is asked as a session: paid by any coin or PayPal.
154
+ */
110
155
  async isPaid(paymentId) {
156
+ if (isSessionRef(paymentId)) {
157
+ try {
158
+ const response = await (this.options.fetcher ?? fetch)(`${COINPAY}/api/checkout/sessions/${encodeURIComponent(paymentId)}`, {
159
+ signal: AbortSignal.timeout(15_000),
160
+ headers: { authorization: `Bearer ${this.options.key}` },
161
+ });
162
+ if (!response.ok)
163
+ return null;
164
+ const body = (await response.json());
165
+ if (body.session?.id !== paymentId)
166
+ return null;
167
+ return body.session.status === "paid";
168
+ }
169
+ catch {
170
+ return null;
171
+ }
172
+ }
111
173
  try {
112
174
  const response = await (this.options.fetcher ?? fetch)(`https://coinpayportal.com/api/payments/${encodeURIComponent(paymentId)}`, {
113
175
  signal: AbortSignal.timeout(15_000),
@@ -122,6 +184,34 @@ export class Support {
122
184
  return null;
123
185
  }
124
186
  }
187
+ /**
188
+ * A hosted session's state: "paid", "open" (with when it expires) or
189
+ * anything else CoinPay says (expired, canceled); null when it did not
190
+ * answer. An older one-coin payment is "paid" or "gone": its page cannot be
191
+ * reopened, so it is never handed out again.
192
+ */
193
+ async sessionState(ref) {
194
+ if (!isSessionRef(ref)) {
195
+ const paid = await this.isPaid(ref);
196
+ return paid === null ? null : { status: paid ? "paid" : "gone", expiresAt: 0 };
197
+ }
198
+ try {
199
+ const response = await (this.options.fetcher ?? fetch)(`${COINPAY}/api/checkout/sessions/${encodeURIComponent(ref)}`, {
200
+ signal: AbortSignal.timeout(15_000),
201
+ headers: { authorization: `Bearer ${this.options.key}` },
202
+ });
203
+ if (!response.ok)
204
+ return null;
205
+ const body = (await response.json());
206
+ if (body.session?.id !== ref)
207
+ return null;
208
+ const expiresAt = Date.parse(String(body.session.expires_at ?? "")) || 0;
209
+ return { status: String(body.session.status ?? ""), expiresAt };
210
+ }
211
+ catch {
212
+ return null;
213
+ }
214
+ }
125
215
  /** A CoinPay checkout for this much, to this holder; the address to send the payer to. */
126
216
  async checkout(input) {
127
217
  const holder = typeof input.holder === "string" ? this.holder(input.holder) : undefined;
@@ -130,30 +220,17 @@ export class Support {
130
220
  const amount = Number(input.amountUsd);
131
221
  if (!Number.isFinite(amount) || amount < MIN_USD || amount > MAX_USD)
132
222
  throw new SupportError(`Choose an amount from $${MIN_USD} to $${MAX_USD}.`, 400, "invalid_amount");
133
- const { business, coins } = await this.coins();
134
- const coin = typeof input.coin === "string" ? input.coin.toUpperCase() : "";
135
- if (!coins.includes(coin))
136
- throw new SupportError(`Pay with one of: ${coins.join(", ")}.`, 400, "invalid_coin");
137
223
  const clean = (value) => (typeof value === "string" ? value.replace(/[\u0000-\u001f]/g, " ").slice(0, 120) : "");
138
224
  const order = randomUUID();
139
- const created = await this.provider("/api/payments/create", {
140
- method: "POST",
141
- headers: { "idempotency-key": `nixamp-support-${order}` },
142
- body: JSON.stringify({
143
- business_id: business,
144
- amount_usd: Math.round(amount * 100) / 100,
145
- currency: coin.toLowerCase(),
146
- payment_method: "crypto",
147
- description: `Support ${holder.name} on nixamp`,
148
- metadata: { app: "nixamp", product: "support", holder: holder.id, order, station: clean(input.station), track: clean(input.track), ...(input.metadata ?? {}) },
149
- redirect_url: `${this.options.site}/support/${holder.id}?thanks=1`,
150
- }),
225
+ return this.session({
226
+ amountUsd: amount,
227
+ description: `Support ${holder.name} on nixamp`,
228
+ metadata: { product: "support", holder: holder.id, order, station: clean(input.station), track: clean(input.track), ...(input.metadata ?? {}) },
229
+ redirect: `${this.options.site}/support/${holder.id}?thanks=1`,
230
+ cancel: `${this.options.site}/support/${holder.id}`,
231
+ email: input.email,
232
+ accountId: input.accountId ?? null,
151
233
  });
152
- const payment = created["payment"];
153
- const id = typeof payment?.id === "string" ? payment.id : "";
154
- if (!/^[A-Za-z0-9_-]{6,80}$/.test(id))
155
- throw new SupportError("The checkout did not start; nothing was charged. Try again.", 502, "provider");
156
- return { url: `https://coinpayportal.com/pay/${id}`, paymentId: id };
157
234
  }
158
235
  }
159
236
  const STYLE = `
@@ -190,16 +267,14 @@ const SCRIPT = `
190
267
  }
191
268
  const monthly = () => document.querySelector("input[name=often]:checked").value === "monthly";
192
269
  for (const r of document.querySelectorAll("input[name=often]")) r.addEventListener("change", () => {
193
- $("#email-box").hidden = !monthly();
270
+ $("#email-label").textContent = monthly() ? "Email, for the receipt and each month's payment link (and the cancel link)" : "Email, for the receipt";
194
271
  $("#go").textContent = monthly() ? "Pay the first month" : "Continue to payment";
195
272
  });
196
273
  if (params.get("thanks")) { $("#status").className = "thanks"; $("#status").textContent = "Thank you. Your payment is on its way."; }
197
274
  try {
198
275
  const info = await (await fetch("/api/v1/support/" + encodeURIComponent(holder), { cache: "no-store" })).json();
199
276
  if (info.name) { $("#who").textContent = info.name; document.title = "Support " + info.name + " · nixamp"; }
200
- const coins = info.coins || [];
201
- $("#coin").replaceChildren(...coins.map((c) => { const o = document.createElement("option"); o.value = c; o.textContent = c.replace(/_/g, " "); return o; }));
202
- if (!coins.length) { $("#go").disabled = true; $("#status").textContent = info.error || "Payments are not available right now."; }
277
+ if (info.error) { $("#go").disabled = true; $("#status").textContent = info.error; }
203
278
  } catch { $("#status").textContent = "Payments are not available right now."; }
204
279
  $("#form").addEventListener("submit", async (event) => {
205
280
  event.preventDefault();
@@ -209,8 +284,8 @@ const SCRIPT = `
209
284
  $("#status").textContent = "Starting the checkout…";
210
285
  try {
211
286
  const r = await fetch("/api/v1/support/" + encodeURIComponent(holder), { method: "POST", headers: { "content-type": "application/json" },
212
- body: JSON.stringify({ amountUsd: usd, coin: $("#coin").value, station: params.get("station") || "", track: params.get("track") || "",
213
- ...(monthly() ? { monthly: true, email: $("#email").value } : {}) }) });
287
+ body: JSON.stringify({ amountUsd: usd, email: $("#email").value, station: params.get("station") || "", track: params.get("track") || "",
288
+ ...(monthly() ? { monthly: true } : {}) }) });
214
289
  const body = await r.json();
215
290
  if (r.ok && body.url) { location.href = body.url; return; }
216
291
  $("#status").textContent = body.error || "That did not work.";
@@ -233,22 +308,18 @@ export const SUPPORT_PAGE = `<!doctype html>
233
308
  <main>
234
309
  <div class="card">
235
310
  <h1>Support <span id="who">the artist</span></h1>
236
- <p>Pay the copyright holder of the music you are listening to, directly, in crypto through CoinPay. Once, or every month.</p>
311
+ <p>Pay the copyright holder of the music you are listening to, directly, through CoinPay: any coin, or PayPal. Once, or every month.</p>
237
312
  <form id="form">
238
313
  <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>
239
314
  <div class="amounts">${AMOUNTS.map((usd, at) => `<button type="button" data-usd="${usd}" aria-pressed="${at === 1}">$${usd}</button>`).join("")}</div>
240
315
  <label for="other">Or another amount (US$)</label>
241
316
  <input id="other" type="number" min="1" max="1000" step="1" inputmode="numeric">
242
- <div id="email-box" hidden>
243
- <label for="email">Email, for each month's payment link (and the cancel link)</label>
244
- <input id="email" type="email" autocomplete="email" maxlength="254">
245
- </div>
246
- <label for="coin">Pay with</label>
247
- <select id="coin"></select>
317
+ <label id="email-label" for="email">Email, for the receipt</label>
318
+ <input id="email" type="email" autocomplete="email" maxlength="254" required>
248
319
  <button id="go" class="go" type="submit">Continue to payment</button>
249
320
  <div id="status" role="status" aria-live="polite"></div>
250
321
  </form>
251
- <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>
322
+ <small>Payments go through <a href="https://coinpayportal.com">CoinPay</a> to nixamp.com, which pays the holder. You pick the coin, or PayPal, on CoinPay's page. 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>
252
323
  </div>
253
324
  </main>
254
325
  <script>${SCRIPT}</script>
@@ -276,9 +347,10 @@ export function nextMonth(at) {
276
347
  * Monthly support, run here rather than by CoinPay: CoinPay's only merchant
277
348
  * subscriptions are Stripe's, and its recurring invoices refuse a scoped key.
278
349
  * A subscription is an email, an amount, a coin and a holder. Month one is
279
- * paid at once on CoinPay's checkout; each month after, a new payment is made
280
- * and its link emailed, with a one-click cancel. A month left unpaid ends it
281
- * (lapsed) rather than emailing forever.
350
+ * paid at once on CoinPay's checkout; each month after, the subscription's
351
+ * stable pay link is emailed (it opens a fresh CoinPay session whenever it is
352
+ * followed, since a session expires within two hours), with a one-click
353
+ * cancel. A month left unpaid ends it (lapsed) rather than emailing forever.
282
354
  */
283
355
  export class SupportSubscriptions {
284
356
  options;
@@ -298,6 +370,9 @@ export class SupportSubscriptions {
298
370
  cycles INTEGER NOT NULL DEFAULT 0, last_payment_id TEXT, next_due TIMESTAMPTZ NOT NULL,
299
371
  station TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL, canceled_at TIMESTAMPTZ)`);
300
372
  await this.options.db.query("CREATE INDEX IF NOT EXISTS support_subscriptions_due ON support_subscriptions (status, next_due)");
373
+ // Every checkout opened for the current month, space separated: a payer
374
+ // may open several from the pay link, and any one of them paid counts.
375
+ await this.options.db.query("ALTER TABLE support_subscriptions ADD COLUMN IF NOT EXISTS cycle_payment_ids TEXT NOT NULL DEFAULT ''");
301
376
  })().catch((error) => { this.schema = null; throw error; });
302
377
  return this.schema;
303
378
  }
@@ -315,14 +390,17 @@ export class SupportSubscriptions {
315
390
  throw new SupportError("That email already has ten monthly supports.", 409, "too_many");
316
391
  const id = randomUUID();
317
392
  const token = randomUUID().replace(/-/g, "");
318
- const checkout = await this.options.support.checkout({ ...input, holder: holder.id, metadata: { subscription: id, cycle: 1 } });
393
+ const checkout = await this.options.support.checkout({ ...input, email, holder: holder.id, metadata: { subscription: id, cycle: 1 } });
394
+ // The payer picks the coin (or PayPal) on CoinPay; an older page may still name one.
395
+ const coin = (typeof input.coin === "string" && input.coin.trim() ? input.coin : "ANY").toUpperCase();
396
+ const paidWith = coin === "ANY" ? "" : ` in ${coin}`;
319
397
  const now = this.now();
320
- 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)
321
- 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)]);
398
+ 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, cycle_payment_ids)
399
+ VALUES ($1,$2,$3,$4,$5,$6,1,$7,$8,$9,$10,$7)`, [id, token, holder.id, email, Math.round(Number(input.amountUsd) * 100), coin, checkout.paymentId, new Date(nextMonth(now)), typeof input.station === "string" ? input.station.slice(0, 20) : "", new Date(now)]);
322
400
  await this.mail(email, `Your monthly support for ${holder.name}`, [
323
- `Thank you for supporting ${holder.name} on nixamp, $${Number(input.amountUsd).toFixed(2)} a month in ${String(input.coin).toUpperCase()}.`,
324
- `This month: ${checkout.url}`,
325
- `Each month we email you a new payment link like this one. Nothing is taken without you paying it.`,
401
+ `Thank you for supporting ${holder.name} on nixamp, $${Number(input.amountUsd).toFixed(2)} a month${paidWith}.`,
402
+ `Pay this month: ${this.payLink(id, token)}`,
403
+ `That link works all month: each time you open it, it starts a fresh checkout (or shows that this month is paid). Each month we email you the same link. Nothing is taken without you paying it.`,
326
404
  `Stop at any time: ${this.cancelLink(id, token)}`,
327
405
  ]);
328
406
  return { url: checkout.url, id };
@@ -330,6 +408,81 @@ export class SupportSubscriptions {
330
408
  cancelLink(id, token) {
331
409
  return `${this.options.site}/support/cancel?s=${encodeURIComponent(id)}&t=${encodeURIComponent(token)}`;
332
410
  }
411
+ /**
412
+ * The subscription's stable "pay this month" link. A CoinPay session lives
413
+ * at most two hours, so an emailed session link was dead by the time most
414
+ * people read it; this one opens a fresh session whenever it is followed.
415
+ * The token is the subscription's own secret (the cancel link's).
416
+ */
417
+ payLink(id, token) {
418
+ return `${this.options.site}/support/pay/${encodeURIComponent(id)}/${encodeURIComponent(token)}`;
419
+ }
420
+ /** The checkouts opened for the subscription's current month, newest last. */
421
+ static cycleIds(row) {
422
+ const listed = String(row["cycle_payment_ids"] ?? "").split(/\s+/).filter(Boolean);
423
+ const last = String(row["last_payment_id"] ?? "");
424
+ if (last && !listed.includes(last))
425
+ listed.push(last);
426
+ return listed;
427
+ }
428
+ /** Whether any of this month's checkouts is paid; null when CoinPay could not say and none is known paid. */
429
+ async cyclePaid(row) {
430
+ let unknown = false;
431
+ for (const ref of SupportSubscriptions.cycleIds(row)) {
432
+ const paid = await this.paid(ref);
433
+ if (paid === true)
434
+ return true;
435
+ if (paid === null)
436
+ unknown = true;
437
+ }
438
+ return unknown ? null : false;
439
+ }
440
+ /**
441
+ * Following the pay link: this month's checkout to send the payer to.
442
+ * "paid" when a checkout this month is already paid; "redirect" to a
443
+ * session still open for at least 15 minutes, else to a new one.
444
+ */
445
+ async open(id, token) {
446
+ if (!/^[0-9a-f-]{36}$/.test(id) || !/^[0-9a-f]{32}$/.test(token))
447
+ throw new SupportError("That payment link is not one of ours.", 404, "not_found");
448
+ await this.ready();
449
+ const found = await this.options.db.query("SELECT * FROM support_subscriptions WHERE id = $1 AND token = $2", [id, token]);
450
+ const row = found.rows[0];
451
+ if (!row)
452
+ throw new SupportError("That payment link is not one of ours.", 404, "not_found");
453
+ const holderId = String(row["holder"]);
454
+ const status = String(row["status"]);
455
+ if (status === "canceled" || status === "lapsed")
456
+ return { state: status, holder: holderId };
457
+ const holder = this.options.support.holder(holderId);
458
+ if (!holder)
459
+ throw new SupportError("Nobody by that name is supported here any more.", 404, "not_found");
460
+ const ids = SupportSubscriptions.cycleIds(row);
461
+ const newest = ids.at(-1);
462
+ let unknown = false;
463
+ for (const ref of ids) {
464
+ const state = await this.options.support.sessionState(ref);
465
+ if (state === null) {
466
+ unknown = true;
467
+ continue;
468
+ }
469
+ if (state.status === "paid")
470
+ return { state: "paid", holder: holderId };
471
+ if (ref === newest && state.status === "open" && state.expiresAt - this.now() >= 15 * 60_000)
472
+ return { state: "redirect", url: payUrl(ref) };
473
+ }
474
+ // A checkout CoinPay could not report on may be paid: do not stack new ones on it.
475
+ if (unknown)
476
+ throw new SupportError("The payment service did not answer; nothing was charged. Try the link again in a minute.", 502, "provider");
477
+ if (ids.length >= 20)
478
+ throw new SupportError("This month's link has opened too many checkouts; reply to the email and we will sort it out.", 429, "too_many");
479
+ const checkout = await this.options.support.checkout({ holder: holder.id, amountUsd: Number(row["amount_cents"]) / 100, email: String(row["email"]),
480
+ station: String(row["station"] ?? ""), metadata: { subscription: id, cycle: Number(row["cycles"]) } });
481
+ // Appended under the row's own month, so a renewal racing this click is not overwritten.
482
+ await this.options.db.query(`UPDATE support_subscriptions SET last_payment_id = $3,
483
+ cycle_payment_ids = trim(both ' ' from cycle_payment_ids || ' ' || $3) WHERE id = $1 AND cycles = $2`, [id, Number(row["cycles"]), checkout.paymentId]);
484
+ return { state: "redirect", url: checkout.url };
485
+ }
333
486
  async cancel(id, token) {
334
487
  await this.ready();
335
488
  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())]);
@@ -343,19 +496,8 @@ export class SupportSubscriptions {
343
496
  return { canceled: true, holder: String(row["holder"]) };
344
497
  }
345
498
  async paid(paymentId) {
346
- try {
347
- const response = await (this.options.fetcher ?? fetch)(`https://coinpayportal.com/api/payments/${encodeURIComponent(paymentId)}`, {
348
- signal: AbortSignal.timeout(15_000),
349
- headers: { authorization: `Bearer ${this.options.support.key}` },
350
- });
351
- if (!response.ok)
352
- return null;
353
- const body = (await response.json());
354
- return PAID.has(String(body.payment?.status ?? ""));
355
- }
356
- catch {
357
- return null;
358
- }
499
+ // A session (cs_...) or an older one-coin payment, asked the same way checkouts are.
500
+ return this.options.support.isPaid(paymentId);
359
501
  }
360
502
  /** Every subscription now due: a new month's payment and its link, or lapsed when last month went unpaid. */
361
503
  async renew() {
@@ -365,8 +507,7 @@ export class SupportSubscriptions {
365
507
  let lapsed = 0;
366
508
  for (const row of due.rows) {
367
509
  const id = String(row["id"]);
368
- const last = String(row["last_payment_id"] ?? "");
369
- const paid = last ? await this.paid(last) : false;
510
+ const paid = await this.cyclePaid(row);
370
511
  if (paid === null)
371
512
  continue; // CoinPay did not answer: ask again next time.
372
513
  const holder = this.options.support.holder(String(row["holder"]));
@@ -382,18 +523,13 @@ export class SupportSubscriptions {
382
523
  }
383
524
  const amountUsd = Number(row["amount_cents"]) / 100;
384
525
  const cycle = Number(row["cycles"]) + 1;
385
- let checkout;
386
- try {
387
- checkout = await this.options.support.checkout({ holder: holder.id, amountUsd, coin: String(row["coin"]), station: String(row["station"] ?? ""), metadata: { subscription: id, cycle } });
388
- }
389
- catch (error) {
390
- this.options.log?.(` support: renewal ${id} could not start a payment: ${error.message}`);
391
- continue;
392
- }
393
- 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()))]);
526
+ // No checkout is opened here: the email carries the stable pay link,
527
+ // which opens one when the payer follows it, however late in the month.
528
+ await this.options.db.query("UPDATE support_subscriptions SET cycles = $2, last_payment_id = NULL, cycle_payment_ids = '', next_due = $3 WHERE id = $1 AND cycles = $4", [id, cycle, new Date(nextMonth(new Date(String(row["next_due"])).getTime())), Number(row["cycles"])]);
394
529
  await this.mail(String(row["email"]), `This month's support for ${holder.name}`, [
395
- `Your monthly support for ${holder.name}: $${amountUsd.toFixed(2)} in ${String(row["coin"])}.`,
396
- `Pay this month: ${checkout.url}`,
530
+ `Your monthly support for ${holder.name}: $${amountUsd.toFixed(2)}, in any coin or PayPal.`,
531
+ `Pay this month: ${this.payLink(id, String(row["token"]))}`,
532
+ `The link works all month; each time you open it, it starts a fresh checkout.`,
397
533
  `Stop at any time: ${this.cancelLink(id, String(row["token"]))}`,
398
534
  ]);
399
535
  renewed += 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.67.0",
3
+ "version": "0.67.3",
4
4
  "description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -47,7 +47,7 @@
47
47
  "dependencies": {
48
48
  "@profullstack/auth-system": "^0.8.3",
49
49
  "@profullstack/hqtui": "^0.5.0",
50
- "@profullstack/live-translate": "^0.1.0",
50
+ "@profullstack/live-translate": "^0.3.0",
51
51
  "@profullstack/notifications": "^0.1.0",
52
52
  "@profullstack/synconfig": "^0.1.2",
53
53
  "@profullstack/throttle": "^0.3.3",
package/src/ai-label.ts CHANGED
@@ -59,6 +59,12 @@ export interface LabelSong {
59
59
  videoTagged?: boolean;
60
60
  /** 2 for a storyboard video (scenes that move); absent or 1 for the cover-and-waveform kind, which is made again. */
61
61
  videoVersion?: number;
62
+ /**
63
+ * The singer in its filmed video: "synced" (close-ups lip-synced), "unsynced" (filmed, but no
64
+ * sync could be made, so the band plays there). Absent on a filmed video means the singer was
65
+ * never filmed, and with lip sync on it is filmed again.
66
+ */
67
+ singer?: "synced" | "unsynced";
62
68
  /** Who wrote the words and who sang them, e.g. "llama-3.3 + elevenlabs/music_v2_5". */
63
69
  made: string;
64
70
  composedAt: number;
@@ -812,11 +818,23 @@ export function closeUpTimes(seconds: number, lines: Array<{ start: number }>, c
812
818
  }
813
819
 
814
820
  /**
815
- * The version of a video cut from Veo footage: the band with nobody singing, and the
816
- * singer only in close-ups lip-synced to the song. Anything older (a slideshow, or 4:
817
- * footage whose singers were out of sync) is not shown.
821
+ * The version of a video cut from Veo footage: story scenes and the band playing with
822
+ * nobody singing, the singer only in close-ups lip-synced to the song. Anything older
823
+ * is not shown: a slideshow, 4 (singers out of sync) or 5 (all stage, the singer at
824
+ * the mic in the wide shots).
818
825
  */
819
- export const FILMED = 5;
826
+ export const FILMED = 6;
827
+
828
+ /** Google's answer when the project's daily Veo requests are used up (HTTP 429). */
829
+ export const VEO_QUOTA = /exceeded your current quota|RESOURCE_EXHAUSTED/i;
830
+
831
+ /** The next midnight in California, when Google's daily quotas reset. */
832
+ export function nextPacificMidnight(now: number): number {
833
+ const hour = (at: number): number => Number(new Intl.DateTimeFormat("en-US", { timeZone: "America/Los_Angeles", hour: "numeric", hourCycle: "h23" }).format(new Date(at)));
834
+ let at = Math.floor(now / 3_600_000) * 3_600_000 + 3_600_000;
835
+ for (let i = 0; i < 26 && hour(at) !== 0; i++) at += 3_600_000;
836
+ return at;
837
+ }
820
838
 
821
839
  /**
822
840
  * A single's music video, cut from its filmed shots alone: 4 s cuts, each
@@ -992,6 +1010,8 @@ export class AiLabel {
992
1010
  private alignTried = new Set<string>();
993
1011
  /** The month ("YYYY-MM") Veo said its budget was spent; no more shots until the next. */
994
1012
  private veoSpentIn = "";
1013
+ /** Until when Google's daily Veo quota is used up (its midnight, Pacific): nothing is asked before. */
1014
+ private veoQuotaUntil = 0;
995
1015
  /** Old videos already tried as storyboards since this server started. */
996
1016
  private upgradeTried = new Set<string>();
997
1017
  /** When each single was last put in front of the camera: one that came up short is tried again an hour on. */
@@ -1327,7 +1347,9 @@ export class AiLabel {
1327
1347
  .filter((album) => album.cover || album.coverTried)
1328
1348
  .flatMap((album) => album.songs.filter((song) => song.file && !this.videoFailed.has(song.file)).map((song) => ({ artist, album, song }))));
1329
1349
  // Only a single is filmed, and only with Veo footage: a song with an older (slideshow) video is filmed when its turn comes.
1330
- const missing = songs.filter(({ song }) => this.wantsVeo(song) && (song.videoVersion ?? 1) < FILMED && this.now() - (this.filmTriedAt.get(song.file) ?? 0) > 60 * 60_000);
1350
+ // A filmed video whose singer was never filmed (a quota or a refusal took the close-ups) is filmed again while it stays up.
1351
+ const singerless = (song: LabelSong): boolean => Boolean(this.deps.lipsync) && this.settings.lipsyncShots > 0 && (song.videoVersion ?? 1) >= FILMED && !song.singer;
1352
+ const missing = songs.filter(({ song }) => this.wantsVeo(song) && ((song.videoVersion ?? 1) < FILMED || singerless(song)) && this.now() - (this.filmTriedAt.get(song.file) ?? 0) > 60 * 60_000);
1331
1353
  const untagged = songs.filter(({ song }) => song.video && !song.videoTagged && (song.videoVersion ?? 1) >= FILMED);
1332
1354
  const old: typeof songs = [];
1333
1355
  let made = 0;
@@ -1358,7 +1380,9 @@ export class AiLabel {
1358
1380
  const partial = join(album.folder, `.${randomBytes(4).toString("hex")}.video.mp4`);
1359
1381
  const scenes = join(album.folder, `.scenes-${song.n}-${randomBytes(3).toString("hex")}`);
1360
1382
  try {
1361
- song.videoVersion = await this.filmSong(artist, album, song, scenes, partial, upgrade);
1383
+ const filmed = await this.filmSong(artist, album, song, scenes, partial, upgrade);
1384
+ song.videoVersion = filmed.version;
1385
+ if (filmed.singer) song.singer = filmed.singer;
1362
1386
  renameSync(partial, out);
1363
1387
  song.video = out;
1364
1388
  song.videoTagged = true;
@@ -1381,7 +1405,7 @@ export class AiLabel {
1381
1405
  * Fewer than half the shots filmed (a refusal, the budget spent) makes no
1382
1406
  * video at all: footage stretched that thin would be a loop, not a video.
1383
1407
  */
1384
- private async filmSong(artist: LabelArtist, _album: LabelAlbum, song: LabelSong, scenes: string, out: string, _upgrade = false): Promise<number> {
1408
+ private async filmSong(artist: LabelArtist, _album: LabelAlbum, song: LabelSong, scenes: string, out: string, _upgrade = false): Promise<{ version: number; singer?: "synced" | "unsynced" }> {
1385
1409
  let seconds = song.seconds;
1386
1410
  try {
1387
1411
  const probed = await this.deps.probe(song.file);
@@ -1392,10 +1416,13 @@ export class AiLabel {
1392
1416
  const { band: clips, singer } = await this.filmShots(artist, _album, song, shots, scenes);
1393
1417
  const wanted = this.settings.veoShots + this.settings.storyShots;
1394
1418
  if (clips.length < Math.max(2, Math.ceil(wanted / 2))) throw new Error(`only ${clips.length} of ${wanted} shots filmed so far`);
1419
+ // The singer is the point of the video: with lip sync on, nothing is cut until a close-up is filmed.
1420
+ const singing = Boolean(this.deps.lipsync) && this.settings.lipsyncShots > 0;
1421
+ if (singing && singer.length === 0) throw new Error("the singer's close-ups are not filmed yet");
1395
1422
  const pinned = await this.syncCloseUps(song, singer, seconds, shots);
1396
1423
  await (this.deps.filmedVideo ?? ((input) => renderFilmedVideo(this.deps.ffmpeg, input)))({ audio: song.file, clips, seconds, artist: artist.name, title: song.title, out, ...(pinned.length ? { pinned } : {}) });
1397
1424
  rmSync(shots, { recursive: true, force: true });
1398
- return FILMED;
1425
+ return { version: FILMED, ...(singing ? { singer: pinned.length ? "synced" as const : "unsynced" as const } : {}) };
1399
1426
  }
1400
1427
 
1401
1428
  /**
@@ -1446,7 +1473,8 @@ export class AiLabel {
1446
1473
 
1447
1474
  /** Whether this song gets filmed Veo shots: an album's single, Veo on, and the month's budget not spent. */
1448
1475
  private wantsVeo(song: Pick<LabelSong, "n">): boolean {
1449
- return this.settings.veo && song.n === 1 && Boolean(this.deps.veo) && this.veoSpentIn !== new Date(this.now()).toISOString().slice(0, 7);
1476
+ return this.settings.veo && song.n === 1 && Boolean(this.deps.veo) && this.veoSpentIn !== new Date(this.now()).toISOString().slice(0, 7)
1477
+ && this.now() >= this.veoQuotaUntil;
1450
1478
  }
1451
1479
 
1452
1480
  /**
@@ -1496,10 +1524,11 @@ export class AiLabel {
1496
1524
  }
1497
1525
  // The singer's close-ups only where they can be lip-synced afterwards. Story scenes go without
1498
1526
  // the portraits: they would pull the whole band into every scene.
1527
+ // The singer first: a day's few Veo requests go to what the video is for, then the story and the band.
1499
1528
  const plan = [
1529
+ ...(this.deps.lipsync ? veoPrompts(artist, song, singers, this.settings.lipsyncShots, "singer").map((prompt, i) => ({ prompt, file: join(keep, `singer-${i}.mp4`), into: singer, refs: singerRefs })) : []),
1500
1530
  ...storyFiles.map((file, i) => ({ prompt: storyPrompts[i] ?? "", file, into: story, refs: [] as Buffer[] })),
1501
1531
  ...veoPrompts(artist, song, players, this.settings.veoShots).map((prompt, i) => ({ prompt, file: join(keep, `shot-${i}.mp4`), into: band, refs: playerRefs })),
1502
- ...(this.deps.lipsync ? veoPrompts(artist, song, singers, this.settings.lipsyncShots, "singer").map((prompt, i) => ({ prompt, file: join(keep, `singer-${i}.mp4`), into: singer, refs: singerRefs })) : []),
1503
1532
  ];
1504
1533
  for (const [i, { prompt, file, into, refs }] of plan.entries()) {
1505
1534
  try {
@@ -1525,6 +1554,12 @@ export class AiLabel {
1525
1554
  this.log(`Veo: ${(error as Error).message}; no more videos until next month`);
1526
1555
  break;
1527
1556
  }
1557
+ if (VEO_QUOTA.test((error as Error).message)) {
1558
+ // Google's daily request quota: every further ask today is refused, so none is made.
1559
+ this.veoQuotaUntil = nextPacificMidnight(this.now());
1560
+ this.log(`Veo: Google's daily quota is used up; filming resumes after ${new Date(this.veoQuotaUntil).toISOString()}`);
1561
+ break;
1562
+ }
1528
1563
  this.log(`Veo shot ${i + 1} for "${song.title}": ${(error as Error).message}`);
1529
1564
  }
1530
1565
  }