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.
- package/dist/ai-label.d.ts +12 -0
- package/dist/ai-label.js +34 -5
- package/dist/license-routes.js +6 -13
- package/dist/music-license.js +4 -3
- package/dist/music-store.js +4 -3
- package/dist/server.js +76 -15
- package/dist/store-routes.js +6 -14
- package/dist/support.d.ts +77 -8
- package/dist/support.js +229 -93
- package/dist/veo-studio.d.ts +6 -0
- package/dist/veo-studio.js +68 -36
- package/package.json +2 -2
- package/src/ai-label.ts +40 -6
- package/src/license-routes.ts +6 -13
- package/src/music-license.ts +4 -3
- package/src/music-store.ts +4 -3
- package/src/server.ts +59 -9
- package/src/store-routes.ts +6 -14
- package/src/support.ts +227 -90
- package/src/veo-studio.ts +62 -30
- package/web/dist/assets/{ar-DPe7Pp8y.js → ar-CRV9Quan.js} +1 -1
- package/web/dist/assets/{cs-CJcjEsDV.js → cs-CAoAOSEV.js} +1 -1
- package/web/dist/assets/{da-Bd1GXRyz.js → da-DUvxzaUE.js} +1 -1
- package/web/dist/assets/{de-CLZfazCs.js → de-BdA0QfUh.js} +1 -1
- package/web/dist/assets/{es-BLqEfqLZ.js → es-DfbHSteU.js} +1 -1
- package/web/dist/assets/{fi-anv-MFsC.js → fi-65IkDcD0.js} +1 -1
- package/web/dist/assets/{fr-BzD2HDGv.js → fr-ZCLeigWh.js} +1 -1
- package/web/dist/assets/{hi-D71Abrr0.js → hi-CgUjpeV3.js} +1 -1
- package/web/dist/assets/{hu-BmLNPlTb.js → hu-Nk3lmNzK.js} +1 -1
- package/web/dist/assets/{id-D804zCDF.js → id-CSbL1VEB.js} +1 -1
- package/web/dist/assets/{it-BAuY8mMB.js → it-Ca1dxbJu.js} +1 -1
- package/web/dist/assets/main-3VBJDnTo.js +22 -0
- package/web/dist/assets/{nl-D8x7GCIF.js → nl-BGzV-Nib.js} +1 -1
- package/web/dist/assets/{ru-Ci9fQdcv.js → ru--JBLHgkK.js} +1 -1
- package/web/dist/assets/{sv-CZtgl4E6.js → sv-xxdDl1G_.js} +1 -1
- package/web/dist/assets/{uk-VhX0Jvpy.js → uk-BK5Gpfe7.js} +1 -1
- package/web/dist/assets/{vi-TdUCiab8.js → vi-COp3eRc5.js} +1 -1
- package/web/dist/assets/{zh-BMJXXIBE.js → zh-xl2IR6MI.js} +1 -1
- package/web/dist/index.html +2 -3
- package/web/dist/sw.js +19 -19
- package/web/dist/assets/main-DbQrVbPR.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
|
|
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
|
-
/**
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
|
104
|
-
|
|
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:
|
|
141
|
+
return { url: payUrl(id), paymentId: id };
|
|
108
142
|
}
|
|
109
|
-
/**
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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-
|
|
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
|
-
|
|
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,
|
|
213
|
-
...(monthly() ? { monthly: true
|
|
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,
|
|
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
|
-
<
|
|
243
|
-
|
|
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,
|
|
280
|
-
*
|
|
281
|
-
*
|
|
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),
|
|
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
|
|
324
|
-
`
|
|
325
|
-
`
|
|
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
|
-
|
|
347
|
-
|
|
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
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
|
396
|
-
`Pay this month: ${
|
|
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/dist/veo-studio.d.ts
CHANGED
|
@@ -52,6 +52,12 @@ export declare class VeoStudio {
|
|
|
52
52
|
seconds: number;
|
|
53
53
|
references: Buffer[];
|
|
54
54
|
}): Promise<Buffer>;
|
|
55
|
+
/** Veo 3.1 Fast on fal.ai: with the portraits as references when there are any, else from the prompt alone. */
|
|
56
|
+
private viaFal;
|
|
57
|
+
/** fal.ai takes inputs by URL only (a data: URI is "invalid"): its own storage first. */
|
|
58
|
+
private falUpload;
|
|
59
|
+
/** One fal.ai queue job to its video: submitted, waited on (up to 10 minutes), downloaded. */
|
|
60
|
+
private falJob;
|
|
55
61
|
get canLipsync(): boolean;
|
|
56
62
|
/**
|
|
57
63
|
* A singer's close-up with the mouth redrawn to `audio` (the same seconds of
|
package/dist/veo-studio.js
CHANGED
|
@@ -20,6 +20,8 @@ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
|
20
20
|
import { dirname, join } from "node:path";
|
|
21
21
|
export class VeoBudgetError extends Error {
|
|
22
22
|
}
|
|
23
|
+
/** Google will make no more today (quota) or has no credit left: worth trying fal.ai instead. */
|
|
24
|
+
const GOOGLE_OUT = /quota|prepayment|depleted|RESOURCE_EXHAUSTED|\b429\b/i;
|
|
23
25
|
export class VeoRefused extends Error {
|
|
24
26
|
}
|
|
25
27
|
/** The spend in Postgres, one row a month, reserved atomically. */
|
|
@@ -101,13 +103,76 @@ export class VeoStudio {
|
|
|
101
103
|
if (!(await this.options.ledger.reserve(month, cost, this.cap)))
|
|
102
104
|
throw new VeoBudgetError(`this month's $${this.cap} of Veo is spent`);
|
|
103
105
|
try {
|
|
104
|
-
|
|
106
|
+
try {
|
|
107
|
+
return await this.make(input.prompt, seconds, input.references.slice(0, 3));
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
// Google out of quota for the day, or its prepaid credit spent: the same Veo on fal.ai.
|
|
111
|
+
if (!this.options.falKey || !(error instanceof VeoRefused) || !GOOGLE_OUT.test(error.message))
|
|
112
|
+
throw error;
|
|
113
|
+
return await this.viaFal(this.options.falKey, input.prompt, seconds, input.references.slice(0, 3));
|
|
114
|
+
}
|
|
105
115
|
}
|
|
106
116
|
catch (error) {
|
|
107
117
|
await this.options.ledger.refund(month, cost).catch(() => undefined);
|
|
108
118
|
throw error;
|
|
109
119
|
}
|
|
110
120
|
}
|
|
121
|
+
/** Veo 3.1 Fast on fal.ai: with the portraits as references when there are any, else from the prompt alone. */
|
|
122
|
+
async viaFal(key, prompt, seconds, references) {
|
|
123
|
+
const settings = { prompt: prompt.slice(0, 1800), duration: `${seconds}s`, aspect_ratio: "16:9", resolution: "1080p", generate_audio: false };
|
|
124
|
+
if (references.length) {
|
|
125
|
+
const image_urls = await Promise.all(references.map((bytes, i) => this.falUpload(key, bytes, "image/jpeg", `ref-${i}.jpg`)));
|
|
126
|
+
try {
|
|
127
|
+
return await this.falJob(key, "fal-ai/veo3.1/fast/reference-to-video", { ...settings, image_urls }, "the fal.ai Veo shot");
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
// A reference shot it will not make is tried once from the words alone.
|
|
131
|
+
if (!(error instanceof VeoRefused))
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return await this.falJob(key, "fal-ai/veo3.1/fast", settings, "the fal.ai Veo shot");
|
|
136
|
+
}
|
|
137
|
+
/** fal.ai takes inputs by URL only (a data: URI is "invalid"): its own storage first. */
|
|
138
|
+
async falUpload(key, bytes, type, name) {
|
|
139
|
+
const send = this.options.fetcher ?? fetch;
|
|
140
|
+
const init = await send("https://rest.alpha.fal.ai/storage/upload/initiate?storage_type=fal-cdn-v3", { method: "POST", headers: { authorization: `Key ${key}`, "content-type": "application/json" }, body: JSON.stringify({ content_type: type, file_name: name }), signal: AbortSignal.timeout(30_000) });
|
|
141
|
+
const slot = await init.json().catch(() => ({}));
|
|
142
|
+
if (!slot.upload_url || !slot.file_url)
|
|
143
|
+
throw new VeoRefused(`fal.ai storage refused the upload (HTTP ${init.status})`);
|
|
144
|
+
const put = await send(slot.upload_url, { method: "PUT", headers: { "content-type": type }, body: new Uint8Array(bytes), signal: AbortSignal.timeout(120_000) });
|
|
145
|
+
if (!put.ok)
|
|
146
|
+
throw new VeoRefused(`fal.ai storage refused the upload (HTTP ${put.status})`);
|
|
147
|
+
return slot.file_url;
|
|
148
|
+
}
|
|
149
|
+
/** One fal.ai queue job to its video: submitted, waited on (up to 10 minutes), downloaded. */
|
|
150
|
+
async falJob(key, model, body, what) {
|
|
151
|
+
const send = this.options.fetcher ?? fetch;
|
|
152
|
+
const auth = { authorization: `Key ${key}`, "content-type": "application/json" };
|
|
153
|
+
const started = await send(`https://queue.fal.run/${model}`, { method: "POST", headers: auth, body: JSON.stringify(body), signal: AbortSignal.timeout(60_000) });
|
|
154
|
+
const job = await started.json().catch(() => ({}));
|
|
155
|
+
if (!started.ok || !job.status_url || !job.response_url)
|
|
156
|
+
throw new VeoRefused(`fal.ai refused ${what}: HTTP ${started.status}`);
|
|
157
|
+
const sleep = this.options.sleep ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
|
|
158
|
+
for (let tries = 0; tries < 120; tries++) {
|
|
159
|
+
await sleep(5_000);
|
|
160
|
+
const state = await (await send(job.status_url, { headers: auth, signal: AbortSignal.timeout(30_000) })).json().catch(() => ({}));
|
|
161
|
+
if (state.status !== "COMPLETED")
|
|
162
|
+
continue;
|
|
163
|
+
const answer = await send(job.response_url, { headers: auth, signal: AbortSignal.timeout(30_000) });
|
|
164
|
+
const result = await answer.json().catch(() => ({}));
|
|
165
|
+
const why = typeof result.detail === "string" ? result.detail : result.detail?.[0]?.msg;
|
|
166
|
+
if (!answer.ok || !result.video?.url)
|
|
167
|
+
throw new VeoRefused(`${what} failed: ${why ?? `HTTP ${answer.status}`}`);
|
|
168
|
+
const out = await send(result.video.url, { signal: AbortSignal.timeout(120_000) });
|
|
169
|
+
const bytes = Buffer.from(await out.arrayBuffer());
|
|
170
|
+
if (!out.ok || bytes.length < 10_000)
|
|
171
|
+
throw new VeoRefused(`${what} did not download (HTTP ${out.status})`);
|
|
172
|
+
return bytes;
|
|
173
|
+
}
|
|
174
|
+
throw new VeoRefused(`${what} took too long (10 minutes)`);
|
|
175
|
+
}
|
|
111
176
|
get canLipsync() { return Boolean(this.options.falKey); }
|
|
112
177
|
/**
|
|
113
178
|
* A singer's close-up with the mouth redrawn to `audio` (the same seconds of
|
|
@@ -131,41 +196,8 @@ export class VeoStudio {
|
|
|
131
196
|
}
|
|
132
197
|
}
|
|
133
198
|
async sync(key, video, audio) {
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
// fal.ai takes inputs by URL only (a data: URI is "invalid"): its own storage first.
|
|
137
|
-
const upload = async (bytes, type, name) => {
|
|
138
|
-
const init = await send("https://rest.alpha.fal.ai/storage/upload/initiate?storage_type=fal-cdn-v3", { method: "POST", headers: auth, body: JSON.stringify({ content_type: type, file_name: name }), signal: AbortSignal.timeout(30_000) });
|
|
139
|
-
const slot = await init.json().catch(() => ({}));
|
|
140
|
-
if (!slot.upload_url || !slot.file_url)
|
|
141
|
-
throw new VeoRefused(`fal.ai storage refused the upload (HTTP ${init.status})`);
|
|
142
|
-
const put = await send(slot.upload_url, { method: "PUT", headers: { "content-type": type }, body: new Uint8Array(bytes), signal: AbortSignal.timeout(120_000) });
|
|
143
|
-
if (!put.ok)
|
|
144
|
-
throw new VeoRefused(`fal.ai storage refused the upload (HTTP ${put.status})`);
|
|
145
|
-
return slot.file_url;
|
|
146
|
-
};
|
|
147
|
-
const [videoUrl, audioUrl] = await Promise.all([upload(video, "video/mp4", "shot.mp4"), upload(audio, "audio/mpeg", "vocal.mp3")]);
|
|
148
|
-
const started = await send("https://queue.fal.run/fal-ai/sync-lipsync/v2", { method: "POST", headers: auth, body: JSON.stringify({ video_url: videoUrl, audio_url: audioUrl }), signal: AbortSignal.timeout(60_000) });
|
|
149
|
-
const job = await started.json().catch(() => ({}));
|
|
150
|
-
if (!started.ok || !job.status_url || !job.response_url)
|
|
151
|
-
throw new VeoRefused(`fal.ai refused the lip sync: HTTP ${started.status}`);
|
|
152
|
-
const sleep = this.options.sleep ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
|
|
153
|
-
for (let tries = 0; tries < 120; tries++) {
|
|
154
|
-
await sleep(5_000);
|
|
155
|
-
const state = await (await send(job.status_url, { headers: auth, signal: AbortSignal.timeout(30_000) })).json().catch(() => ({}));
|
|
156
|
-
if (state.status !== "COMPLETED")
|
|
157
|
-
continue;
|
|
158
|
-
const answer = await send(job.response_url, { headers: auth, signal: AbortSignal.timeout(30_000) });
|
|
159
|
-
const result = await answer.json().catch(() => ({}));
|
|
160
|
-
if (!answer.ok || !result.video?.url)
|
|
161
|
-
throw new VeoRefused(`the lip sync failed: ${result.detail?.[0]?.msg ?? `HTTP ${answer.status}`}`);
|
|
162
|
-
const out = await send(result.video.url, { signal: AbortSignal.timeout(120_000) });
|
|
163
|
-
const bytes = Buffer.from(await out.arrayBuffer());
|
|
164
|
-
if (!out.ok || bytes.length < 10_000)
|
|
165
|
-
throw new VeoRefused(`the lip-synced clip did not download (HTTP ${out.status})`);
|
|
166
|
-
return bytes;
|
|
167
|
-
}
|
|
168
|
-
throw new VeoRefused("the lip sync took too long (10 minutes)");
|
|
199
|
+
const [videoUrl, audioUrl] = await Promise.all([this.falUpload(key, video, "video/mp4", "shot.mp4"), this.falUpload(key, audio, "audio/mpeg", "vocal.mp3")]);
|
|
200
|
+
return await this.falJob(key, "fal-ai/sync-lipsync/v2", { video_url: videoUrl, audio_url: audioUrl }, "the lip sync");
|
|
169
201
|
}
|
|
170
202
|
async make(prompt, seconds, references) {
|
|
171
203
|
const send = this.options.fetcher ?? fetch;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nixamp",
|
|
3
|
-
"version": "0.67.
|
|
3
|
+
"version": "0.67.4",
|
|
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.
|
|
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",
|