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.
- package/dist/guide-vods.d.ts +14 -0
- package/dist/guide-vods.js +52 -1
- package/dist/label-routes.js +2 -2
- package/dist/library-cli.js +9 -2
- package/dist/library-routes.d.ts +1 -1
- package/dist/library-routes.js +5 -1
- package/dist/library-usage.d.ts +1 -1
- package/dist/library-usage.js +2 -0
- package/dist/music-client.d.ts +1 -1
- package/dist/music-store.d.ts +8 -4
- package/dist/music-store.js +47 -5
- package/dist/server.js +12 -2
- package/dist/store-routes.js +22 -2
- package/package.json +1 -1
- package/src/guide-vods.ts +57 -2
- package/src/label-routes.ts +2 -2
- package/src/library-cli.ts +6 -2
- package/src/library-routes.ts +6 -2
- package/src/library-usage.ts +2 -0
- package/src/music-client.ts +1 -1
- package/src/music-store.ts +46 -8
- package/src/server.ts +10 -1
- package/src/store-routes.ts +21 -1
- package/web/dist/assets/{ar-BkhH2vKH.js → ar-BAY790r_.js} +1 -1
- package/web/dist/assets/{cs-CP-K-25-.js → cs-Dnd0wU5z.js} +1 -1
- package/web/dist/assets/{da-CMTkA1tx.js → da-BTG_DLNv.js} +1 -1
- package/web/dist/assets/{de-Bp0dC6f_.js → de-BB9QycNI.js} +1 -1
- package/web/dist/assets/{es-Bh0gnI5K.js → es-Cs6keMnw.js} +1 -1
- package/web/dist/assets/{fi-DFq37NoN.js → fi-bxOXvDHn.js} +1 -1
- package/web/dist/assets/{fr-BeoY_SZq.js → fr-B-UE3TVX.js} +1 -1
- package/web/dist/assets/{hi-jtnDWqvZ.js → hi-D4AX0nAY.js} +1 -1
- package/web/dist/assets/{hu-CxNFJfyV.js → hu-CrTnC9NF.js} +1 -1
- package/web/dist/assets/{id-BJnf4dT1.js → id-BslqEFzn.js} +1 -1
- package/web/dist/assets/{it-BeoOw3Pj.js → it-HdYbT4LS.js} +1 -1
- package/web/dist/assets/{main-BD_EbJVT.js → main-C4mFMUwz.js} +2 -2
- package/web/dist/assets/{nl-CM4Ui7kM.js → nl-DAYlarHJ.js} +1 -1
- package/web/dist/assets/{radio-DmVc3wpH.js → radio-dUHZb5wV.js} +1 -1
- package/web/dist/assets/{ru-qTeCF4pp.js → ru-CkbRKXgo.js} +1 -1
- package/web/dist/assets/{sv-C7ZZjU8X.js → sv-8VcXZ-cb.js} +1 -1
- package/web/dist/assets/{uk-CM3OhNI1.js → uk-BfAPe5_Y.js} +1 -1
- package/web/dist/assets/{vi-CQBssZqB.js → vi-DvdY8XXY.js} +1 -1
- package/web/dist/assets/vod-CdMhdfvg.js +1 -0
- package/web/dist/assets/{zh-Djw5VmTU.js → zh-ChBhO3vy.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/radio.html +1 -1
- package/web/dist/sw.js +21 -21
- package/web/dist/vod.html +2 -1
- package/web/dist/assets/vod-BNL_JMl6.js +0 -1
package/dist/guide-vods.d.ts
CHANGED
|
@@ -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>;
|
package/dist/guide-vods.js
CHANGED
|
@@ -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
|
|
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
|
}
|
package/dist/label-routes.js
CHANGED
|
@@ -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;
|
package/dist/library-cli.js
CHANGED
|
@@ -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
|
}
|
package/dist/library-routes.d.ts
CHANGED
|
@@ -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;
|
package/dist/library-routes.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
package/dist/library-usage.d.ts
CHANGED
|
@@ -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`";
|
package/dist/library-usage.js
CHANGED
|
@@ -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
|
|
package/dist/music-client.d.ts
CHANGED
package/dist/music-store.d.ts
CHANGED
|
@@ -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>;
|
package/dist/music-store.js
CHANGED
|
@@ -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) {
|
package/dist/store-routes.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* The music store's pages and API on nixamp.com (music-store.ts).
|
|
3
3
|
*
|
|
4
4
|
* GET /buy?server=&kind=&artist=&album=&n= what it is, the price, email + coin, Pay
|
|
5
|
+
* GET /buy?server=<guide>&kind=vod&vod=<id> the same for a video on demand of a station guide
|
|
5
6
|
* GET /purchases/:id?t= the receipt: waiting, or Download the zip
|
|
6
7
|
* GET /api/v1/store/coins the coins nixamp's CoinPay business takes
|
|
7
8
|
* POST /api/v1/store/checkout {server, kind, artist, album?, n?, email, coin} -> {url}
|
|
@@ -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
|
-
|
|
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
package/src/guide-vods.ts
CHANGED
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Nothing here plays anything: the file's own server does, and this only asks
|
|
13
13
|
* it, now and then, whether the file is still there and what it is.
|
|
14
|
+
*
|
|
15
|
+
* The guide's owner may put a price on one of its own (owned or public
|
|
16
|
+
* domain): watching stays free, and the page offers the file as a download,
|
|
17
|
+
* bought on nixamp.com (music-store.ts, kind "vod"). The grant nixamp.com
|
|
18
|
+
* signs opens /api/v1/guide/vods/<id>/download, which sends the buyer on to
|
|
19
|
+
* the file's server with `download=1`.
|
|
14
20
|
*/
|
|
15
21
|
import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
16
22
|
import { join } from "node:path";
|
|
@@ -32,8 +38,15 @@ export interface GuideVod {
|
|
|
32
38
|
/** The nixamp.com account that published it ("" for this server's owner). */
|
|
33
39
|
by: string;
|
|
34
40
|
publishedAt: number;
|
|
41
|
+
/** What its download costs on nixamp.com, in cents; absent or 0 when it is not sold. */
|
|
42
|
+
priceCents?: number;
|
|
35
43
|
}
|
|
36
44
|
|
|
45
|
+
/** The bases a price may be put on: what the owner may sell, not just share. */
|
|
46
|
+
export const SELLABLE_BASES: readonly RoomBasis[] = ["owned", "public-domain"];
|
|
47
|
+
/** $100: a price above that is a typo. */
|
|
48
|
+
export const VOD_PRICE_MAX_CENTS = 10_000;
|
|
49
|
+
|
|
37
50
|
/** Files one member may have on the guide at once, and files on it in all. */
|
|
38
51
|
export const VODS_PER_MEMBER = 25;
|
|
39
52
|
export const VODS_MAX = 1000;
|
|
@@ -191,6 +204,7 @@ export function vodView(vod: GuideVod, base: string, state: VodState | null): Re
|
|
|
191
204
|
note: vod.note,
|
|
192
205
|
basis: vod.basis,
|
|
193
206
|
publishedAt: vod.publishedAt,
|
|
207
|
+
...(vod.priceCents ? { priceCents: vod.priceCents } : {}),
|
|
194
208
|
available: state !== null,
|
|
195
209
|
play: vod.play,
|
|
196
210
|
page: vodPage(base, vod.id),
|
|
@@ -215,6 +229,8 @@ export interface VodContext {
|
|
|
215
229
|
readBody: (request: IncomingMessage) => Promise<unknown>;
|
|
216
230
|
/** Refused by the caller's rate limit, or null. */
|
|
217
231
|
throttle?: () => { retryAfter: number } | null;
|
|
232
|
+
/** nixamp.com's store grants (music-store.ts GrantVerifier), for a bought download. */
|
|
233
|
+
grants?: { check: (grant: string) => Promise<{ kind: string; artist: string; server: string } | null> };
|
|
218
234
|
}
|
|
219
235
|
|
|
220
236
|
/** Answers /api/v1/guide/vods; false when the path is not one. */
|
|
@@ -222,6 +238,24 @@ export async function handleVods(request: IncomingMessage, response: ServerRespo
|
|
|
222
238
|
if (path !== "/api/v1/guide/vods" && !path.startsWith("/api/v1/guide/vods/")) return false;
|
|
223
239
|
const method = request.method ?? "GET";
|
|
224
240
|
const one = /^\/api\/v1\/guide\/vods\/([A-Za-z0-9_-]{1,32})$/.exec(path);
|
|
241
|
+
const download = /^\/api\/v1\/guide\/vods\/([A-Za-z0-9_-]{1,32})\/download$/.exec(path);
|
|
242
|
+
if (download && (method === "GET" || method === "HEAD")) {
|
|
243
|
+
const grant = new URL(request.url ?? "/", "http://x").searchParams.get("grant") ?? "";
|
|
244
|
+
const scope = ctx.grants ? await ctx.grants.check(grant) : null;
|
|
245
|
+
const vod = ctx.store.get(download[1]!);
|
|
246
|
+
if (!scope || scope.kind !== "vod" || scope.artist !== download[1] || scope.server !== new URL(ctx.base).origin) {
|
|
247
|
+
ctx.json(response, 403, { error: "that download link has expired or is not one of ours: open your purchase again for a fresh one", code: "bad_grant" });
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
if (!vod) {
|
|
251
|
+
ctx.json(response, 404, { error: "that video has been taken off this guide", code: "not_found" });
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
// The file's own server sends it, as an attachment.
|
|
255
|
+
response.writeHead(302, { location: `${vod.play}&download=1`, "cache-control": "private, no-store", "referrer-policy": "no-referrer" });
|
|
256
|
+
response.end();
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
225
259
|
if (method === "GET" || method === "HEAD") {
|
|
226
260
|
if (path === "/api/v1/guide/vods") {
|
|
227
261
|
ctx.json(response, 200, { vods: await vodEntries(ctx.store, ctx.probe, ctx.base) });
|
|
@@ -249,7 +283,7 @@ export async function handleVods(request: IncomingMessage, response: ServerRespo
|
|
|
249
283
|
if (path === "/api/v1/guide/vods" && method === "POST") {
|
|
250
284
|
const raw = await ctx.readBody(request).catch(() => null);
|
|
251
285
|
const body = (typeof raw === "string" ? (() => { try { return JSON.parse(raw) as unknown; } catch { return null; } })() : raw) as
|
|
252
|
-
{ play?: unknown; item?: unknown; basis?: unknown; note?: unknown; title?: unknown; agree?: unknown } | null;
|
|
286
|
+
{ play?: unknown; item?: unknown; basis?: unknown; note?: unknown; title?: unknown; agree?: unknown; price?: unknown } | null;
|
|
253
287
|
// The owner may name one of this server's own items; everybody else hands over the address.
|
|
254
288
|
const own = ctx.canControl && typeof body?.item === "string" && /^[0-9a-f]{16}$/.test(body.item) && ctx.ownPlay ? ctx.ownPlay(body.item) : "";
|
|
255
289
|
const address = vodAddress(own || (typeof body?.play === "string" ? body.play : ""));
|
|
@@ -283,7 +317,28 @@ export async function handleVods(request: IncomingMessage, response: ServerRespo
|
|
|
283
317
|
}
|
|
284
318
|
const title = typeof body?.title === "string" ? body.title.trim().slice(0, 160) : "";
|
|
285
319
|
const note = typeof body?.note === "string" ? body.note.trim().slice(0, 300) : "";
|
|
286
|
-
const
|
|
320
|
+
const owner = taken?.by ?? by;
|
|
321
|
+
// A price, in dollars: only the owner, only on its own, only what it may sell. Left out, the old one stays.
|
|
322
|
+
let priceCents = taken?.priceCents ?? 0;
|
|
323
|
+
if (body?.price !== undefined && body.price !== null) {
|
|
324
|
+
const cents = Math.round(Number(body.price) * 100);
|
|
325
|
+
if (!ctx.canControl || owner !== "") {
|
|
326
|
+
ctx.json(response, 403, { error: "only the guide's owner sells downloads, of what it published itself", code: "not_owner" });
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
if (!Number.isFinite(cents) || cents < 0 || cents > VOD_PRICE_MAX_CENTS) {
|
|
330
|
+
ctx.json(response, 400, { error: `price is in dollars, 0 (not sold) to ${VOD_PRICE_MAX_CENTS / 100}`, code: "bad_price" });
|
|
331
|
+
return true;
|
|
332
|
+
}
|
|
333
|
+
if (cents > 0 && !SELLABLE_BASES.includes(basis)) {
|
|
334
|
+
ctx.json(response, 400, { error: `a download may be sold only of what you own or what is in the public domain, not ${basis}`, code: "not_sellable" });
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
priceCents = cents;
|
|
338
|
+
}
|
|
339
|
+
if (priceCents > 0 && !SELLABLE_BASES.includes(basis)) priceCents = 0;
|
|
340
|
+
// Published again without a title or a note keeps the ones it had (a price added later changes nothing else).
|
|
341
|
+
const vod = ctx.store.put({ play: address.play, name: title || taken?.name || state.name, kind: state.kind, duration: state.duration, basis, note: note || taken?.note || "", by: owner, ...(priceCents > 0 ? { priceCents } : {}) });
|
|
287
342
|
ctx.json(response, 200, { vod: vodView(vod, ctx.base, state), page: vodPage(ctx.base, vod.id) });
|
|
288
343
|
return true;
|
|
289
344
|
}
|
package/src/label-routes.ts
CHANGED
|
@@ -128,11 +128,11 @@ export async function handleLabelRoutes(
|
|
|
128
128
|
const grant = url.searchParams.get("grant") ?? "";
|
|
129
129
|
if (path === "/api/v1/label/zip") {
|
|
130
130
|
const scope = ctx.grants ? await ctx.grants.check(grant) : null;
|
|
131
|
-
if (!scope) {
|
|
131
|
+
if (!scope || scope.kind === "vod") {
|
|
132
132
|
ctx.json(response, 403, { error: "that download link has expired or is not one of ours: open your purchase again for a fresh one", code: "bad_grant" });
|
|
133
133
|
return true;
|
|
134
134
|
}
|
|
135
|
-
const zip = label.zipFor(scope);
|
|
135
|
+
const zip = label.zipFor({ ...scope, kind: scope.kind as Exclude<typeof scope.kind, "vod"> });
|
|
136
136
|
if (!zip) {
|
|
137
137
|
ctx.json(response, 404, { error: "nothing to download yet", code: "not_found" });
|
|
138
138
|
return true;
|
package/src/library-cli.ts
CHANGED
|
@@ -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) {
|