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
@@ -14,7 +14,13 @@ export interface GuideVod {
14
14
  /** The nixamp.com account that published it ("" for this server's owner). */
15
15
  by: string;
16
16
  publishedAt: number;
17
+ /** What its download costs on nixamp.com, in cents; absent or 0 when it is not sold. */
18
+ priceCents?: number;
17
19
  }
20
+ /** The bases a price may be put on: what the owner may sell, not just share. */
21
+ export declare const SELLABLE_BASES: readonly RoomBasis[];
22
+ /** $100: a price above that is a typo. */
23
+ export declare const VOD_PRICE_MAX_CENTS = 10000;
18
24
  /** Files one member may have on the guide at once, and files on it in all. */
19
25
  export declare const VODS_PER_MEMBER = 25;
20
26
  export declare const VODS_MAX = 1000;
@@ -84,6 +90,14 @@ export interface VodContext {
84
90
  throttle?: () => {
85
91
  retryAfter: number;
86
92
  } | null;
93
+ /** nixamp.com's store grants (music-store.ts GrantVerifier), for a bought download. */
94
+ grants?: {
95
+ check: (grant: string) => Promise<{
96
+ kind: string;
97
+ artist: string;
98
+ server: string;
99
+ } | null>;
100
+ };
87
101
  }
88
102
  /** Answers /api/v1/guide/vods; false when the path is not one. */
89
103
  export declare function handleVods(request: IncomingMessage, response: ServerResponse, path: string, ctx: VodContext): Promise<boolean>;
@@ -11,11 +11,21 @@
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";
17
23
  import { randomBytes } from "node:crypto";
18
24
  import { ROOM_BASES } from "./guide-rooms.js";
25
+ /** The bases a price may be put on: what the owner may sell, not just share. */
26
+ export const SELLABLE_BASES = ["owned", "public-domain"];
27
+ /** $100: a price above that is a typo. */
28
+ export const VOD_PRICE_MAX_CENTS = 10_000;
19
29
  /** Files one member may have on the guide at once, and files on it in all. */
20
30
  export const VODS_PER_MEMBER = 25;
21
31
  export const VODS_MAX = 1000;
@@ -167,6 +177,7 @@ export function vodView(vod, base, state) {
167
177
  note: vod.note,
168
178
  basis: vod.basis,
169
179
  publishedAt: vod.publishedAt,
180
+ ...(vod.priceCents ? { priceCents: vod.priceCents } : {}),
170
181
  available: state !== null,
171
182
  play: vod.play,
172
183
  page: vodPage(base, vod.id),
@@ -178,6 +189,24 @@ export async function handleVods(request, response, path, ctx) {
178
189
  return false;
179
190
  const method = request.method ?? "GET";
180
191
  const one = /^\/api\/v1\/guide\/vods\/([A-Za-z0-9_-]{1,32})$/.exec(path);
192
+ const download = /^\/api\/v1\/guide\/vods\/([A-Za-z0-9_-]{1,32})\/download$/.exec(path);
193
+ if (download && (method === "GET" || method === "HEAD")) {
194
+ const grant = new URL(request.url ?? "/", "http://x").searchParams.get("grant") ?? "";
195
+ const scope = ctx.grants ? await ctx.grants.check(grant) : null;
196
+ const vod = ctx.store.get(download[1]);
197
+ if (!scope || scope.kind !== "vod" || scope.artist !== download[1] || scope.server !== new URL(ctx.base).origin) {
198
+ 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" });
199
+ return true;
200
+ }
201
+ if (!vod) {
202
+ ctx.json(response, 404, { error: "that video has been taken off this guide", code: "not_found" });
203
+ return true;
204
+ }
205
+ // The file's own server sends it, as an attachment.
206
+ response.writeHead(302, { location: `${vod.play}&download=1`, "cache-control": "private, no-store", "referrer-policy": "no-referrer" });
207
+ response.end();
208
+ return true;
209
+ }
181
210
  if (method === "GET" || method === "HEAD") {
182
211
  if (path === "/api/v1/guide/vods") {
183
212
  ctx.json(response, 200, { vods: await vodEntries(ctx.store, ctx.probe, ctx.base) });
@@ -243,7 +272,29 @@ export async function handleVods(request, response, path, ctx) {
243
272
  }
244
273
  const title = typeof body?.title === "string" ? body.title.trim().slice(0, 160) : "";
245
274
  const note = typeof body?.note === "string" ? body.note.trim().slice(0, 300) : "";
246
- const vod = ctx.store.put({ play: address.play, name: title || state.name, kind: state.kind, duration: state.duration, basis, note, by: taken?.by ?? by });
275
+ const owner = taken?.by ?? by;
276
+ // A price, in dollars: only the owner, only on its own, only what it may sell. Left out, the old one stays.
277
+ let priceCents = taken?.priceCents ?? 0;
278
+ if (body?.price !== undefined && body.price !== null) {
279
+ const cents = Math.round(Number(body.price) * 100);
280
+ if (!ctx.canControl || owner !== "") {
281
+ ctx.json(response, 403, { error: "only the guide's owner sells downloads, of what it published itself", code: "not_owner" });
282
+ return true;
283
+ }
284
+ if (!Number.isFinite(cents) || cents < 0 || cents > VOD_PRICE_MAX_CENTS) {
285
+ ctx.json(response, 400, { error: `price is in dollars, 0 (not sold) to ${VOD_PRICE_MAX_CENTS / 100}`, code: "bad_price" });
286
+ return true;
287
+ }
288
+ if (cents > 0 && !SELLABLE_BASES.includes(basis)) {
289
+ 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" });
290
+ return true;
291
+ }
292
+ priceCents = cents;
293
+ }
294
+ if (priceCents > 0 && !SELLABLE_BASES.includes(basis))
295
+ priceCents = 0;
296
+ // Published again without a title or a note keeps the ones it had (a price added later changes nothing else).
297
+ 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 } : {}) });
247
298
  ctx.json(response, 200, { vod: vodView(vod, ctx.base, state), page: vodPage(ctx.base, vod.id) });
248
299
  return true;
249
300
  }
@@ -107,11 +107,11 @@ export async function handleLabelRoutes(request, response, path, ctx) {
107
107
  const grant = url.searchParams.get("grant") ?? "";
108
108
  if (path === "/api/v1/label/zip") {
109
109
  const scope = ctx.grants ? await ctx.grants.check(grant) : null;
110
- if (!scope) {
110
+ if (!scope || scope.kind === "vod") {
111
111
  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" });
112
112
  return true;
113
113
  }
114
- const zip = label.zipFor(scope);
114
+ const zip = label.zipFor({ ...scope, kind: scope.kind });
115
115
  if (!zip) {
116
116
  ctx.json(response, 404, { error: "nothing to download yet", code: "not_found" });
117
117
  return true;
@@ -1,3 +1,15 @@
1
1
  import { LIBRARY_COMMANDS, LIBRARY_USAGE } from "./library-usage.ts";
2
2
  export { LIBRARY_COMMANDS, LIBRARY_USAGE };
3
+ /** The public guide a published file goes on, unless --guide or --here says otherwise. */
4
+ export declare const STREAMING_GUIDE = "https://live.streaming.pit.world";
5
+ type Call = (method: string, path: string, body?: unknown) => Promise<{
6
+ status: number;
7
+ body: Record<string, unknown>;
8
+ }>;
9
+ /**
10
+ * The ready files `publish` means: item ids as they are, a folder on this
11
+ * machine as everything under it, anything else as search words. A message
12
+ * when nothing matches.
13
+ */
14
+ export declare function publishPicks(targets: string[], call: Call): Promise<Record<string, unknown>[] | string>;
3
15
  export declare function libraryTool(argv: string[]): Promise<number>;
@@ -9,7 +9,42 @@
9
9
  import { resolveTarget } from "./admin.js";
10
10
  import { KEY_HEADER } from "./share.js";
11
11
  import { LIBRARY_COMMANDS, LIBRARY_USAGE } from "./library-usage.js";
12
+ import { readSession } from "./session.js";
12
13
  export { LIBRARY_COMMANDS, LIBRARY_USAGE };
14
+ /** The public guide a published file goes on, unless --guide or --here says otherwise. */
15
+ export const STREAMING_GUIDE = "https://live.streaming.pit.world";
16
+ /**
17
+ * The ready files `publish` means: item ids as they are, a folder on this
18
+ * machine as everything under it, anything else as search words. A message
19
+ * when nothing matches.
20
+ */
21
+ export async function publishPicks(targets, call) {
22
+ const ready = (items) => items.filter((one) => one["state"] === "ready");
23
+ if (targets.every((one) => /^[0-9a-f]{16}$/.test(one))) {
24
+ const items = await Promise.all(targets.map(async (id) => (await call("GET", `/api/v1/library/items/${id}`)).body["item"]));
25
+ const found = ready(items.filter((one) => Boolean(one)));
26
+ return found.length === targets.length ? found : "not every item is in the library and ready (nixamp library show ITEM)";
27
+ }
28
+ const { existsSync, statSync } = await import("node:fs");
29
+ const { resolve, relative, isAbsolute } = await import("node:path");
30
+ const { homedir } = await import("node:os");
31
+ const path = resolve(targets.join(" ").replace(/^~(?=$|\/)/, homedir()));
32
+ const query = new URLSearchParams({ limit: "500", sort: "path" });
33
+ if (existsSync(path) && statSync(path).isDirectory()) {
34
+ const sources = (await call("GET", "/api/v1/library/sources")).body["sources"] ?? [];
35
+ const source = sources.find((one) => { const rel = relative(String(one["root"] ?? ""), path); return !rel.startsWith("..") && !isAbsolute(rel); });
36
+ if (!source)
37
+ return `${path} is not inside the library (nixamp library sources)`;
38
+ query.set("source", String(source["id"]));
39
+ const folder = relative(String(source["root"]), path);
40
+ if (folder)
41
+ query.set("folder", folder);
42
+ }
43
+ else
44
+ query.set("q", targets.join(" "));
45
+ const found = ready((await call("GET", `/api/v1/library/items?${query}`)).body["items"] ?? []);
46
+ return found.length ? found : "nothing ready in the library matches that (nixamp library search WORDS)";
47
+ }
13
48
  function flag(argv, name) {
14
49
  const at = argv.indexOf(name);
15
50
  return at === -1 ? undefined : argv[at + 1];
@@ -20,7 +55,7 @@ function words(argv) {
20
55
  for (let at = 0; at < argv.length; at++) {
21
56
  const one = argv[at];
22
57
  if (one.startsWith("--")) {
23
- if (!["--json", "--force", "--wait"].includes(one))
58
+ if (!["--json", "--force", "--wait", "--dry-run", "--here"].includes(one))
24
59
  at += 1;
25
60
  continue;
26
61
  }
@@ -184,15 +219,77 @@ export async function libraryTool(argv) {
184
219
  return 2;
185
220
  }
186
221
  case "publish": {
187
- // On demand on this server's guide (guide-vods.ts): the server builds the
188
- // file's public address from its own public URL and listen key.
189
- const [item] = rest;
222
+ // On demand on live.streaming (guide-vods.ts). ITEM, a folder or search
223
+ // words pick the files; each goes to the guide as its public play
224
+ // address, under this machine's nixamp.com session, so a file on any
225
+ // server lands on the one guide. --here publishes on this server's own.
190
226
  const basis = flag(argv, "--basis");
191
- if (!item || !basis) {
192
- console.error("nixamp library publish needs an item id and --basis owned|permission|licensed|creative-commons|public-domain");
227
+ if (!rest.length || !basis) {
228
+ console.error("nixamp library publish needs ITEM, a folder or search words, and --basis owned|permission|licensed|creative-commons|public-domain");
229
+ return 2;
230
+ }
231
+ const items = await publishPicks(rest, call);
232
+ if (typeof items === "string") {
233
+ console.error(`nixamp: ${items}`);
234
+ return 1;
235
+ }
236
+ const title = items.length === 1 ? flag(argv, "--title") ?? "" : "";
237
+ const note = flag(argv, "--note") ?? "";
238
+ // Sold as a download on nixamp.com (the guide's owner only, owned or public domain); 0 stops selling it.
239
+ const priceFlag = flag(argv, "--price");
240
+ const price = priceFlag === undefined ? {} : { price: Number(priceFlag.replace(/^\$/, "")) };
241
+ if ("price" in price && !Number.isFinite(price.price)) {
242
+ console.error("nixamp: --price is in dollars, like 1 or 2.50");
193
243
  return 2;
194
244
  }
195
- return out(await call("POST", "/api/v1/guide/vods", { item, basis, agree: true, title: flag(argv, "--title") ?? "", note: flag(argv, "--note") ?? "" }), (body) => `on demand: ${String(body["page"])}`);
245
+ if (argv.includes("--dry-run")) {
246
+ console.log(`${items.map(itemLine).join("\n")}\n${items.length} to publish (--dry-run: nothing sent)`);
247
+ return 0;
248
+ }
249
+ const here = argv.includes("--here");
250
+ const guide = (flag(argv, "--guide") ?? STREAMING_GUIDE).replace(/\/+$/, "");
251
+ const session = here ? null : readSession();
252
+ if (!here && !session?.token) {
253
+ console.error("nixamp: publishing on live.streaming needs a nixamp.com account on this machine: run nixamp login (or pass --here)");
254
+ return 1;
255
+ }
256
+ let failed = 0;
257
+ const done = [];
258
+ for (const item of items) {
259
+ const id = String(item["id"]);
260
+ let answer;
261
+ if (here)
262
+ answer = await call("POST", "/api/v1/guide/vods", { item: id, basis, agree: true, title, note, ...price });
263
+ else {
264
+ const play = String(((await call("GET", `/api/v1/library/items/${id}`)).body["item"] ?? {})["playAddress"] ?? "");
265
+ if (!play)
266
+ 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" } };
267
+ else {
268
+ try {
269
+ const response = await fetch(`${guide}/api/v1/guide/vods`, {
270
+ method: "POST",
271
+ headers: { "content-type": "application/json", authorization: `Bearer ${session.token}` },
272
+ body: JSON.stringify({ play, basis, agree: true, title, note, ...price }),
273
+ });
274
+ answer = { status: response.status, body: (await response.json().catch(() => ({}))) };
275
+ }
276
+ catch (error) {
277
+ answer = { status: 503, body: { error: `could not reach ${guide}: ${error.message}`, code: "unreachable" } };
278
+ }
279
+ }
280
+ }
281
+ done.push({ item: id, status: answer.status, ...answer.body });
282
+ if (answer.status >= 400) {
283
+ failed += 1;
284
+ if (!json)
285
+ console.error(`nixamp: ${itemLine(item)}: ${String(answer.body["error"] ?? "refused")} (${String(answer.body["code"] ?? answer.status)})`);
286
+ }
287
+ else if (!json)
288
+ console.log(`on demand: ${String(answer.body["page"])} ${String(item["title"] || item["rel"] || id)}`);
289
+ }
290
+ if (json)
291
+ console.log(JSON.stringify({ published: done }, null, 2));
292
+ return failed ? 1 : 0;
196
293
  }
197
294
  case "vods":
198
295
  return out(await call("GET", "/api/v1/guide/vods"), (body) => {
@@ -27,7 +27,9 @@ export interface LibraryContext {
27
27
  retag: (tracks: Track[]) => void;
28
28
  sendFile: (request: IncomingMessage, response: ServerResponse, file: string, cache: string) => void;
29
29
  /** The file as a browser plays it (server.ts playFile): the bytes when it can, MP4 or MP3 when not. */
30
- play?: (request: IncomingMessage, response: ServerResponse, file: string) => Promise<void>;
30
+ play?: (request: IncomingMessage, response: ServerResponse, file: string, download?: string) => Promise<void>;
31
+ /** The item's public play address with the listen key, what a guide publishes on demand; "" when this server has none. */
32
+ playAddress?: (id: string) => string;
31
33
  json: (response: ServerResponse, status: number, body: unknown) => void;
32
34
  readBody: (request: IncomingMessage) => Promise<string>;
33
35
  }
@@ -229,7 +229,11 @@ export async function handleLibrary(request, response, path, url, ctx) {
229
229
  return fail(409, "source_disabled", "that item's source is turned off");
230
230
  // On demand: what a VOD link plays, from the start and seekable when the browser takes the file as it is.
231
231
  if (itemMatch[2] === "play" && ctx.play) {
232
- await ctx.play(request, response, item.path);
232
+ // `download=1`: the same file as an attachment, named for what it is (a bought VOD download).
233
+ const named = url.searchParams.get("download") === "1"
234
+ ? (item.override.title || item.title || item.path.split("/").pop().replace(/\.[^.]+$/, "")).replace(/[\\/:*?"<>|\x00-\x1f]/g, "").slice(0, 160) || item.id
235
+ : "";
236
+ await ctx.play(request, response, item.path, named || undefined);
233
237
  return true;
234
238
  }
235
239
  // Private: a browser may keep it, a shared cache may not.
@@ -237,7 +241,9 @@ export async function handleLibrary(request, response, path, url, ctx) {
237
241
  return true;
238
242
  }
239
243
  if (method === "GET") {
240
- ctx.json(response, 200, { item: itemView(item, ctx.canWrite) });
244
+ // The owner also gets where it plays publicly, for publishing it on another server's guide.
245
+ const play = ctx.canWrite && ctx.playAddress ? ctx.playAddress(item.id) : "";
246
+ ctx.json(response, 200, { item: { ...itemView(item, ctx.canWrite), ...(play ? { playAddress: play } : {}) } });
241
247
  return true;
242
248
  }
243
249
  if (method === "PATCH") {
@@ -1,3 +1,3 @@
1
1
  /** `nixamp library` subcommands and usage, apart so help does not load the client. */
2
2
  export declare const LIBRARY_COMMANDS: Set<string>;
3
- export declare const LIBRARY_USAGE = "nixamp library \u2014 what this server has, indexed\n\n nixamp library PATH the folder the daemon serves (set once)\n nixamp library status items by state, sources, last scans\n nixamp library sources\n nixamp library add PATH index another folder (inside your home, not hidden)\n nixamp library scan [SOURCE] [--force] [--wait]\n nixamp library search WORDS [--artist A] [--album A] [--state failed] [--limit 20]\n nixamp library show ITEM\n nixamp library fix ITEM --title T [--artist A] [--album A] [--genre G] [--track N] [--year N]\n nixamp library remove SOURCE forget a folder; its files are not touched\n nixamp library playlists\n nixamp library playlist new NAME [ITEM...]\n nixamp library playlist show|delete PLAYLIST\n nixamp library playlist add PLAYLIST ITEM...\n nixamp library publish ITEM --basis B [--title T] [--note N]\n on demand on live.streaming: a link that plays it from the start\n (B: owned, permission, licensed, creative-commons, public-domain)\n nixamp library vods what is published on demand, with each link\n nixamp library unpublish VOD take one off\n\n --json machine-readable output\n --url, --key another server, as with `nixamp admin`";
3
+ export declare const LIBRARY_USAGE = "nixamp library \u2014 what this server has, indexed\n\n nixamp library PATH the folder the daemon serves (set once)\n nixamp library status items by state, sources, last scans\n nixamp library sources\n nixamp library add PATH index another folder (inside your home, not hidden)\n nixamp library scan [SOURCE] [--force] [--wait]\n nixamp library search WORDS [--artist A] [--album A] [--state failed] [--limit 20]\n nixamp library show ITEM\n nixamp library fix ITEM --title T [--artist A] [--album A] [--genre G] [--track N] [--year N]\n nixamp library remove SOURCE forget a folder; its files are not touched\n nixamp library playlists\n nixamp library playlist new NAME [ITEM...]\n nixamp library playlist show|delete PLAYLIST\n nixamp library playlist add PLAYLIST ITEM...\n nixamp library publish ITEM... | FOLDER | WORDS --basis B [--title T] [--note N] [--dry-run]\n on demand on live.streaming: a link that plays each from the start,\n from any of your servers (needs nixamp login); --here for this server's own guide\n (B: owned, permission, licensed, creative-commons, public-domain)\n --price USD: sell it as a download on nixamp.com (guide owner, owned or\n public-domain only; publish again with --price 0 to stop)\n nixamp library vods what is published on demand, with each link\n nixamp library unpublish VOD take one off\n\n --json machine-readable output\n --url, --key another server, as with `nixamp admin`";
@@ -15,9 +15,12 @@ export const LIBRARY_USAGE = `nixamp library — what this server has, indexed
15
15
  nixamp library playlist new NAME [ITEM...]
16
16
  nixamp library playlist show|delete PLAYLIST
17
17
  nixamp library playlist add PLAYLIST ITEM...
18
- nixamp library publish ITEM --basis B [--title T] [--note N]
19
- on demand on live.streaming: a link that plays it from the start
18
+ nixamp library publish ITEM... | FOLDER | WORDS --basis B [--title T] [--note N] [--dry-run]
19
+ on demand on live.streaming: a link that plays each from the start,
20
+ from any of your servers (needs nixamp login); --here for this server's own guide
20
21
  (B: owned, permission, licensed, creative-commons, public-domain)
22
+ --price USD: sell it as a download on nixamp.com (guide owner, owned or
23
+ public-domain only; publish again with --price 0 to stop)
21
24
  nixamp library vods what is published on demand, with each link
22
25
  nixamp library unpublish VOD take one off
23
26
 
@@ -32,7 +32,7 @@ export interface Catalogue {
32
32
  }
33
33
  export interface Bought {
34
34
  id: string;
35
- kind: "song" | "album" | "artist";
35
+ kind: "song" | "album" | "artist" | "vod";
36
36
  title: string;
37
37
  artistName: string;
38
38
  server: string;
@@ -1,7 +1,9 @@
1
1
  import type { Db, Support } from "./support.ts";
2
- export type Kind = "song" | "album" | "artist";
3
- /** In cents. */
4
- export declare const PRICES: Record<Kind, number>;
2
+ export type Kind = "song" | "album" | "artist" | "vod";
3
+ /** In cents. A video on demand carries its own price, set where it is published. */
4
+ export declare const PRICES: Record<Exclude<Kind, "vod">, number>;
5
+ /** The guides whose videos may be sold here besides the known servers: live.streaming and NIXAMP_VOD_GUIDES. */
6
+ export declare function vodGuides(env?: Record<string, string | undefined>): string[];
5
7
  export interface Scope {
6
8
  server: string;
7
9
  kind: Kind;
@@ -77,6 +79,8 @@ export declare class MusicStore {
77
79
  private ready;
78
80
  /** A server's catalogue, as it publishes it at /api/v1/label. */
79
81
  private catalogue;
82
+ /** A video on demand of a guide, at the price its owner set; refused when it is not for sale. */
83
+ private describeVod;
80
84
  /** What is for sale at this address, with its name and price; refused when there is no such thing. */
81
85
  describe(input: Record<string, unknown>): Promise<Scope & {
82
86
  title: string;
@@ -115,7 +119,7 @@ export declare class MusicStore {
115
119
  check(grant: string): (Scope & {
116
120
  exp: number;
117
121
  }) | null;
118
- /** Where the zip of a purchase is made: on the server that holds the music. */
122
+ /** Where the zip of a purchase is made: on the server that holds the music (a video: its guide hands over the file). */
119
123
  zipUrl(purchase: Purchase): string;
120
124
  /** What anybody with the purchase's link (or the account) is shown. */
121
125
  view(purchase: Purchase): Record<string, unknown>;
@@ -14,11 +14,21 @@
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 { SupportError, coinOf } from "./support.js";
20
- /** In cents. */
25
+ /** In cents. A video on demand carries its own price, set where it is published. */
21
26
  export const PRICES = { song: 10, album: 100, artist: 200 };
27
+ const VOD_ID = /^[A-Za-z0-9_-]{1,32}$/;
28
+ /** The guides whose videos may be sold here besides the known servers: live.streaming and NIXAMP_VOD_GUIDES. */
29
+ export function vodGuides(env = process.env) {
30
+ return ["https://live.streaming.pit.world", ...(env["NIXAMP_VOD_GUIDES"] ?? "").split(",")].map(serverBase).filter(Boolean);
31
+ }
22
32
  const EMAIL = /^[^\s@<>"]{1,64}@[^\s@<>"]{1,190}\.[a-z]{2,24}$/i;
23
33
  const KEY = /^[a-f0-9]{10}$/;
24
34
  const ALBUM = /^[a-f0-9]{8}$/;
@@ -59,13 +69,13 @@ export function readGrant(secret, grant, now = Date.now()) {
59
69
  if (!Number.isFinite(exp) || exp < now)
60
70
  return null;
61
71
  const kind = parts[2];
62
- if (!(kind in PRICES))
72
+ if (!(kind in PRICES) && kind !== "vod")
63
73
  return null;
64
74
  return { kind, artist: parts[3], album: parts[4] === "-" ? "" : parts[4], n: Number(parts[5]), server: Buffer.from(parts[6], "base64url").toString("utf8"), exp };
65
75
  }
66
76
  /** Whether a grant for `scope` covers this file of the catalogue. */
67
77
  export function covers(scope, artist, album, n) {
68
- if (scope.artist !== artist)
78
+ if (scope.kind === "vod" || scope.artist !== artist)
69
79
  return false;
70
80
  if (scope.kind === "artist")
71
81
  return true;
@@ -149,8 +159,30 @@ export class MusicStore {
149
159
  throw new SupportError("That server's catalogue could not be read. Try again.", 502, "catalogue");
150
160
  return await response.json();
151
161
  }
162
+ /** A video on demand of a guide, at the price its owner set; refused when it is not for sale. */
163
+ async describeVod(input) {
164
+ const server = serverBase(input["server"]);
165
+ const known = server !== "" && (new URL(server).hostname.endsWith(".nixamp.com") || vodGuides().includes(server) || this.options.servers().includes(server));
166
+ if (!known)
167
+ throw new SupportError("That guide does not sell videos here.", 404, "not_found");
168
+ const id = String(input["vod"] ?? input["artist"] ?? "");
169
+ if (!VOD_ID.test(id))
170
+ throw new SupportError("No such video.", 404, "not_found");
171
+ const response = await (this.options.fetcher ?? fetch)(`${server}/api/v1/guide/vods/${id}`, { signal: AbortSignal.timeout(10_000), headers: { accept: "application/json" } });
172
+ if (response.status === 404)
173
+ throw new SupportError("No such video.", 404, "not_found");
174
+ if (!response.ok)
175
+ throw new SupportError("That guide could not be read. Try again.", 502, "catalogue");
176
+ const vod = (await response.json()).vod;
177
+ const cents = Number(vod?.priceCents ?? 0);
178
+ if (!vod || !Number.isInteger(cents) || cents <= 0)
179
+ throw new SupportError("That video is free to watch and not sold as a download.", 404, "not_for_sale");
180
+ 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 };
181
+ }
152
182
  /** What is for sale at this address, with its name and price; refused when there is no such thing. */
153
183
  async describe(input) {
184
+ if (input["kind"] === "vod")
185
+ return this.describeVod(input);
154
186
  // An opaque server id (music-hub.ts), or an https origin.
155
187
  const asked = String(input["server"] ?? "");
156
188
  const server = /^[a-f0-9]{10}$/.test(asked) ? (this.options.resolveServer?.(asked) ?? "") : serverBase(asked);
@@ -191,7 +223,7 @@ export class MusicStore {
191
223
  email,
192
224
  accountId: account?.id || email,
193
225
  order: `music-${id}`,
194
- description: `${item.title} by ${item.artistName} (${item.kind === "song" ? "song" : item.kind === "album" ? "album" : "every album"}, MP3)`,
226
+ description: item.kind === "vod" ? `${item.title} (download)` : `${item.title} by ${item.artistName} (${item.kind === "song" ? "song" : item.kind === "album" ? "album" : "every album"}, MP3)`,
195
227
  metadata: { product: "music", purchase: id, kind: item.kind },
196
228
  redirect: this.link(id, token),
197
229
  });
@@ -240,6 +272,14 @@ export class MusicStore {
240
272
  return again ? row(again) : null;
241
273
  }
242
274
  const purchase = row(updated);
275
+ if (purchase.kind === "vod") {
276
+ await this.mail(purchase.email, `Your download: ${purchase.title}`, [
277
+ `Thank you. "${purchase.title}" is yours to download and keep.`,
278
+ `Download it, now or any time: ${this.link(purchase.id, purchase.token)}`,
279
+ `Sign in to nixamp.com with ${purchase.email} and everything you have bought is under My music on ${this.options.site}/music/me.`,
280
+ ]);
281
+ return purchase;
282
+ }
243
283
  await this.mail(purchase.email, `Your music: ${purchase.title}`, [
244
284
  `Thank you. "${purchase.title}" by ${purchase.artistName} is yours: MP3s with the cover art and the lyrics, in one zip.`,
245
285
  `Download it, now or any time: ${this.link(purchase.id, purchase.token)}`,
@@ -259,8 +299,10 @@ export class MusicStore {
259
299
  check(grant) {
260
300
  return readGrant(this.options.secret, grant, this.now());
261
301
  }
262
- /** Where the zip of a purchase is made: on the server that holds the music. */
302
+ /** Where the zip of a purchase is made: on the server that holds the music (a video: its guide hands over the file). */
263
303
  zipUrl(purchase) {
304
+ if (purchase.kind === "vod")
305
+ return `${purchase.server}/api/v1/guide/vods/${purchase.artist}/download?grant=${encodeURIComponent(this.grant(purchase))}`;
264
306
  return `${purchase.server}/api/v1/label/zip?grant=${encodeURIComponent(this.grant(purchase))}`;
265
307
  }
266
308
  /** What anybody with the purchase's link (or the account) is shown. */
package/dist/server.js CHANGED
@@ -2895,6 +2895,7 @@ export function createHandler(engine, options) {
2895
2895
  },
2896
2896
  json,
2897
2897
  readBody,
2898
+ grants: storeGrants,
2898
2899
  throttle: () => {
2899
2900
  const verdict = guard.check(`vods:${callerOf(request.headers, request.socket.remoteAddress, behindProxy)}`, STATION_CONTROL_LIMIT);
2900
2901
  return verdict.ok ? null : { retryAfter: verdict.retryAfter };
@@ -2954,7 +2955,11 @@ export function createHandler(engine, options) {
2954
2955
  probe: options.library.probe,
2955
2956
  retag: options.library.retag,
2956
2957
  sendFile,
2957
- play: (req, res, file) => playFile(req, res, file, url, options),
2958
+ play: (req, res, file, download) => playFile(req, res, file, url, options, undefined, download),
2959
+ playAddress: (id) => {
2960
+ const publicBase = (options.stations?.publicBase?.() ?? "").replace(/\/+$/, "");
2961
+ return publicBase && listenKey ? `${publicBase}/api/v1/library/items/${id}/play?k=${listenKey}` : "";
2962
+ },
2958
2963
  json,
2959
2964
  readBody,
2960
2965
  });
@@ -6872,7 +6877,13 @@ async function liveAudio(request, response, engine, ffmpeg, ffprobe) {
6872
6877
  * browser can; otherwise an MP4 or MP3 made on the way. `onPicture` hears
6873
6878
  * that ffprobe found a picture in it.
6874
6879
  */
6875
- async function playFile(request, response, file, url, options, onPicture = () => undefined) {
6880
+ async function playFile(request, response, file, url, options, onPicture = () => undefined,
6881
+ /** Sent as an attachment under this name (no extension: it depends on what is sent), for a bought download. */
6882
+ download = "") {
6883
+ const attach = (ext) => {
6884
+ if (download)
6885
+ response.setHeader("content-disposition", `attachment; filename*=UTF-8''${encodeURIComponent(`${download}${ext}`)}`);
6886
+ };
6876
6887
  // A browser asks for every track here, and a matroska or an avi handed
6877
6888
  // to it raw is bytes it cannot play. Seeking is what this route is for
6878
6889
  // and transcoding gives it up, but an unseekable film beats a silent
@@ -6888,6 +6899,7 @@ async function playFile(request, response, file, url, options, onPicture = () =>
6888
6899
  // keep up with playing it, so the answer is worth carrying in the URL.
6889
6900
  const allowHevc = url.searchParams.get("hevc") === "1";
6890
6901
  if (playsInBrowser(file) && capKbps === 0) {
6902
+ attach(extname(file));
6891
6903
  sendFile(request, response, file);
6892
6904
  return;
6893
6905
  }
@@ -6904,10 +6916,12 @@ async function playFile(request, response, file, url, options, onPicture = () =>
6904
6916
  // added by an older nixamp goes to an audio element for ever, and
6905
6917
  // the only cure is noticing and adding it again.
6906
6918
  onPicture();
6919
+ attach(".mp4");
6907
6920
  pipeFfmpeg(request, response, file, options.ffmpeg ?? ["ffmpeg"], videoArgs(codecs, capKbps, { allowHevc }), "video/mp4", transportInputArgs(file, codecs.container));
6908
6921
  return;
6909
6922
  }
6910
6923
  }
6924
+ attach(".mp3");
6911
6925
  transcode(request, response, file, options.ffmpeg ?? ["ffmpeg"]);
6912
6926
  }
6913
6927
  function transcode(request, response, source, ffmpeg) {
@@ -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}
@@ -94,6 +95,21 @@ export async function handleStoreRoutes(request, response, path, url, ctx) {
94
95
  return true;
95
96
  }
96
97
  const me = await ctx.account().catch(() => null);
98
+ if (item.kind === "vod") {
99
+ html(response, 200, page(`Download ${item.title}`, `<div class="card">
100
+ <h1>${escape(item.title)}</h1><p class="by">${escape(item.artistName)}</p>
101
+ <div class="price">${price(item.amountCents)}</div>
102
+ <ul><li>The whole file, to keep and play offline</li><li>Download again any time from your receipt or My music</li></ul>
103
+ <form id="buy">
104
+ <label for="email">Email (your download link goes here)</label>
105
+ <input id="email" name="email" type="email" required autocomplete="email" value="${escape(me?.email ?? "")}">
106
+ <button id="pay" type="submit">Pay ${price(item.amountCents)}</button>
107
+ </form>
108
+ <p id="status" role="status" aria-live="polite"></p>
109
+ <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>
110
+ </div>`, JSON.stringify({ server: item.server, kind: "vod", vod: item.artist })), method);
111
+ return true;
112
+ }
97
113
  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`;
98
114
  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">`;
99
115
  html(response, 200, page(`Download ${item.title}`, `<div class="card">${cover}
@@ -117,11 +133,15 @@ export async function handleStoreRoutes(request, response, path, url, ctx) {
117
133
  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);
118
134
  return true;
119
135
  }
120
- const body = purchase.status === "paid"
136
+ const body = purchase.status === "paid" && purchase.kind === "vod"
121
137
  ? `<div class="card"><h1>${escape(purchase.title)}</h1><p class="by">${escape(purchase.artistName)} · paid ${price(purchase.amountCents)}</p>
138
+ <a class="go" href="${escape(store.zipUrl(purchase))}" download>Download the video</a>
139
+ <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>`
140
+ : purchase.status === "paid"
141
+ ? `<div class="card"><h1>${escape(purchase.title)}</h1><p class="by">${escape(purchase.artistName)} · paid ${price(purchase.amountCents)}</p>
122
142
  <a class="go" href="${escape(store.zipUrl(purchase))}" download>Download the zip</a>
123
143
  <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>`
124
- : `<div class="card"><h1>${escape(purchase.title)}</h1><p class="by">${escape(purchase.artistName)} · ${price(purchase.amountCents)}${escape(paidIn(purchase.coin))}</p>
144
+ : `<div class="card"><h1>${escape(purchase.title)}</h1><p class="by">${escape(purchase.artistName)} · ${price(purchase.amountCents)}${escape(paidIn(purchase.coin))}</p>
125
145
  <p>Waiting for your payment. Once CoinPay confirms it, your download is here and in your email (${escape(purchase.email)}). This page checks again when you reload it.</p>
126
146
  <a class="go" href="${escape(payUrl(purchase.paymentId))}">Open the payment</a></div>`;
127
147
  html(response, 200, page(purchase.title, body), method);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.70.2",
3
+ "version": "0.72.0",
4
4
  "description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
5
5
  "license": "MIT",
6
6
  "type": "module",