nixamp 0.67.1 → 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 +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/package.json +2 -2
  11. package/src/ai-label.ts +40 -6
  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-DPe7Pp8y.js → ar-DxGw3Rs5.js} +1 -1
  19. package/web/dist/assets/{cs-CJcjEsDV.js → cs-CvhFUJ-M.js} +1 -1
  20. package/web/dist/assets/{da-Bd1GXRyz.js → da-DNNaWmG1.js} +1 -1
  21. package/web/dist/assets/{de-CLZfazCs.js → de-Bd73nJDj.js} +1 -1
  22. package/web/dist/assets/{es-BLqEfqLZ.js → es-kql7koI9.js} +1 -1
  23. package/web/dist/assets/{fi-anv-MFsC.js → fi-DOSVk45m.js} +1 -1
  24. package/web/dist/assets/{fr-BzD2HDGv.js → fr-C-PWAVGY.js} +1 -1
  25. package/web/dist/assets/{hi-D71Abrr0.js → hi-DSccyns6.js} +1 -1
  26. package/web/dist/assets/{hu-BmLNPlTb.js → hu-nhOx8KN8.js} +1 -1
  27. package/web/dist/assets/{id-D804zCDF.js → id-B_7XcFMO.js} +1 -1
  28. package/web/dist/assets/{it-BAuY8mMB.js → it-DS_wAQLZ.js} +1 -1
  29. package/web/dist/assets/main-DBqNZR5i.js +22 -0
  30. package/web/dist/assets/{nl-D8x7GCIF.js → nl-E4kRa4hT.js} +1 -1
  31. package/web/dist/assets/{ru-Ci9fQdcv.js → ru-BBTDvHvj.js} +1 -1
  32. package/web/dist/assets/{sv-CZtgl4E6.js → sv-eb2QkbPB.js} +1 -1
  33. package/web/dist/assets/{uk-VhX0Jvpy.js → uk-D8ewz76c.js} +1 -1
  34. package/web/dist/assets/{vi-TdUCiab8.js → vi-DjxETiKX.js} +1 -1
  35. package/web/dist/assets/{zh-BMJXXIBE.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-DbQrVbPR.js +0 -22
@@ -25,6 +25,12 @@ export interface LabelSong {
25
25
  videoTagged?: boolean;
26
26
  /** 2 for a storyboard video (scenes that move); absent or 1 for the cover-and-waveform kind, which is made again. */
27
27
  videoVersion?: number;
28
+ /**
29
+ * The singer in its filmed video: "synced" (close-ups lip-synced), "unsynced" (filmed, but no
30
+ * sync could be made, so the band plays there). Absent on a filmed video means the singer was
31
+ * never filmed, and with lip sync on it is filmed again.
32
+ */
33
+ singer?: "synced" | "unsynced";
28
34
  /** Who wrote the words and who sang them, e.g. "llama-3.3 + elevenlabs/music_v2_5". */
29
35
  made: string;
30
36
  composedAt: number;
@@ -213,6 +219,10 @@ export declare function closeUpTimes(seconds: number, lines: Array<{
213
219
  * the mic in the wide shots).
214
220
  */
215
221
  export declare const FILMED = 6;
222
+ /** Google's answer when the project's daily Veo requests are used up (HTTP 429). */
223
+ export declare const VEO_QUOTA: RegExp;
224
+ /** The next midnight in California, when Google's daily quotas reset. */
225
+ export declare function nextPacificMidnight(now: number): number;
216
226
  /**
217
227
  * A single's music video, cut from its filmed shots alone: 4 s cuts, each
218
228
  * shot's first half then its second, round and round the shots until the song
@@ -348,6 +358,8 @@ export declare class AiLabel {
348
358
  private alignTried;
349
359
  /** The month ("YYYY-MM") Veo said its budget was spent; no more shots until the next. */
350
360
  private veoSpentIn;
361
+ /** Until when Google's daily Veo quota is used up (its midnight, Pacific): nothing is asked before. */
362
+ private veoQuotaUntil;
351
363
  /** Old videos already tried as storyboards since this server started. */
352
364
  private upgradeTried;
353
365
  /** When each single was last put in front of the camera: one that came up short is tried again an hour on. */
package/dist/ai-label.js CHANGED
@@ -678,6 +678,16 @@ export function closeUpTimes(seconds, lines, count, length = 8) {
678
678
  * the mic in the wide shots).
679
679
  */
680
680
  export const FILMED = 6;
681
+ /** Google's answer when the project's daily Veo requests are used up (HTTP 429). */
682
+ export const VEO_QUOTA = /exceeded your current quota|RESOURCE_EXHAUSTED/i;
683
+ /** The next midnight in California, when Google's daily quotas reset. */
684
+ export function nextPacificMidnight(now) {
685
+ const hour = (at) => Number(new Intl.DateTimeFormat("en-US", { timeZone: "America/Los_Angeles", hour: "numeric", hourCycle: "h23" }).format(new Date(at)));
686
+ let at = Math.floor(now / 3_600_000) * 3_600_000 + 3_600_000;
687
+ for (let i = 0; i < 26 && hour(at) !== 0; i++)
688
+ at += 3_600_000;
689
+ return at;
690
+ }
681
691
  /**
682
692
  * A single's music video, cut from its filmed shots alone: 4 s cuts, each
683
693
  * shot's first half then its second, round and round the shots until the song
@@ -814,6 +824,8 @@ export class AiLabel {
814
824
  alignTried = new Set();
815
825
  /** The month ("YYYY-MM") Veo said its budget was spent; no more shots until the next. */
816
826
  veoSpentIn = "";
827
+ /** Until when Google's daily Veo quota is used up (its midnight, Pacific): nothing is asked before. */
828
+ veoQuotaUntil = 0;
817
829
  /** Old videos already tried as storyboards since this server started. */
818
830
  upgradeTried = new Set();
819
831
  /** When each single was last put in front of the camera: one that came up short is tried again an hour on. */
@@ -1169,7 +1181,9 @@ export class AiLabel {
1169
1181
  .filter((album) => album.cover || album.coverTried)
1170
1182
  .flatMap((album) => album.songs.filter((song) => song.file && !this.videoFailed.has(song.file)).map((song) => ({ artist, album, song }))));
1171
1183
  // Only a single is filmed, and only with Veo footage: a song with an older (slideshow) video is filmed when its turn comes.
1172
- const missing = songs.filter(({ song }) => this.wantsVeo(song) && (song.videoVersion ?? 1) < FILMED && this.now() - (this.filmTriedAt.get(song.file) ?? 0) > 60 * 60_000);
1184
+ // A filmed video whose singer was never filmed (a quota or a refusal took the close-ups) is filmed again while it stays up.
1185
+ const singerless = (song) => Boolean(this.deps.lipsync) && this.settings.lipsyncShots > 0 && (song.videoVersion ?? 1) >= FILMED && !song.singer;
1186
+ 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);
1173
1187
  const untagged = songs.filter(({ song }) => song.video && !song.videoTagged && (song.videoVersion ?? 1) >= FILMED);
1174
1188
  const old = [];
1175
1189
  let made = 0;
@@ -1205,7 +1219,10 @@ export class AiLabel {
1205
1219
  const partial = join(album.folder, `.${randomBytes(4).toString("hex")}.video.mp4`);
1206
1220
  const scenes = join(album.folder, `.scenes-${song.n}-${randomBytes(3).toString("hex")}`);
1207
1221
  try {
1208
- song.videoVersion = await this.filmSong(artist, album, song, scenes, partial, upgrade);
1222
+ const filmed = await this.filmSong(artist, album, song, scenes, partial, upgrade);
1223
+ song.videoVersion = filmed.version;
1224
+ if (filmed.singer)
1225
+ song.singer = filmed.singer;
1209
1226
  renameSync(partial, out);
1210
1227
  song.video = out;
1211
1228
  song.videoTagged = true;
@@ -1243,10 +1260,14 @@ export class AiLabel {
1243
1260
  const wanted = this.settings.veoShots + this.settings.storyShots;
1244
1261
  if (clips.length < Math.max(2, Math.ceil(wanted / 2)))
1245
1262
  throw new Error(`only ${clips.length} of ${wanted} shots filmed so far`);
1263
+ // The singer is the point of the video: with lip sync on, nothing is cut until a close-up is filmed.
1264
+ const singing = Boolean(this.deps.lipsync) && this.settings.lipsyncShots > 0;
1265
+ if (singing && singer.length === 0)
1266
+ throw new Error("the singer's close-ups are not filmed yet");
1246
1267
  const pinned = await this.syncCloseUps(song, singer, seconds, shots);
1247
1268
  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 } : {}) });
1248
1269
  rmSync(shots, { recursive: true, force: true });
1249
- return FILMED;
1270
+ return { version: FILMED, ...(singing ? { singer: pinned.length ? "synced" : "unsynced" } : {}) };
1250
1271
  }
1251
1272
  /**
1252
1273
  * The singer's close-ups, each placed on a sung line and its mouth redrawn
@@ -1313,7 +1334,8 @@ export class AiLabel {
1313
1334
  }
1314
1335
  /** Whether this song gets filmed Veo shots: an album's single, Veo on, and the month's budget not spent. */
1315
1336
  wantsVeo(song) {
1316
- return this.settings.veo && song.n === 1 && Boolean(this.deps.veo) && this.veoSpentIn !== new Date(this.now()).toISOString().slice(0, 7);
1337
+ return this.settings.veo && song.n === 1 && Boolean(this.deps.veo) && this.veoSpentIn !== new Date(this.now()).toISOString().slice(0, 7)
1338
+ && this.now() >= this.veoQuotaUntil;
1317
1339
  }
1318
1340
  /**
1319
1341
  * A single's filmed shots: the band, from their portraits (up to three as
@@ -1365,10 +1387,11 @@ export class AiLabel {
1365
1387
  }
1366
1388
  // The singer's close-ups only where they can be lip-synced afterwards. Story scenes go without
1367
1389
  // the portraits: they would pull the whole band into every scene.
1390
+ // The singer first: a day's few Veo requests go to what the video is for, then the story and the band.
1368
1391
  const plan = [
1392
+ ...(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 })) : []),
1369
1393
  ...storyFiles.map((file, i) => ({ prompt: storyPrompts[i] ?? "", file, into: story, refs: [] })),
1370
1394
  ...veoPrompts(artist, song, players, this.settings.veoShots).map((prompt, i) => ({ prompt, file: join(keep, `shot-${i}.mp4`), into: band, refs: playerRefs })),
1371
- ...(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 })) : []),
1372
1395
  ];
1373
1396
  for (const [i, { prompt, file, into, refs }] of plan.entries()) {
1374
1397
  try {
@@ -1405,6 +1428,12 @@ export class AiLabel {
1405
1428
  this.log(`Veo: ${error.message}; no more videos until next month`);
1406
1429
  break;
1407
1430
  }
1431
+ if (VEO_QUOTA.test(error.message)) {
1432
+ // Google's daily request quota: every further ask today is refused, so none is made.
1433
+ this.veoQuotaUntil = nextPacificMidnight(this.now());
1434
+ this.log(`Veo: Google's daily quota is used up; filming resumes after ${new Date(this.veoQuotaUntil).toISOString()}`);
1435
+ break;
1436
+ }
1408
1437
  this.log(`Veo shot ${i + 1} for "${song.title}": ${error.message}`);
1409
1438
  }
1410
1439
  }
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import { createHash } from "node:crypto";
14
14
  import { LICENSE_PRICES, TERMS_VERSION, TIER_NAMES, TIER_USE, licenseTerms } from "./music-license.js";
15
- import { SupportError } from "./support.js";
15
+ import { SupportError, paidIn, payUrl } from "./support.js";
16
16
  const CHECK = /^\/license\/(NXL-[A-Z2-9]{4}-[A-Z2-9]{4})$/;
17
17
  const CHECK_JSON = /^\/api\/v1\/licenses\/(NXL-[A-Z2-9]{4}-[A-Z2-9]{4})$/;
18
18
  const CERTIFICATE = /^\/licenses\/([0-9a-f-]{36})$/;
@@ -58,14 +58,9 @@ const SCRIPT = `
58
58
  if (!form) return;
59
59
  const status = $("#status");
60
60
  const pay = $("#pay");
61
- const label = () => { const one = form.querySelector("input[name=tier]:checked"); pay.textContent = one ? "Pay " + one.dataset.price + " with crypto" : "Pick a licence"; };
61
+ const label = () => { const one = form.querySelector("input[name=tier]:checked"); pay.textContent = one ? "Pay " + one.dataset.price : "Pick a licence"; };
62
62
  form.addEventListener("change", label);
63
63
  label();
64
- fetch("/api/v1/store/coins").then((r) => r.json()).then((coins) => {
65
- const cheap = ["USDC_POL", "USDC_SOL", "SOL", "POL", "LTC", "USDC_BASE", "USDC"];
66
- const list = (coins.coins || []).slice().sort((a, b) => (cheap.indexOf(a) + 1 || 99) - (cheap.indexOf(b) + 1 || 99));
67
- $("#coin").replaceChildren(...list.map((c) => Object.assign(document.createElement("option"), { value: c, textContent: c })));
68
- }).catch(() => { status.textContent = "Payments are not available right now."; });
69
64
  form.addEventListener("submit", async (event) => {
70
65
  event.preventDefault();
71
66
  pay.disabled = true;
@@ -74,7 +69,7 @@ const SCRIPT = `
74
69
  try {
75
70
  const r = await fetch("/api/v1/licenses/checkout", { method: "POST", headers: { "content-type": "application/json" }, credentials: "same-origin",
76
71
  body: JSON.stringify({ ...JSON.parse(document.body.dataset.item), tier: data.get("tier"), licensee: data.get("licensee"), project: data.get("project"),
77
- projectUrl: data.get("projectUrl"), email: data.get("email"), coin: data.get("coin"), agree: data.get("agree") === "on" }) });
72
+ projectUrl: data.get("projectUrl"), email: data.get("email"), agree: data.get("agree") === "on" }) });
78
73
  const body = await r.json();
79
74
  if (!r.ok) throw new Error(body.error || "The checkout did not start.");
80
75
  status.textContent = "Taking you to CoinPay…";
@@ -161,13 +156,11 @@ export async function handleLicenseRoutes(request, response, path, url, ctx) {
161
156
  <input id="projectUrl" name="projectUrl" type="url" maxlength="300" placeholder="https://">
162
157
  <label class="f" for="email">Email (your certificate goes here)</label>
163
158
  <input id="email" name="email" type="email" required autocomplete="email" value="${escape(me?.email ?? "")}">
164
- <label class="f" for="coin">Pay with</label>
165
- <select id="coin" name="coin" required></select>
166
159
  <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>
167
160
  <button id="pay" type="submit">Pay</button>
168
161
  </form>
169
162
  <p id="status" role="status" aria-live="polite"></p>
170
- <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>
163
+ <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>
171
164
  </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);
172
165
  return true;
173
166
  }
@@ -193,9 +186,9 @@ export async function handleLicenseRoutes(request, response, path, url, ctx) {
193
186
  }
194
187
  const body = license.status === "paid"
195
188
  ? certificate(license, ctx, true)
196
- : `<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>
189
+ : `<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>
197
190
  <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>
198
- <a class="go" href="https://coinpayportal.com/pay/${escape(license.paymentId)}">Open the payment</a></div>`;
191
+ <a class="go" href="${escape(payUrl(license.paymentId))}">Open the payment</a></div>`;
199
192
  html(response, 200, page(`Licence ${license.code}`, body), method);
200
193
  return true;
201
194
  }
@@ -16,7 +16,7 @@
16
16
  * under. Paid through nixamp.com's CoinPay business like the store.
17
17
  */
18
18
  import { randomBytes, randomUUID } from "node:crypto";
19
- import { SupportError } from "./support.js";
19
+ import { SupportError, coinOf } from "./support.js";
20
20
  /** In cents, by tier and what is licensed; a missing pair is not sold. */
21
21
  export const LICENSE_PRICES = {
22
22
  creator: { song: 500 },
@@ -132,7 +132,8 @@ export class MusicLicenses {
132
132
  const code = newCode();
133
133
  const checkout = await this.options.support.pay({
134
134
  amountUsd: quote.amountCents / 100,
135
- coin: input["coin"],
135
+ email,
136
+ accountId: account?.id || email,
136
137
  order: `license-${id}`,
137
138
  description: `${TIER_NAMES[quote.tier]} licence: ${quote.title}${quote.kind === "catalog" ? ", one year" : ` by ${quote.artistName}`} for ${project}`,
138
139
  metadata: { product: "music-license", license: id, tier: quote.tier, kind: quote.kind },
@@ -141,7 +142,7 @@ export class MusicLicenses {
141
142
  await this.options.db.query(`INSERT INTO music_licenses (id, code, token, email, user_id, licensee, project, project_url, tier, kind, server, artist, album, n,
142
143
  title, artist_name, credit_url, amount_cents, coin, payment_id, terms, created_at)
143
144
  VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22)`, [id, code, token, email, account?.id ?? "", licensee, project, httpsUrl(input["projectUrl"]), quote.tier, quote.kind, quote.server, quote.artist, quote.album, quote.n,
144
- quote.title, quote.artistName, quote.creditUrl, quote.amountCents, String(input["coin"]).toUpperCase(), checkout.paymentId, TERMS_VERSION, new Date(this.now())]);
145
+ quote.title, quote.artistName, quote.creditUrl, quote.amountCents, coinOf(input["coin"]), checkout.paymentId, TERMS_VERSION, new Date(this.now())]);
145
146
  return { url: checkout.url, id, token, code };
146
147
  }
147
148
  /** The licensee's own page: the certificate once paid. */
@@ -16,7 +16,7 @@
16
16
  * is good before it plays or zips anything (/api/v1/store/grant).
17
17
  */
18
18
  import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
19
- import { SupportError } from "./support.js";
19
+ import { SupportError, coinOf } from "./support.js";
20
20
  /** In cents. */
21
21
  export const PRICES = { song: 10, album: 100, artist: 200 };
22
22
  const EMAIL = /^[^\s@<>"]{1,64}@[^\s@<>"]{1,190}\.[a-z]{2,24}$/i;
@@ -188,14 +188,15 @@ export class MusicStore {
188
188
  const token = randomUUID().replace(/-/g, "");
189
189
  const checkout = await this.options.support.pay({
190
190
  amountUsd: item.amountCents / 100,
191
- coin: input["coin"],
191
+ email,
192
+ accountId: account?.id || email,
192
193
  order: `music-${id}`,
193
194
  description: `${item.title} by ${item.artistName} (${item.kind === "song" ? "song" : item.kind === "album" ? "album" : "every album"}, MP3)`,
194
195
  metadata: { product: "music", purchase: id, kind: item.kind },
195
196
  redirect: this.link(id, token),
196
197
  });
197
198
  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)
198
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)`, [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())]);
199
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)`, [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())]);
199
200
  return { url: checkout.url, id, token };
200
201
  }
201
202
  /** The purchase's own page, which is also the receipt link. */
package/dist/server.js CHANGED
@@ -2183,15 +2183,20 @@ export function createHandler(engine, options) {
2183
2183
  json(response, 400, { error: "invalid JSON" });
2184
2184
  return;
2185
2185
  }
2186
+ // A hosted checkout session's payment names its session; the purchase
2187
+ // row holds the session id (cs_...), an older one the payment id.
2188
+ const refs = [event.sessionId, event.paymentId].filter(Boolean);
2186
2189
  if (options.musicStore) {
2187
2190
  try {
2188
- if (await options.musicStore.owns(event.paymentId)) {
2189
- json(response, 200, await options.musicStore.settle(event.paymentId));
2190
- return;
2191
- }
2192
- if (options.musicLicenses && await options.musicLicenses.owns(event.paymentId)) {
2193
- json(response, 200, await options.musicLicenses.settle(event.paymentId));
2194
- return;
2191
+ for (const ref of refs) {
2192
+ if (await options.musicStore.owns(ref)) {
2193
+ json(response, 200, await options.musicStore.settle(ref));
2194
+ return;
2195
+ }
2196
+ if (options.musicLicenses && await options.musicLicenses.owns(ref)) {
2197
+ json(response, 200, await options.musicLicenses.settle(ref));
2198
+ return;
2199
+ }
2195
2200
  }
2196
2201
  }
2197
2202
  catch (error) {
@@ -2205,7 +2210,7 @@ export function createHandler(engine, options) {
2205
2210
  return;
2206
2211
  }
2207
2212
  try {
2208
- json(response, 200, await options.translationPasses.settle(event.paymentId));
2213
+ json(response, 200, await options.translationPasses.settle(event));
2209
2214
  }
2210
2215
  catch (error) {
2211
2216
  // A 5xx makes CoinPay deliver it again, which is what an unreachable
@@ -2344,7 +2349,7 @@ export function createHandler(engine, options) {
2344
2349
  // llms.txt, the OpenAPI document, the health check and the desktop downloads (nixamp.com).
2345
2350
  !(isDiscoveryPath(path) && Boolean(options.accounts && options.site)) &&
2346
2351
  // Supporting a copyright holder is for anybody listening.
2347
- !/^\/support\/[a-z0-9-]{1,40}$/.test(path) && path !== "/support/cancel" && !/^\/api\/v1\/support\/[a-z0-9-]{1,40}$/.test(path) &&
2352
+ !/^\/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) &&
2348
2353
  // A station's short link, which sends the browser to wherever it plays.
2349
2354
  !/^\/watch\/[a-z0-9]{10}$/.test(path) &&
2350
2355
  // A live room's page: the room's own key travels in ?play=, checked by its server.
@@ -2439,6 +2444,52 @@ export function createHandler(engine, options) {
2439
2444
  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}</p><p><a style="color:#3ddc84" href="https://nixamp.com/guide">Back to the stations</a></p></main></body>`);
2440
2445
  return;
2441
2446
  }
2447
+ /*
2448
+ * A monthly support's "pay this month" link, from its emails: a fresh
2449
+ * CoinPay checkout session each time it is followed (one still open is
2450
+ * reused), so a late payer is never sent to an expired session.
2451
+ */
2452
+ const supportPay = /^\/support\/pay\/([0-9a-f-]{36})\/([0-9a-f]{32})$/.exec(path);
2453
+ if (supportPay && options.supportSubscriptions && (request.method === "GET" || request.method === "HEAD")) {
2454
+ const page = (status, words, again) => {
2455
+ const safe = (text) => text.replace(/[&<>"']/g, (one) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[one]);
2456
+ 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" });
2457
+ 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>`);
2458
+ };
2459
+ // A HEAD (a mail scanner checking the link) never opens a checkout.
2460
+ if (request.method === "HEAD") {
2461
+ page(200, "");
2462
+ return;
2463
+ }
2464
+ const verdict = guard.check(`support-pay:${callerOf(request.headers, request.socket.remoteAddress, behindProxy)}`, { allowed: 10, windowMs: 60_000 });
2465
+ if (!verdict.ok) {
2466
+ page(429, "Too many at once; wait a minute and open the link again.");
2467
+ return;
2468
+ }
2469
+ try {
2470
+ const opened = await options.supportSubscriptions.open(supportPay[1], supportPay[2]);
2471
+ if (opened.state === "redirect") {
2472
+ response.writeHead(303, { location: opened.url, "cache-control": "no-store", "referrer-policy": "no-referrer" });
2473
+ response.end();
2474
+ return;
2475
+ }
2476
+ const name = options.support?.holder(opened.holder)?.name ?? opened.holder;
2477
+ const site = options.site ?? "https://nixamp.com";
2478
+ if (opened.state === "paid")
2479
+ page(200, `This month's support for ${name} is paid. Thank you. Next month's link comes by email.`);
2480
+ else if (opened.state === "canceled")
2481
+ page(200, `Your monthly support for ${name} was cancelled, so there is nothing to pay.`, `${site}/support/${opened.holder}`);
2482
+ else
2483
+ page(200, `Your monthly support for ${name} stopped after a month went unpaid. Nothing is owed.`, `${site}/support/${opened.holder}`);
2484
+ }
2485
+ catch (error) {
2486
+ if (error instanceof SupportError)
2487
+ page(error.status, error.message, error.status >= 500 ? `${url.pathname}` : undefined);
2488
+ else
2489
+ page(502, "The payment service did not answer; nothing was charged. Try the link again in a minute.", url.pathname);
2490
+ }
2491
+ return;
2492
+ }
2442
2493
  /* Support a copyright holder: the page, the coins, and a CoinPay checkout. */
2443
2494
  const supportPage = /^\/support\/([a-z0-9-]{1,40})$/.exec(path);
2444
2495
  if (supportPage && options.support && (request.method === "GET" || request.method === "HEAD")) {
@@ -2477,13 +2528,20 @@ export function createHandler(engine, options) {
2477
2528
  catch {
2478
2529
  body = {};
2479
2530
  }
2531
+ // CoinPay files the payment under the payer: a signed-in member by
2532
+ // account id (their email if the form left it empty), a guest by email.
2533
+ const me = options.accounts ? await options.accounts.whoIs(tokenFrom(request.headers)).catch(() => null) : null;
2534
+ const payer = {
2535
+ email: typeof body["email"] === "string" && body["email"].trim() ? body["email"] : me?.email ?? "",
2536
+ accountId: me?.id ?? null,
2537
+ };
2480
2538
  if (body["monthly"] === true) {
2481
2539
  if (!options.supportSubscriptions)
2482
2540
  throw new SupportError("Monthly support is not set up here.", 503, "unavailable");
2483
- json(response, 200, await options.supportSubscriptions.start({ ...body, holder: holder.id }));
2541
+ json(response, 200, await options.supportSubscriptions.start({ ...body, ...payer, holder: holder.id }));
2484
2542
  }
2485
2543
  else {
2486
- json(response, 200, await support.checkout({ ...body, holder: holder.id }));
2544
+ json(response, 200, await support.checkout({ ...body, ...payer, holder: holder.id }));
2487
2545
  }
2488
2546
  return;
2489
2547
  }
@@ -3630,9 +3688,12 @@ export function createHandler(engine, options) {
3630
3688
  catch {
3631
3689
  throw new SpeechError("Choose a translation pass.", 400);
3632
3690
  }
3633
- if (!body || typeof body !== "object" || typeof body["plan"] !== "string" || typeof body["coin"] !== "string" || typeof body["requestKey"] !== "string")
3634
- throw new SpeechError("Choose a translation pass and payment currency.", 400);
3635
- json(response, 200, await service.checkout(who.id, body["plan"], body["coin"], body["requestKey"]));
3691
+ if (!body || typeof body !== "object" || typeof body["plan"] !== "string" || typeof body["requestKey"] !== "string")
3692
+ throw new SpeechError("Choose a translation pass.", 400);
3693
+ // A CoinPay hosted session (any coin or PayPal) filed under this
3694
+ // account and its email; `coin` only comes from an older open page.
3695
+ const email = who.email || (typeof body["email"] === "string" ? body["email"] : "");
3696
+ json(response, 200, await service.checkout(who.id, body["plan"], typeof body["coin"] === "string" ? body["coin"] : "", body["requestKey"], { email, accountId: who.id }));
3636
3697
  return;
3637
3698
  }
3638
3699
  const order = path.match(/^\/api\/v1\/translation-passes\/orders\/([^/]+)$/);
@@ -7550,7 +7611,7 @@ export async function serve(argv, version = "0.1.0") {
7550
7611
  ? new TranslationPasses({ db: pool, key: coinpayKeyFrom(), site: nixampSite, admins: [options.owner || ""] }) : undefined;
7551
7612
  // Payments to the copyright holders nixamp.com collects for, on its own CoinPay business.
7552
7613
  const supportPayments = accounts ? new Support({ key: coinpayKeyFrom(), site: nixampSite, holders: supportHoldersFrom() }) : undefined;
7553
- // Monthly support: each month a new CoinPay payment and its link by email.
7614
+ // Monthly support: each month its stable pay link by email (a fresh CoinPay session per click).
7554
7615
  const supportMonthly = supportPayments && supportPayments.enabled && pool ? new SupportSubscriptions({
7555
7616
  db: pool,
7556
7617
  support: supportPayments,
@@ -12,7 +12,7 @@
12
12
  import { createHash } from "node:crypto";
13
13
  import { PRICES } from "./music-store.js";
14
14
  import { serverId } from "./music-hub.js";
15
- import { SupportError } from "./support.js";
15
+ import { SupportError, paidIn, payUrl } from "./support.js";
16
16
  const PURCHASE = /^\/purchases\/([0-9a-f-]{36})$/;
17
17
  const ONE = /^\/api\/v1\/store\/purchases\/([0-9a-f-]{36})$/;
18
18
  export function isStorePath(path) {
@@ -45,12 +45,6 @@ const BUY_SCRIPT = `
45
45
  (async () => {
46
46
  const $ = (s) => document.querySelector(s);
47
47
  const status = $("#status");
48
- try {
49
- const coins = await (await fetch("/api/v1/store/coins")).json();
50
- const cheap = ["USDC_POL", "USDC_SOL", "SOL", "POL", "LTC", "USDC_BASE", "USDC"];
51
- const list = (coins.coins || []).slice().sort((a, b) => (cheap.indexOf(a) + 1 || 99) - (cheap.indexOf(b) + 1 || 99));
52
- $("#coin").replaceChildren(...list.map((c) => Object.assign(document.createElement("option"), { value: c, textContent: c })));
53
- } catch { status.textContent = "Payments are not available right now."; }
54
48
  $("#buy").addEventListener("submit", async (event) => {
55
49
  event.preventDefault();
56
50
  const button = $("#pay");
@@ -59,7 +53,7 @@ const BUY_SCRIPT = `
59
53
  const form = new FormData(event.currentTarget);
60
54
  try {
61
55
  const r = await fetch("/api/v1/store/checkout", { method: "POST", headers: { "content-type": "application/json" }, credentials: "same-origin",
62
- body: JSON.stringify({ ...JSON.parse(document.body.dataset.item), email: form.get("email"), coin: form.get("coin") }) });
56
+ body: JSON.stringify({ ...JSON.parse(document.body.dataset.item), email: form.get("email") }) });
63
57
  const body = await r.json();
64
58
  if (!r.ok) throw new Error(body.error || "The checkout did not start.");
65
59
  status.textContent = "Taking you to CoinPay…";
@@ -109,12 +103,10 @@ export async function handleStoreRoutes(request, response, path, url, ctx) {
109
103
  <form id="buy">
110
104
  <label for="email">Email (your download link goes here)</label>
111
105
  <input id="email" name="email" type="email" required autocomplete="email" value="${escape(me?.email ?? "")}">
112
- <label for="coin">Pay with</label>
113
- <select id="coin" name="coin" required></select>
114
- <button id="pay" type="submit">Pay ${price(item.amountCents)} with crypto</button>
106
+ <button id="pay" type="submit">Pay ${price(item.amountCents)}</button>
115
107
  </form>
116
108
  <p id="status" role="status" aria-live="polite"></p>
117
- <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>
109
+ <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>
118
110
  </div>`, JSON.stringify({ server: serverId(item.server), kind: item.kind, artist: item.artist, album: item.album, n: item.n })), method);
119
111
  return true;
120
112
  }
@@ -129,9 +121,9 @@ export async function handleStoreRoutes(request, response, path, url, ctx) {
129
121
  ? `<div class="card"><h1>${escape(purchase.title)}</h1><p class="by">${escape(purchase.artistName)} · paid ${price(purchase.amountCents)}</p>
130
122
  <a class="go" href="${escape(store.zipUrl(purchase))}" download>Download the zip</a>
131
123
  <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>`
132
- : `<div class="card"><h1>${escape(purchase.title)}</h1><p class="by">${escape(purchase.artistName)} · ${price(purchase.amountCents)} in ${escape(purchase.coin)}</p>
124
+ : `<div class="card"><h1>${escape(purchase.title)}</h1><p class="by">${escape(purchase.artistName)} · ${price(purchase.amountCents)}${escape(paidIn(purchase.coin))}</p>
133
125
  <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>
134
- <a class="go" href="https://coinpayportal.com/pay/${escape(purchase.paymentId)}">Open the payment</a></div>`;
126
+ <a class="go" href="${escape(payUrl(purchase.paymentId))}">Open the payment</a></div>`;
135
127
  html(response, 200, page(purchase.title, body), method);
136
128
  return true;
137
129
  }
package/dist/support.d.ts CHANGED
@@ -4,6 +4,21 @@ export interface SupportHolder {
4
4
  }
5
5
  export declare function supportHoldersFrom(env?: Record<string, string | undefined>): SupportHolder[];
6
6
  export declare const AMOUNTS: number[];
7
+ /** A CoinPay hosted checkout session id (cs_...), as stored where a payment id used to be. */
8
+ export declare function isSessionRef(ref: string): boolean;
9
+ /** Where a payer finishes a stored payment: its hosted session, or an older one-coin invoice. */
10
+ export declare function payUrl(ref: string): string;
11
+ /** The site hostname every nixamp sale is filed under at CoinPay. */
12
+ export declare function propertyOf(site: string): string;
13
+ /** The coin a stored payment names: "ANY" for a hosted session, where the payer picks (or PayPal). */
14
+ export declare function coinOf(value: unknown): string;
15
+ /** "in USDC_POL" for an older one-coin payment; nothing for a session. */
16
+ export declare function paidIn(coin: string): string;
17
+ /** Who pays: CoinPay needs an email for the receipt; the account id defaults to it (a guest). */
18
+ export interface Payer {
19
+ email?: unknown;
20
+ accountId?: string | null;
21
+ }
7
22
  export declare class SupportError extends Error {
8
23
  readonly status: number;
9
24
  readonly code: string;
@@ -30,20 +45,49 @@ export declare class Support {
30
45
  business: string;
31
46
  coins: string[];
32
47
  }>;
33
- /** Any CoinPay checkout on nixamp's business (a purchase in the store): no holder, no floor but a cent. */
48
+ /** The POST /api/checkout/sessions body: what CoinPay files the sale under. */
49
+ sessionBody(input: {
50
+ amountUsd: number;
51
+ description: string;
52
+ metadata: Record<string, unknown>;
53
+ redirect: string;
54
+ cancel?: string;
55
+ email: string;
56
+ accountId: string;
57
+ }): Record<string, unknown>;
58
+ /** Open a CoinPay hosted checkout session; its id stands where a payment id used to. */
59
+ private session;
60
+ /**
61
+ * Any CoinPay checkout on nixamp's business (a purchase in the store): no
62
+ * holder, no floor but a cent. A hosted session, so the payer picks any coin
63
+ * or PayPal there; `coin` is what an older page still sends, and is ignored.
64
+ */
34
65
  pay(input: {
35
66
  amountUsd: number;
36
- coin: unknown;
67
+ coin?: unknown;
37
68
  description: string;
38
69
  metadata: Record<string, unknown>;
39
70
  redirect: string;
40
71
  order: string;
41
- }): Promise<{
72
+ } & Payer): Promise<{
42
73
  url: string;
43
74
  paymentId: string;
44
75
  }>;
45
- /** Whether CoinPay counts this payment as paid; null when it did not answer. */
76
+ /**
77
+ * Whether CoinPay counts this payment as paid; null when it did not answer.
78
+ * A session (cs_...) is asked as a session: paid by any coin or PayPal.
79
+ */
46
80
  isPaid(paymentId: string): Promise<boolean | null>;
81
+ /**
82
+ * A hosted session's state: "paid", "open" (with when it expires) or
83
+ * anything else CoinPay says (expired, canceled); null when it did not
84
+ * answer. An older one-coin payment is "paid" or "gone": its page cannot be
85
+ * reopened, so it is never handed out again.
86
+ */
87
+ sessionState(ref: string): Promise<{
88
+ status: string;
89
+ expiresAt: number;
90
+ } | null>;
47
91
  /** A CoinPay checkout for this much, to this holder; the address to send the payer to. */
48
92
  checkout(input: {
49
93
  holder?: unknown;
@@ -52,7 +96,7 @@ export declare class Support {
52
96
  station?: unknown;
53
97
  track?: unknown;
54
98
  metadata?: Record<string, unknown>;
55
- }): Promise<{
99
+ } & Payer): Promise<{
56
100
  url: string;
57
101
  paymentId: string;
58
102
  }>;
@@ -71,9 +115,10 @@ export declare function nextMonth(at: number): number;
71
115
  * Monthly support, run here rather than by CoinPay: CoinPay's only merchant
72
116
  * subscriptions are Stripe's, and its recurring invoices refuse a scoped key.
73
117
  * A subscription is an email, an amount, a coin and a holder. Month one is
74
- * paid at once on CoinPay's checkout; each month after, a new payment is made
75
- * and its link emailed, with a one-click cancel. A month left unpaid ends it
76
- * (lapsed) rather than emailing forever.
118
+ * paid at once on CoinPay's checkout; each month after, the subscription's
119
+ * stable pay link is emailed (it opens a fresh CoinPay session whenever it is
120
+ * followed, since a session expires within two hours), with a one-click
121
+ * cancel. A month left unpaid ends it (lapsed) rather than emailing forever.
77
122
  */
78
123
  export declare class SupportSubscriptions {
79
124
  private readonly options;
@@ -102,11 +147,35 @@ export declare class SupportSubscriptions {
102
147
  email?: unknown;
103
148
  station?: unknown;
104
149
  track?: unknown;
150
+ accountId?: string | null;
105
151
  }): Promise<{
106
152
  url: string;
107
153
  id: string;
108
154
  }>;
109
155
  cancelLink(id: string, token: string): string;
156
+ /**
157
+ * The subscription's stable "pay this month" link. A CoinPay session lives
158
+ * at most two hours, so an emailed session link was dead by the time most
159
+ * people read it; this one opens a fresh session whenever it is followed.
160
+ * The token is the subscription's own secret (the cancel link's).
161
+ */
162
+ payLink(id: string, token: string): string;
163
+ /** The checkouts opened for the subscription's current month, newest last. */
164
+ private static cycleIds;
165
+ /** Whether any of this month's checkouts is paid; null when CoinPay could not say and none is known paid. */
166
+ private cyclePaid;
167
+ /**
168
+ * Following the pay link: this month's checkout to send the payer to.
169
+ * "paid" when a checkout this month is already paid; "redirect" to a
170
+ * session still open for at least 15 minutes, else to a new one.
171
+ */
172
+ open(id: string, token: string): Promise<{
173
+ state: "redirect";
174
+ url: string;
175
+ } | {
176
+ state: "paid" | "canceled" | "lapsed";
177
+ holder: string;
178
+ }>;
110
179
  cancel(id: string, token: string): Promise<{
111
180
  canceled: boolean;
112
181
  holder: string;