nixamp 0.60.2 → 0.60.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/known-servers.d.ts +33 -0
- package/dist/known-servers.js +40 -0
- package/dist/radio-pages.js +6 -1
- package/dist/server.js +22 -7
- package/dist/veo-studio.d.ts +18 -5
- package/dist/veo-studio.js +60 -21
- package/package.json +1 -1
- package/src/known-servers.ts +53 -0
- package/src/radio-pages.ts +4 -1
- package/src/server.ts +21 -7
- package/src/veo-studio.ts +67 -25
- package/web/dist/assets/{ar-9DPCOmaG.js → ar-D_8XCYVi.js} +1 -1
- package/web/dist/assets/{cs-Cav9Gs8C.js → cs-CjrJhDgM.js} +1 -1
- package/web/dist/assets/{da-CiS3aLLY.js → da-Dc7MH25k.js} +1 -1
- package/web/dist/assets/{de-Be0gdWMG.js → de-NQf3tqPM.js} +1 -1
- package/web/dist/assets/{es-CdRfYe8i.js → es-6_O1YsEq.js} +1 -1
- package/web/dist/assets/{fi-C5UAFZtS.js → fi-kDo6uGWc.js} +1 -1
- package/web/dist/assets/{fr-B4qOxKuF.js → fr-CdVXTKUt.js} +1 -1
- package/web/dist/assets/{hi-CSJ2RXel.js → hi-BvoGQedU.js} +1 -1
- package/web/dist/assets/{hu-CQ5YyA1R.js → hu-Brbvrfxm.js} +1 -1
- package/web/dist/assets/{id-CjwGIPsf.js → id-VVHsYRc0.js} +1 -1
- package/web/dist/assets/{it-CLkcLBD0.js → it-BQIjWTn_.js} +1 -1
- package/web/dist/assets/{main-CUpgrT90.js → main-sbQMI_Ki.js} +2 -2
- package/web/dist/assets/{nl-OmbQRQTb.js → nl-KAp6TgQ8.js} +1 -1
- package/web/dist/assets/{ru-DKzgZr9o.js → ru-B2BsAFbn.js} +1 -1
- package/web/dist/assets/{sv-ht1o-0P8.js → sv-iTI1dCtb.js} +1 -1
- package/web/dist/assets/{uk-Kz3DHKMD.js → uk-CuMVTWCK.js} +1 -1
- package/web/dist/assets/{vi-Cq8jzF8f.js → vi-CXhXxE2e.js} +1 -1
- package/web/dist/assets/{zh-D3FicqxI.js → zh-h7Nt3EsU.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +19 -19
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The servers nixamp.com has seen listed lately, and who owns each, kept in
|
|
3
|
+
* Postgres.
|
|
4
|
+
*
|
|
5
|
+
* The directory lives in memory and fills again only as servers heartbeat, so
|
|
6
|
+
* for a minute after every deploy nixamp.com/music had no catalogue and a
|
|
7
|
+
* label server's Veo or release call was refused as "not listed". This keeps
|
|
8
|
+
* the last day's servers so both work straight away; a server still listed
|
|
9
|
+
* always wins over what is remembered.
|
|
10
|
+
*/
|
|
11
|
+
import type { Queryable } from "./veo-studio.ts";
|
|
12
|
+
export interface KnownServer {
|
|
13
|
+
base: string;
|
|
14
|
+
ownerId: string;
|
|
15
|
+
seenAt: number;
|
|
16
|
+
}
|
|
17
|
+
export declare class KnownServers {
|
|
18
|
+
private readonly db;
|
|
19
|
+
private readonly now;
|
|
20
|
+
private known;
|
|
21
|
+
private ready;
|
|
22
|
+
constructor(db: Queryable, now?: () => number);
|
|
23
|
+
private ensure;
|
|
24
|
+
/** What the last process saw in the last day. */
|
|
25
|
+
load(): Promise<void>;
|
|
26
|
+
/** The servers listed now: remembered here and in the database. */
|
|
27
|
+
sync(listed: Array<{
|
|
28
|
+
base: string;
|
|
29
|
+
ownerId: string;
|
|
30
|
+
}>): Promise<void>;
|
|
31
|
+
/** Servers seen in the last day. */
|
|
32
|
+
list(): KnownServer[];
|
|
33
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
2
|
+
export class KnownServers {
|
|
3
|
+
db;
|
|
4
|
+
now;
|
|
5
|
+
known = new Map();
|
|
6
|
+
ready = null;
|
|
7
|
+
constructor(db, now = Date.now) {
|
|
8
|
+
this.db = db;
|
|
9
|
+
this.now = now;
|
|
10
|
+
}
|
|
11
|
+
ensure() {
|
|
12
|
+
return (this.ready ??= this.db.query("CREATE TABLE IF NOT EXISTS music_servers (base TEXT PRIMARY KEY, owner_id TEXT NOT NULL, seen_at TIMESTAMPTZ NOT NULL DEFAULT now())").catch((error) => { this.ready = null; throw error; }));
|
|
13
|
+
}
|
|
14
|
+
/** What the last process saw in the last day. */
|
|
15
|
+
async load() {
|
|
16
|
+
await this.ensure();
|
|
17
|
+
const { rows } = await this.db.query("SELECT base, owner_id, seen_at FROM music_servers WHERE seen_at > $1", [new Date(this.now() - DAY_MS)]);
|
|
18
|
+
for (const row of rows) {
|
|
19
|
+
const base = String(row["base"]);
|
|
20
|
+
if (!this.known.has(base))
|
|
21
|
+
this.known.set(base, { base, ownerId: String(row["owner_id"]), seenAt: new Date(String(row["seen_at"])).getTime() });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** The servers listed now: remembered here and in the database. */
|
|
25
|
+
async sync(listed) {
|
|
26
|
+
await this.ensure();
|
|
27
|
+
const at = this.now();
|
|
28
|
+
for (const one of listed) {
|
|
29
|
+
if (!one.base || !one.ownerId)
|
|
30
|
+
continue;
|
|
31
|
+
this.known.set(one.base, { base: one.base, ownerId: one.ownerId, seenAt: at });
|
|
32
|
+
await this.db.query("INSERT INTO music_servers (base, owner_id, seen_at) VALUES ($1, $2, now()) ON CONFLICT (base) DO UPDATE SET owner_id = EXCLUDED.owner_id, seen_at = now()", [one.base, one.ownerId]);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Servers seen in the last day. */
|
|
36
|
+
list() {
|
|
37
|
+
const since = this.now() - DAY_MS;
|
|
38
|
+
return [...this.known.values()].filter((one) => one.seenAt > since);
|
|
39
|
+
}
|
|
40
|
+
}
|
package/dist/radio-pages.js
CHANGED
|
@@ -407,7 +407,12 @@ export function stationHead(station, slug, base, path = `/radio/${slug}`, image
|
|
|
407
407
|
/** A station's picture for link previews: the cover of an album it plays, from the catalogue. */
|
|
408
408
|
export function stationImage(catalogue, slug, base) {
|
|
409
409
|
const album = [...catalogue.albums.values()].find((one) => one.stations.includes(slug) && (one.coverFile || one.label?.cover));
|
|
410
|
-
|
|
410
|
+
if (!album)
|
|
411
|
+
return "";
|
|
412
|
+
// The label's albums keep their cover beside the songs, not in them: the label serves it.
|
|
413
|
+
if (album.label?.cover)
|
|
414
|
+
return `${base}/api/v1/label/${album.label.artist}/${album.label.album}/cover.jpg`;
|
|
415
|
+
return `${base}/radio/album/${album.slug}/cover.jpg`;
|
|
411
416
|
}
|
|
412
417
|
/** The built station page (or the inline one) as /radio/<name> serves it: its head, the header, indexable. */
|
|
413
418
|
export function asRadioPage(html, station, slug, base, nav, path, image = "") {
|
package/dist/server.js
CHANGED
|
@@ -65,7 +65,8 @@ import { AiLabel, VeoUnavailable } from "./ai-label.js";
|
|
|
65
65
|
import { buyLink, handleLabelRoutes, isPublicLabelPath } from "./label-routes.js";
|
|
66
66
|
import { GrantVerifier, MusicStore, PRICES } from "./music-store.js";
|
|
67
67
|
import { handleMusicRoutes, isMusicPath, MusicHub, musicPageFor, serverId } from "./music-hub.js";
|
|
68
|
-
import { VeoBudgetError, VeoStudio } from "./veo-studio.js";
|
|
68
|
+
import { fileLedger, pgLedger, VeoBudgetError, VeoStudio } from "./veo-studio.js";
|
|
69
|
+
import { KnownServers } from "./known-servers.js";
|
|
69
70
|
import { handleStoreRoutes, isStorePath } from "./store-routes.js";
|
|
70
71
|
import { AiRadio } from "./ai-radio.js";
|
|
71
72
|
import { ListenerShares } from "./listener-shares.js";
|
|
@@ -7490,9 +7491,16 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
7490
7491
|
// The AI label's songs for sale: purchases kept here, the music zipped by the server that holds it.
|
|
7491
7492
|
const jwtSecret = process.env["NIXAMP_JWT_SECRET"] ?? "";
|
|
7492
7493
|
// The servers whose music nixamp.com sells and plays: the directory and the guide's peers.
|
|
7494
|
+
// Remembered in Postgres too, so the catalogue and owners are there the moment a deploy restarts us.
|
|
7495
|
+
const knownServers = options.directory && pool ? new KnownServers(pool) : undefined;
|
|
7496
|
+
const listedServers = () => directory ? directory.list().map((one) => ({ base: peerBase(one.url), ownerId: one.ownerId ?? "" })).filter((one) => one.base) : [];
|
|
7497
|
+
if (knownServers) {
|
|
7498
|
+
void knownServers.load().catch((error) => console.log(` music servers not remembered: ${error instanceof Error ? error.message : String(error)}`));
|
|
7499
|
+
setInterval(() => void knownServers.sync(listedServers()).catch(() => undefined), 60_000).unref();
|
|
7500
|
+
}
|
|
7493
7501
|
const musicServers = () => {
|
|
7494
|
-
const listed =
|
|
7495
|
-
return [...new Set([...guidePeers(), ...listed])];
|
|
7502
|
+
const listed = listedServers().map((one) => one.base);
|
|
7503
|
+
return [...new Set([...guidePeers(), ...listed, ...(knownServers?.list().map((one) => one.base) ?? [])])];
|
|
7496
7504
|
};
|
|
7497
7505
|
// nixamp.com/music: every server's label in one catalogue (music-hub.ts).
|
|
7498
7506
|
const musicHub = options.directory ? new MusicHub({ peers: musicServers }) : undefined;
|
|
@@ -7514,14 +7522,21 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
7514
7522
|
// Veo shots for label music videos, under a monthly dollar cap (veo-studio.ts). nixamp.com only.
|
|
7515
7523
|
const veoStudio = options.directory && process.env["NIXAMP_VEO_API_KEY"] ? new VeoStudio({
|
|
7516
7524
|
key: process.env["NIXAMP_VEO_API_KEY"],
|
|
7517
|
-
|
|
7525
|
+
// The month's spend in Postgres: a deploy recreates the container, and a file there would forget it.
|
|
7526
|
+
ledger: pool ? pgLedger(pool) : fileLedger(VeoStudio.fileFor(stateDir(), options.port)),
|
|
7518
7527
|
monthlyUsd: Number(process.env["NIXAMP_VEO_MONTHLY_USD"] ?? 50),
|
|
7519
7528
|
usdPerSecond: Number(process.env["NIXAMP_VEO_USD_PER_SECOND"] ?? 0.15),
|
|
7520
7529
|
}) : undefined;
|
|
7521
7530
|
/** The signed-in account owns a server listed in the directory at this base. */
|
|
7522
7531
|
const ownsListed = async (request, base) => {
|
|
7523
7532
|
const me = accounts ? await accounts.whoIs(tokenFrom(request.headers)).catch(() => null) : null;
|
|
7524
|
-
|
|
7533
|
+
if (!me || !base)
|
|
7534
|
+
return false;
|
|
7535
|
+
const listed = listedServers();
|
|
7536
|
+
// A server listed now answers for itself; one not (yet) back since a restart, by who last owned it.
|
|
7537
|
+
if (listed.some((one) => one.base === base))
|
|
7538
|
+
return listed.some((one) => one.base === base && one.ownerId === me.id);
|
|
7539
|
+
return Boolean(knownServers?.list().some((one) => one.base === base && one.ownerId === me.id));
|
|
7525
7540
|
};
|
|
7526
7541
|
const musicVeo = async (request) => {
|
|
7527
7542
|
if (!veoStudio)
|
|
@@ -7530,7 +7545,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
7530
7545
|
if (!(await ownsListed(request, base)))
|
|
7531
7546
|
return { status: 403, body: { error: "only the owner of a listed server" } };
|
|
7532
7547
|
if (request.method === "GET")
|
|
7533
|
-
return { status: 200, body: veoStudio.status() };
|
|
7548
|
+
return { status: 200, body: await veoStudio.status() };
|
|
7534
7549
|
if (request.method !== "POST")
|
|
7535
7550
|
return { status: 405, body: { error: "GET or POST" } };
|
|
7536
7551
|
let input;
|
|
@@ -7549,7 +7564,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
7549
7564
|
}
|
|
7550
7565
|
catch (error) {
|
|
7551
7566
|
if (error instanceof VeoBudgetError)
|
|
7552
|
-
return { status: 402, body: { error: error.message, code: "budget", ...veoStudio.status() } };
|
|
7567
|
+
return { status: 402, body: { error: error.message, code: "budget", ...(await veoStudio.status()) } };
|
|
7553
7568
|
return { status: 422, body: { error: error.message, code: "refused" } };
|
|
7554
7569
|
}
|
|
7555
7570
|
};
|
package/dist/veo-studio.d.ts
CHANGED
|
@@ -2,9 +2,24 @@ export declare class VeoBudgetError extends Error {
|
|
|
2
2
|
}
|
|
3
3
|
export declare class VeoRefused extends Error {
|
|
4
4
|
}
|
|
5
|
+
/** Where the month's spend lives. `reserve` adds `usd` only if that keeps the month at or under `cap`. */
|
|
6
|
+
export interface VeoLedger {
|
|
7
|
+
spent(month: string): Promise<number>;
|
|
8
|
+
reserve(month: string, usd: number, cap: number): Promise<boolean>;
|
|
9
|
+
refund(month: string, usd: number): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
export interface Queryable {
|
|
12
|
+
query: (text: string, params?: unknown[]) => Promise<{
|
|
13
|
+
rows: Array<Record<string, unknown>>;
|
|
14
|
+
}>;
|
|
15
|
+
}
|
|
16
|
+
/** The spend in Postgres, one row a month, reserved atomically. */
|
|
17
|
+
export declare function pgLedger(db: Queryable): VeoLedger;
|
|
18
|
+
/** The spend in a file, for a nixamp with no database (its own state directory must survive restarts). */
|
|
19
|
+
export declare function fileLedger(file: string): VeoLedger;
|
|
5
20
|
export interface VeoStudioOptions {
|
|
6
21
|
key: string;
|
|
7
|
-
|
|
22
|
+
ledger: VeoLedger;
|
|
8
23
|
monthlyUsd?: number;
|
|
9
24
|
usdPerSecond?: number;
|
|
10
25
|
model?: string;
|
|
@@ -14,21 +29,19 @@ export interface VeoStudioOptions {
|
|
|
14
29
|
}
|
|
15
30
|
export declare class VeoStudio {
|
|
16
31
|
private readonly options;
|
|
17
|
-
private spent;
|
|
18
32
|
constructor(options: VeoStudioOptions);
|
|
19
33
|
static fileFor(stateDir: string, port: number): string;
|
|
20
34
|
private month;
|
|
21
35
|
private get cap();
|
|
22
36
|
private get rate();
|
|
23
|
-
|
|
24
|
-
status(): {
|
|
37
|
+
status(): Promise<{
|
|
25
38
|
month: string;
|
|
26
39
|
spentUsd: number;
|
|
27
40
|
capUsd: number;
|
|
28
41
|
leftUsd: number;
|
|
29
42
|
usdPerSecond: number;
|
|
30
43
|
secondsLeft: number;
|
|
31
|
-
}
|
|
44
|
+
}>;
|
|
32
45
|
/** One shot: 4, 6 or 8 seconds, 16:9, with up to three reference images (JPEG). */
|
|
33
46
|
clip(input: {
|
|
34
47
|
prompt: string;
|
package/dist/veo-studio.js
CHANGED
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
*
|
|
12
12
|
* The key is NIXAMP_VEO_API_KEY, its own name, so nothing else on nixamp.com
|
|
13
13
|
* starts spending Gemini credit because a Gemini key appeared.
|
|
14
|
+
*
|
|
15
|
+
* The month's spend is kept in Postgres on nixamp.com (a deploy recreates its
|
|
16
|
+
* container and would lose a file, and with it the cap), reserved in one
|
|
17
|
+
* statement that refuses to go over; a file only where there is no database.
|
|
14
18
|
*/
|
|
15
19
|
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
16
20
|
import { dirname, join } from "node:path";
|
|
@@ -18,18 +22,62 @@ export class VeoBudgetError extends Error {
|
|
|
18
22
|
}
|
|
19
23
|
export class VeoRefused extends Error {
|
|
20
24
|
}
|
|
25
|
+
/** The spend in Postgres, one row a month, reserved atomically. */
|
|
26
|
+
export function pgLedger(db) {
|
|
27
|
+
let ready = null;
|
|
28
|
+
const ensure = () => (ready ??= db.query("CREATE TABLE IF NOT EXISTS veo_spend (month TEXT PRIMARY KEY, usd NUMERIC NOT NULL DEFAULT 0)").catch((error) => { ready = null; throw error; }));
|
|
29
|
+
return {
|
|
30
|
+
async spent(month) {
|
|
31
|
+
await ensure();
|
|
32
|
+
return Number((await db.query("SELECT usd FROM veo_spend WHERE month = $1", [month])).rows[0]?.["usd"] ?? 0);
|
|
33
|
+
},
|
|
34
|
+
async reserve(month, usd, cap) {
|
|
35
|
+
await ensure();
|
|
36
|
+
await db.query("INSERT INTO veo_spend (month, usd) VALUES ($1, 0) ON CONFLICT (month) DO NOTHING", [month]);
|
|
37
|
+
const done = await db.query("UPDATE veo_spend SET usd = usd + $2 WHERE month = $1 AND usd + $2 <= $3 RETURNING usd", [month, usd, cap + 1e-9]);
|
|
38
|
+
return done.rows.length > 0;
|
|
39
|
+
},
|
|
40
|
+
async refund(month, usd) {
|
|
41
|
+
await ensure();
|
|
42
|
+
await db.query("UPDATE veo_spend SET usd = GREATEST(0, usd - $2) WHERE month = $1", [month, usd]);
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** The spend in a file, for a nixamp with no database (its own state directory must survive restarts). */
|
|
47
|
+
export function fileLedger(file) {
|
|
48
|
+
let spent = {};
|
|
49
|
+
try {
|
|
50
|
+
spent = JSON.parse(readFileSync(file, "utf8")).spent ?? {};
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
spent = {};
|
|
54
|
+
}
|
|
55
|
+
const save = () => {
|
|
56
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
57
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
58
|
+
writeFileSync(temporary, `${JSON.stringify({ version: 1, spent }, null, 2)}\n`, { mode: 0o600 });
|
|
59
|
+
renameSync(temporary, file);
|
|
60
|
+
};
|
|
61
|
+
return {
|
|
62
|
+
spent: async (month) => spent[month] ?? 0,
|
|
63
|
+
async reserve(month, usd, cap) {
|
|
64
|
+
if ((spent[month] ?? 0) + usd > cap + 1e-9)
|
|
65
|
+
return false;
|
|
66
|
+
spent[month] = (spent[month] ?? 0) + usd;
|
|
67
|
+
save();
|
|
68
|
+
return true;
|
|
69
|
+
},
|
|
70
|
+
async refund(month, usd) {
|
|
71
|
+
spent[month] = Math.max(0, (spent[month] ?? 0) - usd);
|
|
72
|
+
save();
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
21
76
|
const BASE = "https://generativelanguage.googleapis.com/v1beta";
|
|
22
77
|
export class VeoStudio {
|
|
23
78
|
options;
|
|
24
|
-
spent = {};
|
|
25
79
|
constructor(options) {
|
|
26
80
|
this.options = options;
|
|
27
|
-
try {
|
|
28
|
-
this.spent = JSON.parse(readFileSync(options.file, "utf8")).spent ?? {};
|
|
29
|
-
}
|
|
30
|
-
catch {
|
|
31
|
-
this.spent = {};
|
|
32
|
-
}
|
|
33
81
|
}
|
|
34
82
|
static fileFor(stateDir, port) {
|
|
35
83
|
return join(stateDir, `veo-${port}.json`);
|
|
@@ -39,14 +87,8 @@ export class VeoStudio {
|
|
|
39
87
|
}
|
|
40
88
|
get cap() { return this.options.monthlyUsd ?? 50; }
|
|
41
89
|
get rate() { return this.options.usdPerSecond ?? 0.15; }
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const temporary = `${this.options.file}.${process.pid}.tmp`;
|
|
45
|
-
writeFileSync(temporary, `${JSON.stringify({ version: 1, spent: this.spent }, null, 2)}\n`, { mode: 0o600 });
|
|
46
|
-
renameSync(temporary, this.options.file);
|
|
47
|
-
}
|
|
48
|
-
status() {
|
|
49
|
-
const spent = this.spent[this.month()] ?? 0;
|
|
90
|
+
async status() {
|
|
91
|
+
const spent = await this.options.ledger.spent(this.month());
|
|
50
92
|
const left = Math.max(0, this.cap - spent);
|
|
51
93
|
return { month: this.month(), spentUsd: Math.round(spent * 100) / 100, capUsd: this.cap, leftUsd: Math.round(left * 100) / 100, usdPerSecond: this.rate, secondsLeft: Math.floor(left / this.rate) };
|
|
52
94
|
}
|
|
@@ -55,17 +97,14 @@ export class VeoStudio {
|
|
|
55
97
|
const seconds = [4, 6, 8].includes(input.seconds) ? input.seconds : 8;
|
|
56
98
|
const cost = seconds * this.rate;
|
|
57
99
|
const month = this.month();
|
|
58
|
-
|
|
100
|
+
// Reserved first, in one step that will not go over: two at once cannot both squeeze under the cap.
|
|
101
|
+
if (!(await this.options.ledger.reserve(month, cost, this.cap)))
|
|
59
102
|
throw new VeoBudgetError(`this month's $${this.cap} of Veo is spent`);
|
|
60
|
-
// Reserved first, so two at once cannot both squeeze under the cap.
|
|
61
|
-
this.spent[month] = (this.spent[month] ?? 0) + cost;
|
|
62
|
-
this.save();
|
|
63
103
|
try {
|
|
64
104
|
return await this.make(input.prompt, seconds, input.references.slice(0, 3));
|
|
65
105
|
}
|
|
66
106
|
catch (error) {
|
|
67
|
-
this.
|
|
68
|
-
this.save();
|
|
107
|
+
await this.options.ledger.refund(month, cost).catch(() => undefined);
|
|
69
108
|
throw error;
|
|
70
109
|
}
|
|
71
110
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The servers nixamp.com has seen listed lately, and who owns each, kept in
|
|
3
|
+
* Postgres.
|
|
4
|
+
*
|
|
5
|
+
* The directory lives in memory and fills again only as servers heartbeat, so
|
|
6
|
+
* for a minute after every deploy nixamp.com/music had no catalogue and a
|
|
7
|
+
* label server's Veo or release call was refused as "not listed". This keeps
|
|
8
|
+
* the last day's servers so both work straight away; a server still listed
|
|
9
|
+
* always wins over what is remembered.
|
|
10
|
+
*/
|
|
11
|
+
import type { Queryable } from "./veo-studio.ts";
|
|
12
|
+
|
|
13
|
+
export interface KnownServer { base: string; ownerId: string; seenAt: number }
|
|
14
|
+
|
|
15
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
16
|
+
|
|
17
|
+
export class KnownServers {
|
|
18
|
+
private known = new Map<string, KnownServer>();
|
|
19
|
+
private ready: Promise<unknown> | null = null;
|
|
20
|
+
|
|
21
|
+
constructor(private readonly db: Queryable, private readonly now: () => number = Date.now) {}
|
|
22
|
+
|
|
23
|
+
private ensure(): Promise<unknown> {
|
|
24
|
+
return (this.ready ??= this.db.query("CREATE TABLE IF NOT EXISTS music_servers (base TEXT PRIMARY KEY, owner_id TEXT NOT NULL, seen_at TIMESTAMPTZ NOT NULL DEFAULT now())").catch((error: unknown) => { this.ready = null; throw error; }));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** What the last process saw in the last day. */
|
|
28
|
+
async load(): Promise<void> {
|
|
29
|
+
await this.ensure();
|
|
30
|
+
const { rows } = await this.db.query("SELECT base, owner_id, seen_at FROM music_servers WHERE seen_at > $1", [new Date(this.now() - DAY_MS)]);
|
|
31
|
+
for (const row of rows) {
|
|
32
|
+
const base = String(row["base"]);
|
|
33
|
+
if (!this.known.has(base)) this.known.set(base, { base, ownerId: String(row["owner_id"]), seenAt: new Date(String(row["seen_at"])).getTime() });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The servers listed now: remembered here and in the database. */
|
|
38
|
+
async sync(listed: Array<{ base: string; ownerId: string }>): Promise<void> {
|
|
39
|
+
await this.ensure();
|
|
40
|
+
const at = this.now();
|
|
41
|
+
for (const one of listed) {
|
|
42
|
+
if (!one.base || !one.ownerId) continue;
|
|
43
|
+
this.known.set(one.base, { base: one.base, ownerId: one.ownerId, seenAt: at });
|
|
44
|
+
await this.db.query("INSERT INTO music_servers (base, owner_id, seen_at) VALUES ($1, $2, now()) ON CONFLICT (base) DO UPDATE SET owner_id = EXCLUDED.owner_id, seen_at = now()", [one.base, one.ownerId]);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Servers seen in the last day. */
|
|
49
|
+
list(): KnownServer[] {
|
|
50
|
+
const since = this.now() - DAY_MS;
|
|
51
|
+
return [...this.known.values()].filter((one) => one.seenAt > since);
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/radio-pages.ts
CHANGED
|
@@ -496,7 +496,10 @@ export function stationHead(station: StationFace, slug: string, base: string, pa
|
|
|
496
496
|
/** A station's picture for link previews: the cover of an album it plays, from the catalogue. */
|
|
497
497
|
export function stationImage(catalogue: RadioCatalogue, slug: string, base: string): string {
|
|
498
498
|
const album = [...catalogue.albums.values()].find((one) => one.stations.includes(slug) && (one.coverFile || one.label?.cover));
|
|
499
|
-
|
|
499
|
+
if (!album) return "";
|
|
500
|
+
// The label's albums keep their cover beside the songs, not in them: the label serves it.
|
|
501
|
+
if (album.label?.cover) return `${base}/api/v1/label/${album.label.artist}/${album.label.album}/cover.jpg`;
|
|
502
|
+
return `${base}/radio/album/${album.slug}/cover.jpg`;
|
|
500
503
|
}
|
|
501
504
|
|
|
502
505
|
/** The built station page (or the inline one) as /radio/<name> serves it: its head, the header, indexable. */
|
package/src/server.ts
CHANGED
|
@@ -84,7 +84,8 @@ import { AiLabel, VeoUnavailable } from "./ai-label.ts";
|
|
|
84
84
|
import { buyLink, handleLabelRoutes, isPublicLabelPath } from "./label-routes.ts";
|
|
85
85
|
import { GrantVerifier, MusicStore, PRICES } from "./music-store.ts";
|
|
86
86
|
import { handleMusicRoutes, isMusicPath, MusicHub, musicPageFor, serverId } from "./music-hub.ts";
|
|
87
|
-
import { VeoBudgetError, VeoStudio } from "./veo-studio.ts";
|
|
87
|
+
import { fileLedger, pgLedger, VeoBudgetError, VeoStudio } from "./veo-studio.ts";
|
|
88
|
+
import { KnownServers } from "./known-servers.ts";
|
|
88
89
|
import { handleStoreRoutes, isStorePath } from "./store-routes.ts";
|
|
89
90
|
import { AiRadio } from "./ai-radio.ts";
|
|
90
91
|
import { ListenerShares } from "./listener-shares.ts";
|
|
@@ -7964,9 +7965,17 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
7964
7965
|
// The AI label's songs for sale: purchases kept here, the music zipped by the server that holds it.
|
|
7965
7966
|
const jwtSecret = process.env["NIXAMP_JWT_SECRET"] ?? "";
|
|
7966
7967
|
// The servers whose music nixamp.com sells and plays: the directory and the guide's peers.
|
|
7968
|
+
// Remembered in Postgres too, so the catalogue and owners are there the moment a deploy restarts us.
|
|
7969
|
+
const knownServers = options.directory && pool ? new KnownServers(pool as unknown as import("./veo-studio.ts").Queryable) : undefined;
|
|
7970
|
+
const listedServers = (): Array<{ base: string; ownerId: string }> =>
|
|
7971
|
+
directory ? directory.list().map((one) => ({ base: peerBase(one.url), ownerId: one.ownerId ?? "" })).filter((one) => one.base) : [];
|
|
7972
|
+
if (knownServers) {
|
|
7973
|
+
void knownServers.load().catch((error: unknown) => console.log(` music servers not remembered: ${error instanceof Error ? error.message : String(error)}`));
|
|
7974
|
+
setInterval(() => void knownServers.sync(listedServers()).catch(() => undefined), 60_000).unref();
|
|
7975
|
+
}
|
|
7967
7976
|
const musicServers = (): string[] => {
|
|
7968
|
-
const listed =
|
|
7969
|
-
return [...new Set([...guidePeers(), ...listed])];
|
|
7977
|
+
const listed = listedServers().map((one) => one.base);
|
|
7978
|
+
return [...new Set([...guidePeers(), ...listed, ...(knownServers?.list().map((one) => one.base) ?? [])])];
|
|
7970
7979
|
};
|
|
7971
7980
|
// nixamp.com/music: every server's label in one catalogue (music-hub.ts).
|
|
7972
7981
|
const musicHub = options.directory ? new MusicHub({ peers: musicServers }) : undefined;
|
|
@@ -7988,20 +7997,25 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
7988
7997
|
// Veo shots for label music videos, under a monthly dollar cap (veo-studio.ts). nixamp.com only.
|
|
7989
7998
|
const veoStudio = options.directory && process.env["NIXAMP_VEO_API_KEY"] ? new VeoStudio({
|
|
7990
7999
|
key: process.env["NIXAMP_VEO_API_KEY"],
|
|
7991
|
-
|
|
8000
|
+
// The month's spend in Postgres: a deploy recreates the container, and a file there would forget it.
|
|
8001
|
+
ledger: pool ? pgLedger(pool as unknown as import("./veo-studio.ts").Queryable) : fileLedger(VeoStudio.fileFor(stateDir(), options.port)),
|
|
7992
8002
|
monthlyUsd: Number(process.env["NIXAMP_VEO_MONTHLY_USD"] ?? 50),
|
|
7993
8003
|
usdPerSecond: Number(process.env["NIXAMP_VEO_USD_PER_SECOND"] ?? 0.15),
|
|
7994
8004
|
}) : undefined;
|
|
7995
8005
|
/** The signed-in account owns a server listed in the directory at this base. */
|
|
7996
8006
|
const ownsListed = async (request: IncomingMessage, base: string): Promise<boolean> => {
|
|
7997
8007
|
const me = accounts ? await accounts.whoIs(tokenFrom(request.headers)).catch(() => null) : null;
|
|
7998
|
-
|
|
8008
|
+
if (!me || !base) return false;
|
|
8009
|
+
const listed = listedServers();
|
|
8010
|
+
// A server listed now answers for itself; one not (yet) back since a restart, by who last owned it.
|
|
8011
|
+
if (listed.some((one) => one.base === base)) return listed.some((one) => one.base === base && one.ownerId === me.id);
|
|
8012
|
+
return Boolean(knownServers?.list().some((one) => one.base === base && one.ownerId === me.id));
|
|
7999
8013
|
};
|
|
8000
8014
|
const musicVeo = async (request: IncomingMessage): Promise<{ status: number; body: unknown; video?: Buffer }> => {
|
|
8001
8015
|
if (!veoStudio) return { status: 404, body: { error: "Veo is not set up here" } };
|
|
8002
8016
|
const base = peerBase(String(request.headers["x-nixamp-server"] ?? ""));
|
|
8003
8017
|
if (!(await ownsListed(request, base))) return { status: 403, body: { error: "only the owner of a listed server" } };
|
|
8004
|
-
if (request.method === "GET") return { status: 200, body: veoStudio.status() };
|
|
8018
|
+
if (request.method === "GET") return { status: 200, body: await veoStudio.status() };
|
|
8005
8019
|
if (request.method !== "POST") return { status: 405, body: { error: "GET or POST" } };
|
|
8006
8020
|
let input: { prompt?: unknown; seconds?: unknown; references?: unknown };
|
|
8007
8021
|
try { input = JSON.parse(await readBody(request, 2 * 1024 * 1024)) as typeof input; } catch { return { status: 400, body: { error: "a JSON body under 2 MB" } }; }
|
|
@@ -8011,7 +8025,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
8011
8025
|
try {
|
|
8012
8026
|
return { status: 200, body: null, video: await veoStudio.clip({ prompt, seconds: Number(input.seconds ?? 8), references }) };
|
|
8013
8027
|
} catch (error) {
|
|
8014
|
-
if (error instanceof VeoBudgetError) return { status: 402, body: { error: error.message, code: "budget", ...veoStudio.status() } };
|
|
8028
|
+
if (error instanceof VeoBudgetError) return { status: 402, body: { error: error.message, code: "budget", ...(await veoStudio.status()) } };
|
|
8015
8029
|
return { status: 422, body: { error: (error as Error).message, code: "refused" } };
|
|
8016
8030
|
}
|
|
8017
8031
|
};
|
package/src/veo-studio.ts
CHANGED
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
*
|
|
12
12
|
* The key is NIXAMP_VEO_API_KEY, its own name, so nothing else on nixamp.com
|
|
13
13
|
* starts spending Gemini credit because a Gemini key appeared.
|
|
14
|
+
*
|
|
15
|
+
* The month's spend is kept in Postgres on nixamp.com (a deploy recreates its
|
|
16
|
+
* container and would lose a file, and with it the cap), reserved in one
|
|
17
|
+
* statement that refuses to go over; a file only where there is no database.
|
|
14
18
|
*/
|
|
15
19
|
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
16
20
|
import { dirname, join } from "node:path";
|
|
@@ -18,9 +22,65 @@ import { dirname, join } from "node:path";
|
|
|
18
22
|
export class VeoBudgetError extends Error {}
|
|
19
23
|
export class VeoRefused extends Error {}
|
|
20
24
|
|
|
25
|
+
/** Where the month's spend lives. `reserve` adds `usd` only if that keeps the month at or under `cap`. */
|
|
26
|
+
export interface VeoLedger {
|
|
27
|
+
spent(month: string): Promise<number>;
|
|
28
|
+
reserve(month: string, usd: number, cap: number): Promise<boolean>;
|
|
29
|
+
refund(month: string, usd: number): Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface Queryable { query: (text: string, params?: unknown[]) => Promise<{ rows: Array<Record<string, unknown>> }> }
|
|
33
|
+
|
|
34
|
+
/** The spend in Postgres, one row a month, reserved atomically. */
|
|
35
|
+
export function pgLedger(db: Queryable): VeoLedger {
|
|
36
|
+
let ready: Promise<unknown> | null = null;
|
|
37
|
+
const ensure = (): Promise<unknown> => (ready ??= db.query("CREATE TABLE IF NOT EXISTS veo_spend (month TEXT PRIMARY KEY, usd NUMERIC NOT NULL DEFAULT 0)").catch((error: unknown) => { ready = null; throw error; }));
|
|
38
|
+
return {
|
|
39
|
+
async spent(month) {
|
|
40
|
+
await ensure();
|
|
41
|
+
return Number((await db.query("SELECT usd FROM veo_spend WHERE month = $1", [month])).rows[0]?.["usd"] ?? 0);
|
|
42
|
+
},
|
|
43
|
+
async reserve(month, usd, cap) {
|
|
44
|
+
await ensure();
|
|
45
|
+
await db.query("INSERT INTO veo_spend (month, usd) VALUES ($1, 0) ON CONFLICT (month) DO NOTHING", [month]);
|
|
46
|
+
const done = await db.query("UPDATE veo_spend SET usd = usd + $2 WHERE month = $1 AND usd + $2 <= $3 RETURNING usd", [month, usd, cap + 1e-9]);
|
|
47
|
+
return done.rows.length > 0;
|
|
48
|
+
},
|
|
49
|
+
async refund(month, usd) {
|
|
50
|
+
await ensure();
|
|
51
|
+
await db.query("UPDATE veo_spend SET usd = GREATEST(0, usd - $2) WHERE month = $1", [month, usd]);
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The spend in a file, for a nixamp with no database (its own state directory must survive restarts). */
|
|
57
|
+
export function fileLedger(file: string): VeoLedger {
|
|
58
|
+
let spent: Record<string, number> = {};
|
|
59
|
+
try { spent = (JSON.parse(readFileSync(file, "utf8")) as { spent?: Record<string, number> }).spent ?? {}; } catch { spent = {}; }
|
|
60
|
+
const save = (): void => {
|
|
61
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
62
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
63
|
+
writeFileSync(temporary, `${JSON.stringify({ version: 1, spent }, null, 2)}\n`, { mode: 0o600 });
|
|
64
|
+
renameSync(temporary, file);
|
|
65
|
+
};
|
|
66
|
+
return {
|
|
67
|
+
spent: async (month) => spent[month] ?? 0,
|
|
68
|
+
async reserve(month, usd, cap) {
|
|
69
|
+
if ((spent[month] ?? 0) + usd > cap + 1e-9) return false;
|
|
70
|
+
spent[month] = (spent[month] ?? 0) + usd;
|
|
71
|
+
save();
|
|
72
|
+
return true;
|
|
73
|
+
},
|
|
74
|
+
async refund(month, usd) {
|
|
75
|
+
spent[month] = Math.max(0, (spent[month] ?? 0) - usd);
|
|
76
|
+
save();
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
21
81
|
export interface VeoStudioOptions {
|
|
22
82
|
key: string;
|
|
23
|
-
|
|
83
|
+
ledger: VeoLedger;
|
|
24
84
|
monthlyUsd?: number;
|
|
25
85
|
usdPerSecond?: number;
|
|
26
86
|
model?: string;
|
|
@@ -32,15 +92,7 @@ export interface VeoStudioOptions {
|
|
|
32
92
|
const BASE = "https://generativelanguage.googleapis.com/v1beta";
|
|
33
93
|
|
|
34
94
|
export class VeoStudio {
|
|
35
|
-
private
|
|
36
|
-
|
|
37
|
-
constructor(private readonly options: VeoStudioOptions) {
|
|
38
|
-
try {
|
|
39
|
-
this.spent = (JSON.parse(readFileSync(options.file, "utf8")) as { spent?: Record<string, number> }).spent ?? {};
|
|
40
|
-
} catch {
|
|
41
|
-
this.spent = {};
|
|
42
|
-
}
|
|
43
|
-
}
|
|
95
|
+
constructor(private readonly options: VeoStudioOptions) {}
|
|
44
96
|
|
|
45
97
|
static fileFor(stateDir: string, port: number): string {
|
|
46
98
|
return join(stateDir, `veo-${port}.json`);
|
|
@@ -53,15 +105,8 @@ export class VeoStudio {
|
|
|
53
105
|
private get cap(): number { return this.options.monthlyUsd ?? 50; }
|
|
54
106
|
private get rate(): number { return this.options.usdPerSecond ?? 0.15; }
|
|
55
107
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const temporary = `${this.options.file}.${process.pid}.tmp`;
|
|
59
|
-
writeFileSync(temporary, `${JSON.stringify({ version: 1, spent: this.spent }, null, 2)}\n`, { mode: 0o600 });
|
|
60
|
-
renameSync(temporary, this.options.file);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
status(): { month: string; spentUsd: number; capUsd: number; leftUsd: number; usdPerSecond: number; secondsLeft: number } {
|
|
64
|
-
const spent = this.spent[this.month()] ?? 0;
|
|
108
|
+
async status(): Promise<{ month: string; spentUsd: number; capUsd: number; leftUsd: number; usdPerSecond: number; secondsLeft: number }> {
|
|
109
|
+
const spent = await this.options.ledger.spent(this.month());
|
|
65
110
|
const left = Math.max(0, this.cap - spent);
|
|
66
111
|
return { month: this.month(), spentUsd: Math.round(spent * 100) / 100, capUsd: this.cap, leftUsd: Math.round(left * 100) / 100, usdPerSecond: this.rate, secondsLeft: Math.floor(left / this.rate) };
|
|
67
112
|
}
|
|
@@ -71,15 +116,12 @@ export class VeoStudio {
|
|
|
71
116
|
const seconds = [4, 6, 8].includes(input.seconds) ? input.seconds : 8;
|
|
72
117
|
const cost = seconds * this.rate;
|
|
73
118
|
const month = this.month();
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
this.spent[month] = (this.spent[month] ?? 0) + cost;
|
|
77
|
-
this.save();
|
|
119
|
+
// Reserved first, in one step that will not go over: two at once cannot both squeeze under the cap.
|
|
120
|
+
if (!(await this.options.ledger.reserve(month, cost, this.cap))) throw new VeoBudgetError(`this month's $${this.cap} of Veo is spent`);
|
|
78
121
|
try {
|
|
79
122
|
return await this.make(input.prompt, seconds, input.references.slice(0, 3));
|
|
80
123
|
} catch (error) {
|
|
81
|
-
this.
|
|
82
|
-
this.save();
|
|
124
|
+
await this.options.ledger.refund(month, cost).catch(() => undefined);
|
|
83
125
|
throw error;
|
|
84
126
|
}
|
|
85
127
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{t as e}from"./main-
|
|
1
|
+
import{t as e}from"./main-sbQMI_Ki.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};
|