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/src/support.ts
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.
|
|
@@ -29,6 +31,41 @@ export const AMOUNTS = [3, 5, 10, 25, 50];
|
|
|
29
31
|
const MIN_USD = 1;
|
|
30
32
|
const MAX_USD = 1000;
|
|
31
33
|
|
|
34
|
+
const SESSION = /^cs_[A-Za-z0-9]{16,64}$/;
|
|
35
|
+
const COINPAY = "https://coinpayportal.com";
|
|
36
|
+
|
|
37
|
+
/** A CoinPay hosted checkout session id (cs_...), as stored where a payment id used to be. */
|
|
38
|
+
export function isSessionRef(ref: string): boolean {
|
|
39
|
+
return SESSION.test(ref);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Where a payer finishes a stored payment: its hosted session, or an older one-coin invoice. */
|
|
43
|
+
export function payUrl(ref: string): string {
|
|
44
|
+
return isSessionRef(ref) ? `${COINPAY}/checkout/${ref}` : `${COINPAY}/pay/${encodeURIComponent(ref)}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The site hostname every nixamp sale is filed under at CoinPay. */
|
|
48
|
+
export function propertyOf(site: string): string {
|
|
49
|
+
try {
|
|
50
|
+
return new URL(site).hostname.replace(/^www\./, "").toLowerCase() || "nixamp.com";
|
|
51
|
+
} catch {
|
|
52
|
+
return "nixamp.com";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The coin a stored payment names: "ANY" for a hosted session, where the payer picks (or PayPal). */
|
|
57
|
+
export function coinOf(value: unknown): string {
|
|
58
|
+
return typeof value === "string" && /^[A-Za-z0-9_]{2,20}$/.test(value.trim()) ? value.trim().toUpperCase() : "ANY";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** "in USDC_POL" for an older one-coin payment; nothing for a session. */
|
|
62
|
+
export function paidIn(coin: string): string {
|
|
63
|
+
return coin && coin !== "ANY" ? ` in ${coin}` : "";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Who pays: CoinPay needs an email for the receipt; the account id defaults to it (a guest). */
|
|
67
|
+
export interface Payer { email?: unknown; accountId?: string | null }
|
|
68
|
+
|
|
32
69
|
export class SupportError extends Error {
|
|
33
70
|
constructor(message: string, readonly status = 400, readonly code = "invalid") {
|
|
34
71
|
super(message);
|
|
@@ -84,32 +121,64 @@ export class Support {
|
|
|
84
121
|
return this.merchant;
|
|
85
122
|
}
|
|
86
123
|
|
|
87
|
-
/**
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
124
|
+
/** The POST /api/checkout/sessions body: what CoinPay files the sale under. */
|
|
125
|
+
sessionBody(input: { amountUsd: number; description: string; metadata: Record<string, unknown>; redirect: string; cancel?: string; email: string; accountId: string }): Record<string, unknown> {
|
|
126
|
+
return {
|
|
127
|
+
amount: Math.max(0.01, Math.round(input.amountUsd * 100) / 100),
|
|
128
|
+
currency: "USD",
|
|
129
|
+
description: input.description.slice(0, 200),
|
|
130
|
+
customer_email: input.email,
|
|
131
|
+
account_id: input.accountId,
|
|
132
|
+
property: propertyOf(this.options.site),
|
|
133
|
+
metadata: { app: "nixamp", ...input.metadata },
|
|
134
|
+
success_url: input.redirect,
|
|
135
|
+
cancel_url: input.cancel ?? input.redirect,
|
|
136
|
+
expires_in_minutes: 60,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Open a CoinPay hosted checkout session; its id stands where a payment id used to. */
|
|
141
|
+
private async session(input: { amountUsd: number; description: string; metadata: Record<string, unknown>; redirect: string; cancel?: string } & Payer): Promise<{ url: string; paymentId: string }> {
|
|
142
|
+
const email = typeof input.email === "string" ? input.email.trim().toLowerCase() : "";
|
|
143
|
+
if (!EMAIL.test(email)) throw new SupportError("Give an email for the receipt.", 400, "invalid_email");
|
|
144
|
+
const accountId = (typeof input.accountId === "string" && input.accountId.trim()) || email;
|
|
145
|
+
const created = await this.provider("/api/checkout/sessions", {
|
|
93
146
|
method: "POST",
|
|
94
|
-
|
|
95
|
-
body: JSON.stringify({
|
|
96
|
-
business_id: business,
|
|
97
|
-
amount_usd: Math.max(0.01, Math.round(input.amountUsd * 100) / 100),
|
|
98
|
-
currency: coin.toLowerCase(),
|
|
99
|
-
payment_method: "crypto",
|
|
100
|
-
description: input.description.slice(0, 200),
|
|
101
|
-
metadata: { app: "nixamp", ...input.metadata },
|
|
102
|
-
redirect_url: input.redirect,
|
|
103
|
-
}),
|
|
147
|
+
body: JSON.stringify(this.sessionBody({ ...input, email, accountId })),
|
|
104
148
|
});
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
149
|
+
const id = typeof created["id"] === "string" ? created["id"] : "";
|
|
150
|
+
if (!SESSION.test(id)) throw new SupportError("The checkout did not start; nothing was charged. Try again.", 502, "provider");
|
|
151
|
+
return { url: payUrl(id), paymentId: id };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Any CoinPay checkout on nixamp's business (a purchase in the store): no
|
|
156
|
+
* holder, no floor but a cent. A hosted session, so the payer picks any coin
|
|
157
|
+
* or PayPal there; `coin` is what an older page still sends, and is ignored.
|
|
158
|
+
*/
|
|
159
|
+
async pay(input: { amountUsd: number; coin?: unknown; description: string; metadata: Record<string, unknown>; redirect: string; order: string } & Payer): Promise<{ url: string; paymentId: string }> {
|
|
160
|
+
return this.session({ ...input, metadata: { order: input.order, ...input.metadata } });
|
|
109
161
|
}
|
|
110
162
|
|
|
111
|
-
/**
|
|
163
|
+
/**
|
|
164
|
+
* Whether CoinPay counts this payment as paid; null when it did not answer.
|
|
165
|
+
* A session (cs_...) is asked as a session: paid by any coin or PayPal.
|
|
166
|
+
*/
|
|
112
167
|
async isPaid(paymentId: string): Promise<boolean | null> {
|
|
168
|
+
if (isSessionRef(paymentId)) {
|
|
169
|
+
try {
|
|
170
|
+
const response = await (this.options.fetcher ?? fetch)(`${COINPAY}/api/checkout/sessions/${encodeURIComponent(paymentId)}`, {
|
|
171
|
+
signal: AbortSignal.timeout(15_000),
|
|
172
|
+
headers: { authorization: `Bearer ${this.options.key}` },
|
|
173
|
+
});
|
|
174
|
+
if (!response.ok) return null;
|
|
175
|
+
const body = (await response.json()) as { session?: { id?: string; status?: string } };
|
|
176
|
+
if (body.session?.id !== paymentId) return null;
|
|
177
|
+
return body.session.status === "paid";
|
|
178
|
+
} catch {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
113
182
|
try {
|
|
114
183
|
const response = await (this.options.fetcher ?? fetch)(`https://coinpayportal.com/api/payments/${encodeURIComponent(paymentId)}`, {
|
|
115
184
|
signal: AbortSignal.timeout(15_000),
|
|
@@ -123,34 +192,49 @@ export class Support {
|
|
|
123
192
|
}
|
|
124
193
|
}
|
|
125
194
|
|
|
195
|
+
/**
|
|
196
|
+
* A hosted session's state: "paid", "open" (with when it expires) or
|
|
197
|
+
* anything else CoinPay says (expired, canceled); null when it did not
|
|
198
|
+
* answer. An older one-coin payment is "paid" or "gone": its page cannot be
|
|
199
|
+
* reopened, so it is never handed out again.
|
|
200
|
+
*/
|
|
201
|
+
async sessionState(ref: string): Promise<{ status: string; expiresAt: number } | null> {
|
|
202
|
+
if (!isSessionRef(ref)) {
|
|
203
|
+
const paid = await this.isPaid(ref);
|
|
204
|
+
return paid === null ? null : { status: paid ? "paid" : "gone", expiresAt: 0 };
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
const response = await (this.options.fetcher ?? fetch)(`${COINPAY}/api/checkout/sessions/${encodeURIComponent(ref)}`, {
|
|
208
|
+
signal: AbortSignal.timeout(15_000),
|
|
209
|
+
headers: { authorization: `Bearer ${this.options.key}` },
|
|
210
|
+
});
|
|
211
|
+
if (!response.ok) return null;
|
|
212
|
+
const body = (await response.json()) as { session?: { id?: string; status?: string; expires_at?: string } };
|
|
213
|
+
if (body.session?.id !== ref) return null;
|
|
214
|
+
const expiresAt = Date.parse(String(body.session.expires_at ?? "")) || 0;
|
|
215
|
+
return { status: String(body.session.status ?? ""), expiresAt };
|
|
216
|
+
} catch {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
126
221
|
/** A CoinPay checkout for this much, to this holder; the address to send the payer to. */
|
|
127
|
-
async checkout(input: { holder?: unknown; amountUsd?: unknown; coin?: unknown; station?: unknown; track?: unknown; metadata?: Record<string, unknown> }): Promise<{ url: string; paymentId: string }> {
|
|
222
|
+
async checkout(input: { holder?: unknown; amountUsd?: unknown; coin?: unknown; station?: unknown; track?: unknown; metadata?: Record<string, unknown> } & Payer): Promise<{ url: string; paymentId: string }> {
|
|
128
223
|
const holder = typeof input.holder === "string" ? this.holder(input.holder) : undefined;
|
|
129
224
|
if (!holder) throw new SupportError("Nobody by that name is supported here.", 404, "not_found");
|
|
130
225
|
const amount = Number(input.amountUsd);
|
|
131
226
|
if (!Number.isFinite(amount) || amount < MIN_USD || amount > MAX_USD) throw new SupportError(`Choose an amount from $${MIN_USD} to $${MAX_USD}.`, 400, "invalid_amount");
|
|
132
|
-
const { business, coins } = await this.coins();
|
|
133
|
-
const coin = typeof input.coin === "string" ? input.coin.toUpperCase() : "";
|
|
134
|
-
if (!coins.includes(coin)) throw new SupportError(`Pay with one of: ${coins.join(", ")}.`, 400, "invalid_coin");
|
|
135
227
|
const clean = (value: unknown): string => (typeof value === "string" ? value.replace(/[\u0000-\u001f]/g, " ").slice(0, 120) : "");
|
|
136
228
|
const order = randomUUID();
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
description: `Support ${holder.name} on nixamp`,
|
|
146
|
-
metadata: { app: "nixamp", product: "support", holder: holder.id, order, station: clean(input.station), track: clean(input.track), ...(input.metadata ?? {}) },
|
|
147
|
-
redirect_url: `${this.options.site}/support/${holder.id}?thanks=1`,
|
|
148
|
-
}),
|
|
229
|
+
return this.session({
|
|
230
|
+
amountUsd: amount,
|
|
231
|
+
description: `Support ${holder.name} on nixamp`,
|
|
232
|
+
metadata: { product: "support", holder: holder.id, order, station: clean(input.station), track: clean(input.track), ...(input.metadata ?? {}) },
|
|
233
|
+
redirect: `${this.options.site}/support/${holder.id}?thanks=1`,
|
|
234
|
+
cancel: `${this.options.site}/support/${holder.id}`,
|
|
235
|
+
email: input.email,
|
|
236
|
+
accountId: input.accountId ?? null,
|
|
149
237
|
});
|
|
150
|
-
const payment = created["payment"] as { id?: unknown } | undefined;
|
|
151
|
-
const id = typeof payment?.id === "string" ? payment.id : "";
|
|
152
|
-
if (!/^[A-Za-z0-9_-]{6,80}$/.test(id)) throw new SupportError("The checkout did not start; nothing was charged. Try again.", 502, "provider");
|
|
153
|
-
return { url: `https://coinpayportal.com/pay/${id}`, paymentId: id };
|
|
154
238
|
}
|
|
155
239
|
}
|
|
156
240
|
|
|
@@ -189,16 +273,14 @@ const SCRIPT = `
|
|
|
189
273
|
}
|
|
190
274
|
const monthly = () => document.querySelector("input[name=often]:checked").value === "monthly";
|
|
191
275
|
for (const r of document.querySelectorAll("input[name=often]")) r.addEventListener("change", () => {
|
|
192
|
-
$("#email-
|
|
276
|
+
$("#email-label").textContent = monthly() ? "Email, for the receipt and each month's payment link (and the cancel link)" : "Email, for the receipt";
|
|
193
277
|
$("#go").textContent = monthly() ? "Pay the first month" : "Continue to payment";
|
|
194
278
|
});
|
|
195
279
|
if (params.get("thanks")) { $("#status").className = "thanks"; $("#status").textContent = "Thank you. Your payment is on its way."; }
|
|
196
280
|
try {
|
|
197
281
|
const info = await (await fetch("/api/v1/support/" + encodeURIComponent(holder), { cache: "no-store" })).json();
|
|
198
282
|
if (info.name) { $("#who").textContent = info.name; document.title = "Support " + info.name + " · nixamp"; }
|
|
199
|
-
|
|
200
|
-
$("#coin").replaceChildren(...coins.map((c) => { const o = document.createElement("option"); o.value = c; o.textContent = c.replace(/_/g, " "); return o; }));
|
|
201
|
-
if (!coins.length) { $("#go").disabled = true; $("#status").textContent = info.error || "Payments are not available right now."; }
|
|
283
|
+
if (info.error) { $("#go").disabled = true; $("#status").textContent = info.error; }
|
|
202
284
|
} catch { $("#status").textContent = "Payments are not available right now."; }
|
|
203
285
|
$("#form").addEventListener("submit", async (event) => {
|
|
204
286
|
event.preventDefault();
|
|
@@ -208,8 +290,8 @@ const SCRIPT = `
|
|
|
208
290
|
$("#status").textContent = "Starting the checkout…";
|
|
209
291
|
try {
|
|
210
292
|
const r = await fetch("/api/v1/support/" + encodeURIComponent(holder), { method: "POST", headers: { "content-type": "application/json" },
|
|
211
|
-
body: JSON.stringify({ amountUsd: usd,
|
|
212
|
-
...(monthly() ? { monthly: true
|
|
293
|
+
body: JSON.stringify({ amountUsd: usd, email: $("#email").value, station: params.get("station") || "", track: params.get("track") || "",
|
|
294
|
+
...(monthly() ? { monthly: true } : {}) }) });
|
|
213
295
|
const body = await r.json();
|
|
214
296
|
if (r.ok && body.url) { location.href = body.url; return; }
|
|
215
297
|
$("#status").textContent = body.error || "That did not work.";
|
|
@@ -234,22 +316,18 @@ export const SUPPORT_PAGE = `<!doctype html>
|
|
|
234
316
|
<main>
|
|
235
317
|
<div class="card">
|
|
236
318
|
<h1>Support <span id="who">the artist</span></h1>
|
|
237
|
-
<p>Pay the copyright holder of the music you are listening to, directly,
|
|
319
|
+
<p>Pay the copyright holder of the music you are listening to, directly, through CoinPay: any coin, or PayPal. Once, or every month.</p>
|
|
238
320
|
<form id="form">
|
|
239
321
|
<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>
|
|
240
322
|
<div class="amounts">${AMOUNTS.map((usd, at) => `<button type="button" data-usd="${usd}" aria-pressed="${at === 1}">$${usd}</button>`).join("")}</div>
|
|
241
323
|
<label for="other">Or another amount (US$)</label>
|
|
242
324
|
<input id="other" type="number" min="1" max="1000" step="1" inputmode="numeric">
|
|
243
|
-
<
|
|
244
|
-
|
|
245
|
-
<input id="email" type="email" autocomplete="email" maxlength="254">
|
|
246
|
-
</div>
|
|
247
|
-
<label for="coin">Pay with</label>
|
|
248
|
-
<select id="coin"></select>
|
|
325
|
+
<label id="email-label" for="email">Email, for the receipt</label>
|
|
326
|
+
<input id="email" type="email" autocomplete="email" maxlength="254" required>
|
|
249
327
|
<button id="go" class="go" type="submit">Continue to payment</button>
|
|
250
328
|
<div id="status" role="status" aria-live="polite"></div>
|
|
251
329
|
</form>
|
|
252
|
-
<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>
|
|
330
|
+
<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>
|
|
253
331
|
</div>
|
|
254
332
|
</main>
|
|
255
333
|
<script>${SCRIPT}</script>
|
|
@@ -284,9 +362,10 @@ export function nextMonth(at: number): number {
|
|
|
284
362
|
* Monthly support, run here rather than by CoinPay: CoinPay's only merchant
|
|
285
363
|
* subscriptions are Stripe's, and its recurring invoices refuse a scoped key.
|
|
286
364
|
* A subscription is an email, an amount, a coin and a holder. Month one is
|
|
287
|
-
* paid at once on CoinPay's checkout; each month after,
|
|
288
|
-
*
|
|
289
|
-
*
|
|
365
|
+
* paid at once on CoinPay's checkout; each month after, the subscription's
|
|
366
|
+
* stable pay link is emailed (it opens a fresh CoinPay session whenever it is
|
|
367
|
+
* followed, since a session expires within two hours), with a one-click
|
|
368
|
+
* cancel. A month left unpaid ends it (lapsed) rather than emailing forever.
|
|
290
369
|
*/
|
|
291
370
|
export class SupportSubscriptions {
|
|
292
371
|
private schema: Promise<void> | null = null;
|
|
@@ -315,12 +394,15 @@ export class SupportSubscriptions {
|
|
|
315
394
|
cycles INTEGER NOT NULL DEFAULT 0, last_payment_id TEXT, next_due TIMESTAMPTZ NOT NULL,
|
|
316
395
|
station TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL, canceled_at TIMESTAMPTZ)`);
|
|
317
396
|
await this.options.db.query("CREATE INDEX IF NOT EXISTS support_subscriptions_due ON support_subscriptions (status, next_due)");
|
|
397
|
+
// Every checkout opened for the current month, space separated: a payer
|
|
398
|
+
// may open several from the pay link, and any one of them paid counts.
|
|
399
|
+
await this.options.db.query("ALTER TABLE support_subscriptions ADD COLUMN IF NOT EXISTS cycle_payment_ids TEXT NOT NULL DEFAULT ''");
|
|
318
400
|
})().catch((error: unknown) => { this.schema = null; throw error; });
|
|
319
401
|
return this.schema;
|
|
320
402
|
}
|
|
321
403
|
|
|
322
404
|
/** Month one: a subscription and its first checkout. */
|
|
323
|
-
async start(input: { holder?: unknown; amountUsd?: unknown; coin?: unknown; email?: unknown; station?: unknown; track?: unknown }): Promise<{ url: string; id: string }> {
|
|
405
|
+
async start(input: { holder?: unknown; amountUsd?: unknown; coin?: unknown; email?: unknown; station?: unknown; track?: unknown; accountId?: string | null }): Promise<{ url: string; id: string }> {
|
|
324
406
|
const email = typeof input.email === "string" ? input.email.trim().toLowerCase() : "";
|
|
325
407
|
if (!EMAIL.test(email)) throw new SupportError("Give an email for the monthly payment links.", 400, "invalid_email");
|
|
326
408
|
const holder = typeof input.holder === "string" ? this.options.support.holder(input.holder) : undefined;
|
|
@@ -330,15 +412,18 @@ export class SupportSubscriptions {
|
|
|
330
412
|
if (Number(live.rows[0]?.["n"] ?? 0) >= 10) throw new SupportError("That email already has ten monthly supports.", 409, "too_many");
|
|
331
413
|
const id = randomUUID();
|
|
332
414
|
const token = randomUUID().replace(/-/g, "");
|
|
333
|
-
const checkout = await this.options.support.checkout({ ...input, holder: holder.id, metadata: { subscription: id, cycle: 1 } } as Parameters<Support["checkout"]>[0]);
|
|
415
|
+
const checkout = await this.options.support.checkout({ ...input, email, holder: holder.id, metadata: { subscription: id, cycle: 1 } } as Parameters<Support["checkout"]>[0]);
|
|
416
|
+
// The payer picks the coin (or PayPal) on CoinPay; an older page may still name one.
|
|
417
|
+
const coin = (typeof input.coin === "string" && input.coin.trim() ? input.coin : "ANY").toUpperCase();
|
|
418
|
+
const paidWith = coin === "ANY" ? "" : ` in ${coin}`;
|
|
334
419
|
const now = this.now();
|
|
335
|
-
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)
|
|
336
|
-
VALUES ($1,$2,$3,$4,$5,$6,1,$7,$8,$9,$10)`,
|
|
337
|
-
[id, token, holder.id, email, Math.round(Number(input.amountUsd) * 100),
|
|
420
|
+
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)
|
|
421
|
+
VALUES ($1,$2,$3,$4,$5,$6,1,$7,$8,$9,$10,$7)`,
|
|
422
|
+
[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)]);
|
|
338
423
|
await this.mail(email, `Your monthly support for ${holder.name}`, [
|
|
339
|
-
`Thank you for supporting ${holder.name} on nixamp, $${Number(input.amountUsd).toFixed(2)} a month
|
|
340
|
-
`
|
|
341
|
-
`
|
|
424
|
+
`Thank you for supporting ${holder.name} on nixamp, $${Number(input.amountUsd).toFixed(2)} a month${paidWith}.`,
|
|
425
|
+
`Pay this month: ${this.payLink(id, token)}`,
|
|
426
|
+
`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.`,
|
|
342
427
|
`Stop at any time: ${this.cancelLink(id, token)}`,
|
|
343
428
|
]);
|
|
344
429
|
return { url: checkout.url, id };
|
|
@@ -348,6 +433,71 @@ export class SupportSubscriptions {
|
|
|
348
433
|
return `${this.options.site}/support/cancel?s=${encodeURIComponent(id)}&t=${encodeURIComponent(token)}`;
|
|
349
434
|
}
|
|
350
435
|
|
|
436
|
+
/**
|
|
437
|
+
* The subscription's stable "pay this month" link. A CoinPay session lives
|
|
438
|
+
* at most two hours, so an emailed session link was dead by the time most
|
|
439
|
+
* people read it; this one opens a fresh session whenever it is followed.
|
|
440
|
+
* The token is the subscription's own secret (the cancel link's).
|
|
441
|
+
*/
|
|
442
|
+
payLink(id: string, token: string): string {
|
|
443
|
+
return `${this.options.site}/support/pay/${encodeURIComponent(id)}/${encodeURIComponent(token)}`;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** The checkouts opened for the subscription's current month, newest last. */
|
|
447
|
+
private static cycleIds(row: Record<string, unknown>): string[] {
|
|
448
|
+
const listed = String(row["cycle_payment_ids"] ?? "").split(/\s+/).filter(Boolean);
|
|
449
|
+
const last = String(row["last_payment_id"] ?? "");
|
|
450
|
+
if (last && !listed.includes(last)) listed.push(last);
|
|
451
|
+
return listed;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Whether any of this month's checkouts is paid; null when CoinPay could not say and none is known paid. */
|
|
455
|
+
private async cyclePaid(row: Record<string, unknown>): Promise<boolean | null> {
|
|
456
|
+
let unknown = false;
|
|
457
|
+
for (const ref of SupportSubscriptions.cycleIds(row)) {
|
|
458
|
+
const paid = await this.paid(ref);
|
|
459
|
+
if (paid === true) return true;
|
|
460
|
+
if (paid === null) unknown = true;
|
|
461
|
+
}
|
|
462
|
+
return unknown ? null : false;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Following the pay link: this month's checkout to send the payer to.
|
|
467
|
+
* "paid" when a checkout this month is already paid; "redirect" to a
|
|
468
|
+
* session still open for at least 15 minutes, else to a new one.
|
|
469
|
+
*/
|
|
470
|
+
async open(id: string, token: string): Promise<{ state: "redirect"; url: string } | { state: "paid" | "canceled" | "lapsed"; holder: string }> {
|
|
471
|
+
if (!/^[0-9a-f-]{36}$/.test(id) || !/^[0-9a-f]{32}$/.test(token)) throw new SupportError("That payment link is not one of ours.", 404, "not_found");
|
|
472
|
+
await this.ready();
|
|
473
|
+
const found = await this.options.db.query("SELECT * FROM support_subscriptions WHERE id = $1 AND token = $2", [id, token]);
|
|
474
|
+
const row = found.rows[0];
|
|
475
|
+
if (!row) throw new SupportError("That payment link is not one of ours.", 404, "not_found");
|
|
476
|
+
const holderId = String(row["holder"]);
|
|
477
|
+
const status = String(row["status"]);
|
|
478
|
+
if (status === "canceled" || status === "lapsed") return { state: status, holder: holderId };
|
|
479
|
+
const holder = this.options.support.holder(holderId);
|
|
480
|
+
if (!holder) throw new SupportError("Nobody by that name is supported here any more.", 404, "not_found");
|
|
481
|
+
const ids = SupportSubscriptions.cycleIds(row);
|
|
482
|
+
const newest = ids.at(-1);
|
|
483
|
+
let unknown = false;
|
|
484
|
+
for (const ref of ids) {
|
|
485
|
+
const state = await this.options.support.sessionState(ref);
|
|
486
|
+
if (state === null) { unknown = true; continue; }
|
|
487
|
+
if (state.status === "paid") return { state: "paid", holder: holderId };
|
|
488
|
+
if (ref === newest && state.status === "open" && state.expiresAt - this.now() >= 15 * 60_000) return { state: "redirect", url: payUrl(ref) };
|
|
489
|
+
}
|
|
490
|
+
// A checkout CoinPay could not report on may be paid: do not stack new ones on it.
|
|
491
|
+
if (unknown) throw new SupportError("The payment service did not answer; nothing was charged. Try the link again in a minute.", 502, "provider");
|
|
492
|
+
if (ids.length >= 20) 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");
|
|
493
|
+
const checkout = await this.options.support.checkout({ holder: holder.id, amountUsd: Number(row["amount_cents"]) / 100, email: String(row["email"]),
|
|
494
|
+
station: String(row["station"] ?? ""), metadata: { subscription: id, cycle: Number(row["cycles"]) } } as Parameters<Support["checkout"]>[0]);
|
|
495
|
+
// Appended under the row's own month, so a renewal racing this click is not overwritten.
|
|
496
|
+
await this.options.db.query(`UPDATE support_subscriptions SET last_payment_id = $3,
|
|
497
|
+
cycle_payment_ids = trim(both ' ' from cycle_payment_ids || ' ' || $3) WHERE id = $1 AND cycles = $2`, [id, Number(row["cycles"]), checkout.paymentId]);
|
|
498
|
+
return { state: "redirect", url: checkout.url };
|
|
499
|
+
}
|
|
500
|
+
|
|
351
501
|
async cancel(id: string, token: string): Promise<{ canceled: boolean; holder: string }> {
|
|
352
502
|
await this.ready();
|
|
353
503
|
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())]);
|
|
@@ -361,17 +511,8 @@ export class SupportSubscriptions {
|
|
|
361
511
|
}
|
|
362
512
|
|
|
363
513
|
private async paid(paymentId: string): Promise<boolean | null> {
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
signal: AbortSignal.timeout(15_000),
|
|
367
|
-
headers: { authorization: `Bearer ${this.options.support.key}` },
|
|
368
|
-
});
|
|
369
|
-
if (!response.ok) return null;
|
|
370
|
-
const body = (await response.json()) as { payment?: { status?: string } };
|
|
371
|
-
return PAID.has(String(body.payment?.status ?? ""));
|
|
372
|
-
} catch {
|
|
373
|
-
return null;
|
|
374
|
-
}
|
|
514
|
+
// A session (cs_...) or an older one-coin payment, asked the same way checkouts are.
|
|
515
|
+
return this.options.support.isPaid(paymentId);
|
|
375
516
|
}
|
|
376
517
|
|
|
377
518
|
/** Every subscription now due: a new month's payment and its link, or lapsed when last month went unpaid. */
|
|
@@ -382,8 +523,7 @@ export class SupportSubscriptions {
|
|
|
382
523
|
let lapsed = 0;
|
|
383
524
|
for (const row of due.rows) {
|
|
384
525
|
const id = String(row["id"]);
|
|
385
|
-
const
|
|
386
|
-
const paid = last ? await this.paid(last) : false;
|
|
526
|
+
const paid = await this.cyclePaid(row);
|
|
387
527
|
if (paid === null) continue; // CoinPay did not answer: ask again next time.
|
|
388
528
|
const holder = this.options.support.holder(String(row["holder"]));
|
|
389
529
|
if (!paid || !holder) {
|
|
@@ -397,17 +537,14 @@ export class SupportSubscriptions {
|
|
|
397
537
|
}
|
|
398
538
|
const amountUsd = Number(row["amount_cents"]) / 100;
|
|
399
539
|
const cycle = Number(row["cycles"]) + 1;
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
this.options.log?.(` support: renewal ${id} could not start a payment: ${(error as Error).message}`);
|
|
405
|
-
continue;
|
|
406
|
-
}
|
|
407
|
-
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()))]);
|
|
540
|
+
// No checkout is opened here: the email carries the stable pay link,
|
|
541
|
+
// which opens one when the payer follows it, however late in the month.
|
|
542
|
+
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",
|
|
543
|
+
[id, cycle, new Date(nextMonth(new Date(String(row["next_due"])).getTime())), Number(row["cycles"])]);
|
|
408
544
|
await this.mail(String(row["email"]), `This month's support for ${holder.name}`, [
|
|
409
|
-
`Your monthly support for ${holder.name}: $${amountUsd.toFixed(2)} in
|
|
410
|
-
`Pay this month: ${
|
|
545
|
+
`Your monthly support for ${holder.name}: $${amountUsd.toFixed(2)}, in any coin or PayPal.`,
|
|
546
|
+
`Pay this month: ${this.payLink(id, String(row["token"]))}`,
|
|
547
|
+
`The link works all month; each time you open it, it starts a fresh checkout.`,
|
|
411
548
|
`Stop at any time: ${this.cancelLink(id, String(row["token"]))}`,
|
|
412
549
|
]);
|
|
413
550
|
renewed += 1;
|
package/src/veo-studio.ts
CHANGED
|
@@ -20,6 +20,9 @@ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
|
20
20
|
import { dirname, join } from "node:path";
|
|
21
21
|
|
|
22
22
|
export class VeoBudgetError extends Error {}
|
|
23
|
+
|
|
24
|
+
/** Google will make no more today (quota) or has no credit left: worth trying fal.ai instead. */
|
|
25
|
+
const GOOGLE_OUT = /quota|prepayment|depleted|RESOURCE_EXHAUSTED|\b429\b/i;
|
|
23
26
|
export class VeoRefused extends Error {}
|
|
24
27
|
|
|
25
28
|
/** Where the month's spend lives. `reserve` adds `usd` only if that keeps the month at or under `cap`. */
|
|
@@ -123,13 +126,69 @@ export class VeoStudio {
|
|
|
123
126
|
// Reserved first, in one step that will not go over: two at once cannot both squeeze under the cap.
|
|
124
127
|
if (!(await this.options.ledger.reserve(month, cost, this.cap))) throw new VeoBudgetError(`this month's $${this.cap} of Veo is spent`);
|
|
125
128
|
try {
|
|
126
|
-
|
|
129
|
+
try {
|
|
130
|
+
return await this.make(input.prompt, seconds, input.references.slice(0, 3));
|
|
131
|
+
} catch (error) {
|
|
132
|
+
// Google out of quota for the day, or its prepaid credit spent: the same Veo on fal.ai.
|
|
133
|
+
if (!this.options.falKey || !(error instanceof VeoRefused) || !GOOGLE_OUT.test(error.message)) throw error;
|
|
134
|
+
return await this.viaFal(this.options.falKey, input.prompt, seconds, input.references.slice(0, 3));
|
|
135
|
+
}
|
|
127
136
|
} catch (error) {
|
|
128
137
|
await this.options.ledger.refund(month, cost).catch(() => undefined);
|
|
129
138
|
throw error;
|
|
130
139
|
}
|
|
131
140
|
}
|
|
132
141
|
|
|
142
|
+
/** Veo 3.1 Fast on fal.ai: with the portraits as references when there are any, else from the prompt alone. */
|
|
143
|
+
private async viaFal(key: string, prompt: string, seconds: number, references: Buffer[]): Promise<Buffer> {
|
|
144
|
+
const settings = { prompt: prompt.slice(0, 1800), duration: `${seconds}s`, aspect_ratio: "16:9", resolution: "1080p", generate_audio: false };
|
|
145
|
+
if (references.length) {
|
|
146
|
+
const image_urls = await Promise.all(references.map((bytes, i) => this.falUpload(key, bytes, "image/jpeg", `ref-${i}.jpg`)));
|
|
147
|
+
try {
|
|
148
|
+
return await this.falJob(key, "fal-ai/veo3.1/fast/reference-to-video", { ...settings, image_urls }, "the fal.ai Veo shot");
|
|
149
|
+
} catch (error) {
|
|
150
|
+
// A reference shot it will not make is tried once from the words alone.
|
|
151
|
+
if (!(error instanceof VeoRefused)) throw error;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return await this.falJob(key, "fal-ai/veo3.1/fast", settings, "the fal.ai Veo shot");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** fal.ai takes inputs by URL only (a data: URI is "invalid"): its own storage first. */
|
|
158
|
+
private async falUpload(key: string, bytes: Buffer, type: string, name: string): Promise<string> {
|
|
159
|
+
const send = this.options.fetcher ?? fetch;
|
|
160
|
+
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) });
|
|
161
|
+
const slot = await init.json().catch(() => ({})) as { upload_url?: string; file_url?: string };
|
|
162
|
+
if (!slot.upload_url || !slot.file_url) throw new VeoRefused(`fal.ai storage refused the upload (HTTP ${init.status})`);
|
|
163
|
+
const put = await send(slot.upload_url, { method: "PUT", headers: { "content-type": type }, body: new Uint8Array(bytes), signal: AbortSignal.timeout(120_000) });
|
|
164
|
+
if (!put.ok) throw new VeoRefused(`fal.ai storage refused the upload (HTTP ${put.status})`);
|
|
165
|
+
return slot.file_url;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** One fal.ai queue job to its video: submitted, waited on (up to 10 minutes), downloaded. */
|
|
169
|
+
private async falJob(key: string, model: string, body: Record<string, unknown>, what: string): Promise<Buffer> {
|
|
170
|
+
const send = this.options.fetcher ?? fetch;
|
|
171
|
+
const auth = { authorization: `Key ${key}`, "content-type": "application/json" };
|
|
172
|
+
const started = await send(`https://queue.fal.run/${model}`, { method: "POST", headers: auth, body: JSON.stringify(body), signal: AbortSignal.timeout(60_000) });
|
|
173
|
+
const job = await started.json().catch(() => ({})) as { status_url?: string; response_url?: string };
|
|
174
|
+
if (!started.ok || !job.status_url || !job.response_url) throw new VeoRefused(`fal.ai refused ${what}: HTTP ${started.status}`);
|
|
175
|
+
const sleep = this.options.sleep ?? ((ms: number) => new Promise((done) => setTimeout(done, ms)));
|
|
176
|
+
for (let tries = 0; tries < 120; tries++) {
|
|
177
|
+
await sleep(5_000);
|
|
178
|
+
const state = await (await send(job.status_url, { headers: auth, signal: AbortSignal.timeout(30_000) })).json().catch(() => ({})) as { status?: string };
|
|
179
|
+
if (state.status !== "COMPLETED") continue;
|
|
180
|
+
const answer = await send(job.response_url, { headers: auth, signal: AbortSignal.timeout(30_000) });
|
|
181
|
+
const result = await answer.json().catch(() => ({})) as { video?: { url?: string }; detail?: Array<{ msg?: string }> | string };
|
|
182
|
+
const why = typeof result.detail === "string" ? result.detail : result.detail?.[0]?.msg;
|
|
183
|
+
if (!answer.ok || !result.video?.url) throw new VeoRefused(`${what} failed: ${why ?? `HTTP ${answer.status}`}`);
|
|
184
|
+
const out = await send(result.video.url, { signal: AbortSignal.timeout(120_000) });
|
|
185
|
+
const bytes = Buffer.from(await out.arrayBuffer());
|
|
186
|
+
if (!out.ok || bytes.length < 10_000) throw new VeoRefused(`${what} did not download (HTTP ${out.status})`);
|
|
187
|
+
return bytes;
|
|
188
|
+
}
|
|
189
|
+
throw new VeoRefused(`${what} took too long (10 minutes)`);
|
|
190
|
+
}
|
|
191
|
+
|
|
133
192
|
get canLipsync(): boolean { return Boolean(this.options.falKey); }
|
|
134
193
|
|
|
135
194
|
/**
|
|
@@ -152,35 +211,8 @@ export class VeoStudio {
|
|
|
152
211
|
}
|
|
153
212
|
|
|
154
213
|
private async sync(key: string, video: Buffer, audio: Buffer): Promise<Buffer> {
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
// fal.ai takes inputs by URL only (a data: URI is "invalid"): its own storage first.
|
|
158
|
-
const upload = async (bytes: Buffer, type: string, name: string): Promise<string> => {
|
|
159
|
-
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) });
|
|
160
|
-
const slot = await init.json().catch(() => ({})) as { upload_url?: string; file_url?: string };
|
|
161
|
-
if (!slot.upload_url || !slot.file_url) throw new VeoRefused(`fal.ai storage refused the upload (HTTP ${init.status})`);
|
|
162
|
-
const put = await send(slot.upload_url, { method: "PUT", headers: { "content-type": type }, body: new Uint8Array(bytes), signal: AbortSignal.timeout(120_000) });
|
|
163
|
-
if (!put.ok) throw new VeoRefused(`fal.ai storage refused the upload (HTTP ${put.status})`);
|
|
164
|
-
return slot.file_url;
|
|
165
|
-
};
|
|
166
|
-
const [videoUrl, audioUrl] = await Promise.all([upload(video, "video/mp4", "shot.mp4"), upload(audio, "audio/mpeg", "vocal.mp3")]);
|
|
167
|
-
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) });
|
|
168
|
-
const job = await started.json().catch(() => ({})) as { status_url?: string; response_url?: string; detail?: unknown };
|
|
169
|
-
if (!started.ok || !job.status_url || !job.response_url) throw new VeoRefused(`fal.ai refused the lip sync: HTTP ${started.status}`);
|
|
170
|
-
const sleep = this.options.sleep ?? ((ms: number) => new Promise((done) => setTimeout(done, ms)));
|
|
171
|
-
for (let tries = 0; tries < 120; tries++) {
|
|
172
|
-
await sleep(5_000);
|
|
173
|
-
const state = await (await send(job.status_url, { headers: auth, signal: AbortSignal.timeout(30_000) })).json().catch(() => ({})) as { status?: string };
|
|
174
|
-
if (state.status !== "COMPLETED") continue;
|
|
175
|
-
const answer = await send(job.response_url, { headers: auth, signal: AbortSignal.timeout(30_000) });
|
|
176
|
-
const result = await answer.json().catch(() => ({})) as { video?: { url?: string }; detail?: Array<{ msg?: string }> };
|
|
177
|
-
if (!answer.ok || !result.video?.url) throw new VeoRefused(`the lip sync failed: ${result.detail?.[0]?.msg ?? `HTTP ${answer.status}`}`);
|
|
178
|
-
const out = await send(result.video.url, { signal: AbortSignal.timeout(120_000) });
|
|
179
|
-
const bytes = Buffer.from(await out.arrayBuffer());
|
|
180
|
-
if (!out.ok || bytes.length < 10_000) throw new VeoRefused(`the lip-synced clip did not download (HTTP ${out.status})`);
|
|
181
|
-
return bytes;
|
|
182
|
-
}
|
|
183
|
-
throw new VeoRefused("the lip sync took too long (10 minutes)");
|
|
214
|
+
const [videoUrl, audioUrl] = await Promise.all([this.falUpload(key, video, "video/mp4", "shot.mp4"), this.falUpload(key, audio, "audio/mpeg", "vocal.mp3")]);
|
|
215
|
+
return await this.falJob(key, "fal-ai/sync-lipsync/v2", { video_url: videoUrl, audio_url: audioUrl }, "the lip sync");
|
|
184
216
|
}
|
|
185
217
|
|
|
186
218
|
private async make(prompt: string, seconds: number, references: Buffer[]): Promise<Buffer> {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{t as e}from"./main-
|
|
1
|
+
import{t as e}from"./main-3VBJDnTo.js";var t={...e,"No parties live right now.":`لا توجد جلسات مباشرة الآن.`,"Could not refresh parties.":`تعذر تحديث الجلسات.`,"Pick a server in the directory, or paste its link here.":`اختر خادمًا من الدليل أو الصق رابطه هنا.`,"Interface language":`لغة الواجهة`,Language:`اللغة`,"Translation language":`لغة الترجمة`,"Original (auto-detect)":`الأصل (اكتشاف تلقائي)`,Play:`تشغيل`,Pause:`إيقاف مؤقت`,Stop:`إيقاف`,Next:`التالي`,Previous:`السابق`,Select:`اختيار`,"Next/Prev":`التالي/السابق`,Quit:`خروج`,"Now Playing":`قيد التشغيل`,"Spectrum Analyser":`محلل الطيف`,Playlist:`قائمة التشغيل`,Files:`الملفات`,"Open files":`فتح ملفات`,"Open folder":`فتح مجلد`,"Add to library":`إضافة إلى المكتبة`,"Replace playlist":`استبدال قائمة التشغيل`,Filter:`تصفية`,Search:`بحث`,"Nothing loaded.":`لم يُحمّل أي محتوى.`,"Empty.":`فارغ.`,"Loading…":`جارٍ التحميل…`,"Connecting…":`جارٍ الاتصال…`,Connected:`متصل`,"Not connected":`غير متصل`,"No source":`لا يوجد مصدر`,Playing:`يعمل`,Stopped:`متوقف`,Volume:`مستوى الصوت`,Seek:`موضع التشغيل`,"Full screen":`ملء الشاشة`,Download:`تنزيل`,Share:`مشاركة`,Copy:`نسخ`,"Copy link":`نسخ الرابط`,Copied:`تم النسخ`,Save:`حفظ`,Cancel:`إلغاء`,Close:`إغلاق`,Remove:`إزالة`,Delete:`حذف`,Edit:`تعديل`,Add:`إضافة`,Send:`إرسال`,Retry:`إعادة المحاولة`,Back:`رجوع`,"Next page":`الصفحة التالية`,"Previous page":`الصفحة السابقة`,"Show more":`عرض المزيد`,Panels:`اللوحات`,"Reset layout":`إعادة ضبط التخطيط`,Show:`إظهار`,Hide:`إخفاء`,Collapse:`طي`,Expand:`توسيع`,"Move up":`نقل لأعلى`,"Move down":`نقل لأسفل`,Install:`تثبيت`,"TV mode":`وضع التلفاز`,On:`مفعّل`,Off:`معطّل`,Account:`الحساب`,"Sign in":`تسجيل الدخول`,"Sign out":`تسجيل الخروج`,"Create account":`إنشاء حساب`,Email:`البريد الإلكتروني`,Password:`كلمة المرور`,"Forgot password?":`نسيت كلمة المرور؟`,"Welcome back":`مرحبًا بعودتك`,Profile:`الملف الشخصي`,Handle:`اسم المستخدم`,"Your servers":`خوادمك`,Server:`الخادم`,Connect:`اتصال`,Disconnect:`قطع الاتصال`,Directory:`الدليل`,Favourites:`المفضلة`,Follow:`متابعة`,Unfollow:`إلغاء المتابعة`,Notifications:`الإشعارات`,"Phone number":`رقم الهاتف`,"Save number":`حفظ الرقم`,Transcript:`النص المفرغ`,Captions:`الترجمة النصية`,"Translate audio":`ترجمة الصوت`,"Audio language":`لغة الصوت`,"Audio options":`خيارات الصوت`,"Keep background sound":`الاحتفاظ بأصوات الخلفية`,"Background level":`مستوى صوت الخلفية`,"Speaker voices":`أصوات المتحدثين`,"Any voice":`أي صوت`,"Male voice":`صوت رجالي`,"Female voice":`صوت نسائي`,"Original audio.":`الصوت الأصلي.`,"Buy translated audio":`شراء الصوت المترجم`,"Audio credit":`رصيد الصوت`,"Buy pass":`شراء تصريح`,Pass:`تصريح`,"Pay with":`الدفع باستخدام`,"Check payment":`التحقق من الدفع`,"Open checkout":`الانتقال للدفع`,"Free sessions":`جلسات مجانية`,"Usage rates and expiry":`الأسعار ومدة الصلاحية`,Catalogs:`الكتالوجات`,"Add catalog":`إضافة كتالوج`,"Catalog name":`اسم الكتالوج`,Link:`الرابط`,"Play link":`تشغيل الرابط`,"Go live":`بدء بث مباشر`,Publish:`نشر`,Listen:`استماع`,"Live now":`مباشر الآن`,Upcoming:`قريبًا`,Parties:`حفلات المشاهدة`,"Join party":`الانضمام للحفلة`,"Invite code":`رمز الدعوة`,Invite:`دعوة`,Chat:`الدردشة`,"Chat message":`رسالة دردشة`,People:`الأشخاص`,Speaker:`متحدث`,Speakers:`المتحدثون`,Listener:`مستمع`,Moderator:`مشرف`,"Raise hand":`رفع اليد`,"Hand raises":`الأيدي المرفوعة`,Questions:`الأسئلة`,Resources:`الموارد`,Replay:`إعادة العرض`,Schedule:`الجدول`,Event:`الفعالية`,Host:`المضيف`,About:`حول`,Stage:`المنصة`,Title:`العنوان`,Description:`الوصف`,Topic:`الموضوع`,Public:`عام`,"Anyone with the link":`أي شخص لديه الرابط`,"Invited people":`الأشخاص المدعوون`,"Who can join":`من يمكنه الانضمام`,"Date and time":`التاريخ والوقت`,"Expected length":`المدة المتوقعة`,Repeat:`تكرار`,"Does not repeat":`لا يتكرر`,"Every day":`كل يوم`,"Every week":`كل أسبوع`,Homepage:`الموقع الإلكتروني`,"Host name":`اسم المضيف`,"Profile photo URL":`رابط صورة الملف الشخصي`,"Broadcast link":`رابط البث`,"Edit event":`تعديل الفعالية`,"Save changes":`حفظ التغييرات`,"Create live":`إنشاء بث مباشر`,"Schedule class":`جدولة درس`,"Start class":`بدء الدرس`,"End event":`إنهاء الفعالية`,"End session":`إنهاء الجلسة`,"Session ended":`انتهت الجلسة`,"Event ended":`انتهت الفعالية`,Cancelled:`ملغاة`,"Replay available":`إعادة العرض متاحة`,Join:`انضمام`,"Create invite":`إنشاء دعوة`,"Invite as":`الدعوة بصفة`,"Watch now":`شاهد الآن`,"Start a room":`إنشاء غرفة`,"Recent replays":`إعادات العرض الأخيرة`,"Your account":`حسابك`,"Write with AI":`الكتابة بالذكاء الاصطناعي`,"Suggested title":`العنوان المقترح`,"Suggested description":`الوصف المقترح`,"Suggested topic":`الموضوع المقترح`,"Use these details":`استخدام هذه التفاصيل`,"Learn something live.":`تعلّم شيئًا مباشرة.`,"Screen share":`مشاركة الشاشة`,"Upload and broadcast":`رفع وبث`,Help:`مساعدة`,"Reload Player":`إعادة تحميل المشغل`,"Copy Bundled CLI Path":`نسخ مسار أداة سطر الأوامر المضمنة`,"Developer tools":`أدوات المطور`,Undo:`تراجع`,Redo:`إعادة`,Cut:`قص`,Paste:`لصق`,"Select all":`تحديد الكل`,Status:`الحالة`,"Activity log":`سجل النشاط`,Admin:`الإدارة`,"Playback controls":`عناصر التحكم بالتشغيل`,"Tracks and folders":`المقاطع والمجلدات`,"Original-language captions.":`ترجمة نصية باللغة الأصلية.`,"Skip to playback controls":`الانتقال إلى عناصر التحكم بالتشغيل`,"Skip to playlist":`الانتقال إلى قائمة التشغيل`,"Skip to transcript and translated audio":`الانتقال إلى النص المفرغ والصوت المترجم`};export{t as default};
|