nixamp 0.48.1 → 0.50.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/dist/ai-keys.d.ts +1 -1
  2. package/dist/ai-keys.js +2 -0
  3. package/dist/ai-label.d.ts +239 -0
  4. package/dist/ai-label.js +774 -0
  5. package/dist/ai-radio.d.ts +11 -5
  6. package/dist/ai-radio.js +27 -11
  7. package/dist/ai-text.d.ts +2 -0
  8. package/dist/ai-text.js +4 -2
  9. package/dist/label-routes.d.ts +28 -0
  10. package/dist/label-routes.js +314 -0
  11. package/dist/music-store.d.ts +121 -0
  12. package/dist/music-store.js +289 -0
  13. package/dist/server.d.ts +6 -0
  14. package/dist/server.js +99 -11
  15. package/dist/station-cli.js +109 -0
  16. package/dist/station-guide.d.ts +1 -1
  17. package/dist/station-guide.js +5 -1
  18. package/dist/station-mcp.d.ts +247 -20
  19. package/dist/station-mcp.js +36 -0
  20. package/dist/station-usage.d.ts +1 -1
  21. package/dist/station-usage.js +15 -1
  22. package/dist/store-routes.d.ts +16 -0
  23. package/dist/store-routes.js +189 -0
  24. package/dist/support.d.ts +14 -0
  25. package/dist/support.js +41 -0
  26. package/dist/track-facts.d.ts +8 -0
  27. package/dist/zip-stream.d.ts +13 -0
  28. package/dist/zip-stream.js +119 -0
  29. package/package.json +1 -1
  30. package/src/ai-keys.ts +2 -0
  31. package/src/ai-label.ts +873 -0
  32. package/src/ai-radio.ts +30 -13
  33. package/src/ai-text.ts +6 -2
  34. package/src/label-routes.ts +336 -0
  35. package/src/music-store.ts +333 -0
  36. package/src/server.ts +91 -3
  37. package/src/station-cli.ts +102 -0
  38. package/src/station-guide.ts +5 -1
  39. package/src/station-mcp.ts +34 -0
  40. package/src/station-usage.ts +15 -1
  41. package/src/store-routes.ts +204 -0
  42. package/src/support.ts +39 -0
  43. package/src/track-facts.ts +2 -0
  44. package/src/zip-stream.ts +128 -0
  45. package/web/dist/assets/{ar-BvPTr_2x.js → ar-CC_uYRlC.js} +1 -1
  46. package/web/dist/assets/{cs-g4ii7Tkl.js → cs-k5C-jvfX.js} +1 -1
  47. package/web/dist/assets/{da-D9vYCFH7.js → da-CdBzU_fF.js} +1 -1
  48. package/web/dist/assets/{de-lYElbebQ.js → de-B-UWa0W7.js} +1 -1
  49. package/web/dist/assets/{es-CiDbfxFy.js → es-Dod8aCau.js} +1 -1
  50. package/web/dist/assets/{fi-BWlqMMWx.js → fi-C36bBPFW.js} +1 -1
  51. package/web/dist/assets/{fr-D2n5nXQv.js → fr-DWUxs2qv.js} +1 -1
  52. package/web/dist/assets/{hi-DM9Nh04K.js → hi-DjLqhfs8.js} +1 -1
  53. package/web/dist/assets/{hu-Bzb0X4rk.js → hu-CgsOlcq8.js} +1 -1
  54. package/web/dist/assets/{id-j0QazySW.js → id-ZA8yTWX9.js} +1 -1
  55. package/web/dist/assets/{it-BVvva3a6.js → it-DfFhS34g.js} +1 -1
  56. package/web/dist/assets/{main-DPfBhzsz.js → main-CjgQp9um.js} +2 -2
  57. package/web/dist/assets/{nl-CrJQ6obW.js → nl-C257i24U.js} +1 -1
  58. package/web/dist/assets/radio-CDUEoM0y.js +1 -0
  59. package/web/dist/assets/{ru-DFIjjKvG.js → ru--30I7BNC.js} +1 -1
  60. package/web/dist/assets/{station-BKmoJ0Sk.css → station-Be-z7jQ2.css} +1 -1
  61. package/web/dist/assets/{station-X8pffqCW.js → station-D769XVG0.js} +1 -1
  62. package/web/dist/assets/{sv-BO9QPL4K.js → sv-CxzBeXfk.js} +1 -1
  63. package/web/dist/assets/{uk-DwmG3Owv.js → uk-DXHpMk4i.js} +1 -1
  64. package/web/dist/assets/{vi-C-2EzgMe.js → vi-Dg2LT4x3.js} +1 -1
  65. package/web/dist/assets/{zh-BlnCenyZ.js → zh-D3pvejo6.js} +1 -1
  66. package/web/dist/index.html +1 -1
  67. package/web/dist/radio.html +44 -1
  68. package/web/dist/station.html +7 -2
  69. package/web/dist/sw.js +25 -25
  70. package/web/dist/vod.html +2 -2
  71. package/web/dist/watch.html +2 -2
  72. package/web/dist/assets/radio-DI0H8flN.js +0 -1
  73. /package/web/dist/assets/{vod-CdfPMa1u.js → vod-bM1DvPEx.js} +0 -0
  74. /package/web/dist/assets/{watch-hdfnhm3C.js → watch-BbhPO0HS.js} +0 -0
@@ -0,0 +1,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
@@ -78,6 +78,10 @@ import { ListenerStore } from "./library-user.ts";
78
78
  import { Importer } from "./handoff.ts";
79
79
  import { handleHandoffs } from "./handoff-routes.ts";
80
80
  import { handleAiRoutes, isPublicSharePath } from "./ai-routes.ts";
81
+ import { AiLabel } from "./ai-label.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";
81
85
  import { AiRadio } from "./ai-radio.ts";
82
86
  import { ListenerShares } from "./listener-shares.ts";
83
87
  import { HolderStore, TagReader, factsFor } from "./track-facts.ts";
@@ -1746,6 +1750,8 @@ export interface HandlerOptions {
1746
1750
  publicBase?: () => string;
1747
1751
  /** Stations whose music is composed by AI. */
1748
1752
  ai?: AiRadio;
1753
+ /** AI artists and their albums, a genre station each, and the catalogue at /label. */
1754
+ label?: AiLabel;
1749
1755
  /** Tracks anybody may share, sorted onto the air by AI. */
1750
1756
  shares?: ListenerShares;
1751
1757
  /** What a listener may know about the track on the air. */
@@ -1946,6 +1952,8 @@ export interface HandlerOptions {
1946
1952
  supportSubscriptions?: SupportSubscriptions;
1947
1953
  /** Signing secret of nixamp's CoinPay business, for /api/v1/webhooks/coinpay. */
1948
1954
  coinpayWebhookSecret?: string;
1955
+ /** The AI label's songs for sale, nixamp.com only (music-store.ts). */
1956
+ musicStore?: MusicStore;
1949
1957
  sharedTranslations?: SharedTranslations;
1950
1958
  /** Translation: texts in another language, by a model here. The same optional library. */
1951
1959
  translator?: Translator;
@@ -2036,6 +2044,9 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2036
2044
  site: process.env["NIXAMP_SITE"] || "https://nixamp.com",
2037
2045
  ...(listenPassKey ? { secret: listenPassKey } : {}),
2038
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 });
2039
2050
 
2040
2051
  return async function handle(request: IncomingMessage, response: ServerResponse): Promise<void> {
2041
2052
  const url = new URL(request.url ?? "/", "http://localhost");
@@ -2591,6 +2602,15 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2591
2602
  }
2592
2603
  let event: ReturnType<typeof readCoinpayWebhook>;
2593
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
+ }
2594
2614
  if (!options.translationPasses) { json(response, 200, { settled: false, reason: "billing is off here" }); return; }
2595
2615
  try {
2596
2616
  json(response, 200, await options.translationPasses.settle(event.paymentId));
@@ -2722,6 +2742,10 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2722
2742
  !isStationListenPath(path) &&
2723
2743
  // Sharing a track and reporting one are for anybody the guide reaches.
2724
2744
  !isPublicSharePath(path) &&
2745
+ // The AI label's catalogue, its songs and their words.
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)) &&
2725
2749
  // What is on, as MCP, for anybody.
2726
2750
  path !== "/mcp/public" &&
2727
2751
  // Supporting a copyright holder is for anybody listening.
@@ -2905,7 +2929,8 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2905
2929
  ? await roomEntries(options.stations!.rooms.store, options.stations!.rooms.probe, watchBase(options.stations!.publicBase?.() || `${encrypted ? "https" : "http"}://${request.headers.host ?? "localhost"}`))
2906
2930
  : [];
2907
2931
  const ads = guideAds();
2908
- const page = renderGuide([...rooms, ...entries, ...others] as GuideEntry[], options.stations!.publicBase?.() ?? "", ads);
2932
+ const label = options.stations!.label;
2933
+ const page = renderGuide([...rooms, ...entries, ...others] as GuideEntry[], options.stations!.publicBase?.() ?? "", ads, label && label.publicView().genres.length > 0 ? "/label" : "");
2909
2934
  response.writeHead(200, {
2910
2935
  "content-type": "text/html; charset=utf-8",
2911
2936
  "cache-control": "no-cache",
@@ -3067,6 +3092,26 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
3067
3092
  return;
3068
3093
  }
3069
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
+
3107
+ if (options.stations?.label && (isPublicLabelPath(path) || path === "/api/v1/stations/label" || path.startsWith("/api/v1/stations/label/"))) {
3108
+ const control = isPublicLabelPath(path) ? { allowed: false } : await controlOf();
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;
3113
+ }
3114
+
3070
3115
  if (options.stations && (isPublicSharePath(path) || path.startsWith("/api/v1/stations/ai") || path.startsWith("/api/v1/stations/shares") || path.startsWith("/api/v1/stations/holders"))) {
3071
3116
  const control = await controlOf();
3072
3117
  // Behind a proxy we trust (dev2 for live.streaming), the sharer is the
@@ -7279,10 +7324,27 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
7279
7324
  ffmpeg: tools.ffmpeg,
7280
7325
  root: join(stateDir(), "ai-music"),
7281
7326
  owner: options.owner || "",
7327
+ holder: () => aiLabel.holder(),
7328
+ mayStart: mayStartStation,
7329
+ log: (line) => console.log(line),
7330
+ });
7331
+ // The AI label: artists who put out albums on their own, filed under the
7332
+ // library as ai/<genre>/<artist>/<year>-<album>/ (NIXAMP_LABEL_ROOT moves it).
7333
+ const labelHome = libraryIndex.sources().find((one) => one.enabled && !one.managed);
7334
+ const aiLabel = new AiLabel(AiLabel.fileFor(stateDir(), options.port), {
7335
+ index: libraryIndex,
7336
+ store: stationStore,
7337
+ runner: stationRunner,
7338
+ probe: probeFile,
7339
+ ffmpeg: tools.ffmpeg,
7340
+ root: process.env["NIXAMP_LABEL_ROOT"] || (labelHome ? join(labelHome.root, "ai") : join(stateDir(), "ai-label")),
7341
+ owner: options.owner || "",
7282
7342
  mayStart: mayStartStation,
7283
7343
  log: (line) => console.log(line),
7284
7344
  });
7285
7345
  aiRadio.startSchedule();
7346
+ aiLabel.startSchedule();
7347
+ stations.label = aiLabel;
7286
7348
  // Tracks anybody shares from the guide, checked by AI before they air.
7287
7349
  const listenerShares = new ListenerShares(ListenerShares.fileFor(stateDir(), options.port), {
7288
7350
  index: libraryIndex,
@@ -7348,7 +7410,18 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
7348
7410
  try { stamp = String(Math.round(statSync(file).mtimeMs)); } catch { stamp = ""; }
7349
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)}` : "";
7350
7412
  const words = await lyricsFinder.find(file).catch(() => null);
7351
- 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({
7352
7425
  art,
7353
7426
  ...(words ? { lyrics: `/api/v1/stations/${station.id}/lyrics?t=${encodeURIComponent(station.listenToken)}&f=${sha("sha1").update(file + stamp).digest("hex").slice(0, 12)}` } : {}),
7354
7427
  station, file,
@@ -7357,7 +7430,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
7357
7430
  holders: holderStore.list(),
7358
7431
  share: share ? { basis: share.rights.basis, note: share.rights.note, by: share.by } : null,
7359
7432
  aiTrack: composed ? { made: composed.made } : null,
7360
- });
7433
+ }), store };
7361
7434
  };
7362
7435
  // Channels as HLS, on demand, for Safari on a phone: one ffmpeg copying a
7363
7436
  // channel's fragments into short files while somebody is asking for them.
@@ -7502,6 +7575,20 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
7502
7575
  log: (line) => console.log(line),
7503
7576
  }) : undefined;
7504
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;
7505
7592
  if (translationPasses) void translationPasses.access().catch(() => console.error("nixamp: translation checkout could not warm; it will retry on demand"));
7506
7593
  const liveVoice = accounts && process.env["NIXAMP_DUBBING"] !== "off" ? new LiveVoice({
7507
7594
  ...(pool ? { db: pool } : {}),
@@ -8056,6 +8143,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
8056
8143
  ...(translationPasses ? { translationPasses } : {}),
8057
8144
  ...(supportPayments ? { support: supportPayments } : {}),
8058
8145
  ...(supportMonthly ? { supportSubscriptions: supportMonthly } : {}),
8146
+ ...(musicStore ? { musicStore } : {}),
8059
8147
  ...(process.env["COINPAY_WEBHOOK_SECRET"] ? { coinpayWebhookSecret: process.env["COINPAY_WEBHOOK_SECRET"] } : {}),
8060
8148
  ...(sharedTranslations ? { sharedTranslations } : {}),
8061
8149
  ...(translator ? { translator } : {}),
@@ -272,6 +272,8 @@ export async function station(argv: string[]): Promise<number> {
272
272
  }
273
273
  case "ai":
274
274
  return await aiCommand(argv.slice(1), call, show, json);
275
+ case "label":
276
+ return await labelCommand(argv.slice(1), call, show);
275
277
  case "shares":
276
278
  return await sharesCommand(argv.slice(1), call, show);
277
279
  case "lyrics":
@@ -412,6 +414,106 @@ async function aiCommand(argv: string[], call: Call, show: Show, json: boolean):
412
414
  }
413
415
  }
414
416
 
417
+ /** `nixamp station label ...`: AI artists who put out an album a month (ai-label.ts). */
418
+ async function labelCommand(argv: string[], call: Call, show: Show): Promise<number> {
419
+ const [sub, key] = argv;
420
+ const when = (at: number): string => at === -1 ? "never (off)" : at === 0 ? "now" : new Date(at).toISOString().slice(0, 10);
421
+ const describe = (one: Record<string, unknown>): string => {
422
+ const albums = (one["albums"] as Array<Record<string, unknown>> | undefined) ?? [];
423
+ return [
424
+ `${String(one["name"])} [${String(one["key"])}] ${String(one["genre"])}${one["enabled"] ? "" : " (off)"}`,
425
+ ...albums.map((album) => ` ${String(album["year"])} ${String(album["title"])} ${String(album["composed"])}/${String(album["songs"])} songs${album["doneAt"] ? "" : " (recording)"}`),
426
+ ` next album ${when(Number(one["nextAlbumAt"] ?? -1))}`,
427
+ ...(one["lastError"] ? [` problem ${String(one["lastError"])}`] : []),
428
+ ].join("\n");
429
+ };
430
+ switch (sub) {
431
+ case undefined:
432
+ case "status":
433
+ case "list":
434
+ return show(await call("GET", "/api/v1/stations/label"), (body) => {
435
+ const settings = (body["settings"] ?? {}) as Record<string, unknown>;
436
+ const artists = (body["artists"] as Record<string, unknown>[] | undefined) ?? [];
437
+ const roster = (body["roster"] as Array<Record<string, unknown>> | undefined) ?? [];
438
+ return [
439
+ `holder ${String(settings["holder"])}${settings["enabled"] ? "" : " (off)"}`,
440
+ `files ${String(body["root"])}/<genre>/<artist>/<year>-<album>/`,
441
+ `composer ${String(body["composer"] ?? "none")}; words by ${String(body["writer"] ?? "nobody (no model key)")}`,
442
+ `this month ${String(body["secondsThisMonth"])} of ${Number(settings["monthlySeconds"]) === 0 ? "unlimited" : String(settings["monthlySeconds"])} s${body["busy"] ? `; recording ${String(body["busy"])}` : ""}`,
443
+ `albums ${String(settings["songsPerAlbum"])} songs of ${String(settings["songSeconds"])} s; ${String(settings["seedAlbums"])} to start, then one every ${String(settings["everyDays"])} days`,
444
+ ...(body["waiting"] ? [`waiting ${String(body["waiting"])}`] : []),
445
+ "",
446
+ ...(artists.length ? artists.map(describe) : ["Nobody signed yet. nixamp station label sign --roster"]),
447
+ "",
448
+ `roster ${roster.map((one) => `${String(one["key"])}${one["signed"] ? " (signed)" : ""}`).join(", ")}`,
449
+ ].join("\n");
450
+ });
451
+ case "sign": {
452
+ const body = has(argv, "--roster") ? { roster: true } : {
453
+ ...(flag(argv, "--preset") ? { preset: flag(argv, "--preset") } : {}),
454
+ ...(flag(argv, "--name") ? { name: flag(argv, "--name") } : {}),
455
+ ...(flag(argv, "--genre") ? { genre: flag(argv, "--genre") } : {}),
456
+ ...(flag(argv, "--sound") ? { sound: flag(argv, "--sound") } : {}),
457
+ ...(flag(argv, "--voice") ? { voice: flag(argv, "--voice") } : {}),
458
+ ...(flag(argv, "--bio") ? { bio: flag(argv, "--bio") } : {}),
459
+ ...(flag(argv, "--themes") ? { themes: flag(argv, "--themes") } : {}),
460
+ };
461
+ return show(await call("POST", "/api/v1/stations/label/artists", body), (answer) => {
462
+ const signed = (answer["artists"] as Array<Record<string, unknown>> | undefined) ?? (answer["artist"] ? [answer["artist"] as Record<string, unknown>] : []);
463
+ return signed.length ? `Signed ${signed.map((one) => `${String(one["name"])} (${String(one["genre"])})`).join(", ")}. Their first albums are planned and recorded from here.` : "Everybody on the roster is already signed.";
464
+ });
465
+ }
466
+ case "album": {
467
+ if (!key) { console.error("nixamp station label album needs the artist's key (nixamp station label)"); return 2; }
468
+ return show(await call("POST", `/api/v1/stations/label/artists/${key}/album`, {}), (answer) => {
469
+ const album = answer["album"] as Record<string, unknown>;
470
+ return `Planned "${String(album["title"])}" (${((album["songs"] as unknown[]) ?? []).length} songs); recording now, a song at a time.`;
471
+ });
472
+ }
473
+ case "set": {
474
+ if (!key) { console.error("nixamp station label set needs the artist's key"); return 2; }
475
+ const body = {
476
+ ...(flag(argv, "--sound") ? { sound: flag(argv, "--sound") } : {}),
477
+ ...(flag(argv, "--voice") ? { voice: flag(argv, "--voice") } : {}),
478
+ ...(flag(argv, "--bio") ? { bio: flag(argv, "--bio") } : {}),
479
+ ...(flag(argv, "--themes") ? { themes: flag(argv, "--themes") } : {}),
480
+ ...(has(argv, "--off") ? { enabled: false } : has(argv, "--on") ? { enabled: true } : {}),
481
+ };
482
+ return show(await call("PATCH", `/api/v1/stations/label/artists/${key}`, body), (answer) => `${String((answer["artist"] as Record<string, unknown>)["name"])} updated.`);
483
+ }
484
+ case "drop": {
485
+ if (!key) { console.error("nixamp station label drop needs the artist's key"); return 2; }
486
+ const files = has(argv, "--files");
487
+ return show(await call("DELETE", `/api/v1/stations/label/artists/${key}${files ? "?files=1" : ""}`), () =>
488
+ files ? "Dropped, with their music." : "Dropped. Their albums stay on the air; nothing new will be recorded.");
489
+ }
490
+ case "config": {
491
+ const number = (name: string): number | undefined => (flag(argv, name) === undefined ? undefined : Number(flag(argv, name)));
492
+ const body = Object.fromEntries(Object.entries({
493
+ holder: flag(argv, "--holder"),
494
+ monthlySeconds: number("--budget"),
495
+ everyDays: number("--every-days"),
496
+ seedAlbums: number("--seed-albums"),
497
+ songsPerAlbum: number("--songs"),
498
+ songSeconds: number("--seconds"),
499
+ enabled: has(argv, "--off") ? false : has(argv, "--on") ? true : undefined,
500
+ publish: has(argv, "--unlisted") ? false : has(argv, "--public") ? true : undefined,
501
+ }).filter(([, value]) => value !== undefined));
502
+ return show(await call("POST", "/api/v1/stations/label", body), (answer) => {
503
+ const settings = answer["settings"] as Record<string, unknown>;
504
+ return `Label: ${String(settings["holder"])}, ${String(settings["songsPerAlbum"])} songs of ${String(settings["songSeconds"])} s an album, every ${String(settings["everyDays"])} days, at most ${Number(settings["monthlySeconds"]) === 0 ? "unlimited" : `${String(settings["monthlySeconds"])} s`} a month${settings["enabled"] ? "" : " (off)"}.`;
505
+ });
506
+ }
507
+ case "run":
508
+ return show(await call("POST", "/api/v1/stations/label/run", {}), (body) => body["waiting"] ? `Nothing to record now: ${String(body["waiting"])}.` : "Recording whatever is due.");
509
+ case "resume":
510
+ return show(await call("POST", "/api/v1/stations/label", { resume: true }), () => "Recording again.");
511
+ default:
512
+ console.error(`nixamp station label: no command "${sub}".\n\n${USAGE}`);
513
+ return 2;
514
+ }
515
+ }
516
+
415
517
  /** `nixamp station lyrics ...`: our own songs' words, published back to nichedb.dev. */
416
518
  async function lyricsCommand(argv: string[], call: Call, show: Show, json: boolean): Promise<number> {
417
519
  const [sub, id] = argv;