nixamp 0.48.1 → 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.
Files changed (74) hide show
  1. package/dist/ai-keys.d.ts +1 -1
  2. package/dist/ai-keys.js +2 -0
  3. package/dist/ai-label.d.ts +239 -0
  4. package/dist/ai-label.js +774 -0
  5. package/dist/ai-radio.d.ts +11 -5
  6. package/dist/ai-radio.js +27 -11
  7. package/dist/ai-text.d.ts +2 -0
  8. package/dist/ai-text.js +4 -2
  9. package/dist/label-routes.d.ts +28 -0
  10. package/dist/label-routes.js +314 -0
  11. package/dist/music-store.d.ts +121 -0
  12. package/dist/music-store.js +289 -0
  13. package/dist/server.d.ts +6 -0
  14. package/dist/server.js +99 -11
  15. package/dist/station-cli.js +109 -0
  16. package/dist/station-guide.d.ts +1 -1
  17. package/dist/station-guide.js +5 -1
  18. package/dist/station-mcp.d.ts +247 -20
  19. package/dist/station-mcp.js +36 -0
  20. package/dist/station-usage.d.ts +1 -1
  21. package/dist/station-usage.js +15 -1
  22. package/dist/store-routes.d.ts +16 -0
  23. package/dist/store-routes.js +189 -0
  24. package/dist/support.d.ts +14 -0
  25. package/dist/support.js +41 -0
  26. package/dist/track-facts.d.ts +8 -0
  27. package/dist/zip-stream.d.ts +13 -0
  28. package/dist/zip-stream.js +119 -0
  29. package/package.json +1 -1
  30. package/src/ai-keys.ts +2 -0
  31. package/src/ai-label.ts +873 -0
  32. package/src/ai-radio.ts +30 -13
  33. package/src/ai-text.ts +6 -2
  34. package/src/label-routes.ts +336 -0
  35. package/src/music-store.ts +333 -0
  36. package/src/server.ts +91 -3
  37. package/src/station-cli.ts +102 -0
  38. package/src/station-guide.ts +5 -1
  39. package/src/station-mcp.ts +34 -0
  40. package/src/station-usage.ts +15 -1
  41. package/src/store-routes.ts +204 -0
  42. package/src/support.ts +39 -0
  43. package/src/track-facts.ts +2 -0
  44. package/src/zip-stream.ts +128 -0
  45. package/web/dist/assets/{ar-BvPTr_2x.js → ar-CC_uYRlC.js} +1 -1
  46. package/web/dist/assets/{cs-g4ii7Tkl.js → cs-k5C-jvfX.js} +1 -1
  47. package/web/dist/assets/{da-D9vYCFH7.js → da-CdBzU_fF.js} +1 -1
  48. package/web/dist/assets/{de-lYElbebQ.js → de-B-UWa0W7.js} +1 -1
  49. package/web/dist/assets/{es-CiDbfxFy.js → es-Dod8aCau.js} +1 -1
  50. package/web/dist/assets/{fi-BWlqMMWx.js → fi-C36bBPFW.js} +1 -1
  51. package/web/dist/assets/{fr-D2n5nXQv.js → fr-DWUxs2qv.js} +1 -1
  52. package/web/dist/assets/{hi-DM9Nh04K.js → hi-DjLqhfs8.js} +1 -1
  53. package/web/dist/assets/{hu-Bzb0X4rk.js → hu-CgsOlcq8.js} +1 -1
  54. package/web/dist/assets/{id-j0QazySW.js → id-ZA8yTWX9.js} +1 -1
  55. package/web/dist/assets/{it-BVvva3a6.js → it-DfFhS34g.js} +1 -1
  56. package/web/dist/assets/{main-DPfBhzsz.js → main-CjgQp9um.js} +2 -2
  57. package/web/dist/assets/{nl-CrJQ6obW.js → nl-C257i24U.js} +1 -1
  58. package/web/dist/assets/radio-CDUEoM0y.js +1 -0
  59. package/web/dist/assets/{ru-DFIjjKvG.js → ru--30I7BNC.js} +1 -1
  60. package/web/dist/assets/{station-BKmoJ0Sk.css → station-Be-z7jQ2.css} +1 -1
  61. package/web/dist/assets/{station-X8pffqCW.js → station-D769XVG0.js} +1 -1
  62. package/web/dist/assets/{sv-BO9QPL4K.js → sv-CxzBeXfk.js} +1 -1
  63. package/web/dist/assets/{uk-DwmG3Owv.js → uk-DXHpMk4i.js} +1 -1
  64. package/web/dist/assets/{vi-C-2EzgMe.js → vi-Dg2LT4x3.js} +1 -1
  65. package/web/dist/assets/{zh-BlnCenyZ.js → zh-D3pvejo6.js} +1 -1
  66. package/web/dist/index.html +1 -1
  67. package/web/dist/radio.html +44 -1
  68. package/web/dist/station.html +7 -2
  69. package/web/dist/sw.js +25 -25
  70. package/web/dist/vod.html +2 -2
  71. package/web/dist/watch.html +2 -2
  72. package/web/dist/assets/radio-DI0H8flN.js +0 -1
  73. /package/web/dist/assets/{vod-CdfPMa1u.js → vod-bM1DvPEx.js} +0 -0
  74. /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;
@@ -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.48.1",
3
+ "version": "0.50.1",
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-keys.ts CHANGED
@@ -14,6 +14,8 @@ export const AI_KEY_NAMES = [
14
14
  "ELEVENLABS_API_KEY", "GEMINI_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
15
15
  // Any OpenAI-compatible endpoint in OpenAI's place (Cloudflare Workers AI, Ollama, ...) and its model.
16
16
  "NIXAMP_OPENAI_URL", "NIXAMP_OPENAI_MODEL",
17
+ // Which ElevenLabs music model composes: music_v1, music_v2 or music_v2_5 (the default).
18
+ "NIXAMP_MUSIC_MODEL",
17
19
  // nichedb.dev's key for publishing our own songs' words (lyrics-publish.ts).
18
20
  "NIXAMP_LYRICS_KEY",
19
21
  ] as const;