nixamp 0.36.3 → 0.37.0
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-keys.d.ts +9 -0
- package/dist/ai-keys.js +46 -0
- package/dist/ai-radio.d.ts +148 -0
- package/dist/ai-radio.js +477 -0
- package/dist/ai-routes.d.ts +31 -0
- package/dist/ai-routes.js +191 -0
- package/dist/ai-text.d.ts +23 -0
- package/dist/ai-text.js +102 -0
- package/dist/library.d.ts +4 -0
- package/dist/library.js +2 -0
- package/dist/listener-shares.d.ts +136 -0
- package/dist/listener-shares.js +486 -0
- package/dist/server.d.ts +8 -0
- package/dist/server.js +63 -2
- package/dist/station-cli.js +166 -0
- package/dist/station-guide.d.ts +4 -1
- package/dist/station-guide.js +118 -21
- package/dist/station-mcp.d.ts +291 -20
- package/dist/station-mcp.js +66 -0
- package/dist/station-metering.d.ts +1 -1
- package/dist/station-metering.js +21 -9
- package/dist/station-routes.d.ts +4 -0
- package/dist/station-routes.js +8 -1
- package/dist/station-usage.d.ts +1 -1
- package/dist/station-usage.js +19 -0
- package/package.json +1 -1
- package/src/ai-keys.ts +49 -0
- package/src/ai-radio.ts +536 -0
- package/src/ai-routes.ts +220 -0
- package/src/ai-text.ts +117 -0
- package/src/library.ts +6 -0
- package/src/listener-shares.ts +528 -0
- package/src/server.ts +70 -2
- package/src/station-cli.ts +153 -0
- package/src/station-guide.ts +122 -21
- package/src/station-mcp.ts +62 -0
- package/src/station-metering.ts +19 -9
- package/src/station-routes.ts +11 -2
- package/src/station-usage.ts +19 -0
- package/web/dist/assets/{ar-BD2UUquk.js → ar-B2m5eyoV.js} +1 -1
- package/web/dist/assets/{cs-BAnlPO0G.js → cs-Cc5pOkWw.js} +1 -1
- package/web/dist/assets/{da-B3Tu9hTo.js → da-10XVgmQy.js} +1 -1
- package/web/dist/assets/{de-BnhC08J6.js → de-DIBX68fo.js} +1 -1
- package/web/dist/assets/{es-C6f8XeOz.js → es-B5wXCbD_.js} +1 -1
- package/web/dist/assets/{fi-c8nSqL6Y.js → fi-CfqtJU25.js} +1 -1
- package/web/dist/assets/{fr-CCQqbzd3.js → fr-DVpDbDWM.js} +1 -1
- package/web/dist/assets/{hi-oWq0nqXG.js → hi-iQgB7g2O.js} +1 -1
- package/web/dist/assets/{hls-3VKVEQE3-D4IS0V1s.js → hls-3VKVEQE3-Hp3aQLSm.js} +1 -1
- package/web/dist/assets/{hu-DXCXa0U6.js → hu-cL8uAJW0.js} +1 -1
- package/web/dist/assets/{id-i3qTkeDv.js → id-DAEQSrx8.js} +1 -1
- package/web/dist/assets/{it-CHb1I7qg.js → it-DARHJ2-F.js} +1 -1
- package/web/dist/assets/{main-B8hCuIBx.js → main-Bdbkg8vi.js} +2 -2
- package/web/dist/assets/{mpegts-LO6RVLD6-DKSrU2jg.js → mpegts-LO6RVLD6-Be6JzNGd.js} +1 -1
- package/web/dist/assets/{mpegts-DSR-qG9w.js → mpegts-vNdAhc1w.js} +1 -1
- package/web/dist/assets/{nl-iCv9pkTl.js → nl-BNskTvYD.js} +1 -1
- package/web/dist/assets/{ru-BW2-AWx1.js → ru-PA8keJv_.js} +1 -1
- package/web/dist/assets/{sv-DJV6w7__.js → sv-sZu9UVEg.js} +1 -1
- package/web/dist/assets/{uk-CCsBeFUE.js → uk-FmHT0lvK.js} +1 -1
- package/web/dist/assets/{vi-Cckuuziu.js → vi-BV81HbEM.js} +1 -1
- package/web/dist/assets/{zh-Df6AwSwJ.js → zh-CwzApCu7.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +22 -22
package/src/station-cli.ts
CHANGED
|
@@ -222,6 +222,10 @@ export async function station(argv: string[]): Promise<number> {
|
|
|
222
222
|
].join("\n");
|
|
223
223
|
});
|
|
224
224
|
}
|
|
225
|
+
case "ai":
|
|
226
|
+
return await aiCommand(argv.slice(1), call, show, json);
|
|
227
|
+
case "shares":
|
|
228
|
+
return await sharesCommand(argv.slice(1), call, show);
|
|
225
229
|
case "delete": {
|
|
226
230
|
const station = needId();
|
|
227
231
|
if (!station) return 2;
|
|
@@ -239,3 +243,152 @@ export async function station(argv: string[]): Promise<number> {
|
|
|
239
243
|
throw error;
|
|
240
244
|
}
|
|
241
245
|
}
|
|
246
|
+
|
|
247
|
+
type Call = (method: string, path: string, body?: unknown) => Promise<Answer>;
|
|
248
|
+
type Show = (answer: Answer, line: (body: Record<string, unknown>) => string) => number;
|
|
249
|
+
|
|
250
|
+
async function readStdin(): Promise<string> {
|
|
251
|
+
let text = "";
|
|
252
|
+
for await (const chunk of process.stdin) text += String(chunk);
|
|
253
|
+
return text.trim();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** `nixamp station ai ...` */
|
|
257
|
+
async function aiCommand(argv: string[], call: Call, show: Show, json: boolean): Promise<number> {
|
|
258
|
+
const [sub, key] = argv;
|
|
259
|
+
const describe = (one: Record<string, unknown>): string => [
|
|
260
|
+
`${String(one["name"])} [${String(one["key"])}]${one["enabled"] ? "" : " (off)"}`,
|
|
261
|
+
` style ${String(one["style"])}`,
|
|
262
|
+
` tracks ${String(one["tracks"] ?? (one["tracks"] as unknown[] | undefined)?.length ?? 0)} of ${String(one["trackSeconds"])} s, ${String(one["perDay"])} a day${one["vocals"] ? ", with vocals" : ""}`,
|
|
263
|
+
` station ${one["stationId"] ? String(one["stationId"]) : "made with its first track"}${one["publish"] ? ", public on the guide" : ", unlisted"}`,
|
|
264
|
+
...(one["lastError"] ? [` problem ${String(one["lastError"])}`] : []),
|
|
265
|
+
].join("\n");
|
|
266
|
+
switch (sub) {
|
|
267
|
+
case undefined:
|
|
268
|
+
case "status":
|
|
269
|
+
case "list":
|
|
270
|
+
return show(await call("GET", "/api/v1/stations/ai"), (body) => {
|
|
271
|
+
const keys = (body["keys"] ?? {}) as Record<string, boolean>;
|
|
272
|
+
const stations = (body["stations"] as Record<string, unknown>[] | undefined) ?? [];
|
|
273
|
+
return [
|
|
274
|
+
`composer ${String(body["composer"] ?? "none")}; briefs by ${String(body["writer"])}`,
|
|
275
|
+
`this month ${String(body["secondsThisMonth"])} of ${Number(body["monthlySeconds"]) === 0 ? "unlimited" : String(body["monthlySeconds"])} s${body["busy"] ? `; composing for ${String(body["busy"])}` : ""}`,
|
|
276
|
+
...(body["paused"] ? [`paused ${String(body["paused"])}`] : []),
|
|
277
|
+
`keys ${Object.entries(keys).map(([name, set]) => `${name} ${set ? "set" : "-"}`).join(", ")}`,
|
|
278
|
+
"",
|
|
279
|
+
...(stations.length ? stations.map(describe) : [`No AI stations yet. nixamp station ai create --preset ${((body["presets"] as string[] | undefined) ?? ["lofi"])[0]}`]),
|
|
280
|
+
].join("\n");
|
|
281
|
+
});
|
|
282
|
+
case "create": {
|
|
283
|
+
const seconds = flag(argv, "--seconds");
|
|
284
|
+
const perDay = flag(argv, "--per-day");
|
|
285
|
+
const body = {
|
|
286
|
+
...(flag(argv, "--preset") ? { preset: flag(argv, "--preset") } : {}),
|
|
287
|
+
...(flag(argv, "--name") ? { name: flag(argv, "--name") } : {}),
|
|
288
|
+
...(flag(argv, "--style") ? { style: flag(argv, "--style") } : {}),
|
|
289
|
+
...(flag(argv, "--description") ? { description: flag(argv, "--description") } : {}),
|
|
290
|
+
...(has(argv, "--vocals") ? { vocals: true } : has(argv, "--instrumental") ? { vocals: false } : {}),
|
|
291
|
+
...(seconds ? { trackSeconds: Number(seconds) } : {}),
|
|
292
|
+
...(perDay ? { perDay: Number(perDay) } : {}),
|
|
293
|
+
publish: !has(argv, "--unlisted"),
|
|
294
|
+
};
|
|
295
|
+
return show(await call("POST", "/api/v1/stations/ai/stations", body), (answer) =>
|
|
296
|
+
`Made. Its first tracks are being composed now; the station appears with the first.\n${describe(answer["station"] as Record<string, unknown>)}`);
|
|
297
|
+
}
|
|
298
|
+
case "generate": {
|
|
299
|
+
if (!key) { console.error("nixamp station ai generate needs the AI station's key (nixamp station ai)"); return 2; }
|
|
300
|
+
if (!json) console.log("Composing; this takes a minute or two…");
|
|
301
|
+
return show(await call("POST", `/api/v1/stations/ai/stations/${key}/generate`, {}), (answer) => {
|
|
302
|
+
const track = answer["track"] as Record<string, unknown>;
|
|
303
|
+
return `"${String(track["title"])}" (${String(track["seconds"])} s, ${String(track["made"])}) is on the station.`;
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
case "set": {
|
|
307
|
+
if (!key) { console.error("nixamp station ai set needs the AI station's key"); return 2; }
|
|
308
|
+
const body = {
|
|
309
|
+
...(flag(argv, "--style") ? { style: flag(argv, "--style") } : {}),
|
|
310
|
+
...(flag(argv, "--name") ? { name: flag(argv, "--name") } : {}),
|
|
311
|
+
...(flag(argv, "--per-day") ? { perDay: Number(flag(argv, "--per-day")) } : {}),
|
|
312
|
+
...(flag(argv, "--seconds") ? { trackSeconds: Number(flag(argv, "--seconds")) } : {}),
|
|
313
|
+
...(has(argv, "--off") ? { enabled: false } : has(argv, "--on") ? { enabled: true } : {}),
|
|
314
|
+
...(has(argv, "--vocals") ? { vocals: true } : has(argv, "--instrumental") ? { vocals: false } : {}),
|
|
315
|
+
};
|
|
316
|
+
return show(await call("PATCH", `/api/v1/stations/ai/stations/${key}`, body), (answer) => describe({ ...(answer["station"] as Record<string, unknown>), tracks: ((answer["station"] as Record<string, unknown>)["tracks"] as unknown[]).length }));
|
|
317
|
+
}
|
|
318
|
+
case "remove": {
|
|
319
|
+
if (!key) { console.error("nixamp station ai remove needs the AI station's key"); return 2; }
|
|
320
|
+
const files = has(argv, "--files");
|
|
321
|
+
return show(await call("DELETE", `/api/v1/stations/ai/stations/${key}${files ? "?files=1" : ""}`), () =>
|
|
322
|
+
files ? "Removed, with its station and its music." : "Removed. Its station and its music stay; nothing new will be composed.");
|
|
323
|
+
}
|
|
324
|
+
case "budget":
|
|
325
|
+
return show(await call("POST", "/api/v1/stations/ai", { monthlySeconds: Number(key) }), (answer) => {
|
|
326
|
+
const settings = answer["settings"] as Record<string, unknown>;
|
|
327
|
+
return `AI music: at most ${Number(settings["monthlySeconds"]) === 0 ? "unlimited" : `${String(settings["monthlySeconds"])} s`} a month.`;
|
|
328
|
+
});
|
|
329
|
+
case "resume":
|
|
330
|
+
return show(await call("POST", "/api/v1/stations/ai", { resume: true }), () => "Composing again.");
|
|
331
|
+
case "key": {
|
|
332
|
+
if (!key) { console.error("nixamp station ai key needs a name, e.g. ELEVENLABS_API_KEY, and the value on stdin"); return 2; }
|
|
333
|
+
const value = has(argv, "--clear") ? "" : await readStdin();
|
|
334
|
+
return show(await call("POST", "/api/v1/stations/ai/keys", { name: key, value }), (answer) =>
|
|
335
|
+
`Keys: ${Object.entries((answer["keys"] ?? {}) as Record<string, boolean>).map(([name, set]) => `${name} ${set ? "set" : "-"}`).join(", ")}`);
|
|
336
|
+
}
|
|
337
|
+
default:
|
|
338
|
+
console.error(`nixamp station ai: no command "${sub}".\n\n${USAGE}`);
|
|
339
|
+
return 2;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** `nixamp station shares ...` */
|
|
344
|
+
async function sharesCommand(argv: string[], call: Call, show: Show): Promise<number> {
|
|
345
|
+
const [sub, id] = argv;
|
|
346
|
+
const line = (one: Record<string, unknown>): string =>
|
|
347
|
+
` ${String(one["id"])} ${String(one["state"]).padEnd(9)} ${String(one["genre"] || "?").padEnd(14)} ${String(one["title"] || one["name"])}${one["artist"] ? ` — ${String(one["artist"])}` : ""} [${String((one["rights"] as Record<string, unknown> | undefined)?.["basis"] ?? "")}]${one["reason"] ? `\n ${String(one["reason"])}` : ""}`;
|
|
348
|
+
const summary = (body: Record<string, unknown>): string => {
|
|
349
|
+
const setting = (body["setting"] ?? {}) as Record<string, unknown>;
|
|
350
|
+
const shares = (body["shares"] as Record<string, unknown>[] | undefined) ?? [];
|
|
351
|
+
return [
|
|
352
|
+
`shares ${setting["open"] ? "open" : "closed"}; moderation ${String(setting["moderation"])}${setting["model"] ? "" : " (no AI key: everything waits for you)"}; ${setting["publish"] ? "public on the guide" : "unlisted"}; ${Math.round(Number(setting["maxBytes"]) / 1048576)} MB, ${String(setting["perHour"])} an hour each`,
|
|
353
|
+
...(shares.length ? ["", ...shares.map(line)] : []),
|
|
354
|
+
].join("\n");
|
|
355
|
+
};
|
|
356
|
+
switch (sub) {
|
|
357
|
+
case undefined:
|
|
358
|
+
case "list": {
|
|
359
|
+
const state = flag(argv, "--state");
|
|
360
|
+
return show(await call("GET", `/api/v1/stations/shares${state ? `?state=${encodeURIComponent(state)}` : sub === undefined ? "?state=held" : ""}`), summary);
|
|
361
|
+
}
|
|
362
|
+
case "on":
|
|
363
|
+
case "off": {
|
|
364
|
+
const body = sub === "off" ? { open: false } : {
|
|
365
|
+
open: true,
|
|
366
|
+
moderation: has(argv, "--owner-moderation") ? "owner" : "ai",
|
|
367
|
+
publish: !has(argv, "--unlisted"),
|
|
368
|
+
...(flag(argv, "--max-mb") ? { maxMb: Number(flag(argv, "--max-mb")) } : {}),
|
|
369
|
+
...(flag(argv, "--per-hour") ? { perHour: Number(flag(argv, "--per-hour")) } : {}),
|
|
370
|
+
};
|
|
371
|
+
return show(await call("POST", "/api/v1/stations/shares", body), (answer) => summary(answer));
|
|
372
|
+
}
|
|
373
|
+
case "approve":
|
|
374
|
+
case "reject": {
|
|
375
|
+
if (!id) { console.error(`nixamp station shares ${sub} needs a share id`); return 2; }
|
|
376
|
+
return show(await call("POST", `/api/v1/stations/shares/${id}/${sub}`, { reason: flag(argv, "--reason") ?? "" }), (answer) => line(answer["share"] as Record<string, unknown>));
|
|
377
|
+
}
|
|
378
|
+
case "delete": {
|
|
379
|
+
if (!id) { console.error("nixamp station shares delete needs a share id"); return 2; }
|
|
380
|
+
return show(await call("DELETE", `/api/v1/stations/shares/${id}`), () => `Deleted ${id} and its copy.`);
|
|
381
|
+
}
|
|
382
|
+
case "trust": {
|
|
383
|
+
const { readConfig, writeConfig } = await import("./library.ts");
|
|
384
|
+
const proxies = new Set(readConfig().trustedProxies ?? []);
|
|
385
|
+
if (argv.includes("--remove")) proxies.delete(id ?? ""); else if (id) proxies.add(id);
|
|
386
|
+
writeConfig({ trustedProxies: [...proxies] });
|
|
387
|
+
console.log(proxies.size ? `X-Forwarded-For believed from: ${[...proxies].join(", ")}` : "No proxy is trusted.");
|
|
388
|
+
return 0;
|
|
389
|
+
}
|
|
390
|
+
default:
|
|
391
|
+
console.error(`nixamp station shares: no command "${sub}".\n\n${USAGE}`);
|
|
392
|
+
return 2;
|
|
393
|
+
}
|
|
394
|
+
}
|
package/src/station-guide.ts
CHANGED
|
@@ -10,35 +10,63 @@ import { createHash } from "node:crypto";
|
|
|
10
10
|
import { readConfig } from "./library.ts";
|
|
11
11
|
|
|
12
12
|
const STYLE = `
|
|
13
|
-
:root{--bg:#f6f5f2;--fg:#17171a;--muted:#5d5d66;--card:#fff;--line:#dedcd6;--accent:#1f6feb;--live:#0b7a3b}
|
|
14
|
-
@media (prefers-color-scheme:dark){:root{--bg:#121214;--fg:#ececf1;--muted:#a4a4b0;--card:#1c1c20;--line:#2c2c33;--accent:#6aa7ff;--live:#3fbf7f}}
|
|
13
|
+
:root{--bg:#f6f5f2;--fg:#17171a;--muted:#5d5d66;--card:#fff;--line:#dedcd6;--accent:#1f6feb;--live:#0b7a3b;--warn:#a1460b}
|
|
14
|
+
@media (prefers-color-scheme:dark){:root{--bg:#121214;--fg:#ececf1;--muted:#a4a4b0;--card:#1c1c20;--line:#2c2c33;--accent:#6aa7ff;--live:#3fbf7f;--warn:#f0a35e}}
|
|
15
15
|
*{box-sizing:border-box}html,body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif}
|
|
16
16
|
main{max-width:880px;margin:0 auto;padding:40px 16px}
|
|
17
17
|
h1{margin:0 0 4px;font-size:1.8rem}p.lead{color:var(--muted);margin:0 0 24px}
|
|
18
18
|
h2{font-size:1rem;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);margin:28px 0 10px}
|
|
19
19
|
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:12px}
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px;position:relative}
|
|
21
|
+
.card:hover,.card:focus-within{border-color:var(--accent)}
|
|
22
|
+
a.station{color:inherit;text-decoration:none;display:block}a.station:focus-visible{outline:2px solid var(--accent);outline-offset:4px;border-radius:6px}
|
|
22
23
|
.name{font-weight:700;font-size:1.05rem}.now{color:var(--muted);font-size:.9rem;margin-top:6px;min-height:1.4em}
|
|
23
24
|
.live{color:var(--live);font-size:.8rem;font-weight:700}.empty{color:var(--muted)}
|
|
25
|
+
button.report{margin-top:10px;background:none;border:0;padding:0;color:var(--muted);font:inherit;font-size:.8rem;text-decoration:underline;cursor:pointer}
|
|
26
|
+
button.report:hover{color:var(--warn)}
|
|
27
|
+
#share{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px;margin-top:12px}
|
|
28
|
+
#share label{display:block;font-size:.9rem;margin:10px 0 4px;color:var(--muted)}
|
|
29
|
+
#share input[type=url],#share input[type=text],#share select{width:100%;padding:9px 10px;border:1px solid var(--line);border-radius:8px;background:var(--bg);color:var(--fg);font:inherit}
|
|
30
|
+
#share .agree{display:flex;gap:8px;align-items:flex-start;color:var(--fg);margin-top:12px}
|
|
31
|
+
#share button[type=submit]{margin-top:14px;background:var(--accent);color:#fff;border:0;border-radius:8px;padding:10px 16px;font:inherit;font-weight:600;cursor:pointer}
|
|
32
|
+
#share .or{color:var(--muted);font-size:.85rem;margin:8px 0 0}
|
|
33
|
+
#status{margin-top:10px;font-size:.9rem;min-height:1.4em}
|
|
24
34
|
footer{margin-top:40px;color:var(--muted);font-size:.85rem}footer a{color:inherit}
|
|
25
35
|
`;
|
|
26
36
|
|
|
27
37
|
const SCRIPT = `
|
|
28
38
|
(async () => {
|
|
29
39
|
const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text) n.textContent = text; return n; };
|
|
40
|
+
const say = (text) => { document.querySelector("#status").textContent = text; };
|
|
41
|
+
const report = async (id, button) => {
|
|
42
|
+
if (!confirm("Report the track playing now? It comes off the air until the owner looks at it.")) return;
|
|
43
|
+
button.disabled = true;
|
|
44
|
+
try {
|
|
45
|
+
const r = await fetch("/api/v1/share/report", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ station: id }) });
|
|
46
|
+
const body = await r.json();
|
|
47
|
+
button.textContent = body.reported ? "Reported: off the air" : "Nothing shared is playing";
|
|
48
|
+
} catch { button.textContent = "Could not report; try again"; button.disabled = false; }
|
|
49
|
+
};
|
|
30
50
|
const draw = (stations) => {
|
|
31
|
-
for (const
|
|
32
|
-
const grid = document.querySelector(
|
|
51
|
+
for (const group of ["ai", "shared", "radio", "tv"]) {
|
|
52
|
+
const grid = document.querySelector("#" + group);
|
|
33
53
|
grid.replaceChildren();
|
|
34
|
-
const list = stations.filter((s) => s.kind ===
|
|
35
|
-
document.querySelector(
|
|
54
|
+
const list = stations.filter((s) => (s.group || (s.kind === "video" ? "tv" : "radio")) === group);
|
|
55
|
+
document.querySelector("#" + group + "-title").hidden = list.length === 0;
|
|
36
56
|
for (const s of list) {
|
|
57
|
+
const card = el("div", "card");
|
|
37
58
|
const a = el("a", "station");
|
|
38
59
|
a.href = s.page;
|
|
39
60
|
a.append(el("div", "live", s.state === "live" ? "● LIVE" : "off the air"), el("div", "name", s.name),
|
|
40
61
|
el("div", "now", s.nowPlaying ? [s.nowPlaying.title, s.nowPlaying.artist].filter(Boolean).join(" — ") : ""));
|
|
41
|
-
|
|
62
|
+
card.append(a);
|
|
63
|
+
if (group === "shared") {
|
|
64
|
+
const b = el("button", "report", "Report this track");
|
|
65
|
+
b.type = "button";
|
|
66
|
+
b.addEventListener("click", () => report(s.id, b));
|
|
67
|
+
card.append(b);
|
|
68
|
+
}
|
|
69
|
+
grid.append(card);
|
|
42
70
|
}
|
|
43
71
|
}
|
|
44
72
|
document.querySelector("#empty").hidden = stations.length > 0;
|
|
@@ -49,6 +77,47 @@ const SCRIPT = `
|
|
|
49
77
|
if (r.ok && (r.headers.get("content-type") || "").includes("json")) draw((await r.json()).stations);
|
|
50
78
|
} catch {}
|
|
51
79
|
};
|
|
80
|
+
const watch = async (status) => {
|
|
81
|
+
for (let i = 0; i < 120; i++) {
|
|
82
|
+
await new Promise((done) => setTimeout(done, 3000));
|
|
83
|
+
try {
|
|
84
|
+
const share = (await (await fetch(status, { cache: "no-store" })).json()).share;
|
|
85
|
+
if (!share) return;
|
|
86
|
+
const words = { fetching: "Fetching it…", checking: "Listening to it and checking it…", held: "Waiting for the owner to look at it", approved: "On the air", rejected: "Not accepted", failed: "Could not use it", withdrawn: "Withdrawn", reported: "Reported" };
|
|
87
|
+
say((words[share.state] || share.state) + (share.genre ? " · " + share.genre : "") + (share.reason ? " — " + share.reason : ""));
|
|
88
|
+
if (!["fetching", "checking"].includes(share.state)) { load(); return; }
|
|
89
|
+
} catch {}
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
const form = document.querySelector("#share");
|
|
93
|
+
try {
|
|
94
|
+
const info = await (await fetch("/api/v1/share", { cache: "no-store" })).json();
|
|
95
|
+
if (info.open) {
|
|
96
|
+
document.querySelector("#share-title").hidden = false;
|
|
97
|
+
form.hidden = false;
|
|
98
|
+
document.querySelector("#max").textContent = info.maxMb + " MB";
|
|
99
|
+
}
|
|
100
|
+
} catch {}
|
|
101
|
+
form.addEventListener("submit", async (event) => {
|
|
102
|
+
event.preventDefault();
|
|
103
|
+
const data = new FormData(form);
|
|
104
|
+
const file = data.get("file");
|
|
105
|
+
const link = String(data.get("url") || "").trim();
|
|
106
|
+
const fields = { basis: String(data.get("basis") || ""), note: String(data.get("note") || ""), by: String(data.get("by") || ""), agree: data.get("agree") ? "1" : "" };
|
|
107
|
+
if (!fields.agree) { say("Tick the box to confirm you have the right to share it."); return; }
|
|
108
|
+
if (!link && !(file && file.size)) { say("Paste a link to an MP3, or choose a file."); return; }
|
|
109
|
+
say(file && file.size && !link ? "Uploading…" : "Sending…");
|
|
110
|
+
try {
|
|
111
|
+
const r = file && file.size && !link
|
|
112
|
+
? await fetch("/api/v1/share/upload?" + new URLSearchParams({ ...fields, name: file.name }), { method: "PUT", headers: { "content-type": file.type || "audio/mpeg" }, body: file })
|
|
113
|
+
: await fetch("/api/v1/share", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...fields, agree: true, url: link }) });
|
|
114
|
+
const body = await r.json();
|
|
115
|
+
if (!r.ok) { say(body.error || "That did not work."); return; }
|
|
116
|
+
form.reset();
|
|
117
|
+
say("Got it. Checking it now…");
|
|
118
|
+
watch(body.status);
|
|
119
|
+
} catch { say("Could not reach the station; try again."); }
|
|
120
|
+
});
|
|
52
121
|
await load();
|
|
53
122
|
setInterval(load, 15000);
|
|
54
123
|
})();
|
|
@@ -61,31 +130,37 @@ function escape(text: string): string {
|
|
|
61
130
|
}
|
|
62
131
|
|
|
63
132
|
export interface GuideEntry {
|
|
133
|
+
id?: string;
|
|
64
134
|
name: string;
|
|
65
135
|
kind: "audio" | "video";
|
|
136
|
+
/** ai, shared, radio or tv; from kind when absent. */
|
|
137
|
+
group?: "ai" | "shared" | "radio" | "tv";
|
|
66
138
|
state: string;
|
|
67
139
|
page: string;
|
|
68
140
|
nowPlaying: { title: string; artist: string } | null;
|
|
69
141
|
}
|
|
70
142
|
|
|
143
|
+
const GROUPS = ["ai", "shared", "radio", "tv"] as const;
|
|
144
|
+
|
|
71
145
|
/**
|
|
72
146
|
* The guide with its stations already in it, so it reads the same through a
|
|
73
147
|
* gateway that cannot run its script against this server (the Moshpit's
|
|
74
148
|
* /n/<name>), and for a crawler. Everything from a station is escaped.
|
|
75
149
|
*/
|
|
76
150
|
export function renderGuide(entries: GuideEntry[]): string {
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
.
|
|
151
|
+
const groupOf = (one: GuideEntry): string => one.group ?? (one.kind === "video" ? "tv" : "radio");
|
|
152
|
+
const cards = (group: string): string => entries
|
|
153
|
+
.filter((one) => groupOf(one) === group && /^https?:\/\//.test(one.page))
|
|
154
|
+
.map((one) => `<div class="card"><a class="station" href="${escape(one.page)}"><div class="live">${one.state === "live" ? "● LIVE" : "off the air"}</div><div class="name">${escape(one.name)}</div><div class="now">${one.nowPlaying ? escape([one.nowPlaying.title, one.nowPlaying.artist].filter(Boolean).join(" — ")) : ""}</div></a></div>`)
|
|
80
155
|
.join("");
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
156
|
+
let page = GUIDE_HTML;
|
|
157
|
+
for (const group of GROUPS) {
|
|
158
|
+
const made = cards(group);
|
|
159
|
+
page = page
|
|
160
|
+
.replace(`<h2 id="${group}-title" hidden>`, made ? `<h2 id="${group}-title">` : `<h2 id="${group}-title" hidden>`)
|
|
161
|
+
.replace(`<div id="${group}" class="grid" aria-live="polite"></div>`, `<div id="${group}" class="grid" aria-live="polite">${made}</div>`);
|
|
162
|
+
}
|
|
163
|
+
return page.replace('<p id="empty" class="empty">', entries.length ? '<p id="empty" class="empty" hidden>' : '<p id="empty" class="empty">');
|
|
89
164
|
}
|
|
90
165
|
|
|
91
166
|
export const GUIDE_HTML = `<!doctype html>
|
|
@@ -94,7 +169,7 @@ export const GUIDE_HTML = `<!doctype html>
|
|
|
94
169
|
<meta charset="utf-8">
|
|
95
170
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
96
171
|
<title>Live · stations</title>
|
|
97
|
-
<meta name="description" content="Radio and TV stations, live now.">
|
|
172
|
+
<meta name="description" content="Radio and TV stations, live now: AI-composed music, tracks shared by listeners, and more.">
|
|
98
173
|
<link rel="icon" href="data:,">
|
|
99
174
|
<style>${STYLE}</style>
|
|
100
175
|
</head>
|
|
@@ -102,11 +177,37 @@ export const GUIDE_HTML = `<!doctype html>
|
|
|
102
177
|
<main>
|
|
103
178
|
<h1>Live</h1>
|
|
104
179
|
<p class="lead">Radio and TV, on the air now. Pick one and press play.</p>
|
|
180
|
+
<h2 id="ai-title" hidden>AI Radio</h2>
|
|
181
|
+
<div id="ai" class="grid" aria-live="polite"></div>
|
|
182
|
+
<h2 id="shared-title" hidden>Shared by listeners</h2>
|
|
183
|
+
<div id="shared" class="grid" aria-live="polite"></div>
|
|
105
184
|
<h2 id="radio-title" hidden>Radio</h2>
|
|
106
185
|
<div id="radio" class="grid" aria-live="polite"></div>
|
|
107
186
|
<h2 id="tv-title" hidden>TV</h2>
|
|
108
187
|
<div id="tv" class="grid" aria-live="polite"></div>
|
|
109
188
|
<p id="empty" class="empty">Nothing is on the air here yet.</p>
|
|
189
|
+
<h2 id="share-title" hidden>Share a track</h2>
|
|
190
|
+
<form id="share" hidden>
|
|
191
|
+
<label for="url">A link to an MP3</label>
|
|
192
|
+
<input id="url" name="url" type="url" placeholder="https://example.com/my-song.mp3" inputmode="url">
|
|
193
|
+
<p class="or">or upload one (up to <span id="max">60 MB</span>)</p>
|
|
194
|
+
<input id="file" name="file" type="file" accept="audio/*,.mp3,.ogg,.flac,.m4a,.wav">
|
|
195
|
+
<label for="basis">Why it may be broadcast</label>
|
|
196
|
+
<select id="basis" name="basis" required>
|
|
197
|
+
<option value="owned">I made it</option>
|
|
198
|
+
<option value="permission">I have the artist's permission</option>
|
|
199
|
+
<option value="licensed">It is licensed for broadcast</option>
|
|
200
|
+
<option value="creative-commons">It is Creative Commons (allows sharing)</option>
|
|
201
|
+
<option value="public-domain">It is in the public domain</option>
|
|
202
|
+
</select>
|
|
203
|
+
<label for="note">Anything we should know (licence, link to the artist)</label>
|
|
204
|
+
<input id="note" name="note" type="text" maxlength="300">
|
|
205
|
+
<label for="by">Credit me as (optional)</label>
|
|
206
|
+
<input id="by" name="by" type="text" maxlength="60">
|
|
207
|
+
<label class="agree"><input type="checkbox" name="agree" value="1"> I have the right to share this for broadcast here. AI files it under a genre and checks it before it airs; anything can be reported off the air.</label>
|
|
208
|
+
<button type="submit">Share it</button>
|
|
209
|
+
<div id="status" role="status" aria-live="polite"></div>
|
|
210
|
+
</form>
|
|
110
211
|
<footer>Played by <a href="https://nixamp.com">nixamp</a>, on the Moshpit.</footer>
|
|
111
212
|
</main>
|
|
112
213
|
<script>${SCRIPT}</script>
|
package/src/station-mcp.ts
CHANGED
|
@@ -92,6 +92,37 @@ export const STATION_TOOLS = [
|
|
|
92
92
|
},
|
|
93
93
|
},
|
|
94
94
|
},
|
|
95
|
+
{
|
|
96
|
+
name: "station_ai",
|
|
97
|
+
description: "AI radio: stations whose music is composed by AI (ElevenLabs Music or a local model) a few tracks a day, within a monthly budget of seconds. action status (default), create (preset lofi|synthwave|ambient|chiptune|punk|jazz, or name + style), generate (one track now), update, remove, budget (monthlySeconds), resume. A created station is public on the guide with basis owned unless publish is false.",
|
|
98
|
+
inputSchema: {
|
|
99
|
+
type: "object",
|
|
100
|
+
properties: {
|
|
101
|
+
server: SERVER,
|
|
102
|
+
action: { type: "string", enum: ["status", "create", "generate", "update", "remove", "budget", "resume"] },
|
|
103
|
+
key: { ...STRING, description: "The AI station's key (10 hex characters), from status." },
|
|
104
|
+
preset: STRING, name: STRING, style: { ...STRING, description: "The music in words: genre, instruments, mood, tempo. Name no artist." },
|
|
105
|
+
description: STRING, vocals: { type: "boolean" }, trackSeconds: { type: "integer" }, perDay: { type: "integer" },
|
|
106
|
+
enabled: { type: "boolean" }, publish: { type: "boolean" }, monthlySeconds: { type: "integer" },
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
name: "station_shares",
|
|
112
|
+
description: "Listener shares: anybody may share an MP3 link or file from the public guide; AI files it under a genre and holds anything that looks like a commercial release. action status (default; waiting shares), list (state), configure (open, moderation ai|owner, publish, maxMb, perHour), approve, reject, delete.",
|
|
113
|
+
inputSchema: {
|
|
114
|
+
type: "object",
|
|
115
|
+
properties: {
|
|
116
|
+
server: SERVER,
|
|
117
|
+
action: { type: "string", enum: ["status", "list", "configure", "approve", "reject", "delete"] },
|
|
118
|
+
id: { ...STRING, description: "A share's id (12 hex characters)." },
|
|
119
|
+
state: { type: "string", enum: ["held", "approved", "reported", "rejected", "failed", "withdrawn"] },
|
|
120
|
+
reason: STRING,
|
|
121
|
+
open: { type: "boolean" }, moderation: { type: "string", enum: ["ai", "owner"] }, publish: { type: "boolean" },
|
|
122
|
+
maxMb: { type: "integer" }, perHour: { type: "integer" },
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
},
|
|
95
126
|
{
|
|
96
127
|
name: "station_unpublish",
|
|
97
128
|
description: "Take a station out of the directory, or make it private: its links stop working and listeners are disconnected.",
|
|
@@ -165,6 +196,37 @@ export async function callStationTool(name: string, args: Record<string, unknown
|
|
|
165
196
|
const answer = await call("POST", "/api/v1/stations/auto", pick(["video", "minItems", "minFilms", "maxVideoRunning", "breaksEvery", "start", "off"]));
|
|
166
197
|
return answer.ok ? say(JSON.stringify(answer.body["report"], null, 2)) : refused(answer.body);
|
|
167
198
|
}
|
|
199
|
+
if (name === "station_ai") {
|
|
200
|
+
const action = String(args["action"] ?? "status");
|
|
201
|
+
const key = typeof args["key"] === "string" && /^[a-f0-9]{10}$/.test(args["key"]) ? args["key"] : "";
|
|
202
|
+
const fields = ["preset", "name", "style", "description", "vocals", "trackSeconds", "perDay", "enabled", "publish"];
|
|
203
|
+
const answer = action === "status" ? await call("GET", "/api/v1/stations/ai")
|
|
204
|
+
: action === "create" ? await call("POST", "/api/v1/stations/ai/stations", pick(fields))
|
|
205
|
+
: action === "budget" ? await call("POST", "/api/v1/stations/ai", pick(["monthlySeconds"]))
|
|
206
|
+
: action === "resume" ? await call("POST", "/api/v1/stations/ai", { resume: true })
|
|
207
|
+
: !key ? null
|
|
208
|
+
: action === "generate" ? await call("POST", `/api/v1/stations/ai/stations/${key}/generate`, {})
|
|
209
|
+
: action === "update" ? await call("PATCH", `/api/v1/stations/ai/stations/${key}`, pick(fields))
|
|
210
|
+
: action === "remove" ? await call("DELETE", `/api/v1/stations/ai/stations/${key}`)
|
|
211
|
+
: undefined;
|
|
212
|
+
if (answer === null) return say("Which AI station? Pass its key from action status.", true);
|
|
213
|
+
if (answer === undefined) return say("action is status, create, generate, update, remove, budget or resume", true);
|
|
214
|
+
return answer.ok ? say(JSON.stringify(answer.body, null, 2)) : refused(answer.body);
|
|
215
|
+
}
|
|
216
|
+
if (name === "station_shares") {
|
|
217
|
+
const action = String(args["action"] ?? "status");
|
|
218
|
+
const share = typeof args["id"] === "string" && /^[a-f0-9]{12}$/.test(args["id"]) ? args["id"] : "";
|
|
219
|
+
const answer = action === "status" ? await call("GET", "/api/v1/stations/shares?state=held")
|
|
220
|
+
: action === "list" ? await call("GET", `/api/v1/stations/shares${typeof args["state"] === "string" ? `?state=${encodeURIComponent(args["state"])}` : ""}`)
|
|
221
|
+
: action === "configure" ? await call("POST", "/api/v1/stations/shares", pick(["open", "moderation", "publish", "maxMb", "perHour"]))
|
|
222
|
+
: !share ? null
|
|
223
|
+
: action === "approve" || action === "reject" ? await call("POST", `/api/v1/stations/shares/${share}/${action}`, pick(["reason"]))
|
|
224
|
+
: action === "delete" ? await call("DELETE", `/api/v1/stations/shares/${share}`)
|
|
225
|
+
: undefined;
|
|
226
|
+
if (answer === null) return say("Which share? Pass its id from action status or list.", true);
|
|
227
|
+
if (answer === undefined) return say("action is status, list, configure, approve, reject or delete", true);
|
|
228
|
+
return answer.ok ? say(JSON.stringify(answer.body, null, 2)) : refused(answer.body);
|
|
229
|
+
}
|
|
168
230
|
if (!/^[a-z0-9]{10}$/.test(id) && name !== "station_create") return say("Which station? Pass its id from station_list.", true);
|
|
169
231
|
if (name === "station_get") {
|
|
170
232
|
const answer = await call("GET", `/api/v1/stations/${id}`);
|
package/src/station-metering.ts
CHANGED
|
@@ -12,7 +12,12 @@
|
|
|
12
12
|
*
|
|
13
13
|
* NIXAMP_STATION_MAX_LISTENERS per station (default 200)
|
|
14
14
|
* NIXAMP_MAX_LISTENERS across every station (default 500)
|
|
15
|
-
* NIXAMP_MAX_RUNNING_STATIONS stations on the air at once (default
|
|
15
|
+
* NIXAMP_MAX_RUNNING_STATIONS stations on the air at once (default 20)
|
|
16
|
+
*
|
|
17
|
+
* The same names, lower-cased without the prefix, can be kept in config.json
|
|
18
|
+
* under "limits" (e.g. {"limits": {"runningStations": 30}}); the environment
|
|
19
|
+
* wins when both are set. A daemon is restarted by every update, and an
|
|
20
|
+
* environment variable typed at a terminal does not survive that.
|
|
16
21
|
* NIXAMP_STATION_MONTHLY_GB delivered per station per month (default 0: no allowance)
|
|
17
22
|
*
|
|
18
23
|
* When an allowance runs out the policy is "pause": nobody new is admitted
|
|
@@ -32,16 +37,21 @@ export interface Limits {
|
|
|
32
37
|
monthlyBytes: number;
|
|
33
38
|
}
|
|
34
39
|
|
|
35
|
-
export function limitsFromEnv(env: Record<string, string | undefined> = process.env): Limits {
|
|
36
|
-
const whole = (name: string, fallback: number): number => {
|
|
37
|
-
const
|
|
38
|
-
|
|
40
|
+
export function limitsFromEnv(env: Record<string, string | undefined> = process.env, saved: Partial<Record<keyof Limits | "monthlyGb", unknown>> = {}): Limits {
|
|
41
|
+
const whole = (name: string, key: keyof typeof saved, fallback: number): number => {
|
|
42
|
+
for (const raw of [env[name], saved[key]]) {
|
|
43
|
+
if (raw === undefined || raw === "") continue;
|
|
44
|
+
const value = Number(raw);
|
|
45
|
+
if (Number.isFinite(value) && value >= 0) return Math.floor(value);
|
|
46
|
+
}
|
|
47
|
+
return fallback;
|
|
39
48
|
};
|
|
49
|
+
const gb = env["NIXAMP_STATION_MONTHLY_GB"] ?? saved.monthlyGb;
|
|
40
50
|
return {
|
|
41
|
-
stationListeners: whole("NIXAMP_STATION_MAX_LISTENERS", 200),
|
|
42
|
-
totalListeners: whole("NIXAMP_MAX_LISTENERS", 500),
|
|
43
|
-
runningStations: whole("NIXAMP_MAX_RUNNING_STATIONS",
|
|
44
|
-
monthlyBytes: Math.round(Number(
|
|
51
|
+
stationListeners: whole("NIXAMP_STATION_MAX_LISTENERS", "stationListeners", 200),
|
|
52
|
+
totalListeners: whole("NIXAMP_MAX_LISTENERS", "totalListeners", 500),
|
|
53
|
+
runningStations: whole("NIXAMP_MAX_RUNNING_STATIONS", "runningStations", 20),
|
|
54
|
+
monthlyBytes: Math.round(Number(gb ?? 0) * 1024 ** 3) || 0,
|
|
45
55
|
};
|
|
46
56
|
}
|
|
47
57
|
|
package/src/station-routes.ts
CHANGED
|
@@ -108,12 +108,21 @@ export function publicView(ctx: StationContext, station: Station): PublicStation
|
|
|
108
108
|
};
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
/** Where the guide puts a station: composed by AI, shared by listeners, or the owner's radio or TV. */
|
|
112
|
+
export type GuideGroup = "ai" | "shared" | "radio" | "tv";
|
|
113
|
+
|
|
114
|
+
export function guideGroup(station: Station): GuideGroup {
|
|
115
|
+
if (station.auto.startsWith("ai:")) return "ai";
|
|
116
|
+
if (station.auto.startsWith("share:")) return "shared";
|
|
117
|
+
return station.video ? "tv" : "radio";
|
|
118
|
+
}
|
|
119
|
+
|
|
111
120
|
/** What the public guide shows: public, listed stations, linked at this server's public address. */
|
|
112
|
-
export function guideEntries(ctx: StationContext): Array<PublicStation & { page: string; m3u: string }> {
|
|
121
|
+
export function guideEntries(ctx: StationContext): Array<PublicStation & { page: string; m3u: string; group: GuideGroup }> {
|
|
113
122
|
const base = ctx.publicBase || ctx.base;
|
|
114
123
|
return ctx.store.list()
|
|
115
124
|
.filter((one) => one.visibility === "public" && one.listed)
|
|
116
|
-
.map((one) => ({ ...publicView(ctx, one), page: links(ctx, one, base).page, m3u: links(ctx, one, base).m3u }));
|
|
125
|
+
.map((one) => ({ ...publicView(ctx, one), page: links(ctx, one, base).page, m3u: links(ctx, one, base).m3u, group: guideGroup(one) }));
|
|
117
126
|
}
|
|
118
127
|
|
|
119
128
|
export function ownerView(ctx: StationContext, station: Station): OwnerStation & { links: ReturnType<typeof links>; nowPlaying: string } {
|
package/src/station-usage.ts
CHANGED
|
@@ -18,6 +18,25 @@ export const USAGE = `nixamp station — make a collection into a station
|
|
|
18
18
|
nixamp station auto [--no-video] [--min 10] [--films 3] [--tv 2] [--breaks 600] [--no-start]
|
|
19
19
|
a station per genre, kept in step with the library (--off stops it)
|
|
20
20
|
|
|
21
|
+
AI radio (music composed for a station, a few tracks a day):
|
|
22
|
+
nixamp station ai what is composing, this month's seconds, the stations
|
|
23
|
+
nixamp station ai create (--preset lofi|synthwave|ambient|chiptune|punk|jazz | --name NAME --style TEXT)
|
|
24
|
+
[--vocals] [--seconds 120] [--per-day 2] [--unlisted] public on the guide unless --unlisted
|
|
25
|
+
nixamp station ai generate KEY compose one now
|
|
26
|
+
nixamp station ai set KEY [--style TEXT] [--per-day N] [--seconds N] [--off|--on]
|
|
27
|
+
nixamp station ai remove KEY [--files]
|
|
28
|
+
nixamp station ai budget SECONDS seconds of music a month across AI stations (0: no cap)
|
|
29
|
+
nixamp station ai resume after the provider ran out of credit
|
|
30
|
+
nixamp station ai key NAME ELEVENLABS_API_KEY | GEMINI_API_KEY | OPENAI_API_KEY | ANTHROPIC_API_KEY, from stdin
|
|
31
|
+
|
|
32
|
+
Listener shares (anybody shares an MP3 link or file from the guide; AI files and checks it):
|
|
33
|
+
nixamp station shares the setting and what is waiting
|
|
34
|
+
nixamp station shares on [--owner-moderation] [--unlisted] [--max-mb 60] [--per-hour 6]
|
|
35
|
+
nixamp station shares off
|
|
36
|
+
nixamp station shares list [--state held|approved|reported|rejected]
|
|
37
|
+
nixamp station shares approve|reject|delete SHARE
|
|
38
|
+
nixamp station shares trust ADDRESS [--remove] believe this proxy's X-Forwarded-For
|
|
39
|
+
|
|
21
40
|
--json machine-readable output
|
|
22
41
|
--url, --key another server, as with \`nixamp admin\`
|
|
23
42
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{n as e}from"./main-
|
|
1
|
+
import{n as e}from"./main-Bdbkg8vi.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};
|