nixamp 0.67.1 → 0.67.4

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 (41) hide show
  1. package/dist/ai-label.d.ts +12 -0
  2. package/dist/ai-label.js +34 -5
  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/dist/veo-studio.d.ts +6 -0
  11. package/dist/veo-studio.js +68 -36
  12. package/package.json +2 -2
  13. package/src/ai-label.ts +40 -6
  14. package/src/license-routes.ts +6 -13
  15. package/src/music-license.ts +4 -3
  16. package/src/music-store.ts +4 -3
  17. package/src/server.ts +59 -9
  18. package/src/store-routes.ts +6 -14
  19. package/src/support.ts +227 -90
  20. package/src/veo-studio.ts +62 -30
  21. package/web/dist/assets/{ar-DPe7Pp8y.js → ar-CRV9Quan.js} +1 -1
  22. package/web/dist/assets/{cs-CJcjEsDV.js → cs-CAoAOSEV.js} +1 -1
  23. package/web/dist/assets/{da-Bd1GXRyz.js → da-DUvxzaUE.js} +1 -1
  24. package/web/dist/assets/{de-CLZfazCs.js → de-BdA0QfUh.js} +1 -1
  25. package/web/dist/assets/{es-BLqEfqLZ.js → es-DfbHSteU.js} +1 -1
  26. package/web/dist/assets/{fi-anv-MFsC.js → fi-65IkDcD0.js} +1 -1
  27. package/web/dist/assets/{fr-BzD2HDGv.js → fr-ZCLeigWh.js} +1 -1
  28. package/web/dist/assets/{hi-D71Abrr0.js → hi-CgUjpeV3.js} +1 -1
  29. package/web/dist/assets/{hu-BmLNPlTb.js → hu-Nk3lmNzK.js} +1 -1
  30. package/web/dist/assets/{id-D804zCDF.js → id-CSbL1VEB.js} +1 -1
  31. package/web/dist/assets/{it-BAuY8mMB.js → it-Ca1dxbJu.js} +1 -1
  32. package/web/dist/assets/main-3VBJDnTo.js +22 -0
  33. package/web/dist/assets/{nl-D8x7GCIF.js → nl-BGzV-Nib.js} +1 -1
  34. package/web/dist/assets/{ru-Ci9fQdcv.js → ru--JBLHgkK.js} +1 -1
  35. package/web/dist/assets/{sv-CZtgl4E6.js → sv-xxdDl1G_.js} +1 -1
  36. package/web/dist/assets/{uk-VhX0Jvpy.js → uk-BK5Gpfe7.js} +1 -1
  37. package/web/dist/assets/{vi-TdUCiab8.js → vi-COp3eRc5.js} +1 -1
  38. package/web/dist/assets/{zh-BMJXXIBE.js → zh-xl2IR6MI.js} +1 -1
  39. package/web/dist/index.html +2 -3
  40. package/web/dist/sw.js +19 -19
  41. package/web/dist/assets/main-DbQrVbPR.js +0 -22
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;
@@ -819,6 +825,17 @@ export function closeUpTimes(seconds: number, lines: Array<{ start: number }>, c
819
825
  */
820
826
  export const FILMED = 6;
821
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
+ }
838
+
822
839
  /**
823
840
  * A single's music video, cut from its filmed shots alone: 4 s cuts, each
824
841
  * shot's first half then its second, round and round the shots until the song
@@ -993,6 +1010,8 @@ export class AiLabel {
993
1010
  private alignTried = new Set<string>();
994
1011
  /** The month ("YYYY-MM") Veo said its budget was spent; no more shots until the next. */
995
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;
996
1015
  /** Old videos already tried as storyboards since this server started. */
997
1016
  private upgradeTried = new Set<string>();
998
1017
  /** When each single was last put in front of the camera: one that came up short is tried again an hour on. */
@@ -1328,7 +1347,9 @@ export class AiLabel {
1328
1347
  .filter((album) => album.cover || album.coverTried)
1329
1348
  .flatMap((album) => album.songs.filter((song) => song.file && !this.videoFailed.has(song.file)).map((song) => ({ artist, album, song }))));
1330
1349
  // Only a single is filmed, and only with Veo footage: a song with an older (slideshow) video is filmed when its turn comes.
1331
- 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);
1332
1353
  const untagged = songs.filter(({ song }) => song.video && !song.videoTagged && (song.videoVersion ?? 1) >= FILMED);
1333
1354
  const old: typeof songs = [];
1334
1355
  let made = 0;
@@ -1359,7 +1380,9 @@ export class AiLabel {
1359
1380
  const partial = join(album.folder, `.${randomBytes(4).toString("hex")}.video.mp4`);
1360
1381
  const scenes = join(album.folder, `.scenes-${song.n}-${randomBytes(3).toString("hex")}`);
1361
1382
  try {
1362
- 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;
1363
1386
  renameSync(partial, out);
1364
1387
  song.video = out;
1365
1388
  song.videoTagged = true;
@@ -1382,7 +1405,7 @@ export class AiLabel {
1382
1405
  * Fewer than half the shots filmed (a refusal, the budget spent) makes no
1383
1406
  * video at all: footage stretched that thin would be a loop, not a video.
1384
1407
  */
1385
- 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" }> {
1386
1409
  let seconds = song.seconds;
1387
1410
  try {
1388
1411
  const probed = await this.deps.probe(song.file);
@@ -1393,10 +1416,13 @@ export class AiLabel {
1393
1416
  const { band: clips, singer } = await this.filmShots(artist, _album, song, shots, scenes);
1394
1417
  const wanted = this.settings.veoShots + this.settings.storyShots;
1395
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");
1396
1422
  const pinned = await this.syncCloseUps(song, singer, seconds, shots);
1397
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 } : {}) });
1398
1424
  rmSync(shots, { recursive: true, force: true });
1399
- return FILMED;
1425
+ return { version: FILMED, ...(singing ? { singer: pinned.length ? "synced" as const : "unsynced" as const } : {}) };
1400
1426
  }
1401
1427
 
1402
1428
  /**
@@ -1447,7 +1473,8 @@ export class AiLabel {
1447
1473
 
1448
1474
  /** Whether this song gets filmed Veo shots: an album's single, Veo on, and the month's budget not spent. */
1449
1475
  private wantsVeo(song: Pick<LabelSong, "n">): boolean {
1450
- 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;
1451
1478
  }
1452
1479
 
1453
1480
  /**
@@ -1497,10 +1524,11 @@ export class AiLabel {
1497
1524
  }
1498
1525
  // The singer's close-ups only where they can be lip-synced afterwards. Story scenes go without
1499
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.
1500
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 })) : []),
1501
1530
  ...storyFiles.map((file, i) => ({ prompt: storyPrompts[i] ?? "", file, into: story, refs: [] as Buffer[] })),
1502
1531
  ...veoPrompts(artist, song, players, this.settings.veoShots).map((prompt, i) => ({ prompt, file: join(keep, `shot-${i}.mp4`), into: band, refs: playerRefs })),
1503
- ...(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 })) : []),
1504
1532
  ];
1505
1533
  for (const [i, { prompt, file, into, refs }] of plan.entries()) {
1506
1534
  try {
@@ -1526,6 +1554,12 @@ export class AiLabel {
1526
1554
  this.log(`Veo: ${(error as Error).message}; no more videos until next month`);
1527
1555
  break;
1528
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
+ }
1529
1563
  this.log(`Veo shot ${i + 1} for "${song.title}": ${(error as Error).message}`);
1530
1564
  }
1531
1565
  }
@@ -13,7 +13,7 @@
13
13
  import { createHash } from "node:crypto";
14
14
  import type { IncomingMessage, ServerResponse } from "node:http";
15
15
  import { LICENSE_PRICES, TERMS_VERSION, TIER_NAMES, TIER_USE, licenseTerms, type License, type MusicLicenses, type Quote, type Tier } from "./music-license.ts";
16
- import { SupportError } from "./support.ts";
16
+ import { SupportError, paidIn, payUrl } from "./support.ts";
17
17
 
18
18
  const CHECK = /^\/license\/(NXL-[A-Z2-9]{4}-[A-Z2-9]{4})$/;
19
19
  const CHECK_JSON = /^\/api\/v1\/licenses\/(NXL-[A-Z2-9]{4}-[A-Z2-9]{4})$/;
@@ -74,14 +74,9 @@ const SCRIPT = `
74
74
  if (!form) return;
75
75
  const status = $("#status");
76
76
  const pay = $("#pay");
77
- const label = () => { const one = form.querySelector("input[name=tier]:checked"); pay.textContent = one ? "Pay " + one.dataset.price + " with crypto" : "Pick a licence"; };
77
+ const label = () => { const one = form.querySelector("input[name=tier]:checked"); pay.textContent = one ? "Pay " + one.dataset.price : "Pick a licence"; };
78
78
  form.addEventListener("change", label);
79
79
  label();
80
- fetch("/api/v1/store/coins").then((r) => r.json()).then((coins) => {
81
- const cheap = ["USDC_POL", "USDC_SOL", "SOL", "POL", "LTC", "USDC_BASE", "USDC"];
82
- const list = (coins.coins || []).slice().sort((a, b) => (cheap.indexOf(a) + 1 || 99) - (cheap.indexOf(b) + 1 || 99));
83
- $("#coin").replaceChildren(...list.map((c) => Object.assign(document.createElement("option"), { value: c, textContent: c })));
84
- }).catch(() => { status.textContent = "Payments are not available right now."; });
85
80
  form.addEventListener("submit", async (event) => {
86
81
  event.preventDefault();
87
82
  pay.disabled = true;
@@ -90,7 +85,7 @@ const SCRIPT = `
90
85
  try {
91
86
  const r = await fetch("/api/v1/licenses/checkout", { method: "POST", headers: { "content-type": "application/json" }, credentials: "same-origin",
92
87
  body: JSON.stringify({ ...JSON.parse(document.body.dataset.item), tier: data.get("tier"), licensee: data.get("licensee"), project: data.get("project"),
93
- projectUrl: data.get("projectUrl"), email: data.get("email"), coin: data.get("coin"), agree: data.get("agree") === "on" }) });
88
+ projectUrl: data.get("projectUrl"), email: data.get("email"), agree: data.get("agree") === "on" }) });
94
89
  const body = await r.json();
95
90
  if (!r.ok) throw new Error(body.error || "The checkout did not start.");
96
91
  status.textContent = "Taking you to CoinPay…";
@@ -181,13 +176,11 @@ export async function handleLicenseRoutes(request: IncomingMessage, response: Se
181
176
  <input id="projectUrl" name="projectUrl" type="url" maxlength="300" placeholder="https://">
182
177
  <label class="f" for="email">Email (your certificate goes here)</label>
183
178
  <input id="email" name="email" type="email" required autocomplete="email" value="${escape(me?.email ?? "")}">
184
- <label class="f" for="coin">Pay with</label>
185
- <select id="coin" name="coin" required></select>
186
179
  <label class="agree"><input type="checkbox" name="agree" required> <span>I agree to the <a href="/license/terms" target="_blank" rel="noopener">licence terms</a>, including the credit with a link to the artist's page.</span></label>
187
180
  <button id="pay" type="submit">Pay</button>
188
181
  </form>
189
182
  <p id="status" role="status" aria-live="polite"></p>
190
- <small>Payments go through <a href="https://coinpayportal.com">CoinPay</a> to nixamp.com. You get a certificate with a licence code that anybody can check at ${escape(ctx.site)}/license/&lt;code&gt;.</small>
183
+ <small>Payments go through <a href="https://coinpayportal.com">CoinPay</a> to nixamp.com: pick any coin, or PayPal, on CoinPay's page. You get a certificate with a licence code that anybody can check at ${escape(ctx.site)}/license/&lt;code&gt;.</small>
191
184
  </div>`, { data: JSON.stringify({ kind: quote.kind, ...(quote.kind === "catalog" ? {} : { server: ctx.serverId(quote.server), artist: quote.artist, album: quote.album, n: quote.n }) }) }), method);
192
185
  return true;
193
186
  }
@@ -214,9 +207,9 @@ export async function handleLicenseRoutes(request: IncomingMessage, response: Se
214
207
  }
215
208
  const body = license.status === "paid"
216
209
  ? certificate(license, ctx, true)
217
- : `<div class="card"><h1>${escape(TIER_NAMES[license.tier])} licence ${escape(license.code)}</h1><p class="by">${escape(license.title)} · ${price(license.amountCents)} in ${escape(license.coin)}</p>
210
+ : `<div class="card"><h1>${escape(TIER_NAMES[license.tier])} licence ${escape(license.code)}</h1><p class="by">${escape(license.title)} · ${price(license.amountCents)}${escape(paidIn(license.coin))}</p>
218
211
  <p>Waiting for your payment. Once CoinPay confirms it, your certificate is here and in your email (${escape(license.email)}). This page checks again when you reload it.</p>
219
- <a class="go" href="https://coinpayportal.com/pay/${escape(license.paymentId)}">Open the payment</a></div>`;
212
+ <a class="go" href="${escape(payUrl(license.paymentId))}">Open the payment</a></div>`;
220
213
  html(response, 200, page(`Licence ${license.code}`, body), method);
221
214
  return true;
222
215
  }
@@ -17,7 +17,7 @@
17
17
  */
18
18
  import { randomBytes, randomUUID } from "node:crypto";
19
19
  import type { Db, Support } from "./support.ts";
20
- import { SupportError } from "./support.ts";
20
+ import { SupportError, coinOf } from "./support.ts";
21
21
  import type { MusicStore } from "./music-store.ts";
22
22
 
23
23
  export type Tier = "creator" | "indie" | "commercial" | "catalog";
@@ -203,7 +203,8 @@ export class MusicLicenses {
203
203
  const code = newCode();
204
204
  const checkout = await this.options.support.pay({
205
205
  amountUsd: quote.amountCents / 100,
206
- coin: input["coin"],
206
+ email,
207
+ accountId: account?.id || email,
207
208
  order: `license-${id}`,
208
209
  description: `${TIER_NAMES[quote.tier]} licence: ${quote.title}${quote.kind === "catalog" ? ", one year" : ` by ${quote.artistName}`} for ${project}`,
209
210
  metadata: { product: "music-license", license: id, tier: quote.tier, kind: quote.kind },
@@ -213,7 +214,7 @@ export class MusicLicenses {
213
214
  title, artist_name, credit_url, amount_cents, coin, payment_id, terms, created_at)
214
215
  VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22)`,
215
216
  [id, code, token, email, account?.id ?? "", licensee, project, httpsUrl(input["projectUrl"]), quote.tier, quote.kind, quote.server, quote.artist, quote.album, quote.n,
216
- quote.title, quote.artistName, quote.creditUrl, quote.amountCents, String(input["coin"]).toUpperCase(), checkout.paymentId, TERMS_VERSION, new Date(this.now())]);
217
+ quote.title, quote.artistName, quote.creditUrl, quote.amountCents, coinOf(input["coin"]), checkout.paymentId, TERMS_VERSION, new Date(this.now())]);
217
218
  return { url: checkout.url, id, token, code };
218
219
  }
219
220
 
@@ -17,7 +17,7 @@
17
17
  */
18
18
  import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
19
19
  import type { Db, Support } from "./support.ts";
20
- import { SupportError } from "./support.ts";
20
+ import { SupportError, coinOf } from "./support.ts";
21
21
 
22
22
  export type Kind = "song" | "album" | "artist";
23
23
 
@@ -225,7 +225,8 @@ export class MusicStore {
225
225
  const token = randomUUID().replace(/-/g, "");
226
226
  const checkout = await this.options.support.pay({
227
227
  amountUsd: item.amountCents / 100,
228
- coin: input["coin"],
228
+ email,
229
+ accountId: account?.id || email,
229
230
  order: `music-${id}`,
230
231
  description: `${item.title} by ${item.artistName} (${item.kind === "song" ? "song" : item.kind === "album" ? "album" : "every album"}, MP3)`,
231
232
  metadata: { product: "music", purchase: id, kind: item.kind },
@@ -233,7 +234,7 @@ export class MusicStore {
233
234
  });
234
235
  await this.options.db.query(`INSERT INTO music_purchases (id, token, email, user_id, server, kind, artist, album, n, title, artist_name, amount_cents, coin, payment_id, created_at)
235
236
  VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)`,
236
- [id, token, email, account?.id ?? "", item.server, item.kind, item.artist, item.album, item.n, item.title, item.artistName, item.amountCents, String(input["coin"]).toUpperCase(), checkout.paymentId, new Date(this.now())]);
237
+ [id, token, email, account?.id ?? "", item.server, item.kind, item.artist, item.album, item.n, item.title, item.artistName, item.amountCents, coinOf(input["coin"]), checkout.paymentId, new Date(this.now())]);
237
238
  return { url: checkout.url, id, token };
238
239
  }
239
240
 
package/src/server.ts CHANGED
@@ -2726,10 +2726,15 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2726
2726
  }
2727
2727
  let event: ReturnType<typeof readCoinpayWebhook>;
2728
2728
  try { event = readCoinpayWebhook(JSON.parse(raw)); } catch { json(response, 400, { error: "invalid JSON" }); return; }
2729
+ // A hosted checkout session's payment names its session; the purchase
2730
+ // row holds the session id (cs_...), an older one the payment id.
2731
+ const refs = [event.sessionId, event.paymentId].filter(Boolean);
2729
2732
  if (options.musicStore) {
2730
2733
  try {
2731
- if (await options.musicStore.owns(event.paymentId)) { json(response, 200, await options.musicStore.settle(event.paymentId)); return; }
2732
- if (options.musicLicenses && await options.musicLicenses.owns(event.paymentId)) { json(response, 200, await options.musicLicenses.settle(event.paymentId)); return; }
2734
+ for (const ref of refs) {
2735
+ if (await options.musicStore.owns(ref)) { json(response, 200, await options.musicStore.settle(ref)); return; }
2736
+ if (options.musicLicenses && await options.musicLicenses.owns(ref)) { json(response, 200, await options.musicLicenses.settle(ref)); return; }
2737
+ }
2733
2738
  } catch (error) {
2734
2739
  console.error(`nixamp: CoinPay webhook for ${event.paymentId} (music) could not settle: ${error instanceof Error ? error.message : String(error)}`);
2735
2740
  json(response, 500, { error: "could not settle; retry" });
@@ -2738,7 +2743,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2738
2743
  }
2739
2744
  if (!options.translationPasses) { json(response, 200, { settled: false, reason: "billing is off here" }); return; }
2740
2745
  try {
2741
- json(response, 200, await options.translationPasses.settle(event.paymentId));
2746
+ json(response, 200, await options.translationPasses.settle(event));
2742
2747
  } catch (error) {
2743
2748
  // A 5xx makes CoinPay deliver it again, which is what an unreachable
2744
2749
  // CoinPay or database needs.
@@ -2884,7 +2889,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2884
2889
  // llms.txt, the OpenAPI document, the health check and the desktop downloads (nixamp.com).
2885
2890
  !(isDiscoveryPath(path) && Boolean(options.accounts && options.site)) &&
2886
2891
  // Supporting a copyright holder is for anybody listening.
2887
- !/^\/support\/[a-z0-9-]{1,40}$/.test(path) && path !== "/support/cancel" && !/^\/api\/v1\/support\/[a-z0-9-]{1,40}$/.test(path) &&
2892
+ !/^\/support\/[a-z0-9-]{1,40}$/.test(path) && path !== "/support/cancel" && !/^\/support\/pay\/[0-9a-f-]{36}\/[0-9a-f]{32}$/.test(path) && !/^\/api\/v1\/support\/[a-z0-9-]{1,40}$/.test(path) &&
2888
2893
  // A station's short link, which sends the browser to wherever it plays.
2889
2894
  !/^\/watch\/[a-z0-9]{10}$/.test(path) &&
2890
2895
  // A live room's page: the room's own key travels in ?play=, checked by its server.
@@ -2979,6 +2984,41 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2979
2984
  return;
2980
2985
  }
2981
2986
 
2987
+ /*
2988
+ * A monthly support's "pay this month" link, from its emails: a fresh
2989
+ * CoinPay checkout session each time it is followed (one still open is
2990
+ * reused), so a late payer is never sent to an expired session.
2991
+ */
2992
+ const supportPay = /^\/support\/pay\/([0-9a-f-]{36})\/([0-9a-f]{32})$/.exec(path);
2993
+ if (supportPay && options.supportSubscriptions && (request.method === "GET" || request.method === "HEAD")) {
2994
+ const page = (status: number, words: string, again?: string): void => {
2995
+ const safe = (text: string): string => text.replace(/[&<>"']/g, (one) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[one]!);
2996
+ response.writeHead(status, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'", "referrer-policy": "no-referrer", "x-robots-tag": "noindex" });
2997
+ response.end(request.method === "HEAD" ? undefined : `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Support · nixamp</title><body style="margin:0;background:#080c09;color:#e8efe9;font:16px/1.5 system-ui,sans-serif"><main style="max-width:520px;margin:0 auto;padding:48px 16px"><h1 style="font-size:1.4rem">Monthly support</h1><p>${safe(words)}</p>${again ? `<p><a style="color:#3ddc84" href="${safe(again)}">${safe(again.includes("/support/pay/") ? "Try again" : "Start again")}</a></p>` : ""}<p><a style="color:#3ddc84" href="https://nixamp.com/guide">Back to the stations</a></p></main></body>`);
2998
+ };
2999
+ // A HEAD (a mail scanner checking the link) never opens a checkout.
3000
+ if (request.method === "HEAD") { page(200, ""); return; }
3001
+ const verdict = guard.check(`support-pay:${callerOf(request.headers, request.socket.remoteAddress, behindProxy)}`, { allowed: 10, windowMs: 60_000 });
3002
+ if (!verdict.ok) { page(429, "Too many at once; wait a minute and open the link again."); return; }
3003
+ try {
3004
+ const opened = await options.supportSubscriptions.open(supportPay[1]!, supportPay[2]!);
3005
+ if (opened.state === "redirect") {
3006
+ response.writeHead(303, { location: opened.url, "cache-control": "no-store", "referrer-policy": "no-referrer" });
3007
+ response.end();
3008
+ return;
3009
+ }
3010
+ const name = options.support?.holder(opened.holder)?.name ?? opened.holder;
3011
+ const site = options.site ?? "https://nixamp.com";
3012
+ if (opened.state === "paid") page(200, `This month's support for ${name} is paid. Thank you. Next month's link comes by email.`);
3013
+ else if (opened.state === "canceled") page(200, `Your monthly support for ${name} was cancelled, so there is nothing to pay.`, `${site}/support/${opened.holder}`);
3014
+ else page(200, `Your monthly support for ${name} stopped after a month went unpaid. Nothing is owed.`, `${site}/support/${opened.holder}`);
3015
+ } catch (error) {
3016
+ if (error instanceof SupportError) page(error.status, error.message, error.status >= 500 ? `${url.pathname}` : undefined);
3017
+ else page(502, "The payment service did not answer; nothing was charged. Try the link again in a minute.", url.pathname);
3018
+ }
3019
+ return;
3020
+ }
3021
+
2982
3022
  /* Support a copyright holder: the page, the coins, and a CoinPay checkout. */
2983
3023
  const supportPage = /^\/support\/([a-z0-9-]{1,40})$/.exec(path);
2984
3024
  if (supportPage && options.support && (request.method === "GET" || request.method === "HEAD")) {
@@ -3012,11 +3052,18 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
3012
3052
  }
3013
3053
  let body: Record<string, unknown> = {};
3014
3054
  try { body = JSON.parse(await readBody(request)) as Record<string, unknown>; } catch { body = {}; }
3055
+ // CoinPay files the payment under the payer: a signed-in member by
3056
+ // account id (their email if the form left it empty), a guest by email.
3057
+ const me = options.accounts ? await options.accounts.whoIs(tokenFrom(request.headers)).catch(() => null) : null;
3058
+ const payer = {
3059
+ email: typeof body["email"] === "string" && body["email"].trim() ? body["email"] : me?.email ?? "",
3060
+ accountId: me?.id ?? null,
3061
+ };
3015
3062
  if (body["monthly"] === true) {
3016
3063
  if (!options.supportSubscriptions) throw new SupportError("Monthly support is not set up here.", 503, "unavailable");
3017
- json(response, 200, await options.supportSubscriptions.start({ ...body, holder: holder.id }));
3064
+ json(response, 200, await options.supportSubscriptions.start({ ...body, ...payer, holder: holder.id }));
3018
3065
  } else {
3019
- json(response, 200, await support.checkout({ ...body, holder: holder.id }));
3066
+ json(response, 200, await support.checkout({ ...body, ...payer, holder: holder.id }));
3020
3067
  }
3021
3068
  return;
3022
3069
  }
@@ -4107,8 +4154,11 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
4107
4154
  if (!guard.check(`translation-checkout-ip:${caller}`, { allowed: 10, windowMs: 3600_000 }).ok) throw new SpeechError("Too many new checkouts. Resume your pending purchase.", 429);
4108
4155
  let body: Record<string, unknown>;
4109
4156
  try { body = JSON.parse(await readBody(request, 1024)); } catch { throw new SpeechError("Choose a translation pass.", 400); }
4110
- if (!body || typeof body !== "object" || typeof body["plan"] !== "string" || typeof body["coin"] !== "string" || typeof body["requestKey"] !== "string") throw new SpeechError("Choose a translation pass and payment currency.", 400);
4111
- json(response, 200, await service.checkout(who.id, body["plan"], body["coin"], body["requestKey"])); return;
4157
+ if (!body || typeof body !== "object" || typeof body["plan"] !== "string" || typeof body["requestKey"] !== "string") throw new SpeechError("Choose a translation pass.", 400);
4158
+ // A CoinPay hosted session (any coin or PayPal) filed under this
4159
+ // account and its email; `coin` only comes from an older open page.
4160
+ const email = who.email || (typeof body["email"] === "string" ? body["email"] : "");
4161
+ json(response, 200, await service.checkout(who.id, body["plan"], typeof body["coin"] === "string" ? body["coin"] : "", body["requestKey"], { email, accountId: who.id })); return;
4112
4162
  }
4113
4163
  const order = path.match(/^\/api\/v1\/translation-passes\/orders\/([^/]+)$/);
4114
4164
  if (order && request.method === "GET") {
@@ -8019,7 +8069,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
8019
8069
  ? new TranslationPasses({ db: pool, key: coinpayKeyFrom(), site: nixampSite, admins: [options.owner || ""] }) : undefined;
8020
8070
  // Payments to the copyright holders nixamp.com collects for, on its own CoinPay business.
8021
8071
  const supportPayments = accounts ? new Support({ key: coinpayKeyFrom(), site: nixampSite, holders: supportHoldersFrom() }) : undefined;
8022
- // Monthly support: each month a new CoinPay payment and its link by email.
8072
+ // Monthly support: each month its stable pay link by email (a fresh CoinPay session per click).
8023
8073
  const supportMonthly = supportPayments && supportPayments.enabled && pool ? new SupportSubscriptions({
8024
8074
  db: pool as unknown as import("./support.ts").Db,
8025
8075
  support: supportPayments,
@@ -13,7 +13,7 @@ import { createHash } from "node:crypto";
13
13
  import type { IncomingMessage, ServerResponse } from "node:http";
14
14
  import { PRICES, type MusicStore } from "./music-store.ts";
15
15
  import { serverId } from "./music-hub.ts";
16
- import { SupportError } from "./support.ts";
16
+ import { SupportError, paidIn, payUrl } from "./support.ts";
17
17
 
18
18
  const PURCHASE = /^\/purchases\/([0-9a-f-]{36})$/;
19
19
  const ONE = /^\/api\/v1\/store\/purchases\/([0-9a-f-]{36})$/;
@@ -61,12 +61,6 @@ const BUY_SCRIPT = `
61
61
  (async () => {
62
62
  const $ = (s) => document.querySelector(s);
63
63
  const status = $("#status");
64
- try {
65
- const coins = await (await fetch("/api/v1/store/coins")).json();
66
- const cheap = ["USDC_POL", "USDC_SOL", "SOL", "POL", "LTC", "USDC_BASE", "USDC"];
67
- const list = (coins.coins || []).slice().sort((a, b) => (cheap.indexOf(a) + 1 || 99) - (cheap.indexOf(b) + 1 || 99));
68
- $("#coin").replaceChildren(...list.map((c) => Object.assign(document.createElement("option"), { value: c, textContent: c })));
69
- } catch { status.textContent = "Payments are not available right now."; }
70
64
  $("#buy").addEventListener("submit", async (event) => {
71
65
  event.preventDefault();
72
66
  const button = $("#pay");
@@ -75,7 +69,7 @@ const BUY_SCRIPT = `
75
69
  const form = new FormData(event.currentTarget);
76
70
  try {
77
71
  const r = await fetch("/api/v1/store/checkout", { method: "POST", headers: { "content-type": "application/json" }, credentials: "same-origin",
78
- body: JSON.stringify({ ...JSON.parse(document.body.dataset.item), email: form.get("email"), coin: form.get("coin") }) });
72
+ body: JSON.stringify({ ...JSON.parse(document.body.dataset.item), email: form.get("email") }) });
79
73
  const body = await r.json();
80
74
  if (!r.ok) throw new Error(body.error || "The checkout did not start.");
81
75
  status.textContent = "Taking you to CoinPay…";
@@ -128,12 +122,10 @@ export async function handleStoreRoutes(request: IncomingMessage, response: Serv
128
122
  <form id="buy">
129
123
  <label for="email">Email (your download link goes here)</label>
130
124
  <input id="email" name="email" type="email" required autocomplete="email" value="${escape(me?.email ?? "")}">
131
- <label for="coin">Pay with</label>
132
- <select id="coin" name="coin" required></select>
133
- <button id="pay" type="submit">Pay ${price(item.amountCents)} with crypto</button>
125
+ <button id="pay" type="submit">Pay ${price(item.amountCents)}</button>
134
126
  </form>
135
127
  <p id="status" role="status" aria-live="polite"></p>
136
- <small>Payments go through <a href="https://coinpayportal.com">CoinPay</a> to nixamp.com. For small amounts pick a coin with low fees (USDC on Polygon or Solana, SOL, LTC). Listening on the live stations is always free.${me ? "" : ` <a href="/?redirect=${encodeURIComponent("/music/me")}#account-panel">Sign in</a> with the same email to keep everything under My music.`}</small>
128
+ <small>Payments go through <a href="https://coinpayportal.com">CoinPay</a> to nixamp.com: pick any coin, or PayPal, on CoinPay's page. For small amounts a coin with low fees (USDC on Polygon or Solana, SOL) or PayPal costs least. Listening on the live stations is always free.${me ? "" : ` <a href="/?redirect=${encodeURIComponent("/music/me")}#account-panel">Sign in</a> with the same email to keep everything under My music.`}</small>
137
129
  </div>`, JSON.stringify({ server: serverId(item.server), kind: item.kind, artist: item.artist, album: item.album, n: item.n })), method);
138
130
  return true;
139
131
  }
@@ -148,9 +140,9 @@ export async function handleStoreRoutes(request: IncomingMessage, response: Serv
148
140
  ? `<div class="card"><h1>${escape(purchase.title)}</h1><p class="by">${escape(purchase.artistName)} · paid ${price(purchase.amountCents)}</p>
149
141
  <a class="go" href="${escape(store.zipUrl(purchase))}" download>Download the zip</a>
150
142
  <small>MP3s with the cover art and lyrics, plus each song's .lyrics.txt, cover.jpg and album.json. Keep this page's address (it is in your email too) to download again. Signed in to nixamp.com as ${escape(purchase.email)}, everything you bought is under <a href="/music/me">My music</a>, to play or download.</small></div>`
151
- : `<div class="card"><h1>${escape(purchase.title)}</h1><p class="by">${escape(purchase.artistName)} · ${price(purchase.amountCents)} in ${escape(purchase.coin)}</p>
143
+ : `<div class="card"><h1>${escape(purchase.title)}</h1><p class="by">${escape(purchase.artistName)} · ${price(purchase.amountCents)}${escape(paidIn(purchase.coin))}</p>
152
144
  <p>Waiting for your payment. Once CoinPay confirms it, your download is here and in your email (${escape(purchase.email)}). This page checks again when you reload it.</p>
153
- <a class="go" href="https://coinpayportal.com/pay/${escape(purchase.paymentId)}">Open the payment</a></div>`;
145
+ <a class="go" href="${escape(payUrl(purchase.paymentId))}">Open the payment</a></div>`;
154
146
  html(response, 200, page(purchase.title, body), method);
155
147
  return true;
156
148
  }