nixamp 0.49.0 → 0.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/ai-label.d.ts +29 -0
  2. package/dist/ai-label.js +135 -2
  3. package/dist/label-routes.d.ts +15 -2
  4. package/dist/label-routes.js +68 -9
  5. package/dist/music-store.d.ts +121 -0
  6. package/dist/music-store.js +289 -0
  7. package/dist/server.d.ts +3 -0
  8. package/dist/server.js +73 -12
  9. package/dist/station-usage.d.ts +1 -1
  10. package/dist/station-usage.js +3 -3
  11. package/dist/store-routes.d.ts +16 -0
  12. package/dist/store-routes.js +189 -0
  13. package/dist/support.d.ts +14 -0
  14. package/dist/support.js +41 -0
  15. package/dist/track-facts.d.ts +8 -0
  16. package/dist/track-facts.js +1 -1
  17. package/dist/zip-stream.d.ts +13 -0
  18. package/dist/zip-stream.js +119 -0
  19. package/package.json +1 -1
  20. package/src/ai-label.ts +133 -2
  21. package/src/label-routes.ts +77 -9
  22. package/src/music-store.ts +333 -0
  23. package/src/server.ts +63 -4
  24. package/src/station-usage.ts +3 -3
  25. package/src/store-routes.ts +204 -0
  26. package/src/support.ts +39 -0
  27. package/src/track-facts.ts +3 -1
  28. package/src/zip-stream.ts +128 -0
  29. package/web/dist/assets/{ar-BEVjqkqM.js → ar--GRGOL7F.js} +1 -1
  30. package/web/dist/assets/{cs-DjazXQ15.js → cs-B2ab-8qO.js} +1 -1
  31. package/web/dist/assets/{da-DnyF6SYK.js → da-Ek2g1uTg.js} +1 -1
  32. package/web/dist/assets/{de--q1ph9Ez.js → de-CM7-9Fbk.js} +1 -1
  33. package/web/dist/assets/{es-Do3DkBwD.js → es-BFSOrr3I.js} +1 -1
  34. package/web/dist/assets/{fi-B7sJTx1N.js → fi-D9Tf69sv.js} +1 -1
  35. package/web/dist/assets/{fr-Dy7f5rGi.js → fr-Gh9yYJGo.js} +1 -1
  36. package/web/dist/assets/{hi-C9vSnTQ4.js → hi-B3ow3HJC.js} +1 -1
  37. package/web/dist/assets/{hu-h-eueQE8.js → hu-paju-6Kr.js} +1 -1
  38. package/web/dist/assets/{id-D8Ffd-Pc.js → id-mubOQFX0.js} +1 -1
  39. package/web/dist/assets/{it-BKM_2rj1.js → it-0EM-qnDT.js} +1 -1
  40. package/web/dist/assets/{main-Dl_uRj22.js → main-CECXge6G.js} +2 -2
  41. package/web/dist/assets/{nl-Cj3ETrcl.js → nl-c6HalphH.js} +1 -1
  42. package/web/dist/assets/{radio-zPgyISLf.js → radio-CDUEoM0y.js} +1 -1
  43. package/web/dist/assets/{ru-B5zmGSnG.js → ru-CUKYVlgf.js} +1 -1
  44. package/web/dist/assets/{station-BKmoJ0Sk.css → station-Be-z7jQ2.css} +1 -1
  45. package/web/dist/assets/{station-X8pffqCW.js → station-D769XVG0.js} +1 -1
  46. package/web/dist/assets/{sv-BMtjA1uI.js → sv-BUxBsZR5.js} +1 -1
  47. package/web/dist/assets/{uk-B2vuB6Un.js → uk-708lBssa.js} +1 -1
  48. package/web/dist/assets/{vi-CXam3lDh.js → vi-B1ropUvk.js} +1 -1
  49. package/web/dist/assets/{zh-DnYhyzqb.js → zh-C_ndTsNh.js} +1 -1
  50. package/web/dist/index.html +1 -1
  51. package/web/dist/radio.html +12 -1
  52. package/web/dist/station.html +7 -2
  53. package/web/dist/sw.js +25 -25
  54. package/web/dist/vod.html +2 -2
  55. package/web/dist/watch.html +2 -2
  56. /package/web/dist/assets/{vod-CdfPMa1u.js → vod-bM1DvPEx.js} +0 -0
  57. /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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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;
@@ -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;
@@ -130,7 +130,7 @@ export function factsFor(input) {
130
130
  const copyright = tags["copyright"] || tags["tcop"] || "";
131
131
  let rights;
132
132
  if (copyright) {
133
- rights = { holder: copyright.replace(/^(\(c\)|©|copyright)\s*/i, "").slice(0, 200), basis: "", note: "", license: holder?.license ?? "", source: "tag" };
133
+ rights = { holder: copyright.replace(/^((\(c\)|\(p\)|©|℗|copyright)\s*)+/i, "").slice(0, 200), basis: "", note: "", license: holder?.license ?? "", source: "tag" };
134
134
  }
135
135
  else if (holder) {
136
136
  rights = { holder: holder.name, basis: station.rights?.basis ?? "", note: "", license: holder.license, source: "holder" };
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.49.0",
3
+ "version": "0.51.0",
4
4
  "description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/ai-label.ts CHANGED
@@ -151,6 +151,90 @@ export const ROSTER: Record<string, ArtistPreset> = {
151
151
  bio: "Maps of places that burned down, drawn at full volume.",
152
152
  themes: "ruins, resilience, storms, rebuilding from ashes, defiance",
153
153
  },
154
+ "iron-lantern-society": {
155
+ name: "Iron Lantern Society", genre: "Rock",
156
+ sound: "bluesy classic hard rock, swaggering riffs, Hammond organ, cowbell grooves, big guitar solos, 100-128 BPM",
157
+ voice: "gravelly male baritone with a wide rasp and a rock-and-roll howl",
158
+ bio: "Five road dogs who think every song should end in a bar fight or a sunrise.",
159
+ themes: "the open road, hard luck, loyalty among misfits, last calls and new starts",
160
+ },
161
+ "paper-satellites": {
162
+ name: "Paper Satellites", genre: "Rock",
163
+ sound: "jangly indie rock, chorus-drenched guitars, driving eighth-note bass, crisp drums, shout-along choruses, 130-160 BPM",
164
+ voice: "earnest male tenor, slightly nasal, with gang vocals in the choruses",
165
+ bio: "Bedroom recordings that grew up and got a van.",
166
+ themes: "growing up, small apartments, friendship, leaving and coming back",
167
+ },
168
+ "mira-solace": {
169
+ name: "Mira Solace", genre: "Hip-Hop",
170
+ sound: "melodic modern trap, airy pads, rolling hi-hats, sliding 808s, sparse piano, 130-145 BPM half-time",
171
+ voice: "confident female rapper with a smoky tone who switches to sung melodic hooks",
172
+ bio: "Writes verses on her phone between shifts and performs them like a closing argument.",
173
+ themes: "self-belief, heartbreak turned into fuel, sisterhood, getting paid",
174
+ },
175
+ "cold-brew-cipher": {
176
+ name: "Cold Brew Cipher", genre: "Hip-Hop",
177
+ sound: "jazzy laid-back hip hop, upright bass, muted trumpet, dusty drum breaks, vinyl warmth, 84-92 BPM",
178
+ voice: "relaxed male rapper with a warm baritone, wordplay-heavy, with a soulful sung chorus",
179
+ bio: "A coffee-shop cipher that never closed.",
180
+ themes: "slow mornings, neighbourhood characters, gratitude, wit and wordplay",
181
+ },
182
+ "vapor-meridian": {
183
+ name: "Vapor Meridian", genre: "Synthwave",
184
+ sound: "dark synthwave, pulsing bass sequences, cold arpeggios, gated snares, cinematic swells, 90-110 BPM",
185
+ voice: "cool, breathy female alto, close-miked and haunting",
186
+ bio: "The soundtrack to a neon noir that only exists after midnight.",
187
+ themes: "rain-soaked cities, secrets, rogue machines, love in the static",
188
+ },
189
+ "static-riviera": {
190
+ name: "Static Riviera", genre: "Synthwave",
191
+ sound: "sunny outrun synthwave, bright saw leads, funky slap bass, sax hooks, sunset pads, 110-124 BPM",
192
+ voice: "smooth male tenor with a touch of vocoder on the hooks",
193
+ bio: "A beach-town cassette left in the sun since 1986.",
194
+ themes: "summer nights, coastal highways, arcades, retro romance",
195
+ },
196
+ "boone-calloway": {
197
+ name: "Boone Calloway", genre: "Country",
198
+ sound: "outlaw country, twangy Telecaster, train-beat drums, honky-tonk piano, dobro, 90-120 BPM",
199
+ voice: "deep, weathered male baritone with a slow drawl",
200
+ bio: "Sings like he has been thrown out of every bar in three counties and misses all of them.",
201
+ themes: "freedom, regret, dusty highways, family, whiskey and redemption",
202
+ },
203
+ "gravel-hollow": {
204
+ name: "The Gravel Hollow Band", genre: "Country",
205
+ sound: "bluegrass and Americana, banjo rolls, mandolin chops, fiddle, upright bass, stacked harmonies, 100-140 BPM",
206
+ voice: "a lead male tenor with tight three-part mountain harmonies",
207
+ bio: "A porch band that never learned to play quietly.",
208
+ themes: "mountains, rivers, hard work, faith, homecoming",
209
+ },
210
+ "hollow-sovereign": {
211
+ name: "Hollow Sovereign", genre: "Metal",
212
+ sound: "symphonic doom metal, crushing slow riffs, choirs, strings, thundering double-kick climaxes, 60-90 BPM",
213
+ voice: "soaring operatic female soprano over deep male growled counterpoints",
214
+ bio: "A cathedral of riffs built in the ruins of a kingdom that never was.",
215
+ themes: "fallen empires, myth, grief, endurance, crowns and chains",
216
+ },
217
+ "rust-cathedral": {
218
+ name: "Rust Cathedral", genre: "Metal",
219
+ sound: "fast thrash metal, palm-muted gallop riffs, shredding solos, pounding double kick, 180-220 BPM",
220
+ voice: "aggressive male shout-singing with high screams",
221
+ bio: "Factory-floor thrash forged from overtime and broken machines.",
222
+ themes: "industry, rebellion, machines, the working class fighting back",
223
+ },
224
+ "theo-marlowe": {
225
+ name: "Theo Marlowe", genre: "Pop",
226
+ sound: "groovy funk-pop, slap bass, tight disco drums, bright brass stabs, falsetto hooks, 112-122 BPM",
227
+ voice: "smooth male pop tenor with a playful falsetto",
228
+ bio: "A charmer with a dance floor in his back pocket.",
229
+ themes: "flirting, confidence, summer romance, good nights out",
230
+ },
231
+ "glass-hearts-club": {
232
+ name: "Glass Hearts Club", genre: "Pop",
233
+ sound: "shimmering synth-pop, gated drums, glassy synths, big layered chorus vocals, 118-128 BPM",
234
+ voice: "a trio of bright female voices trading lines and stacking harmonies",
235
+ bio: "Three best friends turning group chats into choruses.",
236
+ themes: "friendship, crushes, breakups survived together, dancing until the lights come on",
237
+ },
154
238
  "lumi-kestrel": {
155
239
  name: "Lumi Kestrel", genre: "Pop",
156
240
  sound: "bright modern dance pop, punchy four-on-the-floor drums, glossy synths, plucky guitar, big pre-chorus lifts, 110-124 BPM",
@@ -165,7 +249,7 @@ export const DEFAULTS: Omit<LabelSettings, "pausedReason"> = {
165
249
  enabled: true,
166
250
  monthlySeconds: 3600,
167
251
  everyDays: 30,
168
- seedAlbums: 2,
252
+ seedAlbums: 1,
169
253
  songsPerAlbum: 8,
170
254
  songSeconds: 150,
171
255
  publish: true,
@@ -218,7 +302,7 @@ const PLAN_SYSTEM = [
218
302
  const LYRICS_SYSTEM = [
219
303
  "You write original English song lyrics for an AI singer. Never quote or imitate an existing song and never name a real artist.",
220
304
  "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.",
305
+ "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
306
  "Only the words that are sung, no stage directions or notes. Answer as JSON: {\"lyrics\": \"the lyrics, lines separated by \\n\"}.",
223
307
  ].join(" ");
224
308
 
@@ -810,6 +894,53 @@ export class AiLabel {
810
894
  return match![2] === "mp3" ? { path: song.file, type: "audio/mpeg" } : { path: song.lyricsFile, type: "text/plain; charset=utf-8" };
811
895
  }
812
896
 
897
+ /** The label song at this path, for the now-playing card's Download buttons. */
898
+ songAt(file: string): { artist: string; album: string; n: number; title: string; artistName: string; albumTitle: string } | null {
899
+ for (const artist of this.artists.values()) {
900
+ for (const album of artist.albums) {
901
+ const song = album.songs.find((one) => one.file === file);
902
+ if (song) return { artist: artist.key, album: album.key, n: song.n, title: song.title, artistName: artist.name, albumTitle: album.title };
903
+ }
904
+ }
905
+ return null;
906
+ }
907
+
908
+ /** A released song's MP3, when this grant's scope covers it. */
909
+ songFile(artistKey: string, albumKey: string, n: number): string {
910
+ return this.artists.get(artistKey)?.albums.find((one) => one.key === albumKey)?.songs.find((one) => one.n === n && one.file)?.file ?? "";
911
+ }
912
+
913
+ /**
914
+ * What a purchase's zip holds: every released song in scope with its words,
915
+ * and each album's cover and album.json, under <artist>/<year>-<album>/.
916
+ * Null when there is nothing (or no such artist).
917
+ */
918
+ zipFor(scope: { kind: "song" | "album" | "artist"; artist: string; album: string; n: number }): { name: string; entries: Array<{ name: string; path?: string; bytes?: Uint8Array }> } | null {
919
+ const artist = this.artists.get(scope.artist);
920
+ if (!artist) return null;
921
+ const albums = artist.albums.filter((one) => scope.kind === "artist" || one.key === scope.album);
922
+ const entries: Array<{ name: string; path?: string; bytes?: Uint8Array }> = [];
923
+ const top = fileName(artist.name);
924
+ for (const album of albums) {
925
+ const songs = album.songs.filter((one) => one.file && (scope.kind !== "song" || one.n === scope.n));
926
+ if (songs.length === 0) continue;
927
+ const folder = `${top}/${album.year}-${fileName(album.title)}`;
928
+ for (const song of songs) {
929
+ entries.push({ name: `${folder}/${songBase(song)}.mp3`, path: song.file });
930
+ entries.push({ name: `${folder}/${songBase(song)}.lyrics.txt`, bytes: Buffer.from(`${song.lyrics}\n`) });
931
+ }
932
+ if (album.cover) entries.push({ name: `${folder}/cover.jpg`, path: album.cover });
933
+ entries.push({ name: `${folder}/album.json`, bytes: Buffer.from(`${JSON.stringify({
934
+ artist: artist.name, genre: artist.genre, album: album.title, year: album.year, concept: album.concept,
935
+ label: this.settings.holder, copyright: `℗ ${album.year} ${this.settings.holder}`, madeWithAi: true,
936
+ songs: songs.map((one) => ({ n: one.n, title: one.title, seconds: one.seconds, file: `${songBase(one)}.mp3`, lyrics: `${songBase(one)}.lyrics.txt` })),
937
+ }, null, 2)}\n`) });
938
+ }
939
+ if (entries.length === 0) return null;
940
+ 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";
941
+ return { name: `${fileName(artist.name)} - ${fileName(what)}.zip`, entries };
942
+ }
943
+
813
944
  private require(key: string): LabelArtist {
814
945
  const artist = this.artists.get(key);
815
946
  if (!artist) throw new StationError("not_found", "no such artist on the label", 404);