nixamp 0.70.2 → 0.72.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/guide-vods.d.ts +14 -0
- package/dist/guide-vods.js +52 -1
- package/dist/label-routes.js +2 -2
- package/dist/library-cli.d.ts +12 -0
- package/dist/library-cli.js +104 -7
- package/dist/library-routes.d.ts +3 -1
- package/dist/library-routes.js +8 -2
- package/dist/library-usage.d.ts +1 -1
- package/dist/library-usage.js +5 -2
- package/dist/music-client.d.ts +1 -1
- package/dist/music-store.d.ts +8 -4
- package/dist/music-store.js +47 -5
- package/dist/server.js +16 -2
- package/dist/store-routes.js +22 -2
- package/package.json +1 -1
- package/src/guide-vods.ts +57 -2
- package/src/label-routes.ts +2 -2
- package/src/library-cli.ts +87 -7
- package/src/library-routes.ts +11 -3
- package/src/library-usage.ts +5 -2
- package/src/music-client.ts +1 -1
- package/src/music-store.ts +46 -8
- package/src/server.ts +14 -1
- package/src/store-routes.ts +21 -1
- package/web/dist/assets/{ar-jDXDCVNY.js → ar-BAY790r_.js} +1 -1
- package/web/dist/assets/{cs-BJnZqm-c.js → cs-Dnd0wU5z.js} +1 -1
- package/web/dist/assets/{da-zJqSSmTU.js → da-BTG_DLNv.js} +1 -1
- package/web/dist/assets/{de-DadRfrUy.js → de-BB9QycNI.js} +1 -1
- package/web/dist/assets/{es-BIQJFRvu.js → es-Cs6keMnw.js} +1 -1
- package/web/dist/assets/{fi-1XwHBLdG.js → fi-bxOXvDHn.js} +1 -1
- package/web/dist/assets/{fr-DTC4ZuwO.js → fr-B-UE3TVX.js} +1 -1
- package/web/dist/assets/{hi-C-xpQYod.js → hi-D4AX0nAY.js} +1 -1
- package/web/dist/assets/{hu-fohpBFEb.js → hu-CrTnC9NF.js} +1 -1
- package/web/dist/assets/{id-CtburpoZ.js → id-BslqEFzn.js} +1 -1
- package/web/dist/assets/{it-D3PL9u_h.js → it-HdYbT4LS.js} +1 -1
- package/web/dist/assets/{main-cn_dq77R.js → main-C4mFMUwz.js} +2 -2
- package/web/dist/assets/{nl-BXp7vaVJ.js → nl-DAYlarHJ.js} +1 -1
- package/web/dist/assets/{radio-DmVc3wpH.js → radio-dUHZb5wV.js} +1 -1
- package/web/dist/assets/{ru-r9WLZlGW.js → ru-CkbRKXgo.js} +1 -1
- package/web/dist/assets/{station-6b_smJHe.css → station-CxJvFwzV.css} +1 -1
- package/web/dist/assets/{sv-QU3rIDyx.js → sv-8VcXZ-cb.js} +1 -1
- package/web/dist/assets/{uk-QYNAUmbC.js → uk-BfAPe5_Y.js} +1 -1
- package/web/dist/assets/{vi-D42PafzI.js → vi-DvdY8XXY.js} +1 -1
- package/web/dist/assets/vod-CdMhdfvg.js +1 -0
- package/web/dist/assets/{zh-D4yW0UaE.js → zh-ChBhO3vy.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/radio.html +1 -1
- package/web/dist/station.html +2 -2
- package/web/dist/sw.js +25 -25
- package/web/dist/vod.html +11 -2
- package/web/dist/watch.html +2 -2
- package/web/dist/assets/vod-BzQvQVLd.js +0 -1
- /package/web/dist/assets/{station-tWQPqySd.js → station-KW1WLKF4.js} +0 -0
- /package/web/dist/assets/{watch-B9LW8D1C.js → watch-fq5gXk7x.js} +0 -0
package/src/guide-vods.ts
CHANGED
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Nothing here plays anything: the file's own server does, and this only asks
|
|
13
13
|
* it, now and then, whether the file is still there and what it is.
|
|
14
|
+
*
|
|
15
|
+
* The guide's owner may put a price on one of its own (owned or public
|
|
16
|
+
* domain): watching stays free, and the page offers the file as a download,
|
|
17
|
+
* bought on nixamp.com (music-store.ts, kind "vod"). The grant nixamp.com
|
|
18
|
+
* signs opens /api/v1/guide/vods/<id>/download, which sends the buyer on to
|
|
19
|
+
* the file's server with `download=1`.
|
|
14
20
|
*/
|
|
15
21
|
import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
16
22
|
import { join } from "node:path";
|
|
@@ -32,8 +38,15 @@ export interface GuideVod {
|
|
|
32
38
|
/** The nixamp.com account that published it ("" for this server's owner). */
|
|
33
39
|
by: string;
|
|
34
40
|
publishedAt: number;
|
|
41
|
+
/** What its download costs on nixamp.com, in cents; absent or 0 when it is not sold. */
|
|
42
|
+
priceCents?: number;
|
|
35
43
|
}
|
|
36
44
|
|
|
45
|
+
/** The bases a price may be put on: what the owner may sell, not just share. */
|
|
46
|
+
export const SELLABLE_BASES: readonly RoomBasis[] = ["owned", "public-domain"];
|
|
47
|
+
/** $100: a price above that is a typo. */
|
|
48
|
+
export const VOD_PRICE_MAX_CENTS = 10_000;
|
|
49
|
+
|
|
37
50
|
/** Files one member may have on the guide at once, and files on it in all. */
|
|
38
51
|
export const VODS_PER_MEMBER = 25;
|
|
39
52
|
export const VODS_MAX = 1000;
|
|
@@ -191,6 +204,7 @@ export function vodView(vod: GuideVod, base: string, state: VodState | null): Re
|
|
|
191
204
|
note: vod.note,
|
|
192
205
|
basis: vod.basis,
|
|
193
206
|
publishedAt: vod.publishedAt,
|
|
207
|
+
...(vod.priceCents ? { priceCents: vod.priceCents } : {}),
|
|
194
208
|
available: state !== null,
|
|
195
209
|
play: vod.play,
|
|
196
210
|
page: vodPage(base, vod.id),
|
|
@@ -215,6 +229,8 @@ export interface VodContext {
|
|
|
215
229
|
readBody: (request: IncomingMessage) => Promise<unknown>;
|
|
216
230
|
/** Refused by the caller's rate limit, or null. */
|
|
217
231
|
throttle?: () => { retryAfter: number } | null;
|
|
232
|
+
/** nixamp.com's store grants (music-store.ts GrantVerifier), for a bought download. */
|
|
233
|
+
grants?: { check: (grant: string) => Promise<{ kind: string; artist: string; server: string } | null> };
|
|
218
234
|
}
|
|
219
235
|
|
|
220
236
|
/** Answers /api/v1/guide/vods; false when the path is not one. */
|
|
@@ -222,6 +238,24 @@ export async function handleVods(request: IncomingMessage, response: ServerRespo
|
|
|
222
238
|
if (path !== "/api/v1/guide/vods" && !path.startsWith("/api/v1/guide/vods/")) return false;
|
|
223
239
|
const method = request.method ?? "GET";
|
|
224
240
|
const one = /^\/api\/v1\/guide\/vods\/([A-Za-z0-9_-]{1,32})$/.exec(path);
|
|
241
|
+
const download = /^\/api\/v1\/guide\/vods\/([A-Za-z0-9_-]{1,32})\/download$/.exec(path);
|
|
242
|
+
if (download && (method === "GET" || method === "HEAD")) {
|
|
243
|
+
const grant = new URL(request.url ?? "/", "http://x").searchParams.get("grant") ?? "";
|
|
244
|
+
const scope = ctx.grants ? await ctx.grants.check(grant) : null;
|
|
245
|
+
const vod = ctx.store.get(download[1]!);
|
|
246
|
+
if (!scope || scope.kind !== "vod" || scope.artist !== download[1] || scope.server !== new URL(ctx.base).origin) {
|
|
247
|
+
ctx.json(response, 403, { error: "that download link has expired or is not one of ours: open your purchase again for a fresh one", code: "bad_grant" });
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
if (!vod) {
|
|
251
|
+
ctx.json(response, 404, { error: "that video has been taken off this guide", code: "not_found" });
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
// The file's own server sends it, as an attachment.
|
|
255
|
+
response.writeHead(302, { location: `${vod.play}&download=1`, "cache-control": "private, no-store", "referrer-policy": "no-referrer" });
|
|
256
|
+
response.end();
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
225
259
|
if (method === "GET" || method === "HEAD") {
|
|
226
260
|
if (path === "/api/v1/guide/vods") {
|
|
227
261
|
ctx.json(response, 200, { vods: await vodEntries(ctx.store, ctx.probe, ctx.base) });
|
|
@@ -249,7 +283,7 @@ export async function handleVods(request: IncomingMessage, response: ServerRespo
|
|
|
249
283
|
if (path === "/api/v1/guide/vods" && method === "POST") {
|
|
250
284
|
const raw = await ctx.readBody(request).catch(() => null);
|
|
251
285
|
const body = (typeof raw === "string" ? (() => { try { return JSON.parse(raw) as unknown; } catch { return null; } })() : raw) as
|
|
252
|
-
{ play?: unknown; item?: unknown; basis?: unknown; note?: unknown; title?: unknown; agree?: unknown } | null;
|
|
286
|
+
{ play?: unknown; item?: unknown; basis?: unknown; note?: unknown; title?: unknown; agree?: unknown; price?: unknown } | null;
|
|
253
287
|
// The owner may name one of this server's own items; everybody else hands over the address.
|
|
254
288
|
const own = ctx.canControl && typeof body?.item === "string" && /^[0-9a-f]{16}$/.test(body.item) && ctx.ownPlay ? ctx.ownPlay(body.item) : "";
|
|
255
289
|
const address = vodAddress(own || (typeof body?.play === "string" ? body.play : ""));
|
|
@@ -283,7 +317,28 @@ export async function handleVods(request: IncomingMessage, response: ServerRespo
|
|
|
283
317
|
}
|
|
284
318
|
const title = typeof body?.title === "string" ? body.title.trim().slice(0, 160) : "";
|
|
285
319
|
const note = typeof body?.note === "string" ? body.note.trim().slice(0, 300) : "";
|
|
286
|
-
const
|
|
320
|
+
const owner = taken?.by ?? by;
|
|
321
|
+
// A price, in dollars: only the owner, only on its own, only what it may sell. Left out, the old one stays.
|
|
322
|
+
let priceCents = taken?.priceCents ?? 0;
|
|
323
|
+
if (body?.price !== undefined && body.price !== null) {
|
|
324
|
+
const cents = Math.round(Number(body.price) * 100);
|
|
325
|
+
if (!ctx.canControl || owner !== "") {
|
|
326
|
+
ctx.json(response, 403, { error: "only the guide's owner sells downloads, of what it published itself", code: "not_owner" });
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
if (!Number.isFinite(cents) || cents < 0 || cents > VOD_PRICE_MAX_CENTS) {
|
|
330
|
+
ctx.json(response, 400, { error: `price is in dollars, 0 (not sold) to ${VOD_PRICE_MAX_CENTS / 100}`, code: "bad_price" });
|
|
331
|
+
return true;
|
|
332
|
+
}
|
|
333
|
+
if (cents > 0 && !SELLABLE_BASES.includes(basis)) {
|
|
334
|
+
ctx.json(response, 400, { error: `a download may be sold only of what you own or what is in the public domain, not ${basis}`, code: "not_sellable" });
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
priceCents = cents;
|
|
338
|
+
}
|
|
339
|
+
if (priceCents > 0 && !SELLABLE_BASES.includes(basis)) priceCents = 0;
|
|
340
|
+
// Published again without a title or a note keeps the ones it had (a price added later changes nothing else).
|
|
341
|
+
const vod = ctx.store.put({ play: address.play, name: title || taken?.name || state.name, kind: state.kind, duration: state.duration, basis, note: note || taken?.note || "", by: owner, ...(priceCents > 0 ? { priceCents } : {}) });
|
|
287
342
|
ctx.json(response, 200, { vod: vodView(vod, ctx.base, state), page: vodPage(ctx.base, vod.id) });
|
|
288
343
|
return true;
|
|
289
344
|
}
|
package/src/label-routes.ts
CHANGED
|
@@ -128,11 +128,11 @@ export async function handleLabelRoutes(
|
|
|
128
128
|
const grant = url.searchParams.get("grant") ?? "";
|
|
129
129
|
if (path === "/api/v1/label/zip") {
|
|
130
130
|
const scope = ctx.grants ? await ctx.grants.check(grant) : null;
|
|
131
|
-
if (!scope) {
|
|
131
|
+
if (!scope || scope.kind === "vod") {
|
|
132
132
|
ctx.json(response, 403, { error: "that download link has expired or is not one of ours: open your purchase again for a fresh one", code: "bad_grant" });
|
|
133
133
|
return true;
|
|
134
134
|
}
|
|
135
|
-
const zip = label.zipFor(scope);
|
|
135
|
+
const zip = label.zipFor({ ...scope, kind: scope.kind as Exclude<typeof scope.kind, "vod"> });
|
|
136
136
|
if (!zip) {
|
|
137
137
|
ctx.json(response, 404, { error: "nothing to download yet", code: "not_found" });
|
|
138
138
|
return true;
|
package/src/library-cli.ts
CHANGED
|
@@ -10,9 +10,44 @@ import { resolveTarget } from "./admin.ts";
|
|
|
10
10
|
import { KEY_HEADER } from "./share.ts";
|
|
11
11
|
|
|
12
12
|
import { LIBRARY_COMMANDS, LIBRARY_USAGE } from "./library-usage.ts";
|
|
13
|
+
import { readSession } from "./session.ts";
|
|
13
14
|
|
|
14
15
|
export { LIBRARY_COMMANDS, LIBRARY_USAGE };
|
|
15
16
|
|
|
17
|
+
/** The public guide a published file goes on, unless --guide or --here says otherwise. */
|
|
18
|
+
export const STREAMING_GUIDE = "https://live.streaming.pit.world";
|
|
19
|
+
|
|
20
|
+
type Call = (method: string, path: string, body?: unknown) => Promise<{ status: number; body: Record<string, unknown> }>;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The ready files `publish` means: item ids as they are, a folder on this
|
|
24
|
+
* machine as everything under it, anything else as search words. A message
|
|
25
|
+
* when nothing matches.
|
|
26
|
+
*/
|
|
27
|
+
export async function publishPicks(targets: string[], call: Call): Promise<Record<string, unknown>[] | string> {
|
|
28
|
+
const ready = (items: Record<string, unknown>[]): Record<string, unknown>[] => items.filter((one) => one["state"] === "ready");
|
|
29
|
+
if (targets.every((one) => /^[0-9a-f]{16}$/.test(one))) {
|
|
30
|
+
const items = await Promise.all(targets.map(async (id) => (await call("GET", `/api/v1/library/items/${id}`)).body["item"] as Record<string, unknown> | undefined));
|
|
31
|
+
const found = ready(items.filter((one): one is Record<string, unknown> => Boolean(one)));
|
|
32
|
+
return found.length === targets.length ? found : "not every item is in the library and ready (nixamp library show ITEM)";
|
|
33
|
+
}
|
|
34
|
+
const { existsSync, statSync } = await import("node:fs");
|
|
35
|
+
const { resolve, relative, isAbsolute } = await import("node:path");
|
|
36
|
+
const { homedir } = await import("node:os");
|
|
37
|
+
const path = resolve(targets.join(" ").replace(/^~(?=$|\/)/, homedir()));
|
|
38
|
+
const query = new URLSearchParams({ limit: "500", sort: "path" });
|
|
39
|
+
if (existsSync(path) && statSync(path).isDirectory()) {
|
|
40
|
+
const sources = ((await call("GET", "/api/v1/library/sources")).body["sources"] as Record<string, unknown>[] | undefined) ?? [];
|
|
41
|
+
const source = sources.find((one) => { const rel = relative(String(one["root"] ?? ""), path); return !rel.startsWith("..") && !isAbsolute(rel); });
|
|
42
|
+
if (!source) return `${path} is not inside the library (nixamp library sources)`;
|
|
43
|
+
query.set("source", String(source["id"]));
|
|
44
|
+
const folder = relative(String(source["root"]), path);
|
|
45
|
+
if (folder) query.set("folder", folder);
|
|
46
|
+
} else query.set("q", targets.join(" "));
|
|
47
|
+
const found = ready(((await call("GET", `/api/v1/library/items?${query}`)).body["items"] as Record<string, unknown>[] | undefined) ?? []);
|
|
48
|
+
return found.length ? found : "nothing ready in the library matches that (nixamp library search WORDS)";
|
|
49
|
+
}
|
|
50
|
+
|
|
16
51
|
function flag(argv: string[], name: string): string | undefined {
|
|
17
52
|
const at = argv.indexOf(name);
|
|
18
53
|
return at === -1 ? undefined : argv[at + 1];
|
|
@@ -24,7 +59,7 @@ function words(argv: string[]): string[] {
|
|
|
24
59
|
for (let at = 0; at < argv.length; at++) {
|
|
25
60
|
const one = argv[at]!;
|
|
26
61
|
if (one.startsWith("--")) {
|
|
27
|
-
if (!["--json", "--force", "--wait"].includes(one)) at += 1;
|
|
62
|
+
if (!["--json", "--force", "--wait", "--dry-run", "--here"].includes(one)) at += 1;
|
|
28
63
|
continue;
|
|
29
64
|
}
|
|
30
65
|
out.push(one);
|
|
@@ -162,13 +197,58 @@ export async function libraryTool(argv: string[]): Promise<number> {
|
|
|
162
197
|
return 2;
|
|
163
198
|
}
|
|
164
199
|
case "publish": {
|
|
165
|
-
// On demand on
|
|
166
|
-
//
|
|
167
|
-
|
|
200
|
+
// On demand on live.streaming (guide-vods.ts). ITEM, a folder or search
|
|
201
|
+
// words pick the files; each goes to the guide as its public play
|
|
202
|
+
// address, under this machine's nixamp.com session, so a file on any
|
|
203
|
+
// server lands on the one guide. --here publishes on this server's own.
|
|
168
204
|
const basis = flag(argv, "--basis");
|
|
169
|
-
if (!
|
|
170
|
-
|
|
171
|
-
|
|
205
|
+
if (!rest.length || !basis) { console.error("nixamp library publish needs ITEM, a folder or search words, and --basis owned|permission|licensed|creative-commons|public-domain"); return 2; }
|
|
206
|
+
const items = await publishPicks(rest, call);
|
|
207
|
+
if (typeof items === "string") { console.error(`nixamp: ${items}`); return 1; }
|
|
208
|
+
const title = items.length === 1 ? flag(argv, "--title") ?? "" : "";
|
|
209
|
+
const note = flag(argv, "--note") ?? "";
|
|
210
|
+
// Sold as a download on nixamp.com (the guide's owner only, owned or public domain); 0 stops selling it.
|
|
211
|
+
const priceFlag = flag(argv, "--price");
|
|
212
|
+
const price = priceFlag === undefined ? {} : { price: Number(priceFlag.replace(/^\$/, "")) };
|
|
213
|
+
if ("price" in price && !Number.isFinite(price.price)) { console.error("nixamp: --price is in dollars, like 1 or 2.50"); return 2; }
|
|
214
|
+
if (argv.includes("--dry-run")) {
|
|
215
|
+
console.log(`${items.map(itemLine).join("\n")}\n${items.length} to publish (--dry-run: nothing sent)`);
|
|
216
|
+
return 0;
|
|
217
|
+
}
|
|
218
|
+
const here = argv.includes("--here");
|
|
219
|
+
const guide = (flag(argv, "--guide") ?? STREAMING_GUIDE).replace(/\/+$/, "");
|
|
220
|
+
const session = here ? null : readSession();
|
|
221
|
+
if (!here && !session?.token) { console.error("nixamp: publishing on live.streaming needs a nixamp.com account on this machine: run nixamp login (or pass --here)"); return 1; }
|
|
222
|
+
let failed = 0;
|
|
223
|
+
const done: Record<string, unknown>[] = [];
|
|
224
|
+
for (const item of items) {
|
|
225
|
+
const id = String(item["id"]);
|
|
226
|
+
let answer: { status: number; body: Record<string, unknown> };
|
|
227
|
+
if (here) answer = await call("POST", "/api/v1/guide/vods", { item: id, basis, agree: true, title, note, ...price });
|
|
228
|
+
else {
|
|
229
|
+
const play = String((((await call("GET", `/api/v1/library/items/${id}`)).body["item"] ?? {}) as Record<string, unknown>)["playAddress"] ?? "");
|
|
230
|
+
if (!play) answer = { status: 409, body: { error: "this server has no public address and listen key to play it from (set one with --public-url)", code: "no_address" } };
|
|
231
|
+
else {
|
|
232
|
+
try {
|
|
233
|
+
const response = await fetch(`${guide}/api/v1/guide/vods`, {
|
|
234
|
+
method: "POST",
|
|
235
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${session!.token}` },
|
|
236
|
+
body: JSON.stringify({ play, basis, agree: true, title, note, ...price }),
|
|
237
|
+
});
|
|
238
|
+
answer = { status: response.status, body: (await response.json().catch(() => ({}))) as Record<string, unknown> };
|
|
239
|
+
} catch (error) {
|
|
240
|
+
answer = { status: 503, body: { error: `could not reach ${guide}: ${(error as Error).message}`, code: "unreachable" } };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
done.push({ item: id, status: answer.status, ...answer.body });
|
|
245
|
+
if (answer.status >= 400) {
|
|
246
|
+
failed += 1;
|
|
247
|
+
if (!json) console.error(`nixamp: ${itemLine(item)}: ${String(answer.body["error"] ?? "refused")} (${String(answer.body["code"] ?? answer.status)})`);
|
|
248
|
+
} else if (!json) console.log(`on demand: ${String(answer.body["page"])} ${String(item["title"] || item["rel"] || id)}`);
|
|
249
|
+
}
|
|
250
|
+
if (json) console.log(JSON.stringify({ published: done }, null, 2));
|
|
251
|
+
return failed ? 1 : 0;
|
|
172
252
|
}
|
|
173
253
|
case "vods":
|
|
174
254
|
return out(await call("GET", "/api/v1/guide/vods"), (body) => {
|
package/src/library-routes.ts
CHANGED
|
@@ -31,7 +31,9 @@ export interface LibraryContext {
|
|
|
31
31
|
retag: (tracks: Track[]) => void;
|
|
32
32
|
sendFile: (request: IncomingMessage, response: ServerResponse, file: string, cache: string) => void;
|
|
33
33
|
/** The file as a browser plays it (server.ts playFile): the bytes when it can, MP4 or MP3 when not. */
|
|
34
|
-
play?: (request: IncomingMessage, response: ServerResponse, file: string) => Promise<void>;
|
|
34
|
+
play?: (request: IncomingMessage, response: ServerResponse, file: string, download?: string) => Promise<void>;
|
|
35
|
+
/** The item's public play address with the listen key, what a guide publishes on demand; "" when this server has none. */
|
|
36
|
+
playAddress?: (id: string) => string;
|
|
35
37
|
json: (response: ServerResponse, status: number, body: unknown) => void;
|
|
36
38
|
readBody: (request: IncomingMessage) => Promise<string>;
|
|
37
39
|
}
|
|
@@ -259,7 +261,11 @@ export async function handleLibrary(
|
|
|
259
261
|
if (!source?.enabled) return fail(409, "source_disabled", "that item's source is turned off");
|
|
260
262
|
// On demand: what a VOD link plays, from the start and seekable when the browser takes the file as it is.
|
|
261
263
|
if (itemMatch[2] === "play" && ctx.play) {
|
|
262
|
-
|
|
264
|
+
// `download=1`: the same file as an attachment, named for what it is (a bought VOD download).
|
|
265
|
+
const named = url.searchParams.get("download") === "1"
|
|
266
|
+
? (item.override.title || item.title || item.path.split("/").pop()!.replace(/\.[^.]+$/, "")).replace(/[\\/:*?"<>|\x00-\x1f]/g, "").slice(0, 160) || item.id
|
|
267
|
+
: "";
|
|
268
|
+
await ctx.play(request, response, item.path, named || undefined);
|
|
263
269
|
return true;
|
|
264
270
|
}
|
|
265
271
|
// Private: a browser may keep it, a shared cache may not.
|
|
@@ -267,7 +273,9 @@ export async function handleLibrary(
|
|
|
267
273
|
return true;
|
|
268
274
|
}
|
|
269
275
|
if (method === "GET") {
|
|
270
|
-
|
|
276
|
+
// The owner also gets where it plays publicly, for publishing it on another server's guide.
|
|
277
|
+
const play = ctx.canWrite && ctx.playAddress ? ctx.playAddress(item.id) : "";
|
|
278
|
+
ctx.json(response, 200, { item: { ...itemView(item, ctx.canWrite), ...(play ? { playAddress: play } : {}) } });
|
|
271
279
|
return true;
|
|
272
280
|
}
|
|
273
281
|
if (method === "PATCH") {
|
package/src/library-usage.ts
CHANGED
|
@@ -16,9 +16,12 @@ export const LIBRARY_USAGE = `nixamp library — what this server has, indexed
|
|
|
16
16
|
nixamp library playlist new NAME [ITEM...]
|
|
17
17
|
nixamp library playlist show|delete PLAYLIST
|
|
18
18
|
nixamp library playlist add PLAYLIST ITEM...
|
|
19
|
-
nixamp library publish ITEM --basis B [--title T] [--note N]
|
|
20
|
-
on demand on live.streaming: a link that plays
|
|
19
|
+
nixamp library publish ITEM... | FOLDER | WORDS --basis B [--title T] [--note N] [--dry-run]
|
|
20
|
+
on demand on live.streaming: a link that plays each from the start,
|
|
21
|
+
from any of your servers (needs nixamp login); --here for this server's own guide
|
|
21
22
|
(B: owned, permission, licensed, creative-commons, public-domain)
|
|
23
|
+
--price USD: sell it as a download on nixamp.com (guide owner, owned or
|
|
24
|
+
public-domain only; publish again with --price 0 to stop)
|
|
22
25
|
nixamp library vods what is published on demand, with each link
|
|
23
26
|
nixamp library unpublish VOD take one off
|
|
24
27
|
|
package/src/music-client.ts
CHANGED
|
@@ -19,7 +19,7 @@ export interface CatalogueArtist { key: string; name: string; genre: string; bio
|
|
|
19
19
|
export interface Catalogue { holder: string; server?: string; genres: Array<{ genre: string; station: string; artists: CatalogueArtist[] }> }
|
|
20
20
|
|
|
21
21
|
export interface Bought {
|
|
22
|
-
id: string; kind: "song" | "album" | "artist"; title: string; artistName: string; server: string;
|
|
22
|
+
id: string; kind: "song" | "album" | "artist" | "vod"; title: string; artistName: string; server: string;
|
|
23
23
|
status: string; paidAt: number; amountUsd: number; zip?: string; link?: string;
|
|
24
24
|
}
|
|
25
25
|
|
package/src/music-store.ts
CHANGED
|
@@ -14,15 +14,27 @@
|
|
|
14
14
|
* nixamp.com never holds the music. It signs a grant -- what was bought, from
|
|
15
15
|
* which server, until when -- and the server asks nixamp.com whether a grant
|
|
16
16
|
* is good before it plays or zips anything (/api/v1/store/grant).
|
|
17
|
+
*
|
|
18
|
+
* The same checkout sells a film or a recording on demand (kind "vod"): one
|
|
19
|
+
* published file of a station guide, at the price the guide's owner set on
|
|
20
|
+
* it. Its grant names the guide and the video's short id, and the guide's
|
|
21
|
+
* /api/v1/guide/vods/<id>/download hands over the file once it checks it.
|
|
17
22
|
*/
|
|
18
23
|
import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
|
|
19
24
|
import type { Db, Support } from "./support.ts";
|
|
20
25
|
import { SupportError, coinOf } from "./support.ts";
|
|
21
26
|
|
|
22
|
-
export type Kind = "song" | "album" | "artist";
|
|
27
|
+
export type Kind = "song" | "album" | "artist" | "vod";
|
|
28
|
+
|
|
29
|
+
/** In cents. A video on demand carries its own price, set where it is published. */
|
|
30
|
+
export const PRICES: Record<Exclude<Kind, "vod">, number> = { song: 10, album: 100, artist: 200 };
|
|
23
31
|
|
|
24
|
-
|
|
25
|
-
|
|
32
|
+
const VOD_ID = /^[A-Za-z0-9_-]{1,32}$/;
|
|
33
|
+
|
|
34
|
+
/** The guides whose videos may be sold here besides the known servers: live.streaming and NIXAMP_VOD_GUIDES. */
|
|
35
|
+
export function vodGuides(env: Record<string, string | undefined> = process.env): string[] {
|
|
36
|
+
return ["https://live.streaming.pit.world", ...(env["NIXAMP_VOD_GUIDES"] ?? "").split(",")].map(serverBase).filter(Boolean);
|
|
37
|
+
}
|
|
26
38
|
|
|
27
39
|
const EMAIL = /^[^\s@<>"]{1,64}@[^\s@<>"]{1,190}\.[a-z]{2,24}$/i;
|
|
28
40
|
const KEY = /^[a-f0-9]{10}$/;
|
|
@@ -90,13 +102,13 @@ export function readGrant(secret: string, grant: string, now = Date.now()): (Sco
|
|
|
90
102
|
const exp = Number(parts[1]);
|
|
91
103
|
if (!Number.isFinite(exp) || exp < now) return null;
|
|
92
104
|
const kind = parts[2] as Kind;
|
|
93
|
-
if (!(kind in PRICES)) return null;
|
|
105
|
+
if (!(kind in PRICES) && kind !== "vod") return null;
|
|
94
106
|
return { kind, artist: parts[3]!, album: parts[4] === "-" ? "" : parts[4]!, n: Number(parts[5]), server: Buffer.from(parts[6]!, "base64url").toString("utf8"), exp };
|
|
95
107
|
}
|
|
96
108
|
|
|
97
109
|
/** Whether a grant for `scope` covers this file of the catalogue. */
|
|
98
110
|
export function covers(scope: Pick<Scope, "kind" | "artist" | "album" | "n">, artist: string, album: string, n?: number): boolean {
|
|
99
|
-
if (scope.artist !== artist) return false;
|
|
111
|
+
if (scope.kind === "vod" || scope.artist !== artist) return false;
|
|
100
112
|
if (scope.kind === "artist") return true;
|
|
101
113
|
if (scope.album !== album) return false;
|
|
102
114
|
if (scope.kind === "album") return true;
|
|
@@ -190,8 +202,25 @@ export class MusicStore {
|
|
|
190
202
|
return await response.json() as LabelView;
|
|
191
203
|
}
|
|
192
204
|
|
|
205
|
+
/** A video on demand of a guide, at the price its owner set; refused when it is not for sale. */
|
|
206
|
+
private async describeVod(input: Record<string, unknown>): Promise<Scope & { title: string; artistName: string; amountCents: number; songs: number }> {
|
|
207
|
+
const server = serverBase(input["server"]);
|
|
208
|
+
const known = server !== "" && (new URL(server).hostname.endsWith(".nixamp.com") || vodGuides().includes(server) || this.options.servers().includes(server));
|
|
209
|
+
if (!known) throw new SupportError("That guide does not sell videos here.", 404, "not_found");
|
|
210
|
+
const id = String(input["vod"] ?? input["artist"] ?? "");
|
|
211
|
+
if (!VOD_ID.test(id)) throw new SupportError("No such video.", 404, "not_found");
|
|
212
|
+
const response = await (this.options.fetcher ?? fetch)(`${server}/api/v1/guide/vods/${id}`, { signal: AbortSignal.timeout(10_000), headers: { accept: "application/json" } });
|
|
213
|
+
if (response.status === 404) throw new SupportError("No such video.", 404, "not_found");
|
|
214
|
+
if (!response.ok) throw new SupportError("That guide could not be read. Try again.", 502, "catalogue");
|
|
215
|
+
const vod = ((await response.json()) as { vod?: { name?: string; artist?: string; priceCents?: number } }).vod;
|
|
216
|
+
const cents = Number(vod?.priceCents ?? 0);
|
|
217
|
+
if (!vod || !Number.isInteger(cents) || cents <= 0) throw new SupportError("That video is free to watch and not sold as a download.", 404, "not_for_sale");
|
|
218
|
+
return { server, kind: "vod", artist: id, album: "", n: 0, title: String(vod.name ?? id).slice(0, 160), artistName: String(vod.artist || "On demand").slice(0, 160), amountCents: cents, songs: 1 };
|
|
219
|
+
}
|
|
220
|
+
|
|
193
221
|
/** What is for sale at this address, with its name and price; refused when there is no such thing. */
|
|
194
222
|
async describe(input: Record<string, unknown>): Promise<Scope & { title: string; artistName: string; amountCents: number; songs: number }> {
|
|
223
|
+
if (input["kind"] === "vod") return this.describeVod(input);
|
|
195
224
|
// An opaque server id (music-hub.ts), or an https origin.
|
|
196
225
|
const asked = String(input["server"] ?? "");
|
|
197
226
|
const server = /^[a-f0-9]{10}$/.test(asked) ? (this.options.resolveServer?.(asked) ?? "") : serverBase(asked);
|
|
@@ -199,7 +228,7 @@ export class MusicStore {
|
|
|
199
228
|
// and certificates nixamp.com issues): the directory empties on every restart of nixamp.com.
|
|
200
229
|
const named = server ? new URL(server).hostname.endsWith(".nixamp.com") : false;
|
|
201
230
|
if (!server || !(named || this.options.servers().includes(server))) throw new SupportError("That server does not sell music here.", 404, "not_found");
|
|
202
|
-
const kind = String(input["kind"] ?? "") as Kind
|
|
231
|
+
const kind = String(input["kind"] ?? "") as Exclude<Kind, "vod">;
|
|
203
232
|
if (!(kind in PRICES)) throw new SupportError("Buy a song, an album or an artist.", 400, "invalid_kind");
|
|
204
233
|
const artistKey = String(input["artist"] ?? "");
|
|
205
234
|
const albumKey = kind === "artist" ? "" : String(input["album"] ?? "");
|
|
@@ -228,7 +257,7 @@ export class MusicStore {
|
|
|
228
257
|
email,
|
|
229
258
|
accountId: account?.id || email,
|
|
230
259
|
order: `music-${id}`,
|
|
231
|
-
description: `${item.title} by ${item.artistName} (${item.kind === "song" ? "song" : item.kind === "album" ? "album" : "every album"}, MP3)`,
|
|
260
|
+
description: item.kind === "vod" ? `${item.title} (download)` : `${item.title} by ${item.artistName} (${item.kind === "song" ? "song" : item.kind === "album" ? "album" : "every album"}, MP3)`,
|
|
232
261
|
metadata: { product: "music", purchase: id, kind: item.kind },
|
|
233
262
|
redirect: this.link(id, token),
|
|
234
263
|
});
|
|
@@ -278,6 +307,14 @@ export class MusicStore {
|
|
|
278
307
|
return again ? row(again) : null;
|
|
279
308
|
}
|
|
280
309
|
const purchase = row(updated);
|
|
310
|
+
if (purchase.kind === "vod") {
|
|
311
|
+
await this.mail(purchase.email, `Your download: ${purchase.title}`, [
|
|
312
|
+
`Thank you. "${purchase.title}" is yours to download and keep.`,
|
|
313
|
+
`Download it, now or any time: ${this.link(purchase.id, purchase.token)}`,
|
|
314
|
+
`Sign in to nixamp.com with ${purchase.email} and everything you have bought is under My music on ${this.options.site}/music/me.`,
|
|
315
|
+
]);
|
|
316
|
+
return purchase;
|
|
317
|
+
}
|
|
281
318
|
await this.mail(purchase.email, `Your music: ${purchase.title}`, [
|
|
282
319
|
`Thank you. "${purchase.title}" by ${purchase.artistName} is yours: MP3s with the cover art and the lyrics, in one zip.`,
|
|
283
320
|
`Download it, now or any time: ${this.link(purchase.id, purchase.token)}`,
|
|
@@ -304,8 +341,9 @@ export class MusicStore {
|
|
|
304
341
|
return readGrant(this.options.secret, grant, this.now());
|
|
305
342
|
}
|
|
306
343
|
|
|
307
|
-
/** Where the zip of a purchase is made: on the server that holds the music. */
|
|
344
|
+
/** Where the zip of a purchase is made: on the server that holds the music (a video: its guide hands over the file). */
|
|
308
345
|
zipUrl(purchase: Purchase): string {
|
|
346
|
+
if (purchase.kind === "vod") return `${purchase.server}/api/v1/guide/vods/${purchase.artist}/download?grant=${encodeURIComponent(this.grant(purchase))}`;
|
|
309
347
|
return `${purchase.server}/api/v1/label/zip?grant=${encodeURIComponent(this.grant(purchase))}`;
|
|
310
348
|
}
|
|
311
349
|
|
package/src/server.ts
CHANGED
|
@@ -3407,6 +3407,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
3407
3407
|
},
|
|
3408
3408
|
json,
|
|
3409
3409
|
readBody,
|
|
3410
|
+
grants: storeGrants,
|
|
3410
3411
|
throttle: () => {
|
|
3411
3412
|
const verdict = guard.check(`vods:${callerOf(request.headers, request.socket.remoteAddress, behindProxy)}`, STATION_CONTROL_LIMIT);
|
|
3412
3413
|
return verdict.ok ? null : { retryAfter: verdict.retryAfter };
|
|
@@ -3469,7 +3470,11 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
3469
3470
|
probe: options.library.probe,
|
|
3470
3471
|
retag: options.library.retag,
|
|
3471
3472
|
sendFile,
|
|
3472
|
-
play: (req, res, file) => playFile(req, res, file, url, options),
|
|
3473
|
+
play: (req, res, file, download) => playFile(req, res, file, url, options, undefined, download),
|
|
3474
|
+
playAddress: (id) => {
|
|
3475
|
+
const publicBase = (options.stations?.publicBase?.() ?? "").replace(/\/+$/, "");
|
|
3476
|
+
return publicBase && listenKey ? `${publicBase}/api/v1/library/items/${id}/play?k=${listenKey}` : "";
|
|
3477
|
+
},
|
|
3473
3478
|
json,
|
|
3474
3479
|
readBody,
|
|
3475
3480
|
});
|
|
@@ -7328,7 +7333,12 @@ async function liveAudio(
|
|
|
7328
7333
|
async function playFile(
|
|
7329
7334
|
request: IncomingMessage, response: ServerResponse, file: string, url: URL,
|
|
7330
7335
|
options: Pick<HandlerOptions, "ffmpeg" | "ffprobe">, onPicture: () => void = () => undefined,
|
|
7336
|
+
/** Sent as an attachment under this name (no extension: it depends on what is sent), for a bought download. */
|
|
7337
|
+
download = "",
|
|
7331
7338
|
): Promise<void> {
|
|
7339
|
+
const attach = (ext: string): void => {
|
|
7340
|
+
if (download) response.setHeader("content-disposition", `attachment; filename*=UTF-8''${encodeURIComponent(`${download}${ext}`)}`);
|
|
7341
|
+
};
|
|
7332
7342
|
// A browser asks for every track here, and a matroska or an avi handed
|
|
7333
7343
|
// to it raw is bytes it cannot play. Seeking is what this route is for
|
|
7334
7344
|
// and transcoding gives it up, but an unseekable film beats a silent
|
|
@@ -7345,6 +7355,7 @@ async function playFile(
|
|
|
7345
7355
|
const allowHevc = url.searchParams.get("hevc") === "1";
|
|
7346
7356
|
|
|
7347
7357
|
if (playsInBrowser(file) && capKbps === 0) {
|
|
7358
|
+
attach(extname(file));
|
|
7348
7359
|
sendFile(request, response, file);
|
|
7349
7360
|
return;
|
|
7350
7361
|
}
|
|
@@ -7361,6 +7372,7 @@ async function playFile(
|
|
|
7361
7372
|
// added by an older nixamp goes to an audio element for ever, and
|
|
7362
7373
|
// the only cure is noticing and adding it again.
|
|
7363
7374
|
onPicture();
|
|
7375
|
+
attach(".mp4");
|
|
7364
7376
|
pipeFfmpeg(
|
|
7365
7377
|
request, response, file, options.ffmpeg ?? ["ffmpeg"],
|
|
7366
7378
|
videoArgs(codecs, capKbps, { allowHevc }), "video/mp4",
|
|
@@ -7369,6 +7381,7 @@ async function playFile(
|
|
|
7369
7381
|
return;
|
|
7370
7382
|
}
|
|
7371
7383
|
}
|
|
7384
|
+
attach(".mp3");
|
|
7372
7385
|
transcode(request, response, file, options.ffmpeg ?? ["ffmpeg"]);
|
|
7373
7386
|
}
|
|
7374
7387
|
|
package/src/store-routes.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* The music store's pages and API on nixamp.com (music-store.ts).
|
|
3
3
|
*
|
|
4
4
|
* GET /buy?server=&kind=&artist=&album=&n= what it is, the price, email + coin, Pay
|
|
5
|
+
* GET /buy?server=<guide>&kind=vod&vod=<id> the same for a video on demand of a station guide
|
|
5
6
|
* GET /purchases/:id?t= the receipt: waiting, or Download the zip
|
|
6
7
|
* GET /api/v1/store/coins the coins nixamp's CoinPay business takes
|
|
7
8
|
* POST /api/v1/store/checkout {server, kind, artist, album?, n?, email, coin} -> {url}
|
|
@@ -113,6 +114,21 @@ export async function handleStoreRoutes(request: IncomingMessage, response: Serv
|
|
|
113
114
|
return true;
|
|
114
115
|
}
|
|
115
116
|
const me = await ctx.account().catch(() => null);
|
|
117
|
+
if (item.kind === "vod") {
|
|
118
|
+
html(response, 200, page(`Download ${item.title}`, `<div class="card">
|
|
119
|
+
<h1>${escape(item.title)}</h1><p class="by">${escape(item.artistName)}</p>
|
|
120
|
+
<div class="price">${price(item.amountCents)}</div>
|
|
121
|
+
<ul><li>The whole file, to keep and play offline</li><li>Download again any time from your receipt or My music</li></ul>
|
|
122
|
+
<form id="buy">
|
|
123
|
+
<label for="email">Email (your download link goes here)</label>
|
|
124
|
+
<input id="email" name="email" type="email" required autocomplete="email" value="${escape(me?.email ?? "")}">
|
|
125
|
+
<button id="pay" type="submit">Pay ${price(item.amountCents)}</button>
|
|
126
|
+
</form>
|
|
127
|
+
<p id="status" role="status" aria-live="polite"></p>
|
|
128
|
+
<small>Payments go through <a href="https://coinpayportal.com">CoinPay</a> to nixamp.com: pick any coin, or PayPal, on CoinPay's page. Watching it on demand stays free: <a href="${escape(`${item.server}/vod/${item.artist}`)}">back to the video</a>.${me ? "" : ` <a href="/?redirect=${encodeURIComponent("/music/me")}#account-panel">Sign in</a> with the same email to keep everything under My music.`}</small>
|
|
129
|
+
</div>`, JSON.stringify({ server: item.server, kind: "vod", vod: item.artist })), method);
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
116
132
|
const what = item.kind === "song" ? "One song" : item.kind === "album" ? `The album, ${item.songs} songs` : `Everything by ${escape(item.artistName)}, ${item.songs} songs so far`;
|
|
117
133
|
const cover = item.kind === "artist" ? "" : `<img src="${escape(`/api/v1/music/file/${serverId(item.server)}/${item.artist}/${item.album}/cover.jpg`)}" alt="" width="160" height="160" style="border-radius:10px;float:right;margin:0 0 8px 12px;max-width:40%;height:auto">`;
|
|
118
134
|
html(response, 200, page(`Download ${item.title}`, `<div class="card">${cover}
|
|
@@ -136,7 +152,11 @@ export async function handleStoreRoutes(request: IncomingMessage, response: Serv
|
|
|
136
152
|
html(response, 404, page("No such purchase", `<div class="card"><h1>No such purchase</h1><p class="by">That link is not one of ours.</p></div>`), method);
|
|
137
153
|
return true;
|
|
138
154
|
}
|
|
139
|
-
const body = purchase.status === "paid"
|
|
155
|
+
const body = purchase.status === "paid" && purchase.kind === "vod"
|
|
156
|
+
? `<div class="card"><h1>${escape(purchase.title)}</h1><p class="by">${escape(purchase.artistName)} · paid ${price(purchase.amountCents)}</p>
|
|
157
|
+
<a class="go" href="${escape(store.zipUrl(purchase))}" download>Download the video</a>
|
|
158
|
+
<small>Keep this page's address (it is in your email too) to download again. Signed in to nixamp.com as ${escape(purchase.email)}, everything you bought is under <a href="/music/me">My music</a>.</small></div>`
|
|
159
|
+
: purchase.status === "paid"
|
|
140
160
|
? `<div class="card"><h1>${escape(purchase.title)}</h1><p class="by">${escape(purchase.artistName)} · paid ${price(purchase.amountCents)}</p>
|
|
141
161
|
<a class="go" href="${escape(store.zipUrl(purchase))}" download>Download the zip</a>
|
|
142
162
|
<small>MP3s with the cover art and lyrics, plus each song's .lyrics.txt, cover.jpg and album.json. Keep this page's address (it is in your email too) to download again. Signed in to nixamp.com as ${escape(purchase.email)}, everything you bought is under <a href="/music/me">My music</a>, to play or download.</small></div>`
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{t as e}from"./main-
|
|
1
|
+
import{t as e}from"./main-C4mFMUwz.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};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{t as e}from"./main-
|
|
1
|
+
import{t as e}from"./main-C4mFMUwz.js";var t={...e,"No parties live right now.":`Právě neprobíhají žádné párty.`,"Could not refresh parties.":`Párty se nepodařilo aktualizovat.`,"Pick a server in the directory, or paste its link here.":`Vyberte server v seznamu nebo sem vložte jeho odkaz.`,"Interface language":`Jazyk rozhraní`,Language:`Jazyk`,"Translation language":`Jazyk překladu`,"Original (auto-detect)":`Původní (automatické rozpoznání)`,Play:`Přehrát`,Pause:`Pozastavit`,Stop:`Zastavit`,Next:`Další`,Previous:`Předchozí`,Select:`Vybrat`,"Next/Prev":`Další/Předchozí`,Quit:`Ukončit`,"Now Playing":`Právě hraje`,"Spectrum Analyser":`Spektrální analyzátor`,Playlist:`Seznam skladeb`,Files:`Soubory`,"Open files":`Otevřít soubory`,"Open folder":`Otevřít složku`,"Add to library":`Přidat do knihovny`,"Replace playlist":`Nahradit seznam`,Filter:`Filtrovat`,Search:`Hledat`,"Nothing loaded.":`Nic není načteno.`,"Empty.":`Prázdné.`,"Loading…":`Načítání…`,"Connecting…":`Připojování…`,Connected:`Připojeno`,"Not connected":`Nepřipojeno`,"No source":`Žádný zdroj`,Playing:`Přehrávání`,Stopped:`Zastaveno`,Volume:`Hlasitost`,Seek:`Pozice přehrávání`,"Full screen":`Celá obrazovka`,Download:`Stáhnout`,Share:`Sdílet`,Copy:`Kopírovat`,"Copy link":`Kopírovat odkaz`,Copied:`Zkopírováno`,Save:`Uložit`,Cancel:`Zrušit`,Close:`Zavřít`,Remove:`Odebrat`,Delete:`Smazat`,Edit:`Upravit`,Add:`Přidat`,Send:`Odeslat`,Retry:`Zkusit znovu`,Back:`Zpět`,"Next page":`Další stránka`,"Previous page":`Předchozí stránka`,"Show more":`Zobrazit více`,Panels:`Panely`,"Reset layout":`Obnovit rozložení`,Show:`Zobrazit`,Hide:`Skrýt`,Collapse:`Sbalit`,Expand:`Rozbalit`,"Move up":`Nahoru`,"Move down":`Dolů`,Install:`Nainstalovat`,"TV mode":`Režim TV`,On:`Zapnuto`,Off:`Vypnuto`,Account:`Účet`,"Sign in":`Přihlásit se`,"Sign out":`Odhlásit se`,"Create account":`Vytvořit účet`,Email:`E-mail`,Password:`Heslo`,"Forgot password?":`Zapomenuté heslo?`,"Welcome back":`Vítejte zpět`,Profile:`Profil`,Handle:`Uživatelské jméno`,"Your servers":`Vaše servery`,Server:`Server`,Connect:`Připojit`,Disconnect:`Odpojit`,Directory:`Adresář`,Favourites:`Oblíbené`,Follow:`Sledovat`,Unfollow:`Přestat sledovat`,Notifications:`Oznámení`,"Phone number":`Telefonní číslo`,"Save number":`Uložit číslo`,Transcript:`Přepis`,Captions:`Titulky`,"Translate audio":`Překládat zvuk`,"Audio language":`Jazyk zvuku`,"Audio options":`Možnosti zvuku`,"Keep background sound":`Zachovat zvuk na pozadí`,"Background level":`Hlasitost pozadí`,"Speaker voices":`Hlasy mluvčích`,"Any voice":`Libovolný hlas`,"Male voice":`Mužský hlas`,"Female voice":`Ženský hlas`,"Original audio.":`Původní zvuk.`,"Buy translated audio":`Koupit překlad zvuku`,"Audio credit":`Zvukový kredit`,"Buy pass":`Koupit přístup`,Pass:`Přístup`,"Pay with":`Zaplatit pomocí`,"Check payment":`Ověřit platbu`,"Open checkout":`Přejít k platbě`,"Free sessions":`Bezplatné relace`,"Usage rates and expiry":`Ceny a platnost`,Catalogs:`Katalogy`,"Add catalog":`Přidat katalog`,"Catalog name":`Název katalogu`,Link:`Odkaz`,"Play link":`Přehrát odkaz`,"Go live":`Vysílat živě`,Publish:`Zveřejnit`,Listen:`Poslouchat`,"Live now":`Právě živě`,Upcoming:`Připravované`,Parties:`Společná sledování`,"Join party":`Připojit se ke sledování`,"Invite code":`Kód pozvánky`,Invite:`Pozvat`,Chat:`Chat`,"Chat message":`Zpráva chatu`,People:`Lidé`,Speaker:`Mluvčí`,Speakers:`Mluvčí`,Listener:`Posluchač`,Moderator:`Moderátor`,"Raise hand":`Zvednout ruku`,"Hand raises":`Zvednuté ruce`,Questions:`Otázky`,Resources:`Materiály`,Replay:`Záznam`,Schedule:`Rozvrh`,Event:`Událost`,Host:`Pořadatel`,About:`O události`,Stage:`Pódium`,Title:`Název`,Description:`Popis`,Topic:`Téma`,Public:`Veřejné`,"Anyone with the link":`Kdokoli s odkazem`,"Invited people":`Pozvané osoby`,"Who can join":`Kdo se může připojit`,"Date and time":`Datum a čas`,"Expected length":`Očekávaná délka`,Repeat:`Opakovat`,"Does not repeat":`Neopakuje se`,"Every day":`Každý den`,"Every week":`Každý týden`,Homepage:`Webové stránky`,"Host name":`Jméno pořadatele`,"Profile photo URL":`URL profilové fotky`,"Broadcast link":`Odkaz na vysílání`,"Edit event":`Upravit událost`,"Save changes":`Uložit změny`,"Create live":`Vytvořit vysílání`,"Schedule class":`Naplánovat lekci`,"Start class":`Zahájit lekci`,"End event":`Ukončit událost`,"End session":`Ukončit relaci`,"Session ended":`Relace ukončena`,"Event ended":`Událost ukončena`,Cancelled:`Zrušeno`,"Replay available":`Záznam je k dispozici`,Join:`Připojit se`,"Create invite":`Vytvořit pozvánku`,"Invite as":`Pozvat jako`,"Watch now":`Sledovat nyní`,"Start a room":`Vytvořit místnost`,"Recent replays":`Nedávné záznamy`,"Your account":`Váš účet`,"Write with AI":`Psát pomocí AI`,"Suggested title":`Navrhovaný název`,"Suggested description":`Navrhovaný popis`,"Suggested topic":`Navrhované téma`,"Use these details":`Použít tyto údaje`,"Learn something live.":`Učte se naživo.`,"Screen share":`Sdílet obrazovku`,"Upload and broadcast":`Nahrát a vysílat`,Help:`Nápověda`,"Reload Player":`Znovu načíst přehrávač`,"Copy Bundled CLI Path":`Kopírovat cestu k přibalenému CLI`,"Developer tools":`Vývojářské nástroje`,Undo:`Zpět`,Redo:`Znovu`,Cut:`Vyjmout`,Paste:`Vložit`,"Select all":`Vybrat vše`,Status:`Stav`,"Activity log":`Protokol aktivit`,Admin:`Správa`,"Playback controls":`Ovládání přehrávání`,"Tracks and folders":`Skladby a složky`,"Original-language captions.":`Titulky v původním jazyce.`,"Skip to playback controls":`Přejít k ovládání přehrávání`,"Skip to playlist":`Přejít k seznamu skladeb`,"Skip to transcript and translated audio":`Přejít k přepisu a přeloženému zvuku`};export{t as default};
|