nixamp 0.36.3 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/dist/ai-keys.d.ts +9 -0
  2. package/dist/ai-keys.js +46 -0
  3. package/dist/ai-radio.d.ts +148 -0
  4. package/dist/ai-radio.js +477 -0
  5. package/dist/ai-routes.d.ts +31 -0
  6. package/dist/ai-routes.js +191 -0
  7. package/dist/ai-text.d.ts +23 -0
  8. package/dist/ai-text.js +102 -0
  9. package/dist/library.d.ts +4 -0
  10. package/dist/library.js +2 -0
  11. package/dist/listener-shares.d.ts +136 -0
  12. package/dist/listener-shares.js +486 -0
  13. package/dist/server.d.ts +8 -0
  14. package/dist/server.js +63 -2
  15. package/dist/station-cli.js +166 -0
  16. package/dist/station-guide.d.ts +4 -1
  17. package/dist/station-guide.js +118 -21
  18. package/dist/station-mcp.d.ts +291 -20
  19. package/dist/station-mcp.js +66 -0
  20. package/dist/station-metering.d.ts +1 -1
  21. package/dist/station-metering.js +21 -9
  22. package/dist/station-routes.d.ts +4 -0
  23. package/dist/station-routes.js +8 -1
  24. package/dist/station-usage.d.ts +1 -1
  25. package/dist/station-usage.js +19 -0
  26. package/package.json +1 -1
  27. package/src/ai-keys.ts +49 -0
  28. package/src/ai-radio.ts +536 -0
  29. package/src/ai-routes.ts +220 -0
  30. package/src/ai-text.ts +117 -0
  31. package/src/library.ts +6 -0
  32. package/src/listener-shares.ts +528 -0
  33. package/src/server.ts +70 -2
  34. package/src/station-cli.ts +153 -0
  35. package/src/station-guide.ts +122 -21
  36. package/src/station-mcp.ts +62 -0
  37. package/src/station-metering.ts +19 -9
  38. package/src/station-routes.ts +11 -2
  39. package/src/station-usage.ts +19 -0
  40. package/web/dist/assets/{ar-BD2UUquk.js → ar-B2m5eyoV.js} +1 -1
  41. package/web/dist/assets/{cs-BAnlPO0G.js → cs-Cc5pOkWw.js} +1 -1
  42. package/web/dist/assets/{da-B3Tu9hTo.js → da-10XVgmQy.js} +1 -1
  43. package/web/dist/assets/{de-BnhC08J6.js → de-DIBX68fo.js} +1 -1
  44. package/web/dist/assets/{es-C6f8XeOz.js → es-B5wXCbD_.js} +1 -1
  45. package/web/dist/assets/{fi-c8nSqL6Y.js → fi-CfqtJU25.js} +1 -1
  46. package/web/dist/assets/{fr-CCQqbzd3.js → fr-DVpDbDWM.js} +1 -1
  47. package/web/dist/assets/{hi-oWq0nqXG.js → hi-iQgB7g2O.js} +1 -1
  48. package/web/dist/assets/{hls-3VKVEQE3-D4IS0V1s.js → hls-3VKVEQE3-Hp3aQLSm.js} +1 -1
  49. package/web/dist/assets/{hu-DXCXa0U6.js → hu-cL8uAJW0.js} +1 -1
  50. package/web/dist/assets/{id-i3qTkeDv.js → id-DAEQSrx8.js} +1 -1
  51. package/web/dist/assets/{it-CHb1I7qg.js → it-DARHJ2-F.js} +1 -1
  52. package/web/dist/assets/{main-B8hCuIBx.js → main-Bdbkg8vi.js} +2 -2
  53. package/web/dist/assets/{mpegts-LO6RVLD6-DKSrU2jg.js → mpegts-LO6RVLD6-Be6JzNGd.js} +1 -1
  54. package/web/dist/assets/{mpegts-DSR-qG9w.js → mpegts-vNdAhc1w.js} +1 -1
  55. package/web/dist/assets/{nl-iCv9pkTl.js → nl-BNskTvYD.js} +1 -1
  56. package/web/dist/assets/{ru-BW2-AWx1.js → ru-PA8keJv_.js} +1 -1
  57. package/web/dist/assets/{sv-DJV6w7__.js → sv-sZu9UVEg.js} +1 -1
  58. package/web/dist/assets/{uk-CCsBeFUE.js → uk-FmHT0lvK.js} +1 -1
  59. package/web/dist/assets/{vi-Cckuuziu.js → vi-BV81HbEM.js} +1 -1
  60. package/web/dist/assets/{zh-Df6AwSwJ.js → zh-CwzApCu7.js} +1 -1
  61. package/web/dist/index.html +1 -1
  62. package/web/dist/sw.js +22 -22
@@ -0,0 +1,486 @@
1
+ /**
2
+ * Listener shares: anybody may hand a station a track -- a link to an MP3,
3
+ * or the file itself -- and AI sorts it onto the air.
4
+ *
5
+ * The person sharing says on what basis it may be broadcast (they made it,
6
+ * they have permission or a licence, it is Creative Commons or public
7
+ * domain) and ticks that they mean it. Nixamp copies the file here (the same
8
+ * guarded fetch as a handoff: public addresses only, every redirect checked,
9
+ * a size cap), ffprobe must find music in it, and then a language model
10
+ * reads what the file says about itself and answers two things: which genre
11
+ * station it belongs on, and whether it looks like something the sharer
12
+ * could not have the right to (a major-label release, a rip). "clear" goes on
13
+ * the air when the owner chose AI moderation; "doubt" waits for the owner;
14
+ * "refuse" (not music, spam) is turned away. With no model key, or with owner
15
+ * moderation, everything waits for the owner.
16
+ *
17
+ * Approved tracks play on "Shared: <genre>" stations, made and kept in step
18
+ * here, public and listed when the owner said so. Anybody listening may
19
+ * report what is playing: a reported track leaves the rotation at once and
20
+ * waits for the owner, which is the takedown path, not an afterthought.
21
+ */
22
+ import { createHash, randomBytes } from "node:crypto";
23
+ import { createWriteStream, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
24
+ import { dirname, extname, join } from "node:path";
25
+ import { Readable, Transform } from "node:stream";
26
+ import { pipeline } from "node:stream/promises";
27
+ import { askJson, hasModel } from "./ai-text.js";
28
+ import { genreBuckets } from "./auto-stations.js";
29
+ import { isAudio } from "./playlist.js";
30
+ import { StationError } from "./stations.js";
31
+ export const SHARE_BASES = ["owned", "permission", "licensed", "creative-commons", "public-domain"];
32
+ export const SHARE_GENRES = ["Rock", "Punk", "Metal", "Alternative", "Pop", "Hip-Hop", "Electronic", "Industrial", "Reggae", "Jazz", "Blues", "Classical", "Country & Folk", "Soul & R&B", "Latin", "Ambient", "Experimental", "World", "Mixed"];
33
+ export const SHARE_DEFAULTS = { open: false, moderation: "ai", publish: true, maxBytes: 60 * 1024 * 1024, perHour: 6 };
34
+ const MAX_REDIRECTS = 3;
35
+ const MIN_SECONDS = 20;
36
+ const MAX_SECONDS = 20 * 60;
37
+ const MAX_HELD = 500;
38
+ function clean(value, max) {
39
+ return typeof value === "string" ? value.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, max) : "";
40
+ }
41
+ /** What a sharer may see of their share. */
42
+ export function sharerView(share) {
43
+ return {
44
+ id: share.id, state: share.state, reason: share.reason, title: share.title, artist: share.artist,
45
+ genre: share.genre, duration: share.duration, createdAt: share.createdAt, updatedAt: share.updatedAt,
46
+ };
47
+ }
48
+ /** What the owner sees: all of it but the sharer's token. */
49
+ export function ownerShareView(share) {
50
+ const { token: _token, ...rest } = share;
51
+ return rest;
52
+ }
53
+ const SYSTEM = [
54
+ "You screen tracks that members of the public share to a community internet radio station, and file each under a genre.",
55
+ "You see only what the file says about itself, its name, where it came from and what the sharer declared.",
56
+ "verdict \"refuse\": not music (speech, silence, a test tone), spam or advertising, or titles that are hateful or sexual.",
57
+ "verdict \"doubt\": anything suggesting a commercially released recording by a signed or well-known artist, or a rip or leak (label or distributor names, 'Official Audio', 'VEVO', 'Remastered', bitrate or scene tags, a famous song title), unless the sharer's basis plausibly covers it (e.g. public domain for a pre-1926 recording).",
58
+ "verdict \"clear\": plausibly the sharer's own or independent work, or Creative Commons or public domain music consistent with the declaration.",
59
+ `genre: exactly one of ${SHARE_GENRES.join(", ")}.`,
60
+ "Answer as JSON: {\"verdict\": \"clear|doubt|refuse\", \"reason\": \"one short sentence\", \"genre\": \"...\", \"title\": \"tidy title or empty\", \"artist\": \"tidy artist or empty\"}.",
61
+ ].join(" ");
62
+ export class ListenerShares {
63
+ file;
64
+ deps;
65
+ shares = new Map();
66
+ settings = { ...SHARE_DEFAULTS };
67
+ salt = randomBytes(16).toString("hex");
68
+ sourceId = "";
69
+ recent = new Map();
70
+ syncing = null;
71
+ constructor(file, deps) {
72
+ this.file = file;
73
+ this.deps = deps;
74
+ try {
75
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
76
+ for (const one of parsed.shares ?? [])
77
+ if (one?.id)
78
+ this.shares.set(one.id, one);
79
+ this.settings = { ...this.settings, ...(parsed.settings ?? {}) };
80
+ if (typeof parsed.salt === "string" && parsed.salt)
81
+ this.salt = parsed.salt;
82
+ }
83
+ catch {
84
+ // Nothing yet.
85
+ }
86
+ // A share that was being fetched or checked when the server stopped is
87
+ // not coming back; say so rather than leave it spinning.
88
+ for (const one of this.shares.values()) {
89
+ if (one.state === "fetching" || one.state === "checking") {
90
+ one.state = "failed";
91
+ one.reason = "the server restarted while this was being read; share it again";
92
+ }
93
+ }
94
+ }
95
+ static fileFor(stateDir, port) {
96
+ return join(stateDir, `shares-${port}.json`);
97
+ }
98
+ now() {
99
+ return this.deps.now?.() ?? Date.now();
100
+ }
101
+ save() {
102
+ mkdirSync(dirname(this.file), { recursive: true });
103
+ const temporary = `${this.file}.${process.pid}.tmp`;
104
+ writeFileSync(temporary, `${JSON.stringify({ version: 1, salt: this.salt, settings: this.settings, shares: [...this.shares.values()] }, null, 2)}\n`, { mode: 0o600 });
105
+ renameSync(temporary, this.file);
106
+ }
107
+ source() {
108
+ if (this.sourceId && this.deps.index.source(this.sourceId))
109
+ return this.sourceId;
110
+ mkdirSync(this.deps.root, { recursive: true });
111
+ this.sourceId = this.deps.index.addManagedSource(this.deps.root).id;
112
+ return this.sourceId;
113
+ }
114
+ setting() {
115
+ return { ...this.settings, model: (this.deps.canAsk ?? hasModel)() };
116
+ }
117
+ configure(input) {
118
+ if (input["open"] !== undefined)
119
+ this.settings.open = input["open"] === true;
120
+ if (input["publish"] !== undefined)
121
+ this.settings.publish = input["publish"] === true;
122
+ if (input["moderation"] !== undefined) {
123
+ if (input["moderation"] !== "ai" && input["moderation"] !== "owner")
124
+ throw new StationError("invalid_moderation", "moderation is ai or owner");
125
+ this.settings.moderation = input["moderation"];
126
+ }
127
+ if (input["maxMb"] !== undefined) {
128
+ const mb = Number(input["maxMb"]);
129
+ if (!Number.isInteger(mb) || mb < 1 || mb > 500)
130
+ throw new StationError("invalid_size", "maxMb is 1 to 500");
131
+ this.settings.maxBytes = mb * 1024 * 1024;
132
+ }
133
+ if (input["perHour"] !== undefined) {
134
+ const perHour = Number(input["perHour"]);
135
+ if (!Number.isInteger(perHour) || perHour < 1 || perHour > 1000)
136
+ throw new StationError("invalid_rate", "perHour is 1 to 1000");
137
+ this.settings.perHour = perHour;
138
+ }
139
+ this.save();
140
+ void this.sync();
141
+ return this.settings;
142
+ }
143
+ list(state) {
144
+ return [...this.shares.values()].filter((one) => !state || one.state === state).sort((a, b) => b.createdAt - a.createdAt);
145
+ }
146
+ get(id) {
147
+ return this.shares.get(id);
148
+ }
149
+ /** The share, if this token is the one its sharer was given. */
150
+ forSharer(id, token) {
151
+ const share = this.shares.get(id);
152
+ if (!share || token.length !== share.token.length)
153
+ return undefined;
154
+ return createHash("sha256").update(token).digest("hex") === createHash("sha256").update(share.token).digest("hex") ? share : undefined;
155
+ }
156
+ fromOf(caller) {
157
+ return createHash("sha256").update(`${this.salt}:${caller}`).digest("hex").slice(0, 16);
158
+ }
159
+ /** Throws when shares are closed or this sharer has shared enough for the hour. */
160
+ admit(caller) {
161
+ if (!this.settings.open)
162
+ throw new StationError("shares_closed", "this server is not taking shared tracks", 403);
163
+ if (this.list("held").length >= MAX_HELD)
164
+ throw new StationError("queue_full", "too many tracks are waiting to be checked; try again later", 503);
165
+ const from = this.fromOf(caller);
166
+ const at = this.now();
167
+ const times = (this.recent.get(from) ?? []).filter((one) => one > at - 3600_000);
168
+ if (times.length >= this.settings.perHour)
169
+ throw new StationError("rate_limited", `at most ${this.settings.perHour} shares an hour`, 429);
170
+ times.push(at);
171
+ this.recent.set(from, times);
172
+ return from;
173
+ }
174
+ declared(input) {
175
+ const basis = input["basis"];
176
+ if (typeof basis !== "string" || !SHARE_BASES.includes(basis)) {
177
+ throw new StationError("rights_required", `say on what basis it may be broadcast: ${SHARE_BASES.join(", ")}`);
178
+ }
179
+ if (input["agree"] !== true && input["agree"] !== "1" && input["agree"] !== "true") {
180
+ throw new StationError("agreement_required", "confirm you have the right to share this for broadcast");
181
+ }
182
+ return { basis: basis, note: clean(input["note"], 300), by: clean(input["by"], 60) };
183
+ }
184
+ blank(via, url, name, rights, from) {
185
+ const id = randomBytes(6).toString("hex");
186
+ const ext = extname(name).toLowerCase();
187
+ const safe = name.split(/[\\/]/).pop().replace(/[^\w .()-]/g, "_").replace(/^\.+/, "").slice(0, 120) || "track";
188
+ return {
189
+ id, token: randomBytes(16).toString("base64url"), via, url, name: safe,
190
+ file: join(this.deps.root, id, isAudio(`x${ext}`) ? safe : `${safe}.mp3`),
191
+ bytes: 0, sha256: "", state: "fetching", reason: "", title: "", artist: "", genre: "", duration: 0,
192
+ rights: { basis: rights.basis, note: rights.note }, by: rights.by, from,
193
+ check: { model: "", verdict: "", reason: "" }, reports: [], createdAt: this.now(), updatedAt: this.now(),
194
+ };
195
+ }
196
+ /** A link to a music file. Answers at once; the fetch and the check carry on. */
197
+ fromUrl(input, caller) {
198
+ const rights = this.declared(input);
199
+ const link = clean(input["url"], 2000);
200
+ let parsed;
201
+ try {
202
+ parsed = new URL(link);
203
+ }
204
+ catch {
205
+ throw new StationError("invalid_url", "paste a link to a music file (https://.../song.mp3)");
206
+ }
207
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:")
208
+ throw new StationError("invalid_url", "links are http or https");
209
+ if ([...this.shares.values()].some((one) => one.url === link && one.state !== "failed" && one.state !== "withdrawn")) {
210
+ throw new StationError("already_shared", "that link has been shared already", 409);
211
+ }
212
+ const from = this.admit(caller);
213
+ const share = this.blank("url", link, decodeURIComponent(parsed.pathname.split("/").pop() || "track.mp3"), rights, from);
214
+ this.shares.set(share.id, share);
215
+ this.save();
216
+ void this.fetchAndCheck(share);
217
+ return share;
218
+ }
219
+ /** The file itself, as the request body. Resolves once it is all here; the check carries on. */
220
+ async fromUpload(request, input, caller) {
221
+ const rights = this.declared(input);
222
+ const name = clean(input["name"], 200) || "upload.mp3";
223
+ const type = String(request.headers["content-type"] ?? "");
224
+ if (!type.startsWith("audio/") && !isAudio(name))
225
+ throw new StationError("not_audio", "share a music file (MP3, OGG, FLAC, M4A, WAV)");
226
+ const declaredLength = Number(request.headers["content-length"] ?? "0");
227
+ if (declaredLength > this.settings.maxBytes)
228
+ throw new StationError("too_big", `files up to ${Math.round(this.settings.maxBytes / 1048576)} MB`, 413);
229
+ const from = this.admit(caller);
230
+ const share = this.blank("upload", "", name, rights, from);
231
+ this.shares.set(share.id, share);
232
+ try {
233
+ await this.write(share, request);
234
+ }
235
+ catch (error) {
236
+ this.fail(share, error.message);
237
+ throw error instanceof StationError ? error : new StationError("upload_failed", share.reason);
238
+ }
239
+ void this.check(share);
240
+ return share;
241
+ }
242
+ /** The sharer takes it back: off the air and its copy gone. */
243
+ withdraw(share) {
244
+ share.state = "withdrawn";
245
+ share.reason = "withdrawn by the person who shared it";
246
+ rmSync(dirname(share.file), { recursive: true, force: true });
247
+ share.updatedAt = this.now();
248
+ this.save();
249
+ void this.sync();
250
+ return share;
251
+ }
252
+ /** A listener says what is playing should not be: off the air at once, for the owner to look at. */
253
+ report(input, caller) {
254
+ let share;
255
+ if (typeof input.share === "string")
256
+ share = this.shares.get(input.share);
257
+ else if (typeof input.station === "string") {
258
+ const file = this.deps.nowPlaying(input.station);
259
+ share = [...this.shares.values()].find((one) => one.file === file);
260
+ }
261
+ if (!share || share.state !== "approved")
262
+ return { reported: false, title: "" };
263
+ share.reports.push({ at: this.now(), reason: clean(input.reason, 300), from: this.fromOf(caller) });
264
+ share.state = "reported";
265
+ share.reason = `reported by a listener${share.reports.at(-1).reason ? `: ${share.reports.at(-1).reason}` : ""}`;
266
+ share.updatedAt = this.now();
267
+ this.save();
268
+ this.deps.log?.(` shares: "${share.title || share.name}" was reported and is off the air until the owner looks at it`);
269
+ // Off the air now, not when the song ends: the station moves past it.
270
+ const playingOn = typeof input.station === "string" ? input.station : "";
271
+ void this.sync().then(() => { if (playingOn)
272
+ this.deps.runner.skip(playingOn); });
273
+ return { reported: true, title: share.title || share.name };
274
+ }
275
+ /** The owner's decision. */
276
+ decide(id, decision, reason = "") {
277
+ const share = this.shares.get(id);
278
+ if (!share)
279
+ throw new StationError("not_found", "no such share", 404);
280
+ if (decision === "delete") {
281
+ rmSync(dirname(share.file), { recursive: true, force: true });
282
+ this.shares.delete(id);
283
+ }
284
+ else if (decision === "approve") {
285
+ if (!["held", "rejected", "reported"].includes(share.state))
286
+ throw new StationError("not_waiting", `it is ${share.state}`, 409);
287
+ share.state = "approved";
288
+ share.reason = reason || "approved by the owner";
289
+ }
290
+ else {
291
+ share.state = "rejected";
292
+ share.reason = reason || "not accepted by the owner";
293
+ }
294
+ if (share)
295
+ share.updatedAt = this.now();
296
+ this.save();
297
+ void this.sync();
298
+ return decision === "delete" ? null : share;
299
+ }
300
+ fail(share, reason) {
301
+ share.state = "failed";
302
+ share.reason = reason;
303
+ share.updatedAt = this.now();
304
+ rmSync(dirname(share.file), { recursive: true, force: true });
305
+ this.save();
306
+ }
307
+ /** Bytes to the share's file, counted, capped and hashed. */
308
+ async write(share, body) {
309
+ mkdirSync(dirname(share.file), { recursive: true });
310
+ const part = `${share.file}.part`;
311
+ const hash = createHash("sha256");
312
+ let bytes = 0;
313
+ const max = this.settings.maxBytes;
314
+ const counter = new Transform({
315
+ transform(chunk, _encoding, done) {
316
+ bytes += chunk.length;
317
+ if (bytes > max)
318
+ return done(new StationError("too_big", `files up to ${Math.round(max / 1048576)} MB`, 413));
319
+ hash.update(chunk);
320
+ done(null, chunk);
321
+ },
322
+ });
323
+ try {
324
+ await pipeline(body, counter, createWriteStream(part, { mode: 0o640 }));
325
+ }
326
+ catch (error) {
327
+ rmSync(part, { force: true });
328
+ throw error;
329
+ }
330
+ const sha256 = hash.digest("hex");
331
+ if ([...this.shares.values()].some((one) => one.id !== share.id && one.sha256 === sha256 && one.state !== "withdrawn" && one.state !== "failed")) {
332
+ rmSync(part, { force: true });
333
+ throw new StationError("already_shared", "that track has been shared already", 409);
334
+ }
335
+ renameSync(part, share.file);
336
+ share.bytes = bytes;
337
+ share.sha256 = sha256;
338
+ share.state = "checking";
339
+ share.updatedAt = this.now();
340
+ this.save();
341
+ }
342
+ async fetchAndCheck(share) {
343
+ try {
344
+ const send = this.deps.fetcher ?? fetch;
345
+ let at = share.url;
346
+ let response = null;
347
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
348
+ const refused = await this.deps.guard(at);
349
+ if (refused)
350
+ throw new Error(refused);
351
+ response = await send(at, { redirect: "manual", signal: AbortSignal.timeout(5 * 60_000), headers: { "user-agent": "nixamp-shares" } });
352
+ const location = response.headers.get("location");
353
+ if (response.status >= 300 && response.status < 400 && location) {
354
+ at = new URL(location, at).toString();
355
+ continue;
356
+ }
357
+ break;
358
+ }
359
+ if (!response || !response.ok || !response.body)
360
+ throw new Error(`the link answered HTTP ${response?.status ?? "nothing"}`);
361
+ const type = (response.headers.get("content-type") ?? "").toLowerCase();
362
+ if (type.startsWith("text/html"))
363
+ throw new Error("that link is a web page, not a music file; link the .mp3 itself");
364
+ const length = Number(response.headers.get("content-length") ?? "0");
365
+ if (length > this.settings.maxBytes)
366
+ throw new Error(`the file is over ${Math.round(this.settings.maxBytes / 1048576)} MB`);
367
+ await this.write(share, Readable.fromWeb(response.body));
368
+ }
369
+ catch (error) {
370
+ this.fail(share, error.message);
371
+ return;
372
+ }
373
+ await this.check(share);
374
+ }
375
+ /** ffprobe, then the model: on the air, waiting, or turned away. */
376
+ async check(share) {
377
+ try {
378
+ await this.deps.index.scan(this.source(), this.deps.probe).done;
379
+ const item = this.deps.index.itemAt(share.file);
380
+ if (!item || item.state !== "ready")
381
+ throw new Error(item?.error || "there is no music in that file that a station can play");
382
+ if (item.duration > 0 && item.duration < MIN_SECONDS)
383
+ throw new Error(`it is ${Math.round(item.duration)} s; a track is at least ${MIN_SECONDS} s`);
384
+ if (item.duration > MAX_SECONDS)
385
+ throw new Error("it is over 20 minutes; share a track, not a mix or a show");
386
+ share.duration = Math.round(item.duration);
387
+ share.title = item.title || share.name.replace(/\.[a-z0-9]+$/i, "");
388
+ share.artist = item.artist;
389
+ share.genre = genreBuckets(item.genre)[0] ?? "";
390
+ let verdict = "doubt";
391
+ let reason = "waiting for the owner (no AI check on this server)";
392
+ if ((this.deps.canAsk ?? hasModel)()) {
393
+ try {
394
+ const answer = await (this.deps.ask ?? askJson)({
395
+ system: SYSTEM,
396
+ prompt: JSON.stringify({
397
+ file: share.name, title: item.title, artist: item.artist, album: item.album, genreTag: item.genre, year: item.year,
398
+ seconds: share.duration, from: share.url ? new URL(share.url).host : "uploaded", declared: share.rights,
399
+ }),
400
+ });
401
+ const said = answer.value;
402
+ verdict = said["verdict"] === "clear" || said["verdict"] === "refuse" ? said["verdict"] : "doubt";
403
+ reason = clean(said["reason"], 300) || verdict;
404
+ const genre = SHARE_GENRES.find((one) => one.toLowerCase() === String(said["genre"] ?? "").toLowerCase());
405
+ if (!share.genre && genre)
406
+ share.genre = genre;
407
+ if (!item.title && clean(said["title"], 200))
408
+ share.title = clean(said["title"], 200);
409
+ if (!item.artist && clean(said["artist"], 200))
410
+ share.artist = clean(said["artist"], 200);
411
+ share.check = { model: answer.model, verdict, reason };
412
+ }
413
+ catch (error) {
414
+ reason = `waiting for the owner (the AI check failed: ${error.message})`;
415
+ }
416
+ }
417
+ share.genre ||= "Mixed";
418
+ if (verdict === "refuse") {
419
+ share.state = "rejected";
420
+ share.reason = reason;
421
+ rmSync(dirname(share.file), { recursive: true, force: true });
422
+ }
423
+ else if (verdict === "clear" && this.settings.moderation === "ai") {
424
+ share.state = "approved";
425
+ share.reason = "";
426
+ }
427
+ else {
428
+ share.state = "held";
429
+ share.reason = verdict === "clear" ? "waiting for the owner" : reason;
430
+ }
431
+ share.updatedAt = this.now();
432
+ this.save();
433
+ this.deps.log?.(` shares: "${share.title}" (${share.genre}) is ${share.state}${share.reason ? `: ${share.reason}` : ""}`);
434
+ await this.sync();
435
+ }
436
+ catch (error) {
437
+ this.fail(share, error.message);
438
+ }
439
+ }
440
+ /** Each genre's shared station, with exactly the approved tracks; one with none goes quiet. */
441
+ sync() {
442
+ if (this.syncing)
443
+ return this.syncing.then(() => this.sync());
444
+ this.syncing = (async () => {
445
+ const byGenre = new Map();
446
+ for (const share of this.shares.values()) {
447
+ if (share.state !== "approved")
448
+ continue;
449
+ byGenre.set(share.genre, [...(byGenre.get(share.genre) ?? []), share.file]);
450
+ }
451
+ const genres = new Set([...byGenre.keys(), ...this.deps.store.list().filter((one) => one.auto.startsWith("share:")).map((one) => one.auto.slice(6))]);
452
+ for (const genre of genres) {
453
+ const files = (byGenre.get(genre) ?? []).filter((file) => this.deps.index.itemAt(file)?.state === "ready");
454
+ const existing = this.deps.store.byAuto(`share:${genre}`);
455
+ if (files.length === 0) {
456
+ if (existing && existing.desired === "running")
457
+ this.deps.runner.stop(existing.id);
458
+ if (existing && existing.listed)
459
+ this.deps.store.setAccess(existing.id, { listed: false }, this.deps.owner || "shares");
460
+ continue;
461
+ }
462
+ let station = existing;
463
+ if (!station) {
464
+ station = this.deps.store.create({ name: `Shared: ${genre}`, description: `${genre} shared by listeners. Share yours, or report a track, from the guide.`, shuffle: true, repeat: true }, files, this.deps.owner);
465
+ this.deps.store.markAuto(station.id, `share:${genre}`);
466
+ }
467
+ else {
468
+ this.deps.store.update(station.id, {}, files);
469
+ }
470
+ if (this.settings.publish) {
471
+ this.deps.store.setAccess(station.id, {
472
+ visibility: "public",
473
+ listed: true,
474
+ rights: { basis: "permission", note: "Each track was shared by someone who declared the right to have it broadcast; listeners can report any track off the air." },
475
+ }, this.deps.owner || "shares");
476
+ }
477
+ else if (station.visibility === "public") {
478
+ this.deps.store.setAccess(station.id, { visibility: "unlisted" }, this.deps.owner || "shares");
479
+ }
480
+ if (station.desired !== "running" && this.deps.mayStart())
481
+ await this.deps.runner.start(station.id).catch(() => undefined);
482
+ }
483
+ })().finally(() => { this.syncing = null; });
484
+ return this.syncing;
485
+ }
486
+ }
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 { AiRadio } from "./ai-radio.ts";
28
+ import { ListenerShares } from "./listener-shares.ts";
27
29
  import { StationRunner } from "./station-runner.ts";
28
30
  import { StationStore } from "./stations.ts";
29
31
  import { StationUsage, type Limits } from "./station-metering.ts";
@@ -591,6 +593,12 @@ export interface HandlerOptions {
591
593
  };
592
594
  /** This server's public https address, once it is known. */
593
595
  publicBase?: () => string;
596
+ /** Stations whose music is composed by AI. */
597
+ ai?: AiRadio;
598
+ /** Tracks anybody may share, sorted onto the air by AI. */
599
+ shares?: ListenerShares;
600
+ /** Proxies (by address) whose X-Forwarded-For is believed, for per-sharer limits. */
601
+ trustedProxies?: () => Set<string>;
594
602
  };
595
603
  web: string | null;
596
604
  /** Branded clients on this same backend, keyed by an explicitly configured host. */
package/dist/server.js CHANGED
@@ -60,10 +60,13 @@ import { LibraryIndex, probeItem, trackOf } from "./library-index.js";
60
60
  import { ListenerStore } from "./library-user.js";
61
61
  import { Importer } from "./handoff.js";
62
62
  import { handleHandoffs } from "./handoff-routes.js";
63
+ import { handleAiRoutes, isPublicSharePath } from "./ai-routes.js";
64
+ import { AiRadio } from "./ai-radio.js";
65
+ import { ListenerShares } from "./listener-shares.js";
63
66
  import { STATION_PAGE, STATION_PAGE_CSP, STATION_PAGE_HTML } from "./station-page.js";
64
67
  import { GUIDE_CSP, guideHosts, renderGuide } from "./station-guide.js";
65
68
  import { StationRunner } from "./station-runner.js";
66
- import { StationError, StationStore, stationOfChannel } from "./stations.js";
69
+ import { StationError, StationStore, channelOf, stationOfChannel } from "./stations.js";
67
70
  import { STATION_CONTROL_LIMIT, StationUsage, limitsFromEnv } from "./station-metering.js";
68
71
  import { StationBreaks } from "./station-breaks.js";
69
72
  import { DEFAULT_AUTO, syncAutoStations } from "./auto-stations.js";
@@ -2149,6 +2152,8 @@ export function createHandler(engine, options) {
2149
2152
  !isSignInPath(path) &&
2150
2153
  // A station's link is its own key, checked by the station.
2151
2154
  !isStationListenPath(path) &&
2155
+ // Sharing a track and reporting one are for anybody the guide reaches.
2156
+ !isPublicSharePath(path) &&
2152
2157
  !STATION_PAGE.test(path) &&
2153
2158
  !isGuidePage &&
2154
2159
  !eventChannelRequest) {
@@ -2277,6 +2282,29 @@ export function createHandler(engine, options) {
2277
2282
  });
2278
2283
  return;
2279
2284
  }
2285
+ if (options.stations && (isPublicSharePath(path) || path.startsWith("/api/v1/stations/ai") || path.startsWith("/api/v1/stations/shares"))) {
2286
+ const control = await controlOf();
2287
+ // Behind a proxy we trust (dev2 for live.streaming), the sharer is the
2288
+ // first address it names; otherwise whoever connected.
2289
+ const socket = (request.socket.remoteAddress ?? "").replace(/^::ffff:/, "");
2290
+ const trusted = behindProxy || (options.stations.trustedProxies?.().has(socket) ?? false);
2291
+ const caller = callerOf(request.headers, socket, trusted);
2292
+ const handled = await handleAiRoutes(request, response, path, url, {
2293
+ ...(options.stations.ai ? { ai: options.stations.ai } : {}),
2294
+ ...(options.stations.shares ? { shares: options.stations.shares } : {}),
2295
+ canControl: control.allowed,
2296
+ caller,
2297
+ json,
2298
+ readBody,
2299
+ headers: CORS,
2300
+ throttle: () => {
2301
+ const verdict = guard.check(`shares:${caller}`, STATION_CONTROL_LIMIT);
2302
+ return verdict.ok ? null : { retryAfter: verdict.retryAfter };
2303
+ },
2304
+ });
2305
+ if (handled)
2306
+ return;
2307
+ }
2280
2308
  if (options.stations && (path === "/api/v1/stations" || path.startsWith("/api/v1/stations/"))) {
2281
2309
  const control = await controlOf();
2282
2310
  const encrypted = request.socket.encrypted === true;
@@ -6215,7 +6243,7 @@ export async function serve(argv, version = "0.1.0") {
6215
6243
  const stationStore = new StationStore(StationStore.fileFor(stateDir(), options.port));
6216
6244
  // What stations deliver, per month, and the operator's limits on it.
6217
6245
  const stationUsage = new StationUsage(StationUsage.fileFor(stateDir(), options.port));
6218
- const stationLimits = limitsFromEnv();
6246
+ const stationLimits = limitsFromEnv(process.env, readConfig().limits ?? {});
6219
6247
  process.once("exit", () => stationUsage.flush());
6220
6248
  // Breaks between songs and films: the network's spot, or a house one.
6221
6249
  const stationBreaks = new StationBreaks({
@@ -6308,6 +6336,7 @@ export async function serve(argv, version = "0.1.0") {
6308
6336
  };
6309
6337
  const stations = {
6310
6338
  store: stationStore, runner: stationRunner, usage: stationUsage, limits: stationLimits, auto: stationAuto,
6339
+ trustedProxies: () => new Set([...(process.env["NIXAMP_TRUSTED_PROXIES"] ?? "").split(","), ...(readConfig().trustedProxies ?? [])].map((one) => one.trim()).filter(Boolean)),
6311
6340
  // Known once the server has worked out its addresses, after it is listening.
6312
6341
  publicBase: () => (publishable_?.url ?? "").replace(/\/+$/, ""),
6313
6342
  };
@@ -6321,6 +6350,38 @@ export async function serve(argv, version = "0.1.0") {
6321
6350
  log: (line) => console.log(line),
6322
6351
  });
6323
6352
  importer.resume();
6353
+ const mayStartStation = () => stationLimits.runningStations === 0 || stationStore.list().filter((one) => one.desired === "running").length < stationLimits.runningStations;
6354
+ // AI radio: music composed for its stations, a few tracks a day, in a folder of our own.
6355
+ const aiRadio = new AiRadio(AiRadio.fileFor(stateDir(), options.port), {
6356
+ index: libraryIndex,
6357
+ store: stationStore,
6358
+ runner: stationRunner,
6359
+ probe: probeFile,
6360
+ ffmpeg: tools.ffmpeg,
6361
+ root: join(stateDir(), "ai-music"),
6362
+ owner: options.owner || "",
6363
+ mayStart: mayStartStation,
6364
+ log: (line) => console.log(line),
6365
+ });
6366
+ aiRadio.startSchedule();
6367
+ // Tracks anybody shares from the guide, checked by AI before they air.
6368
+ const listenerShares = new ListenerShares(ListenerShares.fileFor(stateDir(), options.port), {
6369
+ index: libraryIndex,
6370
+ store: stationStore,
6371
+ runner: stationRunner,
6372
+ probe: probeFile,
6373
+ guard: (url) => refusedDestination(url),
6374
+ root: join(stateDir(), "shares"),
6375
+ owner: options.owner || "",
6376
+ mayStart: mayStartStation,
6377
+ nowPlaying: (id) => {
6378
+ const info = channels.info(channelOf(id));
6379
+ return info?.playlist?.[info.playlistAt ?? 0] ?? "";
6380
+ },
6381
+ log: (line) => console.log(line),
6382
+ });
6383
+ stations.ai = aiRadio;
6384
+ stations.shares = listenerShares;
6324
6385
  // Channels as HLS, on demand, for Safari on a phone: one ffmpeg copying a
6325
6386
  // channel's fragments into short files while somebody is asking for them.
6326
6387
  // What things are, from nichedb.dev, remembered beside the keys so a