nixamp 0.49.0 → 0.50.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/ai-label.d.ts +29 -0
  2. package/dist/ai-label.js +50 -1
  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/store-routes.d.ts +16 -0
  10. package/dist/store-routes.js +189 -0
  11. package/dist/support.d.ts +14 -0
  12. package/dist/support.js +41 -0
  13. package/dist/track-facts.d.ts +8 -0
  14. package/dist/zip-stream.d.ts +13 -0
  15. package/dist/zip-stream.js +119 -0
  16. package/package.json +1 -1
  17. package/src/ai-label.ts +48 -1
  18. package/src/label-routes.ts +77 -9
  19. package/src/music-store.ts +333 -0
  20. package/src/server.ts +63 -4
  21. package/src/store-routes.ts +204 -0
  22. package/src/support.ts +39 -0
  23. package/src/track-facts.ts +2 -0
  24. package/src/zip-stream.ts +128 -0
  25. package/web/dist/assets/{ar-BEVjqkqM.js → ar-CC_uYRlC.js} +1 -1
  26. package/web/dist/assets/{cs-DjazXQ15.js → cs-k5C-jvfX.js} +1 -1
  27. package/web/dist/assets/{da-DnyF6SYK.js → da-CdBzU_fF.js} +1 -1
  28. package/web/dist/assets/{de--q1ph9Ez.js → de-B-UWa0W7.js} +1 -1
  29. package/web/dist/assets/{es-Do3DkBwD.js → es-Dod8aCau.js} +1 -1
  30. package/web/dist/assets/{fi-B7sJTx1N.js → fi-C36bBPFW.js} +1 -1
  31. package/web/dist/assets/{fr-Dy7f5rGi.js → fr-DWUxs2qv.js} +1 -1
  32. package/web/dist/assets/{hi-C9vSnTQ4.js → hi-DjLqhfs8.js} +1 -1
  33. package/web/dist/assets/{hu-h-eueQE8.js → hu-CgsOlcq8.js} +1 -1
  34. package/web/dist/assets/{id-D8Ffd-Pc.js → id-ZA8yTWX9.js} +1 -1
  35. package/web/dist/assets/{it-BKM_2rj1.js → it-DfFhS34g.js} +1 -1
  36. package/web/dist/assets/{main-Dl_uRj22.js → main-CjgQp9um.js} +2 -2
  37. package/web/dist/assets/{nl-Cj3ETrcl.js → nl-C257i24U.js} +1 -1
  38. package/web/dist/assets/{radio-zPgyISLf.js → radio-CDUEoM0y.js} +1 -1
  39. package/web/dist/assets/{ru-B5zmGSnG.js → ru--30I7BNC.js} +1 -1
  40. package/web/dist/assets/{station-BKmoJ0Sk.css → station-Be-z7jQ2.css} +1 -1
  41. package/web/dist/assets/{station-X8pffqCW.js → station-D769XVG0.js} +1 -1
  42. package/web/dist/assets/{sv-BMtjA1uI.js → sv-CxzBeXfk.js} +1 -1
  43. package/web/dist/assets/{uk-B2vuB6Un.js → uk-DXHpMk4i.js} +1 -1
  44. package/web/dist/assets/{vi-CXam3lDh.js → vi-Dg2LT4x3.js} +1 -1
  45. package/web/dist/assets/{zh-DnYhyzqb.js → zh-D3pvejo6.js} +1 -1
  46. package/web/dist/index.html +1 -1
  47. package/web/dist/radio.html +12 -1
  48. package/web/dist/station.html +7 -2
  49. package/web/dist/sw.js +25 -25
  50. package/web/dist/vod.html +2 -2
  51. package/web/dist/watch.html +2 -2
  52. /package/web/dist/assets/{vod-CdfPMa1u.js → vod-bM1DvPEx.js} +0 -0
  53. /package/web/dist/assets/{watch-hdfnhm3C.js → watch-BbhPO0HS.js} +0 -0
@@ -0,0 +1,289 @@
1
+ /**
2
+ * The music store on nixamp.com: the AI label's songs as files, bought with
3
+ * crypto through CoinPay.
4
+ *
5
+ * Listening is free on the live stations (with their breaks). What is sold is
6
+ * the files: a song for 10 cents, an album for $1, everything by an artist
7
+ * for $2, as a zip made on the fly by the server that holds them (MP3s with
8
+ * the cover and the words inside, each song's .lyrics.txt, cover.jpg and
9
+ * album.json). A buyer gives an email; once CoinPay says paid (its webhook,
10
+ * or asked when the buyer comes back), the email gets a link to the purchase,
11
+ * and the account with that email sees every purchase under My music on
12
+ * nixamp.com/radio, to play or download again.
13
+ *
14
+ * nixamp.com never holds the music. It signs a grant -- what was bought, from
15
+ * which server, until when -- and the server asks nixamp.com whether a grant
16
+ * is good before it plays or zips anything (/api/v1/store/grant).
17
+ */
18
+ import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
19
+ import { SupportError } from "./support.js";
20
+ /** In cents. */
21
+ export const PRICES = { song: 10, album: 100, artist: 200 };
22
+ const EMAIL = /^[^\s@<>"]{1,64}@[^\s@<>"]{1,190}\.[a-z]{2,24}$/i;
23
+ const KEY = /^[a-f0-9]{10}$/;
24
+ const ALBUM = /^[a-f0-9]{8}$/;
25
+ /** A grant is good for a day; pages mint a fresh one each time. */
26
+ const GRANT_MS = 24 * 3600_000;
27
+ /** A server's public address as given: https, no path, no trailing slash. */
28
+ export function serverBase(value) {
29
+ if (typeof value !== "string")
30
+ return "";
31
+ try {
32
+ const url = new URL(value);
33
+ if (url.protocol !== "https:" || url.username || url.password)
34
+ return "";
35
+ return url.origin;
36
+ }
37
+ catch {
38
+ return "";
39
+ }
40
+ }
41
+ function sign(secret, text) {
42
+ return createHmac("sha256", secret).update(text).digest("base64url");
43
+ }
44
+ /** `g1.<exp>.<kind>.<artist>.<album|->.<n>.<server, base64url>.<sig>` */
45
+ export function makeGrant(secret, scope, exp) {
46
+ const body = ["g1", String(exp), scope.kind, scope.artist, scope.album || "-", String(scope.n), Buffer.from(scope.server).toString("base64url")].join(".");
47
+ return `${body}.${sign(secret, body)}`;
48
+ }
49
+ export function readGrant(secret, grant, now = Date.now()) {
50
+ const parts = grant.split(".");
51
+ if (parts.length !== 8 || parts[0] !== "g1")
52
+ return null;
53
+ const body = parts.slice(0, 7).join(".");
54
+ const expected = Buffer.from(sign(secret, body));
55
+ const given = Buffer.from(parts[7]);
56
+ if (expected.length !== given.length || !timingSafeEqual(expected, given))
57
+ return null;
58
+ const exp = Number(parts[1]);
59
+ if (!Number.isFinite(exp) || exp < now)
60
+ return null;
61
+ const kind = parts[2];
62
+ if (!(kind in PRICES))
63
+ return null;
64
+ return { kind, artist: parts[3], album: parts[4] === "-" ? "" : parts[4], n: Number(parts[5]), server: Buffer.from(parts[6], "base64url").toString("utf8"), exp };
65
+ }
66
+ /** Whether a grant for `scope` covers this file of the catalogue. */
67
+ export function covers(scope, artist, album, n) {
68
+ if (scope.artist !== artist)
69
+ return false;
70
+ if (scope.kind === "artist")
71
+ return true;
72
+ if (scope.album !== album)
73
+ return false;
74
+ if (scope.kind === "album")
75
+ return true;
76
+ return n === undefined ? false : scope.n === n;
77
+ }
78
+ /**
79
+ * On a server that holds the music: whether nixamp.com signed this grant,
80
+ * asked at /api/v1/store/grant and remembered (a good one until it expires,
81
+ * a refused one for 5 minutes). nixamp.com itself checks with its secret.
82
+ */
83
+ export class GrantVerifier {
84
+ options;
85
+ known = new Map();
86
+ constructor(options) {
87
+ this.options = options;
88
+ }
89
+ async check(grant) {
90
+ const now = this.options.now?.() ?? Date.now();
91
+ if (!/^g1\.[A-Za-z0-9._-]{20,600}$/.test(grant))
92
+ return null;
93
+ if (this.options.secret)
94
+ return readGrant(this.options.secret, grant, now);
95
+ const hit = this.known.get(grant);
96
+ if (hit && hit.until > now)
97
+ return hit.scope;
98
+ try {
99
+ const response = await (this.options.fetcher ?? fetch)(`${this.options.site}/api/v1/store/grant?g=${encodeURIComponent(grant)}`, { signal: AbortSignal.timeout(5_000) });
100
+ if (response.status >= 500)
101
+ return null;
102
+ const body = await response.json();
103
+ const scope = body.ok && body.scope ? body.scope : null;
104
+ if (this.known.size > 5000)
105
+ this.known.clear();
106
+ this.known.set(grant, { scope, until: scope ? scope.exp : now + 5 * 60_000 });
107
+ return scope;
108
+ }
109
+ catch {
110
+ return null;
111
+ }
112
+ }
113
+ }
114
+ function row(one) {
115
+ return {
116
+ id: String(one["id"]), token: String(one["token"]), email: String(one["email"]), userId: String(one["user_id"] ?? ""),
117
+ server: String(one["server"]), kind: String(one["kind"]), artist: String(one["artist"]), album: String(one["album"] ?? ""), n: Number(one["n"] ?? 0),
118
+ title: String(one["title"]), artistName: String(one["artist_name"]), amountCents: Number(one["amount_cents"]), coin: String(one["coin"]),
119
+ paymentId: String(one["payment_id"] ?? ""), status: one["status"] === "paid" ? "paid" : "pending",
120
+ createdAt: new Date(String(one["created_at"])).getTime(), paidAt: one["paid_at"] ? new Date(String(one["paid_at"])).getTime() : 0,
121
+ };
122
+ }
123
+ export class MusicStore {
124
+ options;
125
+ schema = null;
126
+ constructor(options) {
127
+ this.options = options;
128
+ }
129
+ now() {
130
+ return this.options.now?.() ?? Date.now();
131
+ }
132
+ ready() {
133
+ this.schema ??= (async () => {
134
+ await this.options.db.query(`CREATE TABLE IF NOT EXISTS music_purchases (
135
+ id TEXT PRIMARY KEY, token TEXT NOT NULL, email TEXT NOT NULL, user_id TEXT NOT NULL DEFAULT '',
136
+ server TEXT NOT NULL, kind TEXT NOT NULL, artist TEXT NOT NULL, album TEXT NOT NULL DEFAULT '', n INTEGER NOT NULL DEFAULT 0,
137
+ title TEXT NOT NULL, artist_name TEXT NOT NULL, amount_cents INTEGER NOT NULL, coin TEXT NOT NULL,
138
+ payment_id TEXT, status TEXT NOT NULL DEFAULT 'pending', created_at TIMESTAMPTZ NOT NULL, paid_at TIMESTAMPTZ)`);
139
+ await this.options.db.query("CREATE INDEX IF NOT EXISTS music_purchases_email ON music_purchases (lower(email))");
140
+ await this.options.db.query("CREATE INDEX IF NOT EXISTS music_purchases_user ON music_purchases (user_id)");
141
+ await this.options.db.query("CREATE INDEX IF NOT EXISTS music_purchases_payment ON music_purchases (payment_id)");
142
+ })().catch((error) => { this.schema = null; throw error; });
143
+ return this.schema;
144
+ }
145
+ /** A server's catalogue, as it publishes it at /api/v1/label. */
146
+ async catalogue(server) {
147
+ const response = await (this.options.fetcher ?? fetch)(`${server}/api/v1/label`, { signal: AbortSignal.timeout(10_000), headers: { accept: "application/json" } });
148
+ if (!response.ok)
149
+ throw new SupportError("That server's catalogue could not be read. Try again.", 502, "catalogue");
150
+ return await response.json();
151
+ }
152
+ /** What is for sale at this address, with its name and price; refused when there is no such thing. */
153
+ async describe(input) {
154
+ const server = serverBase(input["server"]);
155
+ // A server in the directory or the guide's peers, or one named under nixamp.com (whose names
156
+ // and certificates nixamp.com issues): the directory empties on every restart of nixamp.com.
157
+ const named = server ? new URL(server).hostname.endsWith(".nixamp.com") : false;
158
+ if (!server || !(named || this.options.servers().includes(server)))
159
+ throw new SupportError("That server does not sell music here.", 404, "not_found");
160
+ const kind = String(input["kind"] ?? "");
161
+ if (!(kind in PRICES))
162
+ throw new SupportError("Buy a song, an album or an artist.", 400, "invalid_kind");
163
+ const artistKey = String(input["artist"] ?? "");
164
+ const albumKey = kind === "artist" ? "" : String(input["album"] ?? "");
165
+ const n = kind === "song" ? Number(input["n"]) : 0;
166
+ if (!KEY.test(artistKey) || (kind !== "artist" && !ALBUM.test(albumKey)) || (kind === "song" && !(Number.isInteger(n) && n > 0)))
167
+ throw new SupportError("No such music.", 404, "not_found");
168
+ const view = await this.catalogue(server);
169
+ const artist = view.genres.flatMap((one) => one.artists).find((one) => one.key === artistKey);
170
+ const album = artist?.albums.find((one) => one.key === albumKey);
171
+ const song = album?.songs.find((one) => one.n === n);
172
+ if (!artist || (kind !== "artist" && !album) || (kind === "song" && !song))
173
+ throw new SupportError("No such music.", 404, "not_found");
174
+ const title = kind === "artist" ? `Everything by ${artist.name}` : kind === "album" ? `${album.title} (${album.year})` : `${song.title}`;
175
+ const songs = kind === "artist" ? artist.albums.reduce((sum, one) => sum + one.songs.length, 0) : kind === "album" ? album.songs.length : 1;
176
+ return { server, kind, artist: artistKey, album: albumKey, n, title, artistName: artist.name, amountCents: PRICES[kind], songs };
177
+ }
178
+ /** A purchase waiting for its payment, and where to pay. Signed in, it is the account's too. */
179
+ async checkout(input, account) {
180
+ const email = (typeof input["email"] === "string" && input["email"].trim() ? input["email"] : account?.email ?? "").trim().toLowerCase();
181
+ if (!EMAIL.test(email))
182
+ throw new SupportError("Give the email your music should go to.", 400, "invalid_email");
183
+ const item = await this.describe(input);
184
+ await this.ready();
185
+ const id = randomUUID();
186
+ const token = randomUUID().replace(/-/g, "");
187
+ const checkout = await this.options.support.pay({
188
+ amountUsd: item.amountCents / 100,
189
+ coin: input["coin"],
190
+ order: `music-${id}`,
191
+ description: `${item.title} by ${item.artistName} (${item.kind === "song" ? "song" : item.kind === "album" ? "album" : "every album"}, MP3)`,
192
+ metadata: { product: "music", purchase: id, kind: item.kind },
193
+ redirect: this.link(id, token),
194
+ });
195
+ await this.options.db.query(`INSERT INTO music_purchases (id, token, email, user_id, server, kind, artist, album, n, title, artist_name, amount_cents, coin, payment_id, created_at)
196
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)`, [id, token, email, account?.id ?? "", item.server, item.kind, item.artist, item.album, item.n, item.title, item.artistName, item.amountCents, String(input["coin"]).toUpperCase(), checkout.paymentId, new Date(this.now())]);
197
+ return { url: checkout.url, id, token };
198
+ }
199
+ /** The purchase's own page, which is also the receipt link. */
200
+ link(id, token) {
201
+ return `${this.options.site}/purchases/${id}?t=${token}`;
202
+ }
203
+ /** A purchase by its link; a pending one asks CoinPay whether it has been paid since. */
204
+ async get(id, token) {
205
+ await this.ready();
206
+ const found = (await this.options.db.query("SELECT * FROM music_purchases WHERE id = $1 AND token = $2", [id, token])).rows[0];
207
+ if (!found)
208
+ return null;
209
+ const purchase = row(found);
210
+ if (purchase.status === "pending" && purchase.paymentId && (await this.options.support.isPaid(purchase.paymentId)) === true)
211
+ return (await this.paid(purchase.id)) ?? purchase;
212
+ return purchase;
213
+ }
214
+ /** CoinPay's webhook: a payment of ours that is now paid. False when it is not a music purchase. */
215
+ async settle(paymentId) {
216
+ await this.ready();
217
+ const found = (await this.options.db.query("SELECT id, status FROM music_purchases WHERE payment_id = $1", [paymentId])).rows[0];
218
+ if (!found)
219
+ return { settled: false };
220
+ if (found["status"] === "paid")
221
+ return { settled: true, purchase: String(found["id"]) };
222
+ if ((await this.options.support.isPaid(paymentId)) !== true)
223
+ return { settled: false, purchase: String(found["id"]) };
224
+ await this.paid(String(found["id"]));
225
+ return { settled: true, purchase: String(found["id"]) };
226
+ }
227
+ /** Whether CoinPay's webhook is about one of ours. */
228
+ async owns(paymentId) {
229
+ await this.ready();
230
+ return (await this.options.db.query("SELECT 1 FROM music_purchases WHERE payment_id = $1", [paymentId])).rows.length > 0;
231
+ }
232
+ /** Marks it paid once (the first to get there sends the receipt). */
233
+ async paid(id) {
234
+ const updated = (await this.options.db.query("UPDATE music_purchases SET status = 'paid', paid_at = $2 WHERE id = $1 AND status = 'pending' RETURNING *", [id, new Date(this.now())])).rows[0];
235
+ if (!updated) {
236
+ const again = (await this.options.db.query("SELECT * FROM music_purchases WHERE id = $1", [id])).rows[0];
237
+ return again ? row(again) : null;
238
+ }
239
+ const purchase = row(updated);
240
+ await this.mail(purchase.email, `Your music: ${purchase.title}`, [
241
+ `Thank you. "${purchase.title}" by ${purchase.artistName} is yours: MP3s with the cover art and the lyrics, in one zip.`,
242
+ `Download it, now or any time: ${this.link(purchase.id, purchase.token)}`,
243
+ `Sign in to nixamp.com with ${purchase.email} and everything you have bought is under My music on ${this.options.site}/radio#music, to play or download again.`,
244
+ ]);
245
+ return purchase;
246
+ }
247
+ /** Every paid purchase of this account: made signed in, or to its email. */
248
+ async forAccount(account) {
249
+ await this.ready();
250
+ const rows = await this.options.db.query("SELECT * FROM music_purchases WHERE status = 'paid' AND (user_id = $1 OR lower(email) = lower($2)) ORDER BY paid_at DESC LIMIT 500", [account.id, account.email]);
251
+ return rows.rows.map(row);
252
+ }
253
+ grant(purchase) {
254
+ return makeGrant(this.options.secret, purchase, this.now() + GRANT_MS);
255
+ }
256
+ check(grant) {
257
+ return readGrant(this.options.secret, grant, this.now());
258
+ }
259
+ /** Where the zip of a purchase is made: on the server that holds the music. */
260
+ zipUrl(purchase) {
261
+ return `${purchase.server}/api/v1/label/zip?grant=${encodeURIComponent(this.grant(purchase))}`;
262
+ }
263
+ /** What anybody with the purchase's link (or the account) is shown. */
264
+ view(purchase) {
265
+ return {
266
+ id: purchase.id, kind: purchase.kind, title: purchase.title, artistName: purchase.artistName, server: purchase.server,
267
+ artist: purchase.artist, album: purchase.album, n: purchase.n, amountUsd: purchase.amountCents / 100, coin: purchase.coin,
268
+ status: purchase.status, createdAt: purchase.createdAt, paidAt: purchase.paidAt,
269
+ ...(purchase.status === "paid" ? { grant: this.grant(purchase), zip: this.zipUrl(purchase), link: this.link(purchase.id, purchase.token) } : {}),
270
+ };
271
+ }
272
+ async mail(to, subject, lines) {
273
+ if (!this.options.mail) {
274
+ this.options.log?.(` store: no RESEND_API_KEY, so "${subject}" to ${to} was not sent`);
275
+ return;
276
+ }
277
+ try {
278
+ await (this.options.fetcher ?? fetch)("https://api.resend.com/emails", {
279
+ method: "POST",
280
+ signal: AbortSignal.timeout(15_000),
281
+ headers: { authorization: `Bearer ${this.options.mail.apiKey}`, "content-type": "application/json" },
282
+ body: JSON.stringify({ from: this.options.mail.from, to: [to], subject, text: `${lines.join("\n\n")}\n\nnixamp.com` }),
283
+ });
284
+ }
285
+ catch (error) {
286
+ this.options.log?.(` store: mail to ${to} failed: ${error.message}`);
287
+ }
288
+ }
289
+ }
package/dist/server.d.ts CHANGED
@@ -25,6 +25,7 @@ import { LibraryIndex, type Probed } from "./library-index.ts";
25
25
  import { ListenerStore } from "./library-user.ts";
26
26
  import { Importer } from "./handoff.ts";
27
27
  import { AiLabel } from "./ai-label.ts";
28
+ import { MusicStore } from "./music-store.ts";
28
29
  import { AiRadio } from "./ai-radio.ts";
29
30
  import { ListenerShares } from "./listener-shares.ts";
30
31
  import { Support, SupportSubscriptions } from "./support.ts";
@@ -830,6 +831,8 @@ export interface HandlerOptions {
830
831
  supportSubscriptions?: SupportSubscriptions;
831
832
  /** Signing secret of nixamp's CoinPay business, for /api/v1/webhooks/coinpay. */
832
833
  coinpayWebhookSecret?: string;
834
+ /** The AI label's songs for sale, nixamp.com only (music-store.ts). */
835
+ musicStore?: MusicStore;
833
836
  sharedTranslations?: SharedTranslations;
834
837
  /** Translation: texts in another language, by a model here. The same optional library. */
835
838
  translator?: Translator;
package/dist/server.js CHANGED
@@ -62,7 +62,9 @@ import { Importer } from "./handoff.js";
62
62
  import { handleHandoffs } from "./handoff-routes.js";
63
63
  import { handleAiRoutes, isPublicSharePath } from "./ai-routes.js";
64
64
  import { AiLabel } from "./ai-label.js";
65
- import { handleLabelRoutes, isPublicLabelPath } from "./label-routes.js";
65
+ import { buyLink, handleLabelRoutes, isPublicLabelPath } from "./label-routes.js";
66
+ import { GrantVerifier, MusicStore } from "./music-store.js";
67
+ import { handleStoreRoutes, isStorePath } from "./store-routes.js";
66
68
  import { AiRadio } from "./ai-radio.js";
67
69
  import { ListenerShares } from "./listener-shares.js";
68
70
  import { HolderStore, TagReader, factsFor } from "./track-facts.js";
@@ -1491,6 +1493,9 @@ export function createHandler(engine, options) {
1491
1493
  site: process.env["NIXAMP_SITE"] || "https://nixamp.com",
1492
1494
  ...(listenPassKey ? { secret: listenPassKey } : {}),
1493
1495
  });
1496
+ // The music store lives on nixamp.com; a server holding the label's music asks it about grants.
1497
+ const storeSite = (process.env["NIXAMP_SITE"] || "https://nixamp.com").replace(/\/+$/, "");
1498
+ const storeGrants = new GrantVerifier({ site: storeSite });
1494
1499
  return async function handle(request, response) {
1495
1500
  const url = new URL(request.url ?? "/", "http://localhost");
1496
1501
  const path = url.pathname;
@@ -2057,6 +2062,19 @@ export function createHandler(engine, options) {
2057
2062
  json(response, 400, { error: "invalid JSON" });
2058
2063
  return;
2059
2064
  }
2065
+ if (options.musicStore) {
2066
+ try {
2067
+ if (await options.musicStore.owns(event.paymentId)) {
2068
+ json(response, 200, await options.musicStore.settle(event.paymentId));
2069
+ return;
2070
+ }
2071
+ }
2072
+ catch (error) {
2073
+ console.error(`nixamp: CoinPay webhook for ${event.paymentId} (music) could not settle: ${error instanceof Error ? error.message : String(error)}`);
2074
+ json(response, 500, { error: "could not settle; retry" });
2075
+ return;
2076
+ }
2077
+ }
2060
2078
  if (!options.translationPasses) {
2061
2079
  json(response, 200, { settled: false, reason: "billing is off here" });
2062
2080
  return;
@@ -2181,6 +2199,8 @@ export function createHandler(engine, options) {
2181
2199
  !isPublicSharePath(path) &&
2182
2200
  // The AI label's catalogue, its songs and their words.
2183
2201
  !(isPublicLabelPath(path) && Boolean(options.stations?.label)) &&
2202
+ // The music store: its pages, checkout, receipts and grants (nixamp.com).
2203
+ !(isStorePath(path) && Boolean(options.musicStore)) &&
2184
2204
  // What is on, as MCP, for anybody.
2185
2205
  path !== "/mcp/public" &&
2186
2206
  // Supporting a copyright holder is for anybody listening.
@@ -2528,9 +2548,24 @@ export function createHandler(engine, options) {
2528
2548
  });
2529
2549
  return;
2530
2550
  }
2551
+ if (options.musicStore && options.support && isStorePath(path)) {
2552
+ const support = options.support;
2553
+ const accounts = options.accounts;
2554
+ if (await handleStoreRoutes(request, response, path, url, {
2555
+ store: options.musicStore,
2556
+ coins: async () => (await support.coins()).coins,
2557
+ account: async () => (accounts ? await accounts.whoIs(tokenFrom(request.headers)) : null),
2558
+ json,
2559
+ readBody,
2560
+ }))
2561
+ return;
2562
+ }
2531
2563
  if (options.stations?.label && (isPublicLabelPath(path) || path === "/api/v1/stations/label" || path.startsWith("/api/v1/stations/label/"))) {
2532
2564
  const control = isPublicLabelPath(path) ? { allowed: false } : await controlOf();
2533
- if (await handleLabelRoutes(request, response, path, { label: options.stations.label, canControl: control.allowed, json, readBody }))
2565
+ if (await handleLabelRoutes(request, response, path, {
2566
+ label: options.stations.label, canControl: control.allowed, json, readBody,
2567
+ grants: storeGrants, site: storeSite, publicBase: options.stations.publicBase?.() ?? "",
2568
+ }))
2534
2569
  return;
2535
2570
  }
2536
2571
  if (options.stations && (isPublicSharePath(path) || path.startsWith("/api/v1/stations/ai") || path.startsWith("/api/v1/stations/shares") || path.startsWith("/api/v1/stations/holders"))) {
@@ -6880,16 +6915,27 @@ export async function serve(argv, version = "0.1.0") {
6880
6915
  }
6881
6916
  const art = picture ? `/api/v1/stations/${station.id}/art?t=${encodeURIComponent(station.listenToken)}&f=${sha("sha1").update(file + stamp).digest("hex").slice(0, 12)}` : "";
6882
6917
  const words = await lyricsFinder.find(file).catch(() => null);
6883
- return factsFor({
6884
- art,
6885
- ...(words ? { lyrics: `/api/v1/stations/${station.id}/lyrics?t=${encodeURIComponent(station.listenToken)}&f=${sha("sha1").update(file + stamp).digest("hex").slice(0, 12)}` } : {}),
6886
- station, file,
6887
- tags: await tagReader.read(file),
6888
- known: trackFor(engine, file),
6889
- holders: holderStore.list(),
6890
- share: share ? { basis: share.rights.basis, note: share.rights.note, by: share.by } : null,
6891
- aiTrack: composed ? { made: composed.made } : null,
6892
- });
6918
+ // A label song is for sale on nixamp.com: the song, its album, everything by the artist.
6919
+ const song = aiLabel.songAt(file);
6920
+ const here = stations.publicBase?.() ?? "";
6921
+ const shop = (process.env["NIXAMP_SITE"] || "https://nixamp.com").replace(/\/+$/, "");
6922
+ const store = song && /^https:\/\//.test(here) ? {
6923
+ song: buyLink(shop, here, "song", song.artist, song.album, song.n),
6924
+ album: buyLink(shop, here, "album", song.artist, song.album),
6925
+ artist: buyLink(shop, here, "artist", song.artist),
6926
+ artistName: song.artistName,
6927
+ albumTitle: song.albumTitle,
6928
+ } : null;
6929
+ return { ...factsFor({
6930
+ art,
6931
+ ...(words ? { lyrics: `/api/v1/stations/${station.id}/lyrics?t=${encodeURIComponent(station.listenToken)}&f=${sha("sha1").update(file + stamp).digest("hex").slice(0, 12)}` } : {}),
6932
+ station, file,
6933
+ tags: await tagReader.read(file),
6934
+ known: trackFor(engine, file),
6935
+ holders: holderStore.list(),
6936
+ share: share ? { basis: share.rights.basis, note: share.rights.note, by: share.by } : null,
6937
+ aiTrack: composed ? { made: composed.made } : null,
6938
+ }), store };
6893
6939
  };
6894
6940
  // Channels as HLS, on demand, for Safari on a phone: one ffmpeg copying a
6895
6941
  // channel's fragments into short files while somebody is asking for them.
@@ -7029,6 +7075,20 @@ export async function serve(argv, version = "0.1.0") {
7029
7075
  log: (line) => console.log(line),
7030
7076
  }) : undefined;
7031
7077
  supportMonthly?.startSchedule();
7078
+ // The AI label's songs for sale: purchases kept here, the music zipped by the server that holds it.
7079
+ const jwtSecret = process.env["NIXAMP_JWT_SECRET"] ?? "";
7080
+ const musicStore = supportPayments && supportPayments.enabled && pool && jwtSecret ? new MusicStore({
7081
+ db: pool,
7082
+ support: supportPayments,
7083
+ mail: process.env["RESEND_API_KEY"] ? { apiKey: process.env["RESEND_API_KEY"], from: process.env["NIXAMP_MAIL_FROM"] ?? "nixamp <notifications@nixamp.com>" } : null,
7084
+ site: nixampSite,
7085
+ secret: sha("sha256").update(`music-grant:${jwtSecret}`).digest("hex"),
7086
+ servers: () => {
7087
+ const listed = directory ? directory.list().map((one) => peerBase(one.url)).filter(Boolean) : [];
7088
+ return [...new Set([...guidePeers(), ...listed])];
7089
+ },
7090
+ log: (line) => console.log(line),
7091
+ }) : undefined;
7032
7092
  if (translationPasses)
7033
7093
  void translationPasses.access().catch(() => console.error("nixamp: translation checkout could not warm; it will retry on demand"));
7034
7094
  const liveVoice = accounts && process.env["NIXAMP_DUBBING"] !== "off" ? new LiveVoice({
@@ -7579,6 +7639,7 @@ export async function serve(argv, version = "0.1.0") {
7579
7639
  ...(translationPasses ? { translationPasses } : {}),
7580
7640
  ...(supportPayments ? { support: supportPayments } : {}),
7581
7641
  ...(supportMonthly ? { supportSubscriptions: supportMonthly } : {}),
7642
+ ...(musicStore ? { musicStore } : {}),
7582
7643
  ...(process.env["COINPAY_WEBHOOK_SECRET"] ? { coinpayWebhookSecret: process.env["COINPAY_WEBHOOK_SECRET"] } : {}),
7583
7644
  ...(sharedTranslations ? { sharedTranslations } : {}),
7584
7645
  ...(translator ? { translator } : {}),
@@ -0,0 +1,16 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { type MusicStore } from "./music-store.ts";
3
+ export declare function isStorePath(path: string): boolean;
4
+ export interface StoreRoutesContext {
5
+ store: MusicStore;
6
+ coins: () => Promise<string[]>;
7
+ /** The signed-in account, or null. */
8
+ account: () => Promise<{
9
+ id: string;
10
+ email: string;
11
+ } | null>;
12
+ json: (response: ServerResponse, status: number, body: unknown) => void;
13
+ readBody: (request: IncomingMessage) => Promise<string>;
14
+ }
15
+ export declare const STORE_CSP: string;
16
+ export declare function handleStoreRoutes(request: IncomingMessage, response: ServerResponse, path: string, url: URL, ctx: StoreRoutesContext): Promise<boolean>;