nixamp 0.71.0 → 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 (48) 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.js +9 -2
  5. package/dist/library-routes.d.ts +1 -1
  6. package/dist/library-routes.js +5 -1
  7. package/dist/library-usage.d.ts +1 -1
  8. package/dist/library-usage.js +2 -0
  9. package/dist/music-client.d.ts +1 -1
  10. package/dist/music-store.d.ts +8 -4
  11. package/dist/music-store.js +47 -5
  12. package/dist/server.js +12 -2
  13. package/dist/store-routes.js +22 -2
  14. package/package.json +1 -1
  15. package/src/guide-vods.ts +57 -2
  16. package/src/label-routes.ts +2 -2
  17. package/src/library-cli.ts +6 -2
  18. package/src/library-routes.ts +6 -2
  19. package/src/library-usage.ts +2 -0
  20. package/src/music-client.ts +1 -1
  21. package/src/music-store.ts +46 -8
  22. package/src/server.ts +10 -1
  23. package/src/store-routes.ts +21 -1
  24. package/web/dist/assets/{ar-BkhH2vKH.js → ar-BAY790r_.js} +1 -1
  25. package/web/dist/assets/{cs-CP-K-25-.js → cs-Dnd0wU5z.js} +1 -1
  26. package/web/dist/assets/{da-CMTkA1tx.js → da-BTG_DLNv.js} +1 -1
  27. package/web/dist/assets/{de-Bp0dC6f_.js → de-BB9QycNI.js} +1 -1
  28. package/web/dist/assets/{es-Bh0gnI5K.js → es-Cs6keMnw.js} +1 -1
  29. package/web/dist/assets/{fi-DFq37NoN.js → fi-bxOXvDHn.js} +1 -1
  30. package/web/dist/assets/{fr-BeoY_SZq.js → fr-B-UE3TVX.js} +1 -1
  31. package/web/dist/assets/{hi-jtnDWqvZ.js → hi-D4AX0nAY.js} +1 -1
  32. package/web/dist/assets/{hu-CxNFJfyV.js → hu-CrTnC9NF.js} +1 -1
  33. package/web/dist/assets/{id-BJnf4dT1.js → id-BslqEFzn.js} +1 -1
  34. package/web/dist/assets/{it-BeoOw3Pj.js → it-HdYbT4LS.js} +1 -1
  35. package/web/dist/assets/{main-BD_EbJVT.js → main-C4mFMUwz.js} +2 -2
  36. package/web/dist/assets/{nl-CM4Ui7kM.js → nl-DAYlarHJ.js} +1 -1
  37. package/web/dist/assets/{radio-DmVc3wpH.js → radio-dUHZb5wV.js} +1 -1
  38. package/web/dist/assets/{ru-qTeCF4pp.js → ru-CkbRKXgo.js} +1 -1
  39. package/web/dist/assets/{sv-C7ZZjU8X.js → sv-8VcXZ-cb.js} +1 -1
  40. package/web/dist/assets/{uk-CM3OhNI1.js → uk-BfAPe5_Y.js} +1 -1
  41. package/web/dist/assets/{vi-CQBssZqB.js → vi-DvdY8XXY.js} +1 -1
  42. package/web/dist/assets/vod-CdMhdfvg.js +1 -0
  43. package/web/dist/assets/{zh-Djw5VmTU.js → zh-ChBhO3vy.js} +1 -1
  44. package/web/dist/index.html +1 -1
  45. package/web/dist/radio.html +1 -1
  46. package/web/dist/sw.js +21 -21
  47. package/web/dist/vod.html +2 -1
  48. package/web/dist/assets/vod-BNL_JMl6.js +0 -1
@@ -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;
@@ -235,6 +235,13 @@ export async function libraryTool(argv) {
235
235
  }
236
236
  const title = items.length === 1 ? flag(argv, "--title") ?? "" : "";
237
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");
243
+ return 2;
244
+ }
238
245
  if (argv.includes("--dry-run")) {
239
246
  console.log(`${items.map(itemLine).join("\n")}\n${items.length} to publish (--dry-run: nothing sent)`);
240
247
  return 0;
@@ -252,7 +259,7 @@ export async function libraryTool(argv) {
252
259
  const id = String(item["id"]);
253
260
  let answer;
254
261
  if (here)
255
- answer = await call("POST", "/api/v1/guide/vods", { item: id, basis, agree: true, title, note });
262
+ answer = await call("POST", "/api/v1/guide/vods", { item: id, basis, agree: true, title, note, ...price });
256
263
  else {
257
264
  const play = String(((await call("GET", `/api/v1/library/items/${id}`)).body["item"] ?? {})["playAddress"] ?? "");
258
265
  if (!play)
@@ -262,7 +269,7 @@ export async function libraryTool(argv) {
262
269
  const response = await fetch(`${guide}/api/v1/guide/vods`, {
263
270
  method: "POST",
264
271
  headers: { "content-type": "application/json", authorization: `Bearer ${session.token}` },
265
- body: JSON.stringify({ play, basis, agree: true, title, note }),
272
+ body: JSON.stringify({ play, basis, agree: true, title, note, ...price }),
266
273
  });
267
274
  answer = { status: response.status, body: (await response.json().catch(() => ({}))) };
268
275
  }
@@ -27,7 +27,7 @@ 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
31
  /** The item's public play address with the listen key, what a guide publishes on demand; "" when this server has none. */
32
32
  playAddress?: (id: string) => string;
33
33
  json: (response: ServerResponse, status: number, body: unknown) => void;
@@ -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.
@@ -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... | 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 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`";
@@ -19,6 +19,8 @@ export const LIBRARY_USAGE = `nixamp library — what this server has, indexed
19
19
  on demand on live.streaming: a link that plays each from the start,
20
20
  from any of your servers (needs nixamp login); --here for this server's own guide
21
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)
22
24
  nixamp library vods what is published on demand, with each link
23
25
  nixamp library unpublish VOD take one off
24
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,7 @@ 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),
2958
2959
  playAddress: (id) => {
2959
2960
  const publicBase = (options.stations?.publicBase?.() ?? "").replace(/\/+$/, "");
2960
2961
  return publicBase && listenKey ? `${publicBase}/api/v1/library/items/${id}/play?k=${listenKey}` : "";
@@ -6876,7 +6877,13 @@ async function liveAudio(request, response, engine, ffmpeg, ffprobe) {
6876
6877
  * browser can; otherwise an MP4 or MP3 made on the way. `onPicture` hears
6877
6878
  * that ffprobe found a picture in it.
6878
6879
  */
6879
- 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
+ };
6880
6887
  // A browser asks for every track here, and a matroska or an avi handed
6881
6888
  // to it raw is bytes it cannot play. Seeking is what this route is for
6882
6889
  // and transcoding gives it up, but an unseekable film beats a silent
@@ -6892,6 +6899,7 @@ async function playFile(request, response, file, url, options, onPicture = () =>
6892
6899
  // keep up with playing it, so the answer is worth carrying in the URL.
6893
6900
  const allowHevc = url.searchParams.get("hevc") === "1";
6894
6901
  if (playsInBrowser(file) && capKbps === 0) {
6902
+ attach(extname(file));
6895
6903
  sendFile(request, response, file);
6896
6904
  return;
6897
6905
  }
@@ -6908,10 +6916,12 @@ async function playFile(request, response, file, url, options, onPicture = () =>
6908
6916
  // added by an older nixamp goes to an audio element for ever, and
6909
6917
  // the only cure is noticing and adding it again.
6910
6918
  onPicture();
6919
+ attach(".mp4");
6911
6920
  pipeFfmpeg(request, response, file, options.ffmpeg ?? ["ffmpeg"], videoArgs(codecs, capKbps, { allowHevc }), "video/mp4", transportInputArgs(file, codecs.container));
6912
6921
  return;
6913
6922
  }
6914
6923
  }
6924
+ attach(".mp3");
6915
6925
  transcode(request, response, file, options.ffmpeg ?? ["ffmpeg"]);
6916
6926
  }
6917
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.71.0",
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",
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;
@@ -207,6 +207,10 @@ export async function libraryTool(argv: string[]): Promise<number> {
207
207
  if (typeof items === "string") { console.error(`nixamp: ${items}`); return 1; }
208
208
  const title = items.length === 1 ? flag(argv, "--title") ?? "" : "";
209
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; }
210
214
  if (argv.includes("--dry-run")) {
211
215
  console.log(`${items.map(itemLine).join("\n")}\n${items.length} to publish (--dry-run: nothing sent)`);
212
216
  return 0;
@@ -220,7 +224,7 @@ export async function libraryTool(argv: string[]): Promise<number> {
220
224
  for (const item of items) {
221
225
  const id = String(item["id"]);
222
226
  let answer: { status: number; body: Record<string, unknown> };
223
- if (here) answer = await call("POST", "/api/v1/guide/vods", { item: id, basis, agree: true, title, note });
227
+ if (here) answer = await call("POST", "/api/v1/guide/vods", { item: id, basis, agree: true, title, note, ...price });
224
228
  else {
225
229
  const play = String((((await call("GET", `/api/v1/library/items/${id}`)).body["item"] ?? {}) as Record<string, unknown>)["playAddress"] ?? "");
226
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" } };
@@ -229,7 +233,7 @@ export async function libraryTool(argv: string[]): Promise<number> {
229
233
  const response = await fetch(`${guide}/api/v1/guide/vods`, {
230
234
  method: "POST",
231
235
  headers: { "content-type": "application/json", authorization: `Bearer ${session!.token}` },
232
- body: JSON.stringify({ play, basis, agree: true, title, note }),
236
+ body: JSON.stringify({ play, basis, agree: true, title, note, ...price }),
233
237
  });
234
238
  answer = { status: response.status, body: (await response.json().catch(() => ({}))) as Record<string, unknown> };
235
239
  } catch (error) {