nixamp 0.70.2 → 0.72.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/guide-vods.d.ts +14 -0
- package/dist/guide-vods.js +52 -1
- package/dist/label-routes.js +2 -2
- package/dist/library-cli.d.ts +12 -0
- package/dist/library-cli.js +104 -7
- package/dist/library-routes.d.ts +3 -1
- package/dist/library-routes.js +8 -2
- package/dist/library-usage.d.ts +1 -1
- package/dist/library-usage.js +5 -2
- package/dist/music-client.d.ts +1 -1
- package/dist/music-store.d.ts +8 -4
- package/dist/music-store.js +47 -5
- package/dist/server.js +16 -2
- package/dist/store-routes.js +22 -2
- package/package.json +1 -1
- package/src/guide-vods.ts +57 -2
- package/src/label-routes.ts +2 -2
- package/src/library-cli.ts +87 -7
- package/src/library-routes.ts +11 -3
- package/src/library-usage.ts +5 -2
- package/src/music-client.ts +1 -1
- package/src/music-store.ts +46 -8
- package/src/server.ts +14 -1
- package/src/store-routes.ts +21 -1
- package/web/dist/assets/{ar-jDXDCVNY.js → ar-BAY790r_.js} +1 -1
- package/web/dist/assets/{cs-BJnZqm-c.js → cs-Dnd0wU5z.js} +1 -1
- package/web/dist/assets/{da-zJqSSmTU.js → da-BTG_DLNv.js} +1 -1
- package/web/dist/assets/{de-DadRfrUy.js → de-BB9QycNI.js} +1 -1
- package/web/dist/assets/{es-BIQJFRvu.js → es-Cs6keMnw.js} +1 -1
- package/web/dist/assets/{fi-1XwHBLdG.js → fi-bxOXvDHn.js} +1 -1
- package/web/dist/assets/{fr-DTC4ZuwO.js → fr-B-UE3TVX.js} +1 -1
- package/web/dist/assets/{hi-C-xpQYod.js → hi-D4AX0nAY.js} +1 -1
- package/web/dist/assets/{hu-fohpBFEb.js → hu-CrTnC9NF.js} +1 -1
- package/web/dist/assets/{id-CtburpoZ.js → id-BslqEFzn.js} +1 -1
- package/web/dist/assets/{it-D3PL9u_h.js → it-HdYbT4LS.js} +1 -1
- package/web/dist/assets/{main-cn_dq77R.js → main-C4mFMUwz.js} +2 -2
- package/web/dist/assets/{nl-BXp7vaVJ.js → nl-DAYlarHJ.js} +1 -1
- package/web/dist/assets/{radio-DmVc3wpH.js → radio-dUHZb5wV.js} +1 -1
- package/web/dist/assets/{ru-r9WLZlGW.js → ru-CkbRKXgo.js} +1 -1
- package/web/dist/assets/{station-6b_smJHe.css → station-CxJvFwzV.css} +1 -1
- package/web/dist/assets/{sv-QU3rIDyx.js → sv-8VcXZ-cb.js} +1 -1
- package/web/dist/assets/{uk-QYNAUmbC.js → uk-BfAPe5_Y.js} +1 -1
- package/web/dist/assets/{vi-D42PafzI.js → vi-DvdY8XXY.js} +1 -1
- package/web/dist/assets/vod-CdMhdfvg.js +1 -0
- package/web/dist/assets/{zh-D4yW0UaE.js → zh-ChBhO3vy.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/radio.html +1 -1
- package/web/dist/station.html +2 -2
- package/web/dist/sw.js +25 -25
- package/web/dist/vod.html +11 -2
- package/web/dist/watch.html +2 -2
- package/web/dist/assets/vod-BzQvQVLd.js +0 -1
- /package/web/dist/assets/{station-tWQPqySd.js → station-KW1WLKF4.js} +0 -0
- /package/web/dist/assets/{watch-B9LW8D1C.js → watch-fq5gXk7x.js} +0 -0
package/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.d.ts
CHANGED
|
@@ -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>;
|
package/dist/library-cli.js
CHANGED
|
@@ -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
|
|
188
|
-
//
|
|
189
|
-
|
|
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 (!
|
|
192
|
-
console.error("nixamp library publish needs
|
|
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
|
-
|
|
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) => {
|
package/dist/library-routes.d.ts
CHANGED
|
@@ -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
|
}
|
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.
|
|
@@ -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
|
-
|
|
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") {
|
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 --basis B [--title T] [--note N]\n on demand on live.streaming: a link that plays
|
|
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
|
@@ -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
|
|
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
|
|
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,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) {
|
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);
|