nixamp 0.61.1 → 0.62.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 (44) hide show
  1. package/dist/ai-label.d.ts +2 -1
  2. package/dist/ai-label.js +5 -3
  3. package/dist/label-routes.js +3 -3
  4. package/dist/license-routes.d.ts +17 -0
  5. package/dist/license-routes.js +241 -0
  6. package/dist/music-hub.d.ts +1 -0
  7. package/dist/music-hub.js +9 -6
  8. package/dist/music-license.d.ts +120 -0
  9. package/dist/music-license.js +266 -0
  10. package/dist/server.d.ts +3 -0
  11. package/dist/server.js +30 -0
  12. package/package.json +1 -1
  13. package/src/ai-label.ts +5 -4
  14. package/src/label-routes.ts +3 -3
  15. package/src/license-routes.ts +258 -0
  16. package/src/music-hub.ts +10 -7
  17. package/src/music-license.ts +349 -0
  18. package/src/server.ts +29 -0
  19. package/web/dist/assets/{ar-CYzjQKmG.js → ar-Eigh2EY7.js} +1 -1
  20. package/web/dist/assets/client-B_mfdrFh.js +1 -0
  21. package/web/dist/assets/{cs-DiSibpnb.js → cs-BiYj6suH.js} +1 -1
  22. package/web/dist/assets/{da-butHBZ3x.js → da-Bbv8v9LC.js} +1 -1
  23. package/web/dist/assets/{de-CmBXpW1X.js → de-Ct4J4O2-.js} +1 -1
  24. package/web/dist/assets/{es-CpqprbhX.js → es-C1QHkBcK.js} +1 -1
  25. package/web/dist/assets/{fi-B1hMfasn.js → fi-BL22uj7L.js} +1 -1
  26. package/web/dist/assets/{fr-Cjc6bkRE.js → fr-CbHGddiD.js} +1 -1
  27. package/web/dist/assets/{hi-DjEO-zwR.js → hi-CEnEoHJB.js} +1 -1
  28. package/web/dist/assets/{hu-BsR0uaL9.js → hu-V6N4YyNP.js} +1 -1
  29. package/web/dist/assets/{id-B9R43LCb.js → id-Dlur-A5Q.js} +1 -1
  30. package/web/dist/assets/{it-qzlsZzmL.js → it-CtiClrIo.js} +1 -1
  31. package/web/dist/assets/main-CFJDb9Yd.js +22 -0
  32. package/web/dist/assets/{music-CVLJhYTF.css → music-Dy7J3PeE.css} +1 -1
  33. package/web/dist/assets/music-gaYfGADg.js +1 -0
  34. package/web/dist/assets/{nl-CZUfUvgR.js → nl-Crjz8b-j.js} +1 -1
  35. package/web/dist/assets/{ru-BBAO-YaP.js → ru-CrwKGInd.js} +1 -1
  36. package/web/dist/assets/{sv-ySaoJU94.js → sv-DG2OJzJH.js} +1 -1
  37. package/web/dist/assets/{uk-FeqZ0VuT.js → uk-BteiNgtQ.js} +1 -1
  38. package/web/dist/assets/{vi-BKb_A-rW.js → vi-CSTApJSE.js} +1 -1
  39. package/web/dist/assets/{zh-DfWRilkD.js → zh-CTag10X-.js} +1 -1
  40. package/web/dist/index.html +2 -1
  41. package/web/dist/music.html +3 -2
  42. package/web/dist/sw.js +22 -21
  43. package/web/dist/assets/main-DACzAtIi.js +0 -22
  44. package/web/dist/assets/music-BTeZvJCU.js +0 -1
@@ -385,7 +385,7 @@ export declare class AiLabel {
385
385
  artists: PublicArtist[];
386
386
  }>;
387
387
  };
388
- /** A released file anybody may fetch: a song, its words, or its album's cover. */
388
+ /** A released file anybody may fetch: its words, its music video, or its album's cover. */
389
389
  publicFile(artistKey: string, albumKey: string, what: string): {
390
390
  path: string;
391
391
  type: string;
@@ -465,6 +465,7 @@ export interface PublicArtist {
465
465
  title: string;
466
466
  seconds: number;
467
467
  lyrics: string;
468
+ video?: boolean;
468
469
  }>;
469
470
  }>;
470
471
  }
package/dist/ai-label.js CHANGED
@@ -1549,7 +1549,7 @@ export class AiLabel {
1549
1549
  .filter((album) => album.songs.some((song) => song.file))
1550
1550
  .map((album) => ({
1551
1551
  key: album.key, title: album.title, year: album.year, concept: album.concept, cover: Boolean(album.cover), complete: album.doneAt > 0,
1552
- songs: album.songs.filter((song) => song.file).map((song) => ({ n: song.n, title: song.title, seconds: song.seconds, lyrics: song.lyrics })),
1552
+ songs: album.songs.filter((song) => song.file).map((song) => ({ n: song.n, title: song.title, seconds: song.seconds, lyrics: song.lyrics, ...(song.video ? { video: true } : {}) })),
1553
1553
  }))
1554
1554
  .reverse(),
1555
1555
  }))
@@ -1557,17 +1557,19 @@ export class AiLabel {
1557
1557
  })).filter((one) => one.artists.length > 0);
1558
1558
  return { holder: this.settings.holder, genres };
1559
1559
  }
1560
- /** A released file anybody may fetch: a song, its words, or its album's cover. */
1560
+ /** A released file anybody may fetch: its words, its music video, or its album's cover. */
1561
1561
  publicFile(artistKey, albumKey, what) {
1562
1562
  const album = this.artists.get(artistKey)?.albums.find((one) => one.key === albumKey);
1563
1563
  if (!album)
1564
1564
  return null;
1565
1565
  if (what === "cover.jpg")
1566
1566
  return album.cover ? { path: album.cover, type: "image/jpeg" } : null;
1567
- const match = /^(\d{1,3})\.(mp3|txt)$/.exec(what);
1567
+ const match = /^(\d{1,3})\.(mp3|txt|mp4)$/.exec(what);
1568
1568
  const song = match ? album.songs.find((one) => one.n === Number(match[1]) && one.file) : undefined;
1569
1569
  if (!song)
1570
1570
  return null;
1571
+ if (match[2] === "mp4")
1572
+ return song.video ? { path: song.video, type: "video/mp4" } : null;
1571
1573
  return match[2] === "mp3" ? { path: song.file, type: "audio/mpeg" } : { path: song.lyricsFile, type: "text/plain; charset=utf-8" };
1572
1574
  }
1573
1575
  /** The label song at this path, for the now-playing card's Download buttons. */
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Public, no key: `/label` (the catalogue as a page: every artist, album and
5
5
  * song, playable, with its words), `/api/v1/label` (the same as JSON) and
6
- * `/api/v1/label/<artist>/<album>/<n>.mp3|<n>.txt|cover.jpg` (the released
7
- * files themselves). The owner's are under /api/v1/stations/label.
6
+ * `/api/v1/label/<artist>/<album>/<n>.mp3|<n>.txt|<n>.mp4|cover.jpg` (the released
7
+ * files themselves; a music video is free to watch, like a promo). The owner's are under /api/v1/stations/label.
8
8
  */
9
9
  import { createHash } from "node:crypto";
10
10
  import { createReadStream, statSync } from "node:fs";
@@ -17,7 +17,7 @@ import { adCsp, withPageAd } from "./station-guide.js";
17
17
  // Listening is free on the live stations (with their breaks); the songs as
18
18
  // files are sold, so only the words and the cover are public here, and an
19
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)$/;
20
+ const FILE = /^\/api\/v1\/label\/([a-f0-9]{10})\/([a-f0-9]{8})\/(\d{1,3}\.(?:txt|mp3|mp4)|cover\.jpg)$/;
21
21
  /** An artist's OpenProfile pages and portraits: artist.md, members/<slug>.md, members/<slug>.jpg. */
22
22
  const PROFILE = /^\/api\/v1\/label\/([a-f0-9]{10})\/(artist\.md|members\/[a-z0-9-]{1,40}\.(?:md|jpg))$/;
23
23
  /** Whether this path is the label's public face (an MP3 or the zip still asks for its grant). */
@@ -0,0 +1,17 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { type MusicLicenses } from "./music-license.ts";
3
+ export declare function isLicensePath(path: string): boolean;
4
+ export interface LicenseRoutesContext {
5
+ licenses: MusicLicenses;
6
+ account: () => Promise<{
7
+ id: string;
8
+ email: string;
9
+ } | null>;
10
+ json: (response: ServerResponse, status: number, body: unknown) => void;
11
+ readBody: (request: IncomingMessage) => Promise<string>;
12
+ /** The opaque id a server goes by in nixamp.com's addresses. */
13
+ serverId: (base: string) => string;
14
+ site: string;
15
+ }
16
+ export declare const LICENSE_CSP: string;
17
+ export declare function handleLicenseRoutes(request: IncomingMessage, response: ServerResponse, path: string, url: URL, ctx: LicenseRoutesContext): Promise<boolean>;
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Music licences on nixamp.com (music-license.ts): the pages and the API.
3
+ *
4
+ * GET /license?server=&kind=song|album&artist=&album=&n= pick a licence for a song or album, then pay
5
+ * GET /license?kind=catalog the Catalog pass, $29 a year
6
+ * GET /license/terms the terms, in plain words
7
+ * GET /license/NXL-XXXX-XXXX anybody's check of a licence
8
+ * GET /licenses/:id?t= the licensee's certificate (printable)
9
+ * POST /api/v1/licenses/checkout {tier, kind, server?, artist?, album?, n?, licensee, project, projectUrl?, email, coin, agree} -> {url}
10
+ * GET /api/v1/licenses/NXL-XXXX-XXXX the check, as JSON
11
+ * GET /api/v1/licenses the signed-in account's licences
12
+ */
13
+ import { createHash } from "node:crypto";
14
+ import { LICENSE_PRICES, TERMS_VERSION, TIER_NAMES, TIER_USE, licenseTerms } from "./music-license.js";
15
+ import { SupportError } from "./support.js";
16
+ const CHECK = /^\/license\/(NXL-[A-Z2-9]{4}-[A-Z2-9]{4})$/;
17
+ const CHECK_JSON = /^\/api\/v1\/licenses\/(NXL-[A-Z2-9]{4}-[A-Z2-9]{4})$/;
18
+ const CERTIFICATE = /^\/licenses\/([0-9a-f-]{36})$/;
19
+ export function isLicensePath(path) {
20
+ return path === "/license" || path === "/license/terms" || CHECK.test(path) || CERTIFICATE.test(path)
21
+ || path === "/api/v1/licenses/checkout" || path === "/api/v1/licenses" || CHECK_JSON.test(path);
22
+ }
23
+ const escape = (text) => text.replace(/[&<>"']/g, (one) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[one]);
24
+ const hash = (text) => `'sha256-${createHash("sha256").update(text).digest("base64")}'`;
25
+ const price = (cents) => `$${(cents / 100).toFixed(cents % 100 ? 2 : 0)}`;
26
+ const day = (ms) => new Date(ms).toISOString().slice(0, 10);
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.55 system-ui,-apple-system,"Segoe UI",sans-serif}
30
+ main{max-width:640px;margin:0 auto;padding:40px 16px}
31
+ .card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:24px;margin-bottom:16px}
32
+ h1{margin:0 0 4px;font-size:1.5rem;overflow-wrap:anywhere}h2{font-size:1.05rem;margin:22px 0 6px}.by{color:var(--muted);margin:0 0 16px}
33
+ .cover{border-radius:10px;float:right;margin:0 0 8px 12px;width:120px;height:120px;object-fit:cover;max-width:35%}
34
+ fieldset{border:0;padding:0;margin:16px 0 0}legend{color:var(--muted);font-size:.9rem;margin-bottom:6px}
35
+ .tier{display:grid;grid-template-columns:auto 1fr auto;gap:4px 12px;align-items:start;border:1px solid var(--line);border-radius:12px;padding:12px;margin-bottom:8px;cursor:pointer}
36
+ .tier:has(input:checked){border-color:var(--accent)}.tier strong{display:block}.tier span{color:var(--muted);font-size:.9rem}.tier b{color:var(--accent);white-space:nowrap}
37
+ label.f{display:block;color:var(--muted);font-size:.9rem;margin:12px 0 4px}
38
+ input[type=text],input[type=email],input[type=url],select{width:100%;padding:10px;border-radius:10px;border:1px solid var(--line);background:var(--bg);color:var(--fg);font:inherit}
39
+ .agree{display:flex;gap:8px;align-items:flex-start;margin-top:14px;color:var(--muted);font-size:.9rem}
40
+ 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}
41
+ button:disabled{opacity:.5;cursor:wait}
42
+ :focus-visible{outline:3px solid var(--accent);outline-offset:2px}
43
+ #status{min-height:1.4em;margin-top:12px;color:var(--muted)}
44
+ small,.muted{color:var(--muted)}a{color:var(--accent)}
45
+ dl{display:grid;grid-template-columns:max-content 1fr;gap:6px 16px;margin:16px 0}dt{color:var(--muted)}dd{margin:0;overflow-wrap:anywhere}
46
+ .ok{display:inline-block;padding:2px 10px;border-radius:999px;background:#14361f;color:var(--accent);font-weight:700;font-size:.85rem}
47
+ .no{display:inline-block;padding:2px 10px;border-radius:999px;background:#3a1717;color:#ff8f8f;font-weight:700;font-size:.85rem}
48
+ code{display:block;white-space:pre-wrap;background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:10px;font-size:.9rem;overflow-wrap:anywhere}
49
+ .print{background:transparent;border:1px solid var(--line);color:var(--fg)}
50
+ @media print{:root{--bg:#fff;--fg:#000;--muted:#444;--card:#fff;--line:#999;--accent:#000}.noprint{display:none}.card{border-color:#000}}
51
+ `;
52
+ const SCRIPT = `
53
+ (() => {
54
+ const $ = (s) => document.querySelector(s);
55
+ const print = $("#print");
56
+ if (print) print.addEventListener("click", () => window.print());
57
+ const form = $("#license");
58
+ if (!form) return;
59
+ const status = $("#status");
60
+ const pay = $("#pay");
61
+ const label = () => { const one = form.querySelector("input[name=tier]:checked"); pay.textContent = one ? "Pay " + one.dataset.price + " with crypto" : "Pick a licence"; };
62
+ form.addEventListener("change", label);
63
+ label();
64
+ fetch("/api/v1/store/coins").then((r) => r.json()).then((coins) => {
65
+ const cheap = ["USDC_POL", "USDC_SOL", "SOL", "POL", "LTC", "USDC_BASE", "USDC"];
66
+ const list = (coins.coins || []).slice().sort((a, b) => (cheap.indexOf(a) + 1 || 99) - (cheap.indexOf(b) + 1 || 99));
67
+ $("#coin").replaceChildren(...list.map((c) => Object.assign(document.createElement("option"), { value: c, textContent: c })));
68
+ }).catch(() => { status.textContent = "Payments are not available right now."; });
69
+ form.addEventListener("submit", async (event) => {
70
+ event.preventDefault();
71
+ pay.disabled = true;
72
+ status.textContent = "Starting the checkout…";
73
+ const data = new FormData(form);
74
+ try {
75
+ const r = await fetch("/api/v1/licenses/checkout", { method: "POST", headers: { "content-type": "application/json" }, credentials: "same-origin",
76
+ body: JSON.stringify({ ...JSON.parse(document.body.dataset.item), tier: data.get("tier"), licensee: data.get("licensee"), project: data.get("project"),
77
+ projectUrl: data.get("projectUrl"), email: data.get("email"), coin: data.get("coin"), agree: data.get("agree") === "on" }) });
78
+ const body = await r.json();
79
+ if (!r.ok) throw new Error(body.error || "The checkout did not start.");
80
+ status.textContent = "Taking you to CoinPay…";
81
+ location.href = body.url;
82
+ } catch (error) {
83
+ status.textContent = error.message;
84
+ pay.disabled = false;
85
+ }
86
+ });
87
+ })();
88
+ `;
89
+ export const LICENSE_CSP = ["default-src 'none'", `script-src ${hash(SCRIPT)}`, `style-src ${hash(STYLE)}`, "img-src 'self' https:", "connect-src 'self'", "form-action 'none'", "base-uri 'none'", "frame-ancestors 'none'"].join("; ");
90
+ function page(title, body, options = {}) {
91
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
92
+ <title>${escape(title)} · nixamp</title>${options.index ? "" : `<meta name="robots" content="noindex">`}${options.description ? `<meta name="description" content="${escape(options.description)}">` : ""}
93
+ <link rel="icon" href="/favicon.png" type="image/png"><style>${STYLE}</style></head>
94
+ <body${options.data ? ` data-item="${escape(options.data)}"` : ""}><main>${body}</main><script>${SCRIPT}</script></body></html>`;
95
+ }
96
+ function html(response, status, text, method) {
97
+ response.writeHead(status, { "content-type": "text/html; charset=utf-8", "content-security-policy": LICENSE_CSP, "cache-control": "no-store", "referrer-policy": "no-referrer" });
98
+ response.end(method === "HEAD" ? undefined : text);
99
+ }
100
+ /** The tiers sold for what is being licensed, cheapest first. */
101
+ function tiersFor(kind) {
102
+ return Object.keys(LICENSE_PRICES).filter((tier) => LICENSE_PRICES[tier][kind]).sort((a, b) => LICENSE_PRICES[a][kind] - LICENSE_PRICES[b][kind]);
103
+ }
104
+ function certificate(license, ctx, full) {
105
+ const view = ctx.licenses.view(license, full);
106
+ const active = Boolean(view["active"]);
107
+ return `<div class="card">
108
+ <p>${active ? `<span class="ok">Active licence</span>` : license.status === "paid" ? `<span class="no">Pass ended ${day(license.expiresAt)}</span>` : `<span class="no">Not paid yet</span>`}</p>
109
+ <h1>${escape(TIER_NAMES[license.tier])} licence ${escape(license.code)}</h1>
110
+ <p class="by">${escape(license.title)}${license.kind === "catalog" ? "" : ` · ${escape(license.artistName)}`}</p>
111
+ <dl>
112
+ <dt>Licensee</dt><dd>${escape(license.licensee)}</dd>
113
+ <dt>Project</dt><dd>${escape(license.project)}${license.projectUrl ? ` · <a href="${escape(license.projectUrl)}" rel="nofollow noopener">${escape(license.projectUrl)}</a>` : ""}</dd>
114
+ <dt>Covers</dt><dd>${escape(TIER_USE[license.tier])}</dd>
115
+ <dt>Licensor</dt><dd>${escape(ctx.licenses.holder)}</dd>
116
+ ${license.paidAt ? `<dt>Issued</dt><dd>${day(license.paidAt)}</dd>` : ""}
117
+ ${license.expiresAt ? `<dt>New projects until</dt><dd>${day(license.expiresAt)} (projects published before then stay licensed)</dd>` : `<dt>Term</dt><dd>For good, worldwide, non-exclusive</dd>`}
118
+ <dt>Terms</dt><dd><a href="/license/terms">Version ${escape(license.terms)}</a></dd>
119
+ <dt>Check</dt><dd><a href="/license/${escape(license.code)}">${escape(ctx.site)}/license/${escape(license.code)}</a></dd>
120
+ </dl>
121
+ <h2>Credit line</h2>
122
+ <p class="muted">Required wherever the project shows credits or a description, with the link:</p>
123
+ <code>${escape(String(view["credit"]))}</code>
124
+ ${full ? `<button id="print" class="print noprint" type="button">Print or save as PDF</button>` : ""}
125
+ </div>`;
126
+ }
127
+ export async function handleLicenseRoutes(request, response, path, url, ctx) {
128
+ if (!isLicensePath(path))
129
+ return false;
130
+ const method = request.method ?? "GET";
131
+ const licenses = ctx.licenses;
132
+ try {
133
+ if (path === "/license" && (method === "GET" || method === "HEAD")) {
134
+ const asked = Object.fromEntries(url.searchParams.entries());
135
+ const kind = asked["kind"] === "catalog" ? "catalog" : asked["kind"] === "album" ? "album" : "song";
136
+ let quote;
137
+ try {
138
+ quote = await licenses.quote({ ...asked, kind, tier: tiersFor(kind)[0] });
139
+ }
140
+ catch (error) {
141
+ html(response, 404, page("Not licensable", `<div class="card"><h1>Not licensable</h1><p class="by">${escape(error.message)}</p><p><a href="/music">Back to the music</a></p></div>`), method);
142
+ return true;
143
+ }
144
+ const me = await ctx.account().catch(() => null);
145
+ const cover = quote.kind === "catalog" ? "" : `<img class="cover" src="${escape(`/api/v1/music/file/${ctx.serverId(quote.server)}/${quote.artist}/${quote.album}/cover.jpg`)}" alt="">`;
146
+ const tiers = tiersFor(quote.kind);
147
+ const options = tiers.map((tier, at) => `<label class="tier"><input type="radio" name="tier" value="${tier}" data-price="${price(LICENSE_PRICES[tier][quote.kind])}"${at === (tiers.length > 1 ? 1 : 0) ? " checked" : ""}>
148
+ <div><strong>${escape(TIER_NAMES[tier])}</strong><span>${escape(TIER_USE[tier])}</span></div><b>${price(LICENSE_PRICES[tier][quote.kind])}${tier === "catalog" ? "/year" : ""}</b></label>`).join("");
149
+ const what = quote.kind === "catalog" ? `Every song by every artist ${escape(licenses.holder)} releases on nixamp` : quote.kind === "album" ? `The album "${escape(quote.title)}" by ${escape(quote.artistName)}` : `"${escape(quote.title)}" by ${escape(quote.artistName)}`;
150
+ html(response, 200, page(`License ${quote.title}`, `<div class="card">${cover}
151
+ <h1>License ${quote.kind === "catalog" ? "the whole catalogue" : "this music"}</h1>
152
+ <p class="by">${what}</p>
153
+ <p class="muted">For your game, app, video or podcast. A download is for listening; a licence lets you put the music in your project and publish it. Worldwide, non-exclusive, and every use credits the artist with a link to their page.</p>
154
+ <form id="license">
155
+ <fieldset><legend>Licence</legend>${options}</fieldset>
156
+ <label class="f" for="licensee">Licensed to (your name or company)</label>
157
+ <input id="licensee" name="licensee" type="text" required minlength="2" maxlength="120" autocomplete="organization">
158
+ <label class="f" for="project">${quote.kind === "catalog" ? "Your studio, channel or business" : "Project the music goes in"}</label>
159
+ <input id="project" name="project" type="text" required minlength="2" maxlength="160">
160
+ <label class="f" for="projectUrl">Project link (optional)</label>
161
+ <input id="projectUrl" name="projectUrl" type="url" maxlength="300" placeholder="https://">
162
+ <label class="f" for="email">Email (your certificate goes here)</label>
163
+ <input id="email" name="email" type="email" required autocomplete="email" value="${escape(me?.email ?? "")}">
164
+ <label class="f" for="coin">Pay with</label>
165
+ <select id="coin" name="coin" required></select>
166
+ <label class="agree"><input type="checkbox" name="agree" required> <span>I agree to the <a href="/license/terms" target="_blank" rel="noopener">licence terms</a>, including the credit with a link to the artist's page.</span></label>
167
+ <button id="pay" type="submit">Pay</button>
168
+ </form>
169
+ <p id="status" role="status" aria-live="polite"></p>
170
+ <small>Payments go through <a href="https://coinpayportal.com">CoinPay</a> to nixamp.com. You get a certificate with a licence code that anybody can check at ${escape(ctx.site)}/license/&lt;code&gt;.</small>
171
+ </div>`, { data: JSON.stringify({ kind: quote.kind, ...(quote.kind === "catalog" ? {} : { server: ctx.serverId(quote.server), artist: quote.artist, album: quote.album, n: quote.n }) }) }), method);
172
+ return true;
173
+ }
174
+ if (path === "/license/terms" && (method === "GET" || method === "HEAD")) {
175
+ const sections = licenseTerms(licenses.holder, ctx.site).map((one) => `<h2>${escape(one.heading)}</h2><p>${escape(one.text)}</p>`).join("");
176
+ html(response, 200, page("Music licence terms", `<div class="card"><h1>Music licence terms</h1><p class="by">Version ${TERMS_VERSION} · ${escape(licenses.holder)}</p>${sections}<p><a href="/music">The music</a> · <a href="/license?kind=catalog">Catalog pass</a></p></div>`, { index: true, description: `How you may use ${licenses.holder}'s AI music in your games, apps and videos: Creator, Indie, Commercial and the Catalog pass.` }), method);
177
+ return true;
178
+ }
179
+ const check = CHECK.exec(path);
180
+ if (check && (method === "GET" || method === "HEAD")) {
181
+ const license = await licenses.byCode(check[1]);
182
+ html(response, license ? 200 : 404, page(license ? `Licence ${license.code}` : "No such licence", license
183
+ ? certificate(license, ctx, false)
184
+ : `<div class="card"><h1>No such licence</h1><p class="by">${escape(check[1])} is not a paid nixamp music licence.</p></div>`), method);
185
+ return true;
186
+ }
187
+ const mine = CERTIFICATE.exec(path);
188
+ if (mine && (method === "GET" || method === "HEAD")) {
189
+ const license = await licenses.get(mine[1], url.searchParams.get("t") ?? "");
190
+ if (!license) {
191
+ html(response, 404, page("No such licence", `<div class="card"><h1>No such licence</h1><p class="by">That link is not one of ours.</p></div>`), method);
192
+ return true;
193
+ }
194
+ const body = license.status === "paid"
195
+ ? certificate(license, ctx, true)
196
+ : `<div class="card"><h1>${escape(TIER_NAMES[license.tier])} licence ${escape(license.code)}</h1><p class="by">${escape(license.title)} · ${price(license.amountCents)} in ${escape(license.coin)}</p>
197
+ <p>Waiting for your payment. Once CoinPay confirms it, your certificate is here and in your email (${escape(license.email)}). This page checks again when you reload it.</p>
198
+ <a class="go" href="https://coinpayportal.com/pay/${escape(license.paymentId)}">Open the payment</a></div>`;
199
+ html(response, 200, page(`Licence ${license.code}`, body), method);
200
+ return true;
201
+ }
202
+ const checkJson = CHECK_JSON.exec(path);
203
+ if (checkJson && method === "GET") {
204
+ const license = await licenses.byCode(checkJson[1]);
205
+ response.setHeader("access-control-allow-origin", "*");
206
+ ctx.json(response, license ? 200 : 404, license ? { license: licenses.view(license, false) } : { error: "no such licence", code: "not_found" });
207
+ return true;
208
+ }
209
+ if (path === "/api/v1/licenses/checkout" && method === "POST") {
210
+ let input;
211
+ try {
212
+ input = JSON.parse(await ctx.readBody(request));
213
+ }
214
+ catch {
215
+ ctx.json(response, 400, { error: "the body is not JSON", code: "bad_json" });
216
+ return true;
217
+ }
218
+ const me = await ctx.account().catch(() => null);
219
+ ctx.json(response, 201, await licenses.checkout(input, me));
220
+ return true;
221
+ }
222
+ if (path === "/api/v1/licenses" && method === "GET") {
223
+ const me = await ctx.account().catch(() => null);
224
+ if (!me) {
225
+ ctx.json(response, 401, { error: "sign in to see your licences", code: "unauthorized" });
226
+ return true;
227
+ }
228
+ ctx.json(response, 200, { licenses: (await licenses.forAccount(me)).map((one) => licenses.view(one, true)) });
229
+ return true;
230
+ }
231
+ ctx.json(response, 405, { error: "not like that", code: "method_not_allowed" });
232
+ return true;
233
+ }
234
+ catch (error) {
235
+ if (error instanceof SupportError)
236
+ ctx.json(response, error.status, { error: error.message, code: error.code });
237
+ else
238
+ ctx.json(response, 500, { error: error instanceof Error ? error.message : String(error), code: "internal" });
239
+ return true;
240
+ }
241
+ }
@@ -26,6 +26,7 @@ interface LabelArtist {
26
26
  title: string;
27
27
  seconds: number;
28
28
  lyrics?: string;
29
+ video?: boolean;
29
30
  }>;
30
31
  }>;
31
32
  }
package/dist/music-hub.js CHANGED
@@ -8,7 +8,7 @@
8
8
  * hands covers, portraits and words through itself.
9
9
  *
10
10
  * GET /api/v1/music the merged catalogue
11
- * GET /api/v1/music/file/<server>/<path> a cover, portrait, profile or words, through nixamp.com
11
+ * GET /api/v1/music/file/<server>/<path> a cover, portrait, profile, words or music video, through nixamp.com
12
12
  * POST /api/v1/music/released a server says an album or song landed: its followers hear
13
13
  */
14
14
  import { createHash } from "node:crypto";
@@ -113,7 +113,7 @@ export class MusicHub {
113
113
  };
114
114
  }
115
115
  }
116
- const FILE = /^\/api\/v1\/music\/file\/([a-f0-9]{10})\/([a-f0-9]{10}\/(?:[a-f0-9]{8}\/(?:\d{1,3}\.txt|cover\.jpg)|artist\.md|members\/[a-z0-9-]{1,40}\.(?:md|jpg)))$/;
116
+ const FILE = /^\/api\/v1\/music\/file\/([a-f0-9]{10})\/([a-f0-9]{10}\/(?:[a-f0-9]{8}\/(?:\d{1,3}\.(?:txt|mp4)|cover\.jpg)|artist\.md|members\/[a-z0-9-]{1,40}\.(?:md|jpg)))$/;
117
117
  const slugOf = (text, max) => text.normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, max);
118
118
  /**
119
119
  * live.streaming's /radio/artist/<slug> and /radio/album/<slug> addresses, on
@@ -220,15 +220,18 @@ export async function handleMusicRoutes(request, response, path, ctx) {
220
220
  return true;
221
221
  }
222
222
  try {
223
- const upstream = await fetch(`${base}/api/v1/label/${file[2]}`, { signal: AbortSignal.timeout(15_000) });
223
+ // A video is asked for in byte ranges (that is how a player seeks); passed on both ways.
224
+ const range = typeof request.headers.range === "string" ? request.headers.range : "";
225
+ const upstream = await fetch(`${base}/api/v1/label/${file[2]}`, { signal: AbortSignal.timeout(15_000), ...(range ? { headers: { range } } : {}) });
224
226
  if (!upstream.ok || !upstream.body) {
225
- ctx.json(response, upstream.status === 404 ? 404 : 502, { error: "not there" });
227
+ ctx.json(response, upstream.status === 404 ? 404 : upstream.status === 416 ? 416 : 502, { error: "not there" });
226
228
  return true;
227
229
  }
228
- response.writeHead(200, {
230
+ const pass = (name) => (upstream.headers.get(name) ? { [name]: upstream.headers.get(name) } : {});
231
+ response.writeHead(upstream.status === 206 ? 206 : 200, {
229
232
  "content-type": upstream.headers.get("content-type") ?? "application/octet-stream",
230
233
  "cache-control": "public, max-age=3600",
231
- ...(upstream.headers.get("content-length") ? { "content-length": upstream.headers.get("content-length") } : {}),
234
+ ...pass("content-length"), ...pass("content-range"), ...pass("accept-ranges"),
232
235
  });
233
236
  if (method === "HEAD") {
234
237
  response.end();
@@ -0,0 +1,120 @@
1
+ import type { Db, Support } from "./support.ts";
2
+ import type { MusicStore } from "./music-store.ts";
3
+ export type Tier = "creator" | "indie" | "commercial" | "catalog";
4
+ export type LicenseKind = "song" | "album" | "catalog";
5
+ /** In cents, by tier and what is licensed; a missing pair is not sold. */
6
+ export declare const LICENSE_PRICES: Record<Tier, Partial<Record<LicenseKind, number>>>;
7
+ export declare const TIER_NAMES: Record<Tier, string>;
8
+ /** What each tier lets a licensee do, in a line. */
9
+ export declare const TIER_USE: Record<Tier, string>;
10
+ export declare const TERMS_VERSION = "1";
11
+ export interface License {
12
+ id: string;
13
+ /** What the certificate and the public check go by: NXL-XXXX-XXXX. */
14
+ code: string;
15
+ token: string;
16
+ email: string;
17
+ userId: string;
18
+ licensee: string;
19
+ project: string;
20
+ projectUrl: string;
21
+ tier: Tier;
22
+ kind: LicenseKind;
23
+ server: string;
24
+ artist: string;
25
+ album: string;
26
+ n: number;
27
+ title: string;
28
+ artistName: string;
29
+ /** Where the credit links: the artist's page, or the label's for the catalogue pass. */
30
+ creditUrl: string;
31
+ amountCents: number;
32
+ coin: string;
33
+ paymentId: string;
34
+ status: "pending" | "paid";
35
+ terms: string;
36
+ createdAt: number;
37
+ paidAt: number;
38
+ /** The catalogue pass only: when new projects stop being covered. 0 is never. */
39
+ expiresAt: number;
40
+ }
41
+ export interface MusicLicensesOptions {
42
+ db: Db;
43
+ support: Support;
44
+ /** The store: what a song or album is, from the server that holds it. */
45
+ store: Pick<MusicStore, "describe">;
46
+ mail: {
47
+ apiKey: string;
48
+ from: string;
49
+ } | null;
50
+ /** https://nixamp.com */
51
+ site: string;
52
+ /** Who licenses: the label's holder. */
53
+ holder?: string;
54
+ /** The opaque id a server goes by in nixamp.com's addresses (music-hub.ts serverId). */
55
+ serverId: (base: string) => string;
56
+ fetcher?: typeof fetch;
57
+ now?: () => number;
58
+ log?: (line: string) => void;
59
+ }
60
+ export interface Quote {
61
+ tier: Tier;
62
+ kind: LicenseKind;
63
+ server: string;
64
+ artist: string;
65
+ album: string;
66
+ n: number;
67
+ title: string;
68
+ artistName: string;
69
+ creditUrl: string;
70
+ amountCents: number;
71
+ }
72
+ export declare class MusicLicenses {
73
+ private readonly options;
74
+ private schema;
75
+ constructor(options: MusicLicensesOptions);
76
+ private now;
77
+ get holder(): string;
78
+ private ready;
79
+ /** What would be licensed and for how much; refused when the pair is not sold or the music is not there. */
80
+ quote(input: Record<string, unknown>): Promise<Quote>;
81
+ /** A licence waiting for its payment, and where to pay. */
82
+ checkout(input: Record<string, unknown>, account: {
83
+ id: string;
84
+ email: string;
85
+ } | null): Promise<{
86
+ url: string;
87
+ id: string;
88
+ token: string;
89
+ code: string;
90
+ }>;
91
+ /** The licensee's own page: the certificate once paid. */
92
+ link(id: string, token: string): string;
93
+ /** Anybody's check of a licence by its code: a client asking whether a game's music is licensed. */
94
+ verifyUrl(code: string): string;
95
+ get(id: string, token: string): Promise<License | null>;
96
+ /** A paid licence by its code, for the public check; a pending one is not anybody's business. */
97
+ byCode(code: string): Promise<License | null>;
98
+ owns(paymentId: string): Promise<boolean>;
99
+ /** CoinPay's webhook for one of ours. */
100
+ settle(paymentId: string): Promise<{
101
+ settled: boolean;
102
+ license?: string;
103
+ }>;
104
+ forAccount(account: {
105
+ id: string;
106
+ email: string;
107
+ }): Promise<License[]>;
108
+ /** Marks it paid once; the catalogue pass runs a year from then. The first to get here sends the certificate. */
109
+ private paid;
110
+ /** What the licensee's page and the public check show. */
111
+ view(license: License, full: boolean): Record<string, unknown>;
112
+ private mail;
113
+ }
114
+ /** The credit a licence asks for, ready to paste. */
115
+ export declare function creditLine(license: Pick<License, "kind" | "title" | "artistName" | "creditUrl" | "code">): string;
116
+ /** The licence terms, version TERMS_VERSION: plain words, shown at /license/terms and agreed to at checkout. */
117
+ export declare function licenseTerms(holder: string, site: string): Array<{
118
+ heading: string;
119
+ text: string;
120
+ }>;