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,333 @@
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 type { Db, Support } from "./support.ts";
20
+ import { SupportError } from "./support.ts";
21
+
22
+ export type Kind = "song" | "album" | "artist";
23
+
24
+ /** In cents. */
25
+ export const PRICES: Record<Kind, number> = { song: 10, album: 100, artist: 200 };
26
+
27
+ const EMAIL = /^[^\s@<>"]{1,64}@[^\s@<>"]{1,190}\.[a-z]{2,24}$/i;
28
+ const KEY = /^[a-f0-9]{10}$/;
29
+ const ALBUM = /^[a-f0-9]{8}$/;
30
+ /** A grant is good for a day; pages mint a fresh one each time. */
31
+ const GRANT_MS = 24 * 3600_000;
32
+
33
+ export interface Scope {
34
+ server: string;
35
+ kind: Kind;
36
+ artist: string;
37
+ album: string;
38
+ n: number;
39
+ }
40
+
41
+ export interface Purchase extends Scope {
42
+ id: string;
43
+ token: string;
44
+ email: string;
45
+ userId: string;
46
+ title: string;
47
+ artistName: string;
48
+ amountCents: number;
49
+ coin: string;
50
+ paymentId: string;
51
+ status: "pending" | "paid";
52
+ createdAt: number;
53
+ paidAt: number;
54
+ }
55
+
56
+ interface LabelSong { n: number; title: string }
57
+ interface LabelAlbum { key: string; title: string; year: number; songs: LabelSong[] }
58
+ interface LabelArtist { key: string; name: string; albums: LabelAlbum[] }
59
+ interface LabelView { holder: string; genres: Array<{ genre: string; artists: LabelArtist[] }> }
60
+
61
+ /** A server's public address as given: https, no path, no trailing slash. */
62
+ export function serverBase(value: unknown): string {
63
+ if (typeof value !== "string") return "";
64
+ try {
65
+ const url = new URL(value);
66
+ if (url.protocol !== "https:" || url.username || url.password) return "";
67
+ return url.origin;
68
+ } catch {
69
+ return "";
70
+ }
71
+ }
72
+
73
+ function sign(secret: string, text: string): string {
74
+ return createHmac("sha256", secret).update(text).digest("base64url");
75
+ }
76
+
77
+ /** `g1.<exp>.<kind>.<artist>.<album|->.<n>.<server, base64url>.<sig>` */
78
+ export function makeGrant(secret: string, scope: Scope, exp: number): string {
79
+ const body = ["g1", String(exp), scope.kind, scope.artist, scope.album || "-", String(scope.n), Buffer.from(scope.server).toString("base64url")].join(".");
80
+ return `${body}.${sign(secret, body)}`;
81
+ }
82
+
83
+ export function readGrant(secret: string, grant: string, now = Date.now()): (Scope & { exp: number }) | null {
84
+ const parts = grant.split(".");
85
+ if (parts.length !== 8 || parts[0] !== "g1") return null;
86
+ const body = parts.slice(0, 7).join(".");
87
+ const expected = Buffer.from(sign(secret, body));
88
+ const given = Buffer.from(parts[7]!);
89
+ if (expected.length !== given.length || !timingSafeEqual(expected, given)) return null;
90
+ const exp = Number(parts[1]);
91
+ if (!Number.isFinite(exp) || exp < now) return null;
92
+ const kind = parts[2] as Kind;
93
+ if (!(kind in PRICES)) return null;
94
+ return { kind, artist: parts[3]!, album: parts[4] === "-" ? "" : parts[4]!, n: Number(parts[5]), server: Buffer.from(parts[6]!, "base64url").toString("utf8"), exp };
95
+ }
96
+
97
+ /** Whether a grant for `scope` covers this file of the catalogue. */
98
+ export function covers(scope: Pick<Scope, "kind" | "artist" | "album" | "n">, artist: string, album: string, n?: number): boolean {
99
+ if (scope.artist !== artist) return false;
100
+ if (scope.kind === "artist") return true;
101
+ if (scope.album !== album) return false;
102
+ if (scope.kind === "album") return true;
103
+ return n === undefined ? false : scope.n === n;
104
+ }
105
+
106
+ /**
107
+ * On a server that holds the music: whether nixamp.com signed this grant,
108
+ * asked at /api/v1/store/grant and remembered (a good one until it expires,
109
+ * a refused one for 5 minutes). nixamp.com itself checks with its secret.
110
+ */
111
+ export class GrantVerifier {
112
+ private known = new Map<string, { scope: (Scope & { exp: number }) | null; until: number }>();
113
+
114
+ constructor(private readonly options: { site: string; secret?: string; fetcher?: typeof fetch; now?: () => number }) {}
115
+
116
+ async check(grant: string): Promise<(Scope & { exp: number }) | null> {
117
+ const now = this.options.now?.() ?? Date.now();
118
+ if (!/^g1\.[A-Za-z0-9._-]{20,600}$/.test(grant)) return null;
119
+ if (this.options.secret) return readGrant(this.options.secret, grant, now);
120
+ const hit = this.known.get(grant);
121
+ if (hit && hit.until > now) return hit.scope;
122
+ try {
123
+ const response = await (this.options.fetcher ?? fetch)(`${this.options.site}/api/v1/store/grant?g=${encodeURIComponent(grant)}`, { signal: AbortSignal.timeout(5_000) });
124
+ if (response.status >= 500) return null;
125
+ const body = await response.json() as { ok?: boolean; scope?: Scope & { exp: number } };
126
+ const scope = body.ok && body.scope ? body.scope : null;
127
+ if (this.known.size > 5000) this.known.clear();
128
+ this.known.set(grant, { scope, until: scope ? scope.exp : now + 5 * 60_000 });
129
+ return scope;
130
+ } catch {
131
+ return null;
132
+ }
133
+ }
134
+ }
135
+
136
+ function row(one: Record<string, unknown>): Purchase {
137
+ return {
138
+ id: String(one["id"]), token: String(one["token"]), email: String(one["email"]), userId: String(one["user_id"] ?? ""),
139
+ server: String(one["server"]), kind: String(one["kind"]) as Kind, artist: String(one["artist"]), album: String(one["album"] ?? ""), n: Number(one["n"] ?? 0),
140
+ title: String(one["title"]), artistName: String(one["artist_name"]), amountCents: Number(one["amount_cents"]), coin: String(one["coin"]),
141
+ paymentId: String(one["payment_id"] ?? ""), status: one["status"] === "paid" ? "paid" : "pending",
142
+ createdAt: new Date(String(one["created_at"])).getTime(), paidAt: one["paid_at"] ? new Date(String(one["paid_at"])).getTime() : 0,
143
+ };
144
+ }
145
+
146
+ export interface MusicStoreOptions {
147
+ db: Db;
148
+ support: Support;
149
+ /** Resend; null leaves the receipt unsent (and says so in the log). */
150
+ mail: { apiKey: string; from: string } | null;
151
+ site: string;
152
+ /** Signs the grants; derived from NIXAMP_JWT_SECRET. */
153
+ secret: string;
154
+ /** The servers whose catalogues may be sold here (the directory and the guide's peers). */
155
+ servers: () => string[];
156
+ fetcher?: typeof fetch;
157
+ now?: () => number;
158
+ log?: (line: string) => void;
159
+ }
160
+
161
+ export class MusicStore {
162
+ private schema: Promise<void> | null = null;
163
+
164
+ constructor(private readonly options: MusicStoreOptions) {}
165
+
166
+ private now(): number {
167
+ return this.options.now?.() ?? Date.now();
168
+ }
169
+
170
+ private ready(): Promise<void> {
171
+ this.schema ??= (async () => {
172
+ await this.options.db.query(`CREATE TABLE IF NOT EXISTS music_purchases (
173
+ id TEXT PRIMARY KEY, token TEXT NOT NULL, email TEXT NOT NULL, user_id TEXT NOT NULL DEFAULT '',
174
+ server TEXT NOT NULL, kind TEXT NOT NULL, artist TEXT NOT NULL, album TEXT NOT NULL DEFAULT '', n INTEGER NOT NULL DEFAULT 0,
175
+ title TEXT NOT NULL, artist_name TEXT NOT NULL, amount_cents INTEGER NOT NULL, coin TEXT NOT NULL,
176
+ payment_id TEXT, status TEXT NOT NULL DEFAULT 'pending', created_at TIMESTAMPTZ NOT NULL, paid_at TIMESTAMPTZ)`);
177
+ await this.options.db.query("CREATE INDEX IF NOT EXISTS music_purchases_email ON music_purchases (lower(email))");
178
+ await this.options.db.query("CREATE INDEX IF NOT EXISTS music_purchases_user ON music_purchases (user_id)");
179
+ await this.options.db.query("CREATE INDEX IF NOT EXISTS music_purchases_payment ON music_purchases (payment_id)");
180
+ })().catch((error: unknown) => { this.schema = null; throw error; });
181
+ return this.schema;
182
+ }
183
+
184
+ /** A server's catalogue, as it publishes it at /api/v1/label. */
185
+ private async catalogue(server: string): Promise<LabelView> {
186
+ const response = await (this.options.fetcher ?? fetch)(`${server}/api/v1/label`, { signal: AbortSignal.timeout(10_000), headers: { accept: "application/json" } });
187
+ if (!response.ok) throw new SupportError("That server's catalogue could not be read. Try again.", 502, "catalogue");
188
+ return await response.json() as LabelView;
189
+ }
190
+
191
+ /** What is for sale at this address, with its name and price; refused when there is no such thing. */
192
+ async describe(input: Record<string, unknown>): Promise<Scope & { title: string; artistName: string; amountCents: number; songs: number }> {
193
+ const server = serverBase(input["server"]);
194
+ // A server in the directory or the guide's peers, or one named under nixamp.com (whose names
195
+ // and certificates nixamp.com issues): the directory empties on every restart of nixamp.com.
196
+ const named = server ? new URL(server).hostname.endsWith(".nixamp.com") : false;
197
+ if (!server || !(named || this.options.servers().includes(server))) throw new SupportError("That server does not sell music here.", 404, "not_found");
198
+ const kind = String(input["kind"] ?? "") as Kind;
199
+ if (!(kind in PRICES)) throw new SupportError("Buy a song, an album or an artist.", 400, "invalid_kind");
200
+ const artistKey = String(input["artist"] ?? "");
201
+ const albumKey = kind === "artist" ? "" : String(input["album"] ?? "");
202
+ const n = kind === "song" ? Number(input["n"]) : 0;
203
+ if (!KEY.test(artistKey) || (kind !== "artist" && !ALBUM.test(albumKey)) || (kind === "song" && !(Number.isInteger(n) && n > 0))) throw new SupportError("No such music.", 404, "not_found");
204
+ const view = await this.catalogue(server);
205
+ const artist = view.genres.flatMap((one) => one.artists).find((one) => one.key === artistKey);
206
+ const album = artist?.albums.find((one) => one.key === albumKey);
207
+ const song = album?.songs.find((one) => one.n === n);
208
+ if (!artist || (kind !== "artist" && !album) || (kind === "song" && !song)) throw new SupportError("No such music.", 404, "not_found");
209
+ const title = kind === "artist" ? `Everything by ${artist.name}` : kind === "album" ? `${album!.title} (${album!.year})` : `${song!.title}`;
210
+ const songs = kind === "artist" ? artist.albums.reduce((sum, one) => sum + one.songs.length, 0) : kind === "album" ? album!.songs.length : 1;
211
+ return { server, kind, artist: artistKey, album: albumKey, n, title, artistName: artist.name, amountCents: PRICES[kind], songs };
212
+ }
213
+
214
+ /** A purchase waiting for its payment, and where to pay. Signed in, it is the account's too. */
215
+ async checkout(input: Record<string, unknown>, account: { id: string; email: string } | null): Promise<{ url: string; id: string; token: string }> {
216
+ const email = (typeof input["email"] === "string" && input["email"].trim() ? input["email"] : account?.email ?? "").trim().toLowerCase();
217
+ if (!EMAIL.test(email)) throw new SupportError("Give the email your music should go to.", 400, "invalid_email");
218
+ const item = await this.describe(input);
219
+ await this.ready();
220
+ const id = randomUUID();
221
+ const token = randomUUID().replace(/-/g, "");
222
+ const checkout = await this.options.support.pay({
223
+ amountUsd: item.amountCents / 100,
224
+ coin: input["coin"],
225
+ order: `music-${id}`,
226
+ description: `${item.title} by ${item.artistName} (${item.kind === "song" ? "song" : item.kind === "album" ? "album" : "every album"}, MP3)`,
227
+ metadata: { product: "music", purchase: id, kind: item.kind },
228
+ redirect: this.link(id, token),
229
+ });
230
+ 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)
231
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)`,
232
+ [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())]);
233
+ return { url: checkout.url, id, token };
234
+ }
235
+
236
+ /** The purchase's own page, which is also the receipt link. */
237
+ link(id: string, token: string): string {
238
+ return `${this.options.site}/purchases/${id}?t=${token}`;
239
+ }
240
+
241
+ /** A purchase by its link; a pending one asks CoinPay whether it has been paid since. */
242
+ async get(id: string, token: string): Promise<Purchase | null> {
243
+ await this.ready();
244
+ const found = (await this.options.db.query("SELECT * FROM music_purchases WHERE id = $1 AND token = $2", [id, token])).rows[0];
245
+ if (!found) return null;
246
+ const purchase = row(found);
247
+ if (purchase.status === "pending" && purchase.paymentId && (await this.options.support.isPaid(purchase.paymentId)) === true) return (await this.paid(purchase.id)) ?? purchase;
248
+ return purchase;
249
+ }
250
+
251
+ /** CoinPay's webhook: a payment of ours that is now paid. False when it is not a music purchase. */
252
+ async settle(paymentId: string): Promise<{ settled: boolean; purchase?: string }> {
253
+ await this.ready();
254
+ const found = (await this.options.db.query("SELECT id, status FROM music_purchases WHERE payment_id = $1", [paymentId])).rows[0];
255
+ if (!found) return { settled: false };
256
+ if (found["status"] === "paid") return { settled: true, purchase: String(found["id"]) };
257
+ if ((await this.options.support.isPaid(paymentId)) !== true) return { settled: false, purchase: String(found["id"]) };
258
+ await this.paid(String(found["id"]));
259
+ return { settled: true, purchase: String(found["id"]) };
260
+ }
261
+
262
+ /** Whether CoinPay's webhook is about one of ours. */
263
+ async owns(paymentId: string): Promise<boolean> {
264
+ await this.ready();
265
+ return (await this.options.db.query("SELECT 1 FROM music_purchases WHERE payment_id = $1", [paymentId])).rows.length > 0;
266
+ }
267
+
268
+ /** Marks it paid once (the first to get there sends the receipt). */
269
+ private async paid(id: string): Promise<Purchase | null> {
270
+ 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];
271
+ if (!updated) {
272
+ const again = (await this.options.db.query("SELECT * FROM music_purchases WHERE id = $1", [id])).rows[0];
273
+ return again ? row(again) : null;
274
+ }
275
+ const purchase = row(updated);
276
+ await this.mail(purchase.email, `Your music: ${purchase.title}`, [
277
+ `Thank you. "${purchase.title}" by ${purchase.artistName} is yours: MP3s with the cover art and the lyrics, in one zip.`,
278
+ `Download it, now or any time: ${this.link(purchase.id, purchase.token)}`,
279
+ `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.`,
280
+ ]);
281
+ return purchase;
282
+ }
283
+
284
+ /** Every paid purchase of this account: made signed in, or to its email. */
285
+ async forAccount(account: { id: string; email: string }): Promise<Purchase[]> {
286
+ await this.ready();
287
+ const rows = await this.options.db.query(
288
+ "SELECT * FROM music_purchases WHERE status = 'paid' AND (user_id = $1 OR lower(email) = lower($2)) ORDER BY paid_at DESC LIMIT 500",
289
+ [account.id, account.email],
290
+ );
291
+ return rows.rows.map(row);
292
+ }
293
+
294
+ grant(purchase: Scope): string {
295
+ return makeGrant(this.options.secret, purchase, this.now() + GRANT_MS);
296
+ }
297
+
298
+ check(grant: string): (Scope & { exp: number }) | null {
299
+ return readGrant(this.options.secret, grant, this.now());
300
+ }
301
+
302
+ /** Where the zip of a purchase is made: on the server that holds the music. */
303
+ zipUrl(purchase: Purchase): string {
304
+ return `${purchase.server}/api/v1/label/zip?grant=${encodeURIComponent(this.grant(purchase))}`;
305
+ }
306
+
307
+ /** What anybody with the purchase's link (or the account) is shown. */
308
+ view(purchase: Purchase): Record<string, unknown> {
309
+ return {
310
+ id: purchase.id, kind: purchase.kind, title: purchase.title, artistName: purchase.artistName, server: purchase.server,
311
+ artist: purchase.artist, album: purchase.album, n: purchase.n, amountUsd: purchase.amountCents / 100, coin: purchase.coin,
312
+ status: purchase.status, createdAt: purchase.createdAt, paidAt: purchase.paidAt,
313
+ ...(purchase.status === "paid" ? { grant: this.grant(purchase), zip: this.zipUrl(purchase), link: this.link(purchase.id, purchase.token) } : {}),
314
+ };
315
+ }
316
+
317
+ private async mail(to: string, subject: string, lines: string[]): Promise<void> {
318
+ if (!this.options.mail) {
319
+ this.options.log?.(` store: no RESEND_API_KEY, so "${subject}" to ${to} was not sent`);
320
+ return;
321
+ }
322
+ try {
323
+ await (this.options.fetcher ?? fetch)("https://api.resend.com/emails", {
324
+ method: "POST",
325
+ signal: AbortSignal.timeout(15_000),
326
+ headers: { authorization: `Bearer ${this.options.mail.apiKey}`, "content-type": "application/json" },
327
+ body: JSON.stringify({ from: this.options.mail.from, to: [to], subject, text: `${lines.join("\n\n")}\n\nnixamp.com` }),
328
+ });
329
+ } catch (error) {
330
+ this.options.log?.(` store: mail to ${to} failed: ${(error as Error).message}`);
331
+ }
332
+ }
333
+ }
package/src/server.ts CHANGED
@@ -79,7 +79,9 @@ import { Importer } from "./handoff.ts";
79
79
  import { handleHandoffs } from "./handoff-routes.ts";
80
80
  import { handleAiRoutes, isPublicSharePath } from "./ai-routes.ts";
81
81
  import { AiLabel } from "./ai-label.ts";
82
- import { handleLabelRoutes, isPublicLabelPath } from "./label-routes.ts";
82
+ import { buyLink, handleLabelRoutes, isPublicLabelPath } from "./label-routes.ts";
83
+ import { GrantVerifier, MusicStore } from "./music-store.ts";
84
+ import { handleStoreRoutes, isStorePath } from "./store-routes.ts";
83
85
  import { AiRadio } from "./ai-radio.ts";
84
86
  import { ListenerShares } from "./listener-shares.ts";
85
87
  import { HolderStore, TagReader, factsFor } from "./track-facts.ts";
@@ -1950,6 +1952,8 @@ export interface HandlerOptions {
1950
1952
  supportSubscriptions?: SupportSubscriptions;
1951
1953
  /** Signing secret of nixamp's CoinPay business, for /api/v1/webhooks/coinpay. */
1952
1954
  coinpayWebhookSecret?: string;
1955
+ /** The AI label's songs for sale, nixamp.com only (music-store.ts). */
1956
+ musicStore?: MusicStore;
1953
1957
  sharedTranslations?: SharedTranslations;
1954
1958
  /** Translation: texts in another language, by a model here. The same optional library. */
1955
1959
  translator?: Translator;
@@ -2040,6 +2044,9 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2040
2044
  site: process.env["NIXAMP_SITE"] || "https://nixamp.com",
2041
2045
  ...(listenPassKey ? { secret: listenPassKey } : {}),
2042
2046
  });
2047
+ // The music store lives on nixamp.com; a server holding the label's music asks it about grants.
2048
+ const storeSite = (process.env["NIXAMP_SITE"] || "https://nixamp.com").replace(/\/+$/, "");
2049
+ const storeGrants = new GrantVerifier({ site: storeSite });
2043
2050
 
2044
2051
  return async function handle(request: IncomingMessage, response: ServerResponse): Promise<void> {
2045
2052
  const url = new URL(request.url ?? "/", "http://localhost");
@@ -2595,6 +2602,15 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2595
2602
  }
2596
2603
  let event: ReturnType<typeof readCoinpayWebhook>;
2597
2604
  try { event = readCoinpayWebhook(JSON.parse(raw)); } catch { json(response, 400, { error: "invalid JSON" }); return; }
2605
+ if (options.musicStore) {
2606
+ try {
2607
+ if (await options.musicStore.owns(event.paymentId)) { json(response, 200, await options.musicStore.settle(event.paymentId)); return; }
2608
+ } catch (error) {
2609
+ console.error(`nixamp: CoinPay webhook for ${event.paymentId} (music) could not settle: ${error instanceof Error ? error.message : String(error)}`);
2610
+ json(response, 500, { error: "could not settle; retry" });
2611
+ return;
2612
+ }
2613
+ }
2598
2614
  if (!options.translationPasses) { json(response, 200, { settled: false, reason: "billing is off here" }); return; }
2599
2615
  try {
2600
2616
  json(response, 200, await options.translationPasses.settle(event.paymentId));
@@ -2728,6 +2744,8 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2728
2744
  !isPublicSharePath(path) &&
2729
2745
  // The AI label's catalogue, its songs and their words.
2730
2746
  !(isPublicLabelPath(path) && Boolean(options.stations?.label)) &&
2747
+ // The music store: its pages, checkout, receipts and grants (nixamp.com).
2748
+ !(isStorePath(path) && Boolean(options.musicStore)) &&
2731
2749
  // What is on, as MCP, for anybody.
2732
2750
  path !== "/mcp/public" &&
2733
2751
  // Supporting a copyright holder is for anybody listening.
@@ -3074,9 +3092,24 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
3074
3092
  return;
3075
3093
  }
3076
3094
 
3095
+ if (options.musicStore && options.support && isStorePath(path)) {
3096
+ const support = options.support;
3097
+ const accounts = options.accounts;
3098
+ if (await handleStoreRoutes(request, response, path, url, {
3099
+ store: options.musicStore,
3100
+ coins: async () => (await support.coins()).coins,
3101
+ account: async () => (accounts ? await accounts.whoIs(tokenFrom(request.headers)) : null),
3102
+ json,
3103
+ readBody,
3104
+ })) return;
3105
+ }
3106
+
3077
3107
  if (options.stations?.label && (isPublicLabelPath(path) || path === "/api/v1/stations/label" || path.startsWith("/api/v1/stations/label/"))) {
3078
3108
  const control = isPublicLabelPath(path) ? { allowed: false } : await controlOf();
3079
- if (await handleLabelRoutes(request, response, path, { label: options.stations.label, canControl: control.allowed, json, readBody })) return;
3109
+ if (await handleLabelRoutes(request, response, path, {
3110
+ label: options.stations.label, canControl: control.allowed, json, readBody,
3111
+ grants: storeGrants, site: storeSite, publicBase: options.stations.publicBase?.() ?? "",
3112
+ })) return;
3080
3113
  }
3081
3114
 
3082
3115
  if (options.stations && (isPublicSharePath(path) || path.startsWith("/api/v1/stations/ai") || path.startsWith("/api/v1/stations/shares") || path.startsWith("/api/v1/stations/holders"))) {
@@ -7377,7 +7410,18 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
7377
7410
  try { stamp = String(Math.round(statSync(file).mtimeMs)); } catch { stamp = ""; }
7378
7411
  const art = picture ? `/api/v1/stations/${station.id}/art?t=${encodeURIComponent(station.listenToken)}&f=${sha("sha1").update(file + stamp).digest("hex").slice(0, 12)}` : "";
7379
7412
  const words = await lyricsFinder.find(file).catch(() => null);
7380
- return factsFor({
7413
+ // A label song is for sale on nixamp.com: the song, its album, everything by the artist.
7414
+ const song = aiLabel.songAt(file);
7415
+ const here = stations.publicBase?.() ?? "";
7416
+ const shop = (process.env["NIXAMP_SITE"] || "https://nixamp.com").replace(/\/+$/, "");
7417
+ const store = song && /^https:\/\//.test(here) ? {
7418
+ song: buyLink(shop, here, "song", song.artist, song.album, song.n),
7419
+ album: buyLink(shop, here, "album", song.artist, song.album),
7420
+ artist: buyLink(shop, here, "artist", song.artist),
7421
+ artistName: song.artistName,
7422
+ albumTitle: song.albumTitle,
7423
+ } : null;
7424
+ return { ...factsFor({
7381
7425
  art,
7382
7426
  ...(words ? { lyrics: `/api/v1/stations/${station.id}/lyrics?t=${encodeURIComponent(station.listenToken)}&f=${sha("sha1").update(file + stamp).digest("hex").slice(0, 12)}` } : {}),
7383
7427
  station, file,
@@ -7386,7 +7430,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
7386
7430
  holders: holderStore.list(),
7387
7431
  share: share ? { basis: share.rights.basis, note: share.rights.note, by: share.by } : null,
7388
7432
  aiTrack: composed ? { made: composed.made } : null,
7389
- });
7433
+ }), store };
7390
7434
  };
7391
7435
  // Channels as HLS, on demand, for Safari on a phone: one ffmpeg copying a
7392
7436
  // channel's fragments into short files while somebody is asking for them.
@@ -7531,6 +7575,20 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
7531
7575
  log: (line) => console.log(line),
7532
7576
  }) : undefined;
7533
7577
  supportMonthly?.startSchedule();
7578
+ // The AI label's songs for sale: purchases kept here, the music zipped by the server that holds it.
7579
+ const jwtSecret = process.env["NIXAMP_JWT_SECRET"] ?? "";
7580
+ const musicStore = supportPayments && supportPayments.enabled && pool && jwtSecret ? new MusicStore({
7581
+ db: pool as unknown as import("./support.ts").Db,
7582
+ support: supportPayments,
7583
+ mail: process.env["RESEND_API_KEY"] ? { apiKey: process.env["RESEND_API_KEY"], from: process.env["NIXAMP_MAIL_FROM"] ?? "nixamp <notifications@nixamp.com>" } : null,
7584
+ site: nixampSite,
7585
+ secret: sha("sha256").update(`music-grant:${jwtSecret}`).digest("hex"),
7586
+ servers: () => {
7587
+ const listed = directory ? directory.list().map((one) => peerBase(one.url)).filter(Boolean) : [];
7588
+ return [...new Set([...guidePeers(), ...listed])];
7589
+ },
7590
+ log: (line) => console.log(line),
7591
+ }) : undefined;
7534
7592
  if (translationPasses) void translationPasses.access().catch(() => console.error("nixamp: translation checkout could not warm; it will retry on demand"));
7535
7593
  const liveVoice = accounts && process.env["NIXAMP_DUBBING"] !== "off" ? new LiveVoice({
7536
7594
  ...(pool ? { db: pool } : {}),
@@ -8085,6 +8143,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
8085
8143
  ...(translationPasses ? { translationPasses } : {}),
8086
8144
  ...(supportPayments ? { support: supportPayments } : {}),
8087
8145
  ...(supportMonthly ? { supportSubscriptions: supportMonthly } : {}),
8146
+ ...(musicStore ? { musicStore } : {}),
8088
8147
  ...(process.env["COINPAY_WEBHOOK_SECRET"] ? { coinpayWebhookSecret: process.env["COINPAY_WEBHOOK_SECRET"] } : {}),
8089
8148
  ...(sharedTranslations ? { sharedTranslations } : {}),
8090
8149
  ...(translator ? { translator } : {}),