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,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
@@ -24,6 +24,8 @@ import { HlsPackagers } from "./hls.ts";
24
24
  import { LibraryIndex, type Probed } from "./library-index.ts";
25
25
  import { ListenerStore } from "./library-user.ts";
26
26
  import { Importer } from "./handoff.ts";
27
+ import { AiLabel } from "./ai-label.ts";
28
+ import { MusicStore } from "./music-store.ts";
27
29
  import { AiRadio } from "./ai-radio.ts";
28
30
  import { ListenerShares } from "./listener-shares.ts";
29
31
  import { Support, SupportSubscriptions } from "./support.ts";
@@ -599,6 +601,8 @@ export interface HandlerOptions {
599
601
  publicBase?: () => string;
600
602
  /** Stations whose music is composed by AI. */
601
603
  ai?: AiRadio;
604
+ /** AI artists and their albums, a genre station each, and the catalogue at /label. */
605
+ label?: AiLabel;
602
606
  /** Tracks anybody may share, sorted onto the air by AI. */
603
607
  shares?: ListenerShares;
604
608
  /** What a listener may know about the track on the air. */
@@ -827,6 +831,8 @@ export interface HandlerOptions {
827
831
  supportSubscriptions?: SupportSubscriptions;
828
832
  /** Signing secret of nixamp's CoinPay business, for /api/v1/webhooks/coinpay. */
829
833
  coinpayWebhookSecret?: string;
834
+ /** The AI label's songs for sale, nixamp.com only (music-store.ts). */
835
+ musicStore?: MusicStore;
830
836
  sharedTranslations?: SharedTranslations;
831
837
  /** Translation: texts in another language, by a model here. The same optional library. */
832
838
  translator?: Translator;
package/dist/server.js CHANGED
@@ -61,6 +61,10 @@ import { ListenerStore } from "./library-user.js";
61
61
  import { Importer } from "./handoff.js";
62
62
  import { handleHandoffs } from "./handoff-routes.js";
63
63
  import { handleAiRoutes, isPublicSharePath } from "./ai-routes.js";
64
+ import { AiLabel } from "./ai-label.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";
64
68
  import { AiRadio } from "./ai-radio.js";
65
69
  import { ListenerShares } from "./listener-shares.js";
66
70
  import { HolderStore, TagReader, factsFor } from "./track-facts.js";
@@ -1489,6 +1493,9 @@ export function createHandler(engine, options) {
1489
1493
  site: process.env["NIXAMP_SITE"] || "https://nixamp.com",
1490
1494
  ...(listenPassKey ? { secret: listenPassKey } : {}),
1491
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 });
1492
1499
  return async function handle(request, response) {
1493
1500
  const url = new URL(request.url ?? "/", "http://localhost");
1494
1501
  const path = url.pathname;
@@ -2055,6 +2062,19 @@ export function createHandler(engine, options) {
2055
2062
  json(response, 400, { error: "invalid JSON" });
2056
2063
  return;
2057
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
+ }
2058
2078
  if (!options.translationPasses) {
2059
2079
  json(response, 200, { settled: false, reason: "billing is off here" });
2060
2080
  return;
@@ -2177,6 +2197,10 @@ export function createHandler(engine, options) {
2177
2197
  !isStationListenPath(path) &&
2178
2198
  // Sharing a track and reporting one are for anybody the guide reaches.
2179
2199
  !isPublicSharePath(path) &&
2200
+ // The AI label's catalogue, its songs and their words.
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)) &&
2180
2204
  // What is on, as MCP, for anybody.
2181
2205
  path !== "/mcp/public" &&
2182
2206
  // Supporting a copyright holder is for anybody listening.
@@ -2368,7 +2392,8 @@ export function createHandler(engine, options) {
2368
2392
  ? await roomEntries(options.stations.rooms.store, options.stations.rooms.probe, watchBase(options.stations.publicBase?.() || `${encrypted ? "https" : "http"}://${request.headers.host ?? "localhost"}`))
2369
2393
  : [];
2370
2394
  const ads = guideAds();
2371
- const page = renderGuide([...rooms, ...entries, ...others], options.stations.publicBase?.() ?? "", ads);
2395
+ const label = options.stations.label;
2396
+ const page = renderGuide([...rooms, ...entries, ...others], options.stations.publicBase?.() ?? "", ads, label && label.publicView().genres.length > 0 ? "/label" : "");
2372
2397
  response.writeHead(200, {
2373
2398
  "content-type": "text/html; charset=utf-8",
2374
2399
  "cache-control": "no-cache",
@@ -2523,6 +2548,26 @@ export function createHandler(engine, options) {
2523
2548
  });
2524
2549
  return;
2525
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
+ }
2563
+ if (options.stations?.label && (isPublicLabelPath(path) || path === "/api/v1/stations/label" || path.startsWith("/api/v1/stations/label/"))) {
2564
+ const control = isPublicLabelPath(path) ? { allowed: false } : await controlOf();
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
+ }))
2569
+ return;
2570
+ }
2526
2571
  if (options.stations && (isPublicSharePath(path) || path.startsWith("/api/v1/stations/ai") || path.startsWith("/api/v1/stations/shares") || path.startsWith("/api/v1/stations/holders"))) {
2527
2572
  const control = await controlOf();
2528
2573
  // Behind a proxy we trust (dev2 for live.streaming), the sharer is the
@@ -6771,10 +6816,27 @@ export async function serve(argv, version = "0.1.0") {
6771
6816
  ffmpeg: tools.ffmpeg,
6772
6817
  root: join(stateDir(), "ai-music"),
6773
6818
  owner: options.owner || "",
6819
+ holder: () => aiLabel.holder(),
6820
+ mayStart: mayStartStation,
6821
+ log: (line) => console.log(line),
6822
+ });
6823
+ // The AI label: artists who put out albums on their own, filed under the
6824
+ // library as ai/<genre>/<artist>/<year>-<album>/ (NIXAMP_LABEL_ROOT moves it).
6825
+ const labelHome = libraryIndex.sources().find((one) => one.enabled && !one.managed);
6826
+ const aiLabel = new AiLabel(AiLabel.fileFor(stateDir(), options.port), {
6827
+ index: libraryIndex,
6828
+ store: stationStore,
6829
+ runner: stationRunner,
6830
+ probe: probeFile,
6831
+ ffmpeg: tools.ffmpeg,
6832
+ root: process.env["NIXAMP_LABEL_ROOT"] || (labelHome ? join(labelHome.root, "ai") : join(stateDir(), "ai-label")),
6833
+ owner: options.owner || "",
6774
6834
  mayStart: mayStartStation,
6775
6835
  log: (line) => console.log(line),
6776
6836
  });
6777
6837
  aiRadio.startSchedule();
6838
+ aiLabel.startSchedule();
6839
+ stations.label = aiLabel;
6778
6840
  // Tracks anybody shares from the guide, checked by AI before they air.
6779
6841
  const listenerShares = new ListenerShares(ListenerShares.fileFor(stateDir(), options.port), {
6780
6842
  index: libraryIndex,
@@ -6853,16 +6915,27 @@ export async function serve(argv, version = "0.1.0") {
6853
6915
  }
6854
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)}` : "";
6855
6917
  const words = await lyricsFinder.find(file).catch(() => null);
6856
- return factsFor({
6857
- art,
6858
- ...(words ? { lyrics: `/api/v1/stations/${station.id}/lyrics?t=${encodeURIComponent(station.listenToken)}&f=${sha("sha1").update(file + stamp).digest("hex").slice(0, 12)}` } : {}),
6859
- station, file,
6860
- tags: await tagReader.read(file),
6861
- known: trackFor(engine, file),
6862
- holders: holderStore.list(),
6863
- share: share ? { basis: share.rights.basis, note: share.rights.note, by: share.by } : null,
6864
- aiTrack: composed ? { made: composed.made } : null,
6865
- });
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 };
6866
6939
  };
6867
6940
  // Channels as HLS, on demand, for Safari on a phone: one ffmpeg copying a
6868
6941
  // channel's fragments into short files while somebody is asking for them.
@@ -7002,6 +7075,20 @@ export async function serve(argv, version = "0.1.0") {
7002
7075
  log: (line) => console.log(line),
7003
7076
  }) : undefined;
7004
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;
7005
7092
  if (translationPasses)
7006
7093
  void translationPasses.access().catch(() => console.error("nixamp: translation checkout could not warm; it will retry on demand"));
7007
7094
  const liveVoice = accounts && process.env["NIXAMP_DUBBING"] !== "off" ? new LiveVoice({
@@ -7552,6 +7639,7 @@ export async function serve(argv, version = "0.1.0") {
7552
7639
  ...(translationPasses ? { translationPasses } : {}),
7553
7640
  ...(supportPayments ? { support: supportPayments } : {}),
7554
7641
  ...(supportMonthly ? { supportSubscriptions: supportMonthly } : {}),
7642
+ ...(musicStore ? { musicStore } : {}),
7555
7643
  ...(process.env["COINPAY_WEBHOOK_SECRET"] ? { coinpayWebhookSecret: process.env["COINPAY_WEBHOOK_SECRET"] } : {}),
7556
7644
  ...(sharedTranslations ? { sharedTranslations } : {}),
7557
7645
  ...(translator ? { translator } : {}),
@@ -282,6 +282,8 @@ export async function station(argv) {
282
282
  }
283
283
  case "ai":
284
284
  return await aiCommand(argv.slice(1), call, show, json);
285
+ case "label":
286
+ return await labelCommand(argv.slice(1), call, show);
285
287
  case "shares":
286
288
  return await sharesCommand(argv.slice(1), call, show);
287
289
  case "lyrics":
@@ -438,6 +440,113 @@ async function aiCommand(argv, call, show, json) {
438
440
  return 2;
439
441
  }
440
442
  }
443
+ /** `nixamp station label ...`: AI artists who put out an album a month (ai-label.ts). */
444
+ async function labelCommand(argv, call, show) {
445
+ const [sub, key] = argv;
446
+ const when = (at) => at === -1 ? "never (off)" : at === 0 ? "now" : new Date(at).toISOString().slice(0, 10);
447
+ const describe = (one) => {
448
+ const albums = one["albums"] ?? [];
449
+ return [
450
+ `${String(one["name"])} [${String(one["key"])}] ${String(one["genre"])}${one["enabled"] ? "" : " (off)"}`,
451
+ ...albums.map((album) => ` ${String(album["year"])} ${String(album["title"])} ${String(album["composed"])}/${String(album["songs"])} songs${album["doneAt"] ? "" : " (recording)"}`),
452
+ ` next album ${when(Number(one["nextAlbumAt"] ?? -1))}`,
453
+ ...(one["lastError"] ? [` problem ${String(one["lastError"])}`] : []),
454
+ ].join("\n");
455
+ };
456
+ switch (sub) {
457
+ case undefined:
458
+ case "status":
459
+ case "list":
460
+ return show(await call("GET", "/api/v1/stations/label"), (body) => {
461
+ const settings = (body["settings"] ?? {});
462
+ const artists = body["artists"] ?? [];
463
+ const roster = body["roster"] ?? [];
464
+ return [
465
+ `holder ${String(settings["holder"])}${settings["enabled"] ? "" : " (off)"}`,
466
+ `files ${String(body["root"])}/<genre>/<artist>/<year>-<album>/`,
467
+ `composer ${String(body["composer"] ?? "none")}; words by ${String(body["writer"] ?? "nobody (no model key)")}`,
468
+ `this month ${String(body["secondsThisMonth"])} of ${Number(settings["monthlySeconds"]) === 0 ? "unlimited" : String(settings["monthlySeconds"])} s${body["busy"] ? `; recording ${String(body["busy"])}` : ""}`,
469
+ `albums ${String(settings["songsPerAlbum"])} songs of ${String(settings["songSeconds"])} s; ${String(settings["seedAlbums"])} to start, then one every ${String(settings["everyDays"])} days`,
470
+ ...(body["waiting"] ? [`waiting ${String(body["waiting"])}`] : []),
471
+ "",
472
+ ...(artists.length ? artists.map(describe) : ["Nobody signed yet. nixamp station label sign --roster"]),
473
+ "",
474
+ `roster ${roster.map((one) => `${String(one["key"])}${one["signed"] ? " (signed)" : ""}`).join(", ")}`,
475
+ ].join("\n");
476
+ });
477
+ case "sign": {
478
+ const body = has(argv, "--roster") ? { roster: true } : {
479
+ ...(flag(argv, "--preset") ? { preset: flag(argv, "--preset") } : {}),
480
+ ...(flag(argv, "--name") ? { name: flag(argv, "--name") } : {}),
481
+ ...(flag(argv, "--genre") ? { genre: flag(argv, "--genre") } : {}),
482
+ ...(flag(argv, "--sound") ? { sound: flag(argv, "--sound") } : {}),
483
+ ...(flag(argv, "--voice") ? { voice: flag(argv, "--voice") } : {}),
484
+ ...(flag(argv, "--bio") ? { bio: flag(argv, "--bio") } : {}),
485
+ ...(flag(argv, "--themes") ? { themes: flag(argv, "--themes") } : {}),
486
+ };
487
+ return show(await call("POST", "/api/v1/stations/label/artists", body), (answer) => {
488
+ const signed = answer["artists"] ?? (answer["artist"] ? [answer["artist"]] : []);
489
+ 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.";
490
+ });
491
+ }
492
+ case "album": {
493
+ if (!key) {
494
+ console.error("nixamp station label album needs the artist's key (nixamp station label)");
495
+ return 2;
496
+ }
497
+ return show(await call("POST", `/api/v1/stations/label/artists/${key}/album`, {}), (answer) => {
498
+ const album = answer["album"];
499
+ return `Planned "${String(album["title"])}" (${(album["songs"] ?? []).length} songs); recording now, a song at a time.`;
500
+ });
501
+ }
502
+ case "set": {
503
+ if (!key) {
504
+ console.error("nixamp station label set needs the artist's key");
505
+ return 2;
506
+ }
507
+ const body = {
508
+ ...(flag(argv, "--sound") ? { sound: flag(argv, "--sound") } : {}),
509
+ ...(flag(argv, "--voice") ? { voice: flag(argv, "--voice") } : {}),
510
+ ...(flag(argv, "--bio") ? { bio: flag(argv, "--bio") } : {}),
511
+ ...(flag(argv, "--themes") ? { themes: flag(argv, "--themes") } : {}),
512
+ ...(has(argv, "--off") ? { enabled: false } : has(argv, "--on") ? { enabled: true } : {}),
513
+ };
514
+ return show(await call("PATCH", `/api/v1/stations/label/artists/${key}`, body), (answer) => `${String(answer["artist"]["name"])} updated.`);
515
+ }
516
+ case "drop": {
517
+ if (!key) {
518
+ console.error("nixamp station label drop needs the artist's key");
519
+ return 2;
520
+ }
521
+ const files = has(argv, "--files");
522
+ return show(await call("DELETE", `/api/v1/stations/label/artists/${key}${files ? "?files=1" : ""}`), () => files ? "Dropped, with their music." : "Dropped. Their albums stay on the air; nothing new will be recorded.");
523
+ }
524
+ case "config": {
525
+ const number = (name) => (flag(argv, name) === undefined ? undefined : Number(flag(argv, name)));
526
+ const body = Object.fromEntries(Object.entries({
527
+ holder: flag(argv, "--holder"),
528
+ monthlySeconds: number("--budget"),
529
+ everyDays: number("--every-days"),
530
+ seedAlbums: number("--seed-albums"),
531
+ songsPerAlbum: number("--songs"),
532
+ songSeconds: number("--seconds"),
533
+ enabled: has(argv, "--off") ? false : has(argv, "--on") ? true : undefined,
534
+ publish: has(argv, "--unlisted") ? false : has(argv, "--public") ? true : undefined,
535
+ }).filter(([, value]) => value !== undefined));
536
+ return show(await call("POST", "/api/v1/stations/label", body), (answer) => {
537
+ const settings = answer["settings"];
538
+ 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)"}.`;
539
+ });
540
+ }
541
+ case "run":
542
+ return show(await call("POST", "/api/v1/stations/label/run", {}), (body) => body["waiting"] ? `Nothing to record now: ${String(body["waiting"])}.` : "Recording whatever is due.");
543
+ case "resume":
544
+ return show(await call("POST", "/api/v1/stations/label", { resume: true }), () => "Recording again.");
545
+ default:
546
+ console.error(`nixamp station label: no command "${sub}".\n\n${USAGE}`);
547
+ return 2;
548
+ }
549
+ }
441
550
  /** `nixamp station lyrics ...`: our own songs' words, published back to nichedb.dev. */
442
551
  async function lyricsCommand(argv, call, show, json) {
443
552
  const [sub, id] = argv;
@@ -27,7 +27,7 @@ export interface GuideEntry {
27
27
  * gateway that cannot run its script against this server (the Moshpit's
28
28
  * /n/<name>), and for a crawler. Everything from a station is escaped.
29
29
  */
30
- export declare function renderGuide(entries: GuideEntry[], apiBase?: string, ads?: GuideAds): string;
30
+ export declare function renderGuide(entries: GuideEntry[], apiBase?: string, ads?: GuideAds, labelHref?: string): string;
31
31
  export declare const GUIDE_HTML: string;
32
32
  /**
33
33
  * The guide's policy. With crawlproof configured (guideAds below) its tracker