nixamp 0.49.0 → 0.50.1
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/ai-label.d.ts +29 -0
- package/dist/ai-label.js +50 -1
- package/dist/label-routes.d.ts +15 -2
- package/dist/label-routes.js +68 -9
- package/dist/music-store.d.ts +121 -0
- package/dist/music-store.js +289 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +73 -12
- package/dist/store-routes.d.ts +16 -0
- package/dist/store-routes.js +189 -0
- package/dist/support.d.ts +14 -0
- package/dist/support.js +41 -0
- package/dist/track-facts.d.ts +8 -0
- package/dist/zip-stream.d.ts +13 -0
- package/dist/zip-stream.js +119 -0
- package/package.json +1 -1
- package/src/ai-label.ts +48 -1
- package/src/label-routes.ts +77 -9
- package/src/music-store.ts +333 -0
- package/src/server.ts +63 -4
- package/src/store-routes.ts +204 -0
- package/src/support.ts +39 -0
- package/src/track-facts.ts +2 -0
- package/src/zip-stream.ts +128 -0
- package/web/dist/assets/{ar-BEVjqkqM.js → ar-CC_uYRlC.js} +1 -1
- package/web/dist/assets/{cs-DjazXQ15.js → cs-k5C-jvfX.js} +1 -1
- package/web/dist/assets/{da-DnyF6SYK.js → da-CdBzU_fF.js} +1 -1
- package/web/dist/assets/{de--q1ph9Ez.js → de-B-UWa0W7.js} +1 -1
- package/web/dist/assets/{es-Do3DkBwD.js → es-Dod8aCau.js} +1 -1
- package/web/dist/assets/{fi-B7sJTx1N.js → fi-C36bBPFW.js} +1 -1
- package/web/dist/assets/{fr-Dy7f5rGi.js → fr-DWUxs2qv.js} +1 -1
- package/web/dist/assets/{hi-C9vSnTQ4.js → hi-DjLqhfs8.js} +1 -1
- package/web/dist/assets/{hu-h-eueQE8.js → hu-CgsOlcq8.js} +1 -1
- package/web/dist/assets/{id-D8Ffd-Pc.js → id-ZA8yTWX9.js} +1 -1
- package/web/dist/assets/{it-BKM_2rj1.js → it-DfFhS34g.js} +1 -1
- package/web/dist/assets/{main-Dl_uRj22.js → main-CjgQp9um.js} +2 -2
- package/web/dist/assets/{nl-Cj3ETrcl.js → nl-C257i24U.js} +1 -1
- package/web/dist/assets/{radio-zPgyISLf.js → radio-CDUEoM0y.js} +1 -1
- package/web/dist/assets/{ru-B5zmGSnG.js → ru--30I7BNC.js} +1 -1
- package/web/dist/assets/{station-BKmoJ0Sk.css → station-Be-z7jQ2.css} +1 -1
- package/web/dist/assets/{station-X8pffqCW.js → station-D769XVG0.js} +1 -1
- package/web/dist/assets/{sv-BMtjA1uI.js → sv-CxzBeXfk.js} +1 -1
- package/web/dist/assets/{uk-B2vuB6Un.js → uk-DXHpMk4i.js} +1 -1
- package/web/dist/assets/{vi-CXam3lDh.js → vi-Dg2LT4x3.js} +1 -1
- package/web/dist/assets/{zh-DnYhyzqb.js → zh-D3pvejo6.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/radio.html +12 -1
- package/web/dist/station.html +7 -2
- package/web/dist/sw.js +25 -25
- package/web/dist/vod.html +2 -2
- package/web/dist/watch.html +2 -2
- /package/web/dist/assets/{vod-CdfPMa1u.js → vod-bM1DvPEx.js} +0 -0
- /package/web/dist/assets/{watch-hdfnhm3C.js → watch-BbhPO0HS.js} +0 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The music store's pages and API on nixamp.com (music-store.ts).
|
|
3
|
+
*
|
|
4
|
+
* GET /buy?server=&kind=&artist=&album=&n= what it is, the price, email + coin, Pay
|
|
5
|
+
* GET /purchases/:id?t= the receipt: waiting, or Download the zip
|
|
6
|
+
* GET /api/v1/store/coins the coins nixamp's CoinPay business takes
|
|
7
|
+
* POST /api/v1/store/checkout {server, kind, artist, album?, n?, email, coin} -> {url}
|
|
8
|
+
* GET /api/v1/store/purchases the signed-in account's music (My music on /radio)
|
|
9
|
+
* GET /api/v1/store/purchases/:id?t= one purchase by its link
|
|
10
|
+
* GET /api/v1/store/grant?g= a server asks whether a grant is good
|
|
11
|
+
*/
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
import { PRICES } from "./music-store.js";
|
|
14
|
+
import { SupportError } from "./support.js";
|
|
15
|
+
const PURCHASE = /^\/purchases\/([0-9a-f-]{36})$/;
|
|
16
|
+
const ONE = /^\/api\/v1\/store\/purchases\/([0-9a-f-]{36})$/;
|
|
17
|
+
export function isStorePath(path) {
|
|
18
|
+
return path === "/buy" || PURCHASE.test(path) || path === "/api/v1/store/coins" || path === "/api/v1/store/checkout"
|
|
19
|
+
|| path === "/api/v1/store/purchases" || ONE.test(path) || path === "/api/v1/store/grant";
|
|
20
|
+
}
|
|
21
|
+
function escape(text) {
|
|
22
|
+
return text.replace(/[&<>"']/g, (one) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[one]);
|
|
23
|
+
}
|
|
24
|
+
function hash(text) {
|
|
25
|
+
return `'sha256-${createHash("sha256").update(text).digest("base64")}'`;
|
|
26
|
+
}
|
|
27
|
+
const STYLE = `
|
|
28
|
+
:root{--bg:#080c09;--fg:#e8efe9;--muted:#9aa79d;--card:#0e1410;--line:#1f2a22;--accent:#3ddc84}
|
|
29
|
+
*{box-sizing:border-box}html,body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif}
|
|
30
|
+
main{max-width:560px;margin:0 auto;padding:40px 16px}
|
|
31
|
+
.card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:24px}
|
|
32
|
+
h1{margin:0 0 4px;font-size:1.5rem;overflow-wrap:anywhere}.by{color:var(--muted);margin:0 0 16px}
|
|
33
|
+
.price{font-size:2rem;font-weight:800;color:var(--accent);margin:8px 0}
|
|
34
|
+
ul{color:var(--muted);padding-left:20px}
|
|
35
|
+
label{display:block;color:var(--muted);font-size:.9rem;margin:12px 0 4px}
|
|
36
|
+
input,select{width:100%;padding:10px;border-radius:10px;border:1px solid var(--line);background:var(--bg);color:var(--fg);font:inherit}
|
|
37
|
+
button,a.go{display:block;text-align:center;text-decoration:none;margin-top:18px;width:100%;padding:12px;border:0;border-radius:999px;background:var(--accent);color:#06120a;font:inherit;font-weight:800;cursor:pointer}
|
|
38
|
+
button:disabled{opacity:.5;cursor:wait}
|
|
39
|
+
button:focus-visible,input:focus-visible,select:focus-visible,a:focus-visible{outline:3px solid var(--accent);outline-offset:2px}
|
|
40
|
+
#status{min-height:1.4em;margin-top:12px;color:var(--muted)}
|
|
41
|
+
small{display:block;margin-top:18px;color:var(--muted)}small a,p a{color:var(--accent)}
|
|
42
|
+
`;
|
|
43
|
+
const BUY_SCRIPT = `
|
|
44
|
+
(async () => {
|
|
45
|
+
const $ = (s) => document.querySelector(s);
|
|
46
|
+
const status = $("#status");
|
|
47
|
+
try {
|
|
48
|
+
const coins = await (await fetch("/api/v1/store/coins")).json();
|
|
49
|
+
const cheap = ["USDC_POL", "USDC_SOL", "SOL", "POL", "LTC", "USDC_BASE", "USDC"];
|
|
50
|
+
const list = (coins.coins || []).slice().sort((a, b) => (cheap.indexOf(a) + 1 || 99) - (cheap.indexOf(b) + 1 || 99));
|
|
51
|
+
$("#coin").replaceChildren(...list.map((c) => Object.assign(document.createElement("option"), { value: c, textContent: c })));
|
|
52
|
+
} catch { status.textContent = "Payments are not available right now."; }
|
|
53
|
+
$("#buy").addEventListener("submit", async (event) => {
|
|
54
|
+
event.preventDefault();
|
|
55
|
+
const button = $("#pay");
|
|
56
|
+
button.disabled = true;
|
|
57
|
+
status.textContent = "Starting the checkout…";
|
|
58
|
+
const form = new FormData(event.currentTarget);
|
|
59
|
+
try {
|
|
60
|
+
const r = await fetch("/api/v1/store/checkout", { method: "POST", headers: { "content-type": "application/json" }, credentials: "same-origin",
|
|
61
|
+
body: JSON.stringify({ ...JSON.parse(document.body.dataset.item), email: form.get("email"), coin: form.get("coin") }) });
|
|
62
|
+
const body = await r.json();
|
|
63
|
+
if (!r.ok) throw new Error(body.error || "The checkout did not start.");
|
|
64
|
+
status.textContent = "Taking you to CoinPay…";
|
|
65
|
+
location.href = body.url;
|
|
66
|
+
} catch (error) {
|
|
67
|
+
status.textContent = error.message;
|
|
68
|
+
button.disabled = false;
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
})();
|
|
72
|
+
`;
|
|
73
|
+
export const STORE_CSP = ["default-src 'none'", `script-src ${hash(BUY_SCRIPT)}`, `style-src ${hash(STYLE)}`, "img-src https:", "connect-src 'self'", "form-action 'none'", "base-uri 'none'", "frame-ancestors 'none'"].join("; ");
|
|
74
|
+
function page(title, body, data = "") {
|
|
75
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
76
|
+
<title>${escape(title)} · nixamp</title><meta name="robots" content="noindex"><link rel="icon" href="/favicon.png" type="image/png"><style>${STYLE}</style></head>
|
|
77
|
+
<body${data ? ` data-item="${escape(data)}"` : ""}><main>${body}</main>${data ? `<script>${BUY_SCRIPT}</script>` : ""}</body></html>`;
|
|
78
|
+
}
|
|
79
|
+
function price(cents) {
|
|
80
|
+
return cents < 100 ? `${cents}¢` : `$${(cents / 100).toFixed(cents % 100 ? 2 : 0)}`;
|
|
81
|
+
}
|
|
82
|
+
function html(response, status, text, method) {
|
|
83
|
+
response.writeHead(status, { "content-type": "text/html; charset=utf-8", "content-security-policy": STORE_CSP, "cache-control": "no-store", "referrer-policy": "no-referrer" });
|
|
84
|
+
response.end(method === "HEAD" ? undefined : text);
|
|
85
|
+
}
|
|
86
|
+
export async function handleStoreRoutes(request, response, path, url, ctx) {
|
|
87
|
+
if (!isStorePath(path))
|
|
88
|
+
return false;
|
|
89
|
+
const method = request.method ?? "GET";
|
|
90
|
+
const store = ctx.store;
|
|
91
|
+
try {
|
|
92
|
+
if (path === "/buy" && (method === "GET" || method === "HEAD")) {
|
|
93
|
+
let item;
|
|
94
|
+
try {
|
|
95
|
+
item = await store.describe(Object.fromEntries(url.searchParams.entries()));
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
html(response, 404, page("Not for sale", `<div class="card"><h1>Not for sale</h1><p class="by">${escape(error.message)}</p></div>`), method);
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
const me = await ctx.account().catch(() => null);
|
|
102
|
+
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`;
|
|
103
|
+
const cover = item.kind === "artist" ? "" : `<img src="${escape(`${item.server}/api/v1/label/${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">`;
|
|
104
|
+
html(response, 200, page(`Download ${item.title}`, `<div class="card">${cover}
|
|
105
|
+
<h1>${escape(item.title)}</h1><p class="by">${escape(item.artistName)}</p>
|
|
106
|
+
<div class="price">${price(item.amountCents)}</div>
|
|
107
|
+
<ul><li>${what}, as MP3</li><li>Album artwork and the lyrics, inside each file and beside it</li><li>One zip, yours to keep; download again any time</li></ul>
|
|
108
|
+
<form id="buy">
|
|
109
|
+
<label for="email">Email (your download link goes here)</label>
|
|
110
|
+
<input id="email" name="email" type="email" required autocomplete="email" value="${escape(me?.email ?? "")}">
|
|
111
|
+
<label for="coin">Pay with</label>
|
|
112
|
+
<select id="coin" name="coin" required></select>
|
|
113
|
+
<button id="pay" type="submit">Pay ${price(item.amountCents)} with crypto</button>
|
|
114
|
+
</form>
|
|
115
|
+
<p id="status" role="status" aria-live="polite"></p>
|
|
116
|
+
<small>Payments go through <a href="https://coinpayportal.com">CoinPay</a> to nixamp.com. For small amounts pick a coin with low fees (USDC on Polygon or Solana, SOL, LTC). Listening on the live stations is always free.${me ? "" : ` <a href="/radio#music">Sign in</a> with the same email to keep everything under My music.`}</small>
|
|
117
|
+
</div>`, JSON.stringify({ server: item.server, kind: item.kind, artist: item.artist, album: item.album, n: item.n })), method);
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
const receipt = PURCHASE.exec(path);
|
|
121
|
+
if (receipt && (method === "GET" || method === "HEAD")) {
|
|
122
|
+
const purchase = await store.get(receipt[1], url.searchParams.get("t") ?? "");
|
|
123
|
+
if (!purchase) {
|
|
124
|
+
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);
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
const body = purchase.status === "paid"
|
|
128
|
+
? `<div class="card"><h1>${escape(purchase.title)}</h1><p class="by">${escape(purchase.artistName)} · paid ${price(purchase.amountCents)}</p>
|
|
129
|
+
<a class="go" href="${escape(store.zipUrl(purchase))}" download>Download the zip</a>
|
|
130
|
+
<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="/radio#music">My music</a>, to play or download.</small></div>`
|
|
131
|
+
: `<div class="card"><h1>${escape(purchase.title)}</h1><p class="by">${escape(purchase.artistName)} · ${price(purchase.amountCents)} in ${escape(purchase.coin)}</p>
|
|
132
|
+
<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>
|
|
133
|
+
<a class="go" href="https://coinpayportal.com/pay/${escape(purchase.paymentId)}">Open the payment</a></div>`;
|
|
134
|
+
html(response, 200, page(purchase.title, body), method);
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
if (path === "/api/v1/store/coins" && method === "GET") {
|
|
138
|
+
ctx.json(response, 200, { coins: await ctx.coins(), prices: { song: PRICES.song / 100, album: PRICES.album / 100, artist: PRICES.artist / 100 } });
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
if (path === "/api/v1/store/checkout" && method === "POST") {
|
|
142
|
+
let input;
|
|
143
|
+
try {
|
|
144
|
+
input = JSON.parse(await ctx.readBody(request));
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
ctx.json(response, 400, { error: "the body is not JSON", code: "bad_json" });
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
const me = await ctx.account().catch(() => null);
|
|
151
|
+
const made = await store.checkout(input, me);
|
|
152
|
+
ctx.json(response, 201, made);
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
if (path === "/api/v1/store/purchases" && method === "GET") {
|
|
156
|
+
const me = await ctx.account().catch(() => null);
|
|
157
|
+
if (!me) {
|
|
158
|
+
ctx.json(response, 401, { error: "sign in to see your music", code: "unauthorized" });
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
ctx.json(response, 200, { email: me.email, purchases: (await store.forAccount(me)).map((one) => store.view(one)) });
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
const one = ONE.exec(path);
|
|
165
|
+
if (one && method === "GET") {
|
|
166
|
+
const purchase = await store.get(one[1], url.searchParams.get("t") ?? "");
|
|
167
|
+
if (!purchase) {
|
|
168
|
+
ctx.json(response, 404, { error: "no such purchase", code: "not_found" });
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
ctx.json(response, 200, { purchase: store.view(purchase) });
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
if (path === "/api/v1/store/grant" && method === "GET") {
|
|
175
|
+
const scope = store.check(url.searchParams.get("g") ?? "");
|
|
176
|
+
ctx.json(response, scope ? 200 : 403, scope ? { ok: true, scope } : { ok: false });
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
ctx.json(response, 405, { error: "not like that", code: "method_not_allowed" });
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
if (error instanceof SupportError)
|
|
184
|
+
ctx.json(response, error.status, { error: error.message, code: error.code });
|
|
185
|
+
else
|
|
186
|
+
ctx.json(response, 500, { error: error instanceof Error ? error.message : String(error), code: "internal" });
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
}
|
package/dist/support.d.ts
CHANGED
|
@@ -30,6 +30,20 @@ export declare class Support {
|
|
|
30
30
|
business: string;
|
|
31
31
|
coins: string[];
|
|
32
32
|
}>;
|
|
33
|
+
/** Any CoinPay checkout on nixamp's business (a purchase in the store): no holder, no floor but a cent. */
|
|
34
|
+
pay(input: {
|
|
35
|
+
amountUsd: number;
|
|
36
|
+
coin: unknown;
|
|
37
|
+
description: string;
|
|
38
|
+
metadata: Record<string, unknown>;
|
|
39
|
+
redirect: string;
|
|
40
|
+
order: string;
|
|
41
|
+
}): Promise<{
|
|
42
|
+
url: string;
|
|
43
|
+
paymentId: string;
|
|
44
|
+
}>;
|
|
45
|
+
/** Whether CoinPay counts this payment as paid; null when it did not answer. */
|
|
46
|
+
isPaid(paymentId: string): Promise<boolean | null>;
|
|
33
47
|
/** A CoinPay checkout for this much, to this holder; the address to send the payer to. */
|
|
34
48
|
checkout(input: {
|
|
35
49
|
holder?: unknown;
|
package/dist/support.js
CHANGED
|
@@ -81,6 +81,47 @@ export class Support {
|
|
|
81
81
|
}
|
|
82
82
|
return this.merchant;
|
|
83
83
|
}
|
|
84
|
+
/** Any CoinPay checkout on nixamp's business (a purchase in the store): no holder, no floor but a cent. */
|
|
85
|
+
async pay(input) {
|
|
86
|
+
const { business, coins } = await this.coins();
|
|
87
|
+
const coin = typeof input.coin === "string" ? input.coin.toUpperCase() : "";
|
|
88
|
+
if (!coins.includes(coin))
|
|
89
|
+
throw new SupportError(`Pay with one of: ${coins.join(", ")}.`, 400, "invalid_coin");
|
|
90
|
+
const created = await this.provider("/api/payments/create", {
|
|
91
|
+
method: "POST",
|
|
92
|
+
headers: { "idempotency-key": `nixamp-${input.order}` },
|
|
93
|
+
body: JSON.stringify({
|
|
94
|
+
business_id: business,
|
|
95
|
+
amount_usd: Math.max(0.01, Math.round(input.amountUsd * 100) / 100),
|
|
96
|
+
currency: coin.toLowerCase(),
|
|
97
|
+
payment_method: "crypto",
|
|
98
|
+
description: input.description.slice(0, 200),
|
|
99
|
+
metadata: { app: "nixamp", ...input.metadata },
|
|
100
|
+
redirect_url: input.redirect,
|
|
101
|
+
}),
|
|
102
|
+
});
|
|
103
|
+
const payment = created["payment"];
|
|
104
|
+
const id = typeof payment?.id === "string" ? payment.id : "";
|
|
105
|
+
if (!/^[A-Za-z0-9_-]{6,80}$/.test(id))
|
|
106
|
+
throw new SupportError("The checkout did not start; nothing was charged. Try again.", 502, "provider");
|
|
107
|
+
return { url: `https://coinpayportal.com/pay/${id}`, paymentId: id };
|
|
108
|
+
}
|
|
109
|
+
/** Whether CoinPay counts this payment as paid; null when it did not answer. */
|
|
110
|
+
async isPaid(paymentId) {
|
|
111
|
+
try {
|
|
112
|
+
const response = await (this.options.fetcher ?? fetch)(`https://coinpayportal.com/api/payments/${encodeURIComponent(paymentId)}`, {
|
|
113
|
+
signal: AbortSignal.timeout(15_000),
|
|
114
|
+
headers: { authorization: `Bearer ${this.options.key}` },
|
|
115
|
+
});
|
|
116
|
+
if (!response.ok)
|
|
117
|
+
return null;
|
|
118
|
+
const body = (await response.json());
|
|
119
|
+
return PAID.has(String(body.payment?.status ?? ""));
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
84
125
|
/** A CoinPay checkout for this much, to this holder; the address to send the payer to. */
|
|
85
126
|
async checkout(input) {
|
|
86
127
|
const holder = typeof input.holder === "string" ? this.holder(input.holder) : undefined;
|
package/dist/track-facts.d.ts
CHANGED
|
@@ -26,6 +26,14 @@ export interface TrackFacts {
|
|
|
26
26
|
art: string;
|
|
27
27
|
/** Where the track's words are, on this station's link; "" when none are known. */
|
|
28
28
|
lyrics: string;
|
|
29
|
+
/** An AI label song: where to buy it, its album, or everything by the artist (nixamp.com/buy). */
|
|
30
|
+
store?: {
|
|
31
|
+
song: string;
|
|
32
|
+
album: string;
|
|
33
|
+
artist: string;
|
|
34
|
+
artistName: string;
|
|
35
|
+
albumTitle: string;
|
|
36
|
+
} | null;
|
|
29
37
|
}
|
|
30
38
|
export interface Holder {
|
|
31
39
|
id: string;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Writable } from "node:stream";
|
|
2
|
+
export declare function crc32(bytes: Uint8Array, crc?: number): number;
|
|
3
|
+
export interface ZipEntry {
|
|
4
|
+
/** The name inside the zip, with "/" between folders. */
|
|
5
|
+
name: string;
|
|
6
|
+
/** A file on disk, or the bytes themselves. */
|
|
7
|
+
path?: string;
|
|
8
|
+
bytes?: Uint8Array;
|
|
9
|
+
}
|
|
10
|
+
/** The zip's size, known before a byte is sent, for Content-Length. */
|
|
11
|
+
export declare function zipSize(entries: ZipEntry[]): number;
|
|
12
|
+
/** Writes the zip to `out` (left open). */
|
|
13
|
+
export declare function writeZip(out: Writable, entries: ZipEntry[], now?: Date): Promise<void>;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A zip written as it is sent: files are stored (MP3s and JPEGs do not
|
|
3
|
+
* shrink), each CRC-32 is worked out while its bytes go by and written after
|
|
4
|
+
* them in a data descriptor, so nothing is staged on disk and the first byte
|
|
5
|
+
* leaves at once. Plain zip (no ZIP64): fine for an album or an artist's
|
|
6
|
+
* catalogue, refused past 4 GB.
|
|
7
|
+
*/
|
|
8
|
+
import { createReadStream, statSync } from "node:fs";
|
|
9
|
+
const TABLE = (() => {
|
|
10
|
+
const table = new Uint32Array(256);
|
|
11
|
+
for (let n = 0; n < 256; n++) {
|
|
12
|
+
let c = n;
|
|
13
|
+
for (let k = 0; k < 8; k++)
|
|
14
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
15
|
+
table[n] = c >>> 0;
|
|
16
|
+
}
|
|
17
|
+
return table;
|
|
18
|
+
})();
|
|
19
|
+
export function crc32(bytes, crc = 0) {
|
|
20
|
+
let c = ~crc >>> 0;
|
|
21
|
+
for (let i = 0; i < bytes.length; i++)
|
|
22
|
+
c = TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
|
|
23
|
+
return ~c >>> 0;
|
|
24
|
+
}
|
|
25
|
+
/** DOS date and time, which is what zip keeps. */
|
|
26
|
+
function dosTime(at) {
|
|
27
|
+
return {
|
|
28
|
+
time: (at.getHours() << 11) | (at.getMinutes() << 5) | Math.floor(at.getSeconds() / 2),
|
|
29
|
+
date: ((Math.max(1980, at.getFullYear()) - 1980) << 9) | ((at.getMonth() + 1) << 5) | at.getDate(),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function write(out, chunk) {
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
if (out.destroyed)
|
|
35
|
+
return reject(new Error("the download was closed"));
|
|
36
|
+
if (out.write(chunk))
|
|
37
|
+
return resolve();
|
|
38
|
+
const fail = () => { out.off("drain", go); reject(new Error("the download was closed")); };
|
|
39
|
+
const go = () => { out.off("close", fail); resolve(); };
|
|
40
|
+
out.once("drain", go);
|
|
41
|
+
out.once("close", fail);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
/** The zip's size, known before a byte is sent, for Content-Length. */
|
|
45
|
+
export function zipSize(entries) {
|
|
46
|
+
let total = 22;
|
|
47
|
+
for (const entry of entries) {
|
|
48
|
+
const name = Buffer.byteLength(entry.name);
|
|
49
|
+
const size = entry.bytes ? entry.bytes.length : statSync(entry.path).size;
|
|
50
|
+
total += 30 + name + size + 16 + 46 + name;
|
|
51
|
+
}
|
|
52
|
+
return total;
|
|
53
|
+
}
|
|
54
|
+
/** Writes the zip to `out` (left open). */
|
|
55
|
+
export async function writeZip(out, entries, now = new Date()) {
|
|
56
|
+
const { time, date } = dosTime(now);
|
|
57
|
+
const central = [];
|
|
58
|
+
let offset = 0;
|
|
59
|
+
for (const entry of entries) {
|
|
60
|
+
const name = Buffer.from(entry.name, "utf8");
|
|
61
|
+
const local = Buffer.alloc(30);
|
|
62
|
+
local.writeUInt32LE(0x04034b50, 0);
|
|
63
|
+
local.writeUInt16LE(20, 4);
|
|
64
|
+
local.writeUInt16LE(0x0808, 6); // sizes in a descriptor after the data; names are UTF-8
|
|
65
|
+
local.writeUInt16LE(0, 8); // stored
|
|
66
|
+
local.writeUInt16LE(time, 10);
|
|
67
|
+
local.writeUInt16LE(date, 12);
|
|
68
|
+
local.writeUInt16LE(name.length, 26);
|
|
69
|
+
await write(out, local);
|
|
70
|
+
await write(out, name);
|
|
71
|
+
let crc = 0;
|
|
72
|
+
let size = 0;
|
|
73
|
+
if (entry.bytes) {
|
|
74
|
+
crc = crc32(entry.bytes);
|
|
75
|
+
size = entry.bytes.length;
|
|
76
|
+
await write(out, entry.bytes);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
for await (const chunk of createReadStream(entry.path)) {
|
|
80
|
+
const bytes = chunk;
|
|
81
|
+
crc = crc32(bytes, crc);
|
|
82
|
+
size += bytes.length;
|
|
83
|
+
await write(out, bytes);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const descriptor = Buffer.alloc(16);
|
|
87
|
+
descriptor.writeUInt32LE(0x08074b50, 0);
|
|
88
|
+
descriptor.writeUInt32LE(crc, 4);
|
|
89
|
+
descriptor.writeUInt32LE(size, 8);
|
|
90
|
+
descriptor.writeUInt32LE(size, 12);
|
|
91
|
+
await write(out, descriptor);
|
|
92
|
+
const header = Buffer.alloc(46);
|
|
93
|
+
header.writeUInt32LE(0x02014b50, 0);
|
|
94
|
+
header.writeUInt16LE(20, 4);
|
|
95
|
+
header.writeUInt16LE(20, 6);
|
|
96
|
+
header.writeUInt16LE(0x0808, 8);
|
|
97
|
+
header.writeUInt16LE(0, 10);
|
|
98
|
+
header.writeUInt16LE(time, 12);
|
|
99
|
+
header.writeUInt16LE(date, 14);
|
|
100
|
+
header.writeUInt32LE(crc, 16);
|
|
101
|
+
header.writeUInt32LE(size, 20);
|
|
102
|
+
header.writeUInt32LE(size, 24);
|
|
103
|
+
header.writeUInt16LE(name.length, 28);
|
|
104
|
+
header.writeUInt32LE(offset, 42);
|
|
105
|
+
central.push(header, name);
|
|
106
|
+
offset += 30 + name.length + size + 16;
|
|
107
|
+
if (offset > 0xffffffff)
|
|
108
|
+
throw new Error("too big for a zip (4 GB)");
|
|
109
|
+
}
|
|
110
|
+
const directory = Buffer.concat(central);
|
|
111
|
+
const end = Buffer.alloc(22);
|
|
112
|
+
end.writeUInt32LE(0x06054b50, 0);
|
|
113
|
+
end.writeUInt16LE(entries.length, 8);
|
|
114
|
+
end.writeUInt16LE(entries.length, 10);
|
|
115
|
+
end.writeUInt32LE(directory.length, 12);
|
|
116
|
+
end.writeUInt32LE(offset, 16);
|
|
117
|
+
await write(out, directory);
|
|
118
|
+
await write(out, end);
|
|
119
|
+
}
|
package/package.json
CHANGED
package/src/ai-label.ts
CHANGED
|
@@ -218,7 +218,7 @@ const PLAN_SYSTEM = [
|
|
|
218
218
|
const LYRICS_SYSTEM = [
|
|
219
219
|
"You write original English song lyrics for an AI singer. Never quote or imitate an existing song and never name a real artist.",
|
|
220
220
|
"Put each section tag on its own line: [Intro], [Verse 1], [Pre-Chorus], [Chorus], [Verse 2], [Bridge], [Outro].",
|
|
221
|
-
"Use two verses, a chorus sung two or three times and a bridge; vivid, specific, singable lines that rhyme where it sounds natural.",
|
|
221
|
+
"Use two verses of four lines, a four-line chorus sung two or three times, a pre-chorus of two lines and a bridge of two to four lines; vivid, specific, singable lines that rhyme where it sounds natural. Never just one line under a section.",
|
|
222
222
|
"Only the words that are sung, no stage directions or notes. Answer as JSON: {\"lyrics\": \"the lyrics, lines separated by \\n\"}.",
|
|
223
223
|
].join(" ");
|
|
224
224
|
|
|
@@ -810,6 +810,53 @@ export class AiLabel {
|
|
|
810
810
|
return match![2] === "mp3" ? { path: song.file, type: "audio/mpeg" } : { path: song.lyricsFile, type: "text/plain; charset=utf-8" };
|
|
811
811
|
}
|
|
812
812
|
|
|
813
|
+
/** The label song at this path, for the now-playing card's Download buttons. */
|
|
814
|
+
songAt(file: string): { artist: string; album: string; n: number; title: string; artistName: string; albumTitle: string } | null {
|
|
815
|
+
for (const artist of this.artists.values()) {
|
|
816
|
+
for (const album of artist.albums) {
|
|
817
|
+
const song = album.songs.find((one) => one.file === file);
|
|
818
|
+
if (song) return { artist: artist.key, album: album.key, n: song.n, title: song.title, artistName: artist.name, albumTitle: album.title };
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
return null;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/** A released song's MP3, when this grant's scope covers it. */
|
|
825
|
+
songFile(artistKey: string, albumKey: string, n: number): string {
|
|
826
|
+
return this.artists.get(artistKey)?.albums.find((one) => one.key === albumKey)?.songs.find((one) => one.n === n && one.file)?.file ?? "";
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* What a purchase's zip holds: every released song in scope with its words,
|
|
831
|
+
* and each album's cover and album.json, under <artist>/<year>-<album>/.
|
|
832
|
+
* Null when there is nothing (or no such artist).
|
|
833
|
+
*/
|
|
834
|
+
zipFor(scope: { kind: "song" | "album" | "artist"; artist: string; album: string; n: number }): { name: string; entries: Array<{ name: string; path?: string; bytes?: Uint8Array }> } | null {
|
|
835
|
+
const artist = this.artists.get(scope.artist);
|
|
836
|
+
if (!artist) return null;
|
|
837
|
+
const albums = artist.albums.filter((one) => scope.kind === "artist" || one.key === scope.album);
|
|
838
|
+
const entries: Array<{ name: string; path?: string; bytes?: Uint8Array }> = [];
|
|
839
|
+
const top = fileName(artist.name);
|
|
840
|
+
for (const album of albums) {
|
|
841
|
+
const songs = album.songs.filter((one) => one.file && (scope.kind !== "song" || one.n === scope.n));
|
|
842
|
+
if (songs.length === 0) continue;
|
|
843
|
+
const folder = `${top}/${album.year}-${fileName(album.title)}`;
|
|
844
|
+
for (const song of songs) {
|
|
845
|
+
entries.push({ name: `${folder}/${songBase(song)}.mp3`, path: song.file });
|
|
846
|
+
entries.push({ name: `${folder}/${songBase(song)}.lyrics.txt`, bytes: Buffer.from(`${song.lyrics}\n`) });
|
|
847
|
+
}
|
|
848
|
+
if (album.cover) entries.push({ name: `${folder}/cover.jpg`, path: album.cover });
|
|
849
|
+
entries.push({ name: `${folder}/album.json`, bytes: Buffer.from(`${JSON.stringify({
|
|
850
|
+
artist: artist.name, genre: artist.genre, album: album.title, year: album.year, concept: album.concept,
|
|
851
|
+
label: this.settings.holder, copyright: `℗ ${album.year} ${this.settings.holder}`, madeWithAi: true,
|
|
852
|
+
songs: songs.map((one) => ({ n: one.n, title: one.title, seconds: one.seconds, file: `${songBase(one)}.mp3`, lyrics: `${songBase(one)}.lyrics.txt` })),
|
|
853
|
+
}, null, 2)}\n`) });
|
|
854
|
+
}
|
|
855
|
+
if (entries.length === 0) return null;
|
|
856
|
+
const what = scope.kind === "artist" ? "Discography" : scope.kind === "album" ? albums[0]!.title : this.songFile(scope.artist, scope.album, scope.n) ? albums[0]!.songs.find((one) => one.n === scope.n)!.title : "Song";
|
|
857
|
+
return { name: `${fileName(artist.name)} - ${fileName(what)}.zip`, entries };
|
|
858
|
+
}
|
|
859
|
+
|
|
813
860
|
private require(key: string): LabelArtist {
|
|
814
861
|
const artist = this.artists.get(key);
|
|
815
862
|
if (!artist) throw new StationError("not_found", "no such artist on the label", 404);
|
package/src/label-routes.ts
CHANGED
|
@@ -10,20 +10,34 @@ import { createHash } from "node:crypto";
|
|
|
10
10
|
import { createReadStream, statSync } from "node:fs";
|
|
11
11
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
12
12
|
import { ROSTER, type AiLabel, type PublicArtist } from "./ai-label.ts";
|
|
13
|
+
import { covers, PRICES, type GrantVerifier } from "./music-store.ts";
|
|
14
|
+
import { writeZip, zipSize } from "./zip-stream.ts";
|
|
13
15
|
import { StationError } from "./stations.ts";
|
|
14
16
|
|
|
15
17
|
// Listening is free on the live stations (with their breaks); the songs as
|
|
16
|
-
// files are sold, so only the words and the cover are public here
|
|
17
|
-
|
|
18
|
+
// files are sold, so only the words and the cover are public here, and an
|
|
19
|
+
// MP3 or a zip needs a grant from nixamp.com's store (music-store.ts).
|
|
20
|
+
const FILE = /^\/api\/v1\/label\/([a-f0-9]{10})\/([a-f0-9]{8})\/(\d{1,3}\.(?:txt|mp3)|cover\.jpg)$/;
|
|
18
21
|
|
|
19
|
-
/** Whether this path is the label's public face. */
|
|
22
|
+
/** Whether this path is the label's public face (an MP3 or the zip still asks for its grant). */
|
|
20
23
|
export function isPublicLabelPath(path: string): boolean {
|
|
21
|
-
return path === "/label" || path === "/api/v1/label" || FILE.test(path);
|
|
24
|
+
return path === "/label" || path === "/api/v1/label" || path === "/api/v1/label/zip" || FILE.test(path);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** nixamp.com's page that sells this: a song, an album or everything by the artist. */
|
|
28
|
+
export function buyLink(site: string, server: string, kind: "song" | "album" | "artist", artist: string, album = "", n = 0): string {
|
|
29
|
+
const query = new URLSearchParams({ server, kind, artist, ...(kind !== "artist" ? { album } : {}), ...(kind === "song" ? { n: String(n) } : {}) });
|
|
30
|
+
return `${site}/buy?${query.toString()}`;
|
|
22
31
|
}
|
|
23
32
|
|
|
24
33
|
export interface LabelRoutesContext {
|
|
25
34
|
label: AiLabel;
|
|
26
35
|
canControl: boolean;
|
|
36
|
+
/** Checks a store grant with nixamp.com; without it nothing is sold or handed out. */
|
|
37
|
+
grants?: GrantVerifier;
|
|
38
|
+
/** nixamp.com, where the store is, and this server's public address (for Buy links). */
|
|
39
|
+
site?: string;
|
|
40
|
+
publicBase?: string;
|
|
27
41
|
json: (response: ServerResponse, status: number, body: unknown) => void;
|
|
28
42
|
readBody: (request: IncomingMessage) => Promise<string>;
|
|
29
43
|
}
|
|
@@ -91,7 +105,8 @@ export async function handleLabelRoutes(
|
|
|
91
105
|
return true;
|
|
92
106
|
}
|
|
93
107
|
if (path === "/label") {
|
|
94
|
-
const
|
|
108
|
+
const shop = ctx.grants && ctx.site && /^https:\/\//.test(ctx.publicBase ?? "") ? { site: ctx.site, server: ctx.publicBase! } : undefined;
|
|
109
|
+
const page = renderLabel(label.publicView(), shop);
|
|
95
110
|
response.writeHead(200, { "content-type": "text/html; charset=utf-8", "content-security-policy": LABEL_CSP, "cache-control": "public, max-age=300" });
|
|
96
111
|
response.end(method === "HEAD" ? undefined : page);
|
|
97
112
|
return true;
|
|
@@ -100,7 +115,45 @@ export async function handleLabelRoutes(
|
|
|
100
115
|
ctx.json(response, 200, label.publicView());
|
|
101
116
|
return true;
|
|
102
117
|
}
|
|
118
|
+
const url = new URL(request.url ?? "/", "http://x");
|
|
119
|
+
const grant = url.searchParams.get("grant") ?? "";
|
|
120
|
+
if (path === "/api/v1/label/zip") {
|
|
121
|
+
const scope = ctx.grants ? await ctx.grants.check(grant) : null;
|
|
122
|
+
if (!scope) {
|
|
123
|
+
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" });
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
const zip = label.zipFor(scope);
|
|
127
|
+
if (!zip) {
|
|
128
|
+
ctx.json(response, 404, { error: "nothing to download yet", code: "not_found" });
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
response.writeHead(200, {
|
|
132
|
+
"content-type": "application/zip",
|
|
133
|
+
"content-length": String(zipSize(zip.entries)),
|
|
134
|
+
"content-disposition": `attachment; filename*=UTF-8''${encodeURIComponent(zip.name)}`,
|
|
135
|
+
"cache-control": "private, no-store",
|
|
136
|
+
});
|
|
137
|
+
if (method === "HEAD") {
|
|
138
|
+
response.end();
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
await writeZip(response, zip.entries).catch(() => response.destroy());
|
|
142
|
+
response.end();
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
103
145
|
const [, artist, album, what] = FILE.exec(path)!;
|
|
146
|
+
if (what!.endsWith(".mp3")) {
|
|
147
|
+
const scope = ctx.grants && grant ? await ctx.grants.check(grant) : null;
|
|
148
|
+
const n = Number(what!.split(".")[0]);
|
|
149
|
+
const file = scope && covers(scope, artist!, album!, n) ? label.songFile(artist!, album!, n) : "";
|
|
150
|
+
if (!file) {
|
|
151
|
+
ctx.json(response, 403, { error: "songs as files are for the people who bought them; listening on the live stations is free", code: "not_bought" });
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
sendFile(request, response, file, "audio/mpeg", file.split("/").pop() ?? "");
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
104
157
|
const found = label.publicFile(artist!, album!, what!);
|
|
105
158
|
if (!found) {
|
|
106
159
|
ctx.json(response, 404, { error: "no such file on the label", code: "not_found" });
|
|
@@ -213,6 +266,7 @@ audio{width:100%;max-width:360px;height:36px}
|
|
|
213
266
|
details{margin-top:4px}summary{cursor:pointer;color:var(--muted);font-size:.9rem}
|
|
214
267
|
pre.lyrics{white-space:pre-wrap;font:inherit;font-size:.95rem;margin:8px 0 0;padding:12px;border-radius:8px;background:var(--bg)}
|
|
215
268
|
.dl{font-size:.85rem}.soon{color:var(--muted);font-size:.85rem}
|
|
269
|
+
a.buy{display:inline-block;font-weight:700;text-decoration:none;border:1px solid var(--accent);border-radius:999px;padding:2px 10px}
|
|
216
270
|
footer{margin-top:48px;color:var(--muted);font-size:.85rem}footer a{color:inherit}
|
|
217
271
|
`;
|
|
218
272
|
|
|
@@ -225,25 +279,39 @@ export const LABEL_CSP = [
|
|
|
225
279
|
"base-uri 'none'", "form-action 'none'", "frame-ancestors 'none'",
|
|
226
280
|
].join("; ");
|
|
227
281
|
|
|
228
|
-
|
|
282
|
+
type Shop = { site: string; server: string } | undefined;
|
|
283
|
+
|
|
284
|
+
function price(cents: number): string {
|
|
285
|
+
return cents < 100 ? `${cents}¢` : `$${(cents / 100).toFixed(cents % 100 ? 2 : 0)}`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function buy(shop: Shop, kind: "song" | "album" | "artist", artist: string, album = "", n = 0, what = ""): string {
|
|
289
|
+
if (!shop) return "";
|
|
290
|
+
return `<a class="buy" href="${escape(buyLink(shop.site, shop.server, kind, artist, album, n))}">Download ${what || kind} · ${price(PRICES[kind])}</a>`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function renderAlbum(artist: PublicArtist, album: PublicArtist["albums"][number], shop: Shop): string {
|
|
229
294
|
const base = `/api/v1/label/${artist.key}/${album.key}`;
|
|
230
295
|
const cover = album.cover ? `<img class="cover" src="${base}/cover.jpg" alt="Cover of ${escape(album.title)}" width="160" height="160" loading="lazy">` : `<div class="blank" aria-hidden="true"></div>`;
|
|
231
296
|
const songs = album.songs.map((song) => `<li><div class="song"><span class="n">${song.n}.</span><span class="t">${escape(song.title)}</span><span class="d">${minutes(song.seconds)}</span></div>`
|
|
232
297
|
+ `<details><summary>Lyrics</summary><pre class="lyrics">${escape(song.lyrics)}</pre></details>`
|
|
233
|
-
+ `<div class="dl"><a href="${base}/${song.n}.txt">lyrics.txt</a
|
|
298
|
+
+ `<div class="dl"><a href="${base}/${song.n}.txt">lyrics.txt</a>${shop ? ` · ${buy(shop, "song", artist.key, album.key, song.n, "MP3")}` : ""}</div></li>`).join("");
|
|
234
299
|
return `<div class="album">${cover}<div><h4>${escape(album.title)} <span class="d">(${album.year})</span></h4>`
|
|
235
300
|
+ `${album.concept ? `<p class="concept">${escape(album.concept)}</p>` : ""}`
|
|
236
301
|
+ `${album.complete ? "" : `<p class="soon">Still being recorded: new songs land here as they are finished.</p>`}`
|
|
302
|
+
+ `${shop ? `<p>${buy(shop, "album", artist.key, album.key, 0, "the album, MP3s with art and lyrics")}</p>` : ""}`
|
|
237
303
|
+ `<ol class="songs">${songs}</ol></div></div>`;
|
|
238
304
|
}
|
|
239
305
|
|
|
240
306
|
/** The label's catalogue as a page: no script, plays in any browser. */
|
|
241
|
-
export function renderLabel(view: ReturnType<AiLabel["publicView"]
|
|
307
|
+
export function renderLabel(view: ReturnType<AiLabel["publicView"]>, shop?: Shop): string {
|
|
242
308
|
const label = view.holder.replace(/,?\s+(inc|llc|ltd|gmbh|co)\.?$/i, "");
|
|
243
309
|
const nav = view.genres.length > 1 ? `<nav class="genres" aria-label="Genres">${view.genres.map((one) => `<a href="#${escape(one.genre.toLowerCase().replace(/[^a-z0-9]+/g, "-"))}">${escape(one.genre)}</a>`).join("")}</nav>` : "";
|
|
244
310
|
const sections = view.genres.map((genre) => `<section class="genre" id="${escape(genre.genre.toLowerCase().replace(/[^a-z0-9]+/g, "-"))}" aria-labelledby="g-${escape(genre.genre.toLowerCase().replace(/[^a-z0-9]+/g, "-"))}">`
|
|
245
311
|
+ `<h2 id="g-${escape(genre.genre.toLowerCase().replace(/[^a-z0-9]+/g, "-"))}">${escape(genre.genre)}${genre.station ? ` <a href="/watch/${escape(genre.station)}">Listen live, free →</a>` : ""}</h2>`
|
|
246
|
-
+ genre.artists.map((artist) => `<article class="artist"><h3>${escape(artist.name)}</h3>${artist.bio ? `<p class="bio">${escape(artist.bio)}</p>` : ""}
|
|
312
|
+
+ genre.artists.map((artist) => `<article class="artist"><h3>${escape(artist.name)}</h3>${artist.bio ? `<p class="bio">${escape(artist.bio)}</p>` : ""}`
|
|
313
|
+
+ `${shop ? `<p>${buy(shop, "artist", artist.key, "", 0, "everything by them")}</p>` : ""}`
|
|
314
|
+
+ `${artist.albums.map((album) => renderAlbum(artist, album, shop)).join("")}</article>`).join("")
|
|
247
315
|
+ `</section>`).join("");
|
|
248
316
|
return `<!doctype html>
|
|
249
317
|
<html lang="en">
|