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.
Files changed (54) hide show
  1. package/dist/guide-vods.d.ts +14 -0
  2. package/dist/guide-vods.js +52 -1
  3. package/dist/label-routes.js +2 -2
  4. package/dist/library-cli.d.ts +12 -0
  5. package/dist/library-cli.js +104 -7
  6. package/dist/library-routes.d.ts +3 -1
  7. package/dist/library-routes.js +8 -2
  8. package/dist/library-usage.d.ts +1 -1
  9. package/dist/library-usage.js +5 -2
  10. package/dist/music-client.d.ts +1 -1
  11. package/dist/music-store.d.ts +8 -4
  12. package/dist/music-store.js +47 -5
  13. package/dist/server.js +16 -2
  14. package/dist/store-routes.js +22 -2
  15. package/package.json +1 -1
  16. package/src/guide-vods.ts +57 -2
  17. package/src/label-routes.ts +2 -2
  18. package/src/library-cli.ts +87 -7
  19. package/src/library-routes.ts +11 -3
  20. package/src/library-usage.ts +5 -2
  21. package/src/music-client.ts +1 -1
  22. package/src/music-store.ts +46 -8
  23. package/src/server.ts +14 -1
  24. package/src/store-routes.ts +21 -1
  25. package/web/dist/assets/{ar-jDXDCVNY.js → ar-BAY790r_.js} +1 -1
  26. package/web/dist/assets/{cs-BJnZqm-c.js → cs-Dnd0wU5z.js} +1 -1
  27. package/web/dist/assets/{da-zJqSSmTU.js → da-BTG_DLNv.js} +1 -1
  28. package/web/dist/assets/{de-DadRfrUy.js → de-BB9QycNI.js} +1 -1
  29. package/web/dist/assets/{es-BIQJFRvu.js → es-Cs6keMnw.js} +1 -1
  30. package/web/dist/assets/{fi-1XwHBLdG.js → fi-bxOXvDHn.js} +1 -1
  31. package/web/dist/assets/{fr-DTC4ZuwO.js → fr-B-UE3TVX.js} +1 -1
  32. package/web/dist/assets/{hi-C-xpQYod.js → hi-D4AX0nAY.js} +1 -1
  33. package/web/dist/assets/{hu-fohpBFEb.js → hu-CrTnC9NF.js} +1 -1
  34. package/web/dist/assets/{id-CtburpoZ.js → id-BslqEFzn.js} +1 -1
  35. package/web/dist/assets/{it-D3PL9u_h.js → it-HdYbT4LS.js} +1 -1
  36. package/web/dist/assets/{main-cn_dq77R.js → main-C4mFMUwz.js} +2 -2
  37. package/web/dist/assets/{nl-BXp7vaVJ.js → nl-DAYlarHJ.js} +1 -1
  38. package/web/dist/assets/{radio-DmVc3wpH.js → radio-dUHZb5wV.js} +1 -1
  39. package/web/dist/assets/{ru-r9WLZlGW.js → ru-CkbRKXgo.js} +1 -1
  40. package/web/dist/assets/{station-6b_smJHe.css → station-CxJvFwzV.css} +1 -1
  41. package/web/dist/assets/{sv-QU3rIDyx.js → sv-8VcXZ-cb.js} +1 -1
  42. package/web/dist/assets/{uk-QYNAUmbC.js → uk-BfAPe5_Y.js} +1 -1
  43. package/web/dist/assets/{vi-D42PafzI.js → vi-DvdY8XXY.js} +1 -1
  44. package/web/dist/assets/vod-CdMhdfvg.js +1 -0
  45. package/web/dist/assets/{zh-D4yW0UaE.js → zh-ChBhO3vy.js} +1 -1
  46. package/web/dist/index.html +1 -1
  47. package/web/dist/radio.html +1 -1
  48. package/web/dist/station.html +2 -2
  49. package/web/dist/sw.js +25 -25
  50. package/web/dist/vod.html +11 -2
  51. package/web/dist/watch.html +2 -2
  52. package/web/dist/assets/vod-BzQvQVLd.js +0 -1
  53. /package/web/dist/assets/{station-tWQPqySd.js → station-KW1WLKF4.js} +0 -0
  54. /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 vod = ctx.store.put({ play: address.play, name: title || state.name, kind: state.kind, duration: state.duration, basis, note, by: taken?.by ?? by });
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
  }
@@ -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;
@@ -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 this server's guide (guide-vods.ts): the server builds the
166
- // file's public address from its own public URL and listen key.
167
- const [item] = rest;
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 (!item || !basis) { console.error("nixamp library publish needs an item id and --basis owned|permission|licensed|creative-commons|public-domain"); return 2; }
170
- return out(await call("POST", "/api/v1/guide/vods", { item, basis, agree: true, title: flag(argv, "--title") ?? "", note: flag(argv, "--note") ?? "" }),
171
- (body) => `on demand: ${String(body["page"])}`);
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) => {
@@ -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
- await ctx.play(request, response, item.path);
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
- ctx.json(response, 200, { item: itemView(item, ctx.canWrite) });
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") {
@@ -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 it from the start
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
 
@@ -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
 
@@ -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
- /** In cents. */
25
- export const PRICES: Record<Kind, number> = { song: 10, album: 100, artist: 200 };
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
 
@@ -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-cn_dq77R.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
+ 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-cn_dq77R.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};
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};