nixamp 0.36.4 → 0.37.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.
- package/dist/ai-keys.d.ts +9 -0
- package/dist/ai-keys.js +46 -0
- package/dist/ai-radio.d.ts +148 -0
- package/dist/ai-radio.js +477 -0
- package/dist/ai-routes.d.ts +31 -0
- package/dist/ai-routes.js +191 -0
- package/dist/ai-text.d.ts +23 -0
- package/dist/ai-text.js +102 -0
- package/dist/library.d.ts +2 -0
- package/dist/library.js +1 -0
- package/dist/listener-shares.d.ts +136 -0
- package/dist/listener-shares.js +486 -0
- package/dist/playlist-input.d.ts +1 -1
- package/dist/playlist-input.js +2 -1
- package/dist/server.d.ts +8 -0
- package/dist/server.js +62 -1
- package/dist/station-cli.js +179 -0
- package/dist/station-guide.d.ts +4 -1
- package/dist/station-guide.js +118 -21
- package/dist/station-mcp.d.ts +291 -20
- package/dist/station-mcp.js +66 -0
- package/dist/station-routes.d.ts +4 -0
- package/dist/station-routes.js +8 -1
- package/dist/station-runner.js +4 -2
- package/dist/station-usage.d.ts +1 -1
- package/dist/station-usage.js +21 -1
- package/package.json +1 -1
- package/src/ai-keys.ts +49 -0
- package/src/ai-radio.ts +536 -0
- package/src/ai-routes.ts +220 -0
- package/src/ai-text.ts +117 -0
- package/src/library.ts +3 -0
- package/src/listener-shares.ts +528 -0
- package/src/playlist-input.ts +3 -2
- package/src/server.ts +69 -1
- package/src/station-cli.ts +165 -0
- package/src/station-guide.ts +122 -21
- package/src/station-mcp.ts +62 -0
- package/src/station-routes.ts +11 -2
- package/src/station-runner.ts +4 -2
- package/src/station-usage.ts +21 -1
- package/web/dist/assets/{ar-B9jc6q-w.js → ar-CO5BKvO0.js} +1 -1
- package/web/dist/assets/{cs-lU25OfSM.js → cs-BUpUmRvP.js} +1 -1
- package/web/dist/assets/{da-CAgdgQoL.js → da-zX4iMHja.js} +1 -1
- package/web/dist/assets/{de-CiusGizF.js → de-Tn7jOSGa.js} +1 -1
- package/web/dist/assets/{es-DVxl-KYi.js → es-7u7Y8_4k.js} +1 -1
- package/web/dist/assets/{fi-C1-78Aib.js → fi-V6JD2KmM.js} +1 -1
- package/web/dist/assets/{fr-BcAXoFCf.js → fr-Cywrbe0W.js} +1 -1
- package/web/dist/assets/{hi-DXdG5DSQ.js → hi-Bi1gVC9W.js} +1 -1
- package/web/dist/assets/{hls-3VKVEQE3-C3dxJvEy.js → hls-3VKVEQE3-DiRfULQE.js} +1 -1
- package/web/dist/assets/{hu-qrAMY3zu.js → hu-R_kphMiO.js} +1 -1
- package/web/dist/assets/{id-Cgtr-WML.js → id-DsjVfoDE.js} +1 -1
- package/web/dist/assets/{it-kuAXXts8.js → it-D_Jw0vo4.js} +1 -1
- package/web/dist/assets/{main-C5IsPzDk.js → main-26sEJB31.js} +2 -2
- package/web/dist/assets/{mpegts-BWzEpcbk.js → mpegts-DCmyLJq-.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-CYjdm0vd.js → mpegts-LO6RVLD6-BCbjcAdN.js} +1 -1
- package/web/dist/assets/{nl-CEXpOYtY.js → nl-D_AiIeeo.js} +1 -1
- package/web/dist/assets/{ru-v7b_XixQ.js → ru-BalU1IsS.js} +1 -1
- package/web/dist/assets/{sv-CJK5wtih.js → sv-CRhToKKj.js} +1 -1
- package/web/dist/assets/{uk-qDSjg5zB.js → uk-BzTMYhrk.js} +1 -1
- package/web/dist/assets/{vi-kckrzXyJ.js → vi-DkoVH56o.js} +1 -1
- package/web/dist/assets/{zh-Cbq-3ikH.js → zh-DHjjJx8Z.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +22 -22
|
@@ -0,0 +1,528 @@
|
|
|
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 type { IncomingMessage } from "node:http";
|
|
25
|
+
import { dirname, extname, join } from "node:path";
|
|
26
|
+
import { Readable, Transform } from "node:stream";
|
|
27
|
+
import { pipeline } from "node:stream/promises";
|
|
28
|
+
import { askJson, hasModel } from "./ai-text.ts";
|
|
29
|
+
import { genreBuckets } from "./auto-stations.ts";
|
|
30
|
+
import type { LibraryIndex, Probed } from "./library-index.ts";
|
|
31
|
+
import { isAudio } from "./playlist.ts";
|
|
32
|
+
import type { StationRunner } from "./station-runner.ts";
|
|
33
|
+
import { StationError, type StationStore } from "./stations.ts";
|
|
34
|
+
|
|
35
|
+
export type ShareState = "fetching" | "checking" | "held" | "approved" | "rejected" | "failed" | "reported" | "withdrawn";
|
|
36
|
+
export type ShareBasis = "owned" | "permission" | "licensed" | "creative-commons" | "public-domain";
|
|
37
|
+
export const SHARE_BASES: readonly ShareBasis[] = ["owned", "permission", "licensed", "creative-commons", "public-domain"];
|
|
38
|
+
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"];
|
|
39
|
+
|
|
40
|
+
export interface Share {
|
|
41
|
+
id: string;
|
|
42
|
+
/** Lets the sharer see how it went, and withdraw it. Never shown to anybody else. */
|
|
43
|
+
token: string;
|
|
44
|
+
via: "url" | "upload";
|
|
45
|
+
/** Where it came from, for the owner. */
|
|
46
|
+
url: string;
|
|
47
|
+
name: string;
|
|
48
|
+
file: string;
|
|
49
|
+
bytes: number;
|
|
50
|
+
sha256: string;
|
|
51
|
+
state: ShareState;
|
|
52
|
+
/** Why it is held, refused, failed or reported, in words. */
|
|
53
|
+
reason: string;
|
|
54
|
+
title: string;
|
|
55
|
+
artist: string;
|
|
56
|
+
genre: string;
|
|
57
|
+
duration: number;
|
|
58
|
+
rights: { basis: ShareBasis; note: string };
|
|
59
|
+
/** A name the sharer chose to be credited with, or "". */
|
|
60
|
+
by: string;
|
|
61
|
+
/** Who shared it, as a salted hash: enough to count, not to find them. */
|
|
62
|
+
from: string;
|
|
63
|
+
check: { model: string; verdict: "clear" | "doubt" | "refuse" | ""; reason: string };
|
|
64
|
+
reports: Array<{ at: number; reason: string; from: string }>;
|
|
65
|
+
createdAt: number;
|
|
66
|
+
updatedAt: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface ShareSettings {
|
|
70
|
+
/** Whether anybody may share at all. */
|
|
71
|
+
open: boolean;
|
|
72
|
+
/** "ai": a clear verdict goes on the air; "owner": everything waits. */
|
|
73
|
+
moderation: "ai" | "owner";
|
|
74
|
+
/** Shared stations are public and on the guide. */
|
|
75
|
+
publish: boolean;
|
|
76
|
+
maxBytes: number;
|
|
77
|
+
/** Shares an hour from one sharer. */
|
|
78
|
+
perHour: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const SHARE_DEFAULTS: ShareSettings = { open: false, moderation: "ai", publish: true, maxBytes: 60 * 1024 * 1024, perHour: 6 };
|
|
82
|
+
const MAX_REDIRECTS = 3;
|
|
83
|
+
const MIN_SECONDS = 20;
|
|
84
|
+
const MAX_SECONDS = 20 * 60;
|
|
85
|
+
const MAX_HELD = 500;
|
|
86
|
+
|
|
87
|
+
export interface SharesDeps {
|
|
88
|
+
index: LibraryIndex;
|
|
89
|
+
store: StationStore;
|
|
90
|
+
runner: StationRunner;
|
|
91
|
+
probe: (path: string) => Promise<Probed>;
|
|
92
|
+
/** "" when this address may be fetched, else why not (destinations.ts). */
|
|
93
|
+
guard: (url: string) => Promise<string>;
|
|
94
|
+
/** The folder shared files go in: a managed library source. */
|
|
95
|
+
root: string;
|
|
96
|
+
owner: string;
|
|
97
|
+
mayStart: () => boolean;
|
|
98
|
+
/** The file a station is playing now, "" when none. */
|
|
99
|
+
nowPlaying: (stationId: string) => string;
|
|
100
|
+
fetcher?: typeof fetch;
|
|
101
|
+
ask?: typeof askJson;
|
|
102
|
+
canAsk?: () => boolean;
|
|
103
|
+
now?: () => number;
|
|
104
|
+
log?: (line: string) => void;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function clean(value: unknown, max: number): string {
|
|
108
|
+
return typeof value === "string" ? value.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, max) : "";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** What a sharer may see of their share. */
|
|
112
|
+
export function sharerView(share: Share): Record<string, unknown> {
|
|
113
|
+
return {
|
|
114
|
+
id: share.id, state: share.state, reason: share.reason, title: share.title, artist: share.artist,
|
|
115
|
+
genre: share.genre, duration: share.duration, createdAt: share.createdAt, updatedAt: share.updatedAt,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** What the owner sees: all of it but the sharer's token. */
|
|
120
|
+
export function ownerShareView(share: Share): Record<string, unknown> {
|
|
121
|
+
const { token: _token, ...rest } = share;
|
|
122
|
+
return rest;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const SYSTEM = [
|
|
126
|
+
"You screen tracks that members of the public share to a community internet radio station, and file each under a genre.",
|
|
127
|
+
"You see only what the file says about itself, its name, where it came from and what the sharer declared.",
|
|
128
|
+
"verdict \"refuse\": not music (speech, silence, a test tone), spam or advertising, or titles that are hateful or sexual.",
|
|
129
|
+
"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).",
|
|
130
|
+
"verdict \"clear\": plausibly the sharer's own or independent work, or Creative Commons or public domain music consistent with the declaration.",
|
|
131
|
+
`genre: exactly one of ${SHARE_GENRES.join(", ")}.`,
|
|
132
|
+
"Answer as JSON: {\"verdict\": \"clear|doubt|refuse\", \"reason\": \"one short sentence\", \"genre\": \"...\", \"title\": \"tidy title or empty\", \"artist\": \"tidy artist or empty\"}.",
|
|
133
|
+
].join(" ");
|
|
134
|
+
|
|
135
|
+
export class ListenerShares {
|
|
136
|
+
private shares = new Map<string, Share>();
|
|
137
|
+
private settings: ShareSettings = { ...SHARE_DEFAULTS };
|
|
138
|
+
private salt = randomBytes(16).toString("hex");
|
|
139
|
+
private sourceId = "";
|
|
140
|
+
private recent = new Map<string, number[]>();
|
|
141
|
+
private syncing: Promise<void> | null = null;
|
|
142
|
+
|
|
143
|
+
constructor(private readonly file: string, private readonly deps: SharesDeps) {
|
|
144
|
+
try {
|
|
145
|
+
const parsed = JSON.parse(readFileSync(file, "utf8")) as { shares?: Share[]; settings?: Partial<ShareSettings>; salt?: string };
|
|
146
|
+
for (const one of parsed.shares ?? []) if (one?.id) this.shares.set(one.id, one);
|
|
147
|
+
this.settings = { ...this.settings, ...(parsed.settings ?? {}) };
|
|
148
|
+
if (typeof parsed.salt === "string" && parsed.salt) this.salt = parsed.salt;
|
|
149
|
+
} catch {
|
|
150
|
+
// Nothing yet.
|
|
151
|
+
}
|
|
152
|
+
// A share that was being fetched or checked when the server stopped is
|
|
153
|
+
// not coming back; say so rather than leave it spinning.
|
|
154
|
+
for (const one of this.shares.values()) {
|
|
155
|
+
if (one.state === "fetching" || one.state === "checking") {
|
|
156
|
+
one.state = "failed";
|
|
157
|
+
one.reason = "the server restarted while this was being read; share it again";
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
static fileFor(stateDir: string, port: number): string {
|
|
163
|
+
return join(stateDir, `shares-${port}.json`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private now(): number {
|
|
167
|
+
return this.deps.now?.() ?? Date.now();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private save(): void {
|
|
171
|
+
mkdirSync(dirname(this.file), { recursive: true });
|
|
172
|
+
const temporary = `${this.file}.${process.pid}.tmp`;
|
|
173
|
+
writeFileSync(temporary, `${JSON.stringify({ version: 1, salt: this.salt, settings: this.settings, shares: [...this.shares.values()] }, null, 2)}\n`, { mode: 0o600 });
|
|
174
|
+
renameSync(temporary, this.file);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private source(): string {
|
|
178
|
+
if (this.sourceId && this.deps.index.source(this.sourceId)) return this.sourceId;
|
|
179
|
+
mkdirSync(this.deps.root, { recursive: true });
|
|
180
|
+
this.sourceId = this.deps.index.addManagedSource(this.deps.root).id;
|
|
181
|
+
return this.sourceId;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
setting(): ShareSettings & { model: boolean } {
|
|
185
|
+
return { ...this.settings, model: (this.deps.canAsk ?? hasModel)() };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
configure(input: Record<string, unknown>): ShareSettings {
|
|
189
|
+
if (input["open"] !== undefined) this.settings.open = input["open"] === true;
|
|
190
|
+
if (input["publish"] !== undefined) this.settings.publish = input["publish"] === true;
|
|
191
|
+
if (input["moderation"] !== undefined) {
|
|
192
|
+
if (input["moderation"] !== "ai" && input["moderation"] !== "owner") throw new StationError("invalid_moderation", "moderation is ai or owner");
|
|
193
|
+
this.settings.moderation = input["moderation"];
|
|
194
|
+
}
|
|
195
|
+
if (input["maxMb"] !== undefined) {
|
|
196
|
+
const mb = Number(input["maxMb"]);
|
|
197
|
+
if (!Number.isInteger(mb) || mb < 1 || mb > 500) throw new StationError("invalid_size", "maxMb is 1 to 500");
|
|
198
|
+
this.settings.maxBytes = mb * 1024 * 1024;
|
|
199
|
+
}
|
|
200
|
+
if (input["perHour"] !== undefined) {
|
|
201
|
+
const perHour = Number(input["perHour"]);
|
|
202
|
+
if (!Number.isInteger(perHour) || perHour < 1 || perHour > 1000) throw new StationError("invalid_rate", "perHour is 1 to 1000");
|
|
203
|
+
this.settings.perHour = perHour;
|
|
204
|
+
}
|
|
205
|
+
this.save();
|
|
206
|
+
void this.sync();
|
|
207
|
+
return this.settings;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
list(state?: string): Share[] {
|
|
211
|
+
return [...this.shares.values()].filter((one) => !state || one.state === state).sort((a, b) => b.createdAt - a.createdAt);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
get(id: string): Share | undefined {
|
|
215
|
+
return this.shares.get(id);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The share, if this token is the one its sharer was given. */
|
|
219
|
+
forSharer(id: string, token: string): Share | undefined {
|
|
220
|
+
const share = this.shares.get(id);
|
|
221
|
+
if (!share || token.length !== share.token.length) return undefined;
|
|
222
|
+
return createHash("sha256").update(token).digest("hex") === createHash("sha256").update(share.token).digest("hex") ? share : undefined;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private fromOf(caller: string): string {
|
|
226
|
+
return createHash("sha256").update(`${this.salt}:${caller}`).digest("hex").slice(0, 16);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Throws when shares are closed or this sharer has shared enough for the hour. */
|
|
230
|
+
private admit(caller: string): string {
|
|
231
|
+
if (!this.settings.open) throw new StationError("shares_closed", "this server is not taking shared tracks", 403);
|
|
232
|
+
if (this.list("held").length >= MAX_HELD) throw new StationError("queue_full", "too many tracks are waiting to be checked; try again later", 503);
|
|
233
|
+
const from = this.fromOf(caller);
|
|
234
|
+
const at = this.now();
|
|
235
|
+
const times = (this.recent.get(from) ?? []).filter((one) => one > at - 3600_000);
|
|
236
|
+
if (times.length >= this.settings.perHour) throw new StationError("rate_limited", `at most ${this.settings.perHour} shares an hour`, 429);
|
|
237
|
+
times.push(at);
|
|
238
|
+
this.recent.set(from, times);
|
|
239
|
+
return from;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private declared(input: Record<string, unknown>): { basis: ShareBasis; note: string; by: string } {
|
|
243
|
+
const basis = input["basis"];
|
|
244
|
+
if (typeof basis !== "string" || !SHARE_BASES.includes(basis as ShareBasis)) {
|
|
245
|
+
throw new StationError("rights_required", `say on what basis it may be broadcast: ${SHARE_BASES.join(", ")}`);
|
|
246
|
+
}
|
|
247
|
+
if (input["agree"] !== true && input["agree"] !== "1" && input["agree"] !== "true") {
|
|
248
|
+
throw new StationError("agreement_required", "confirm you have the right to share this for broadcast");
|
|
249
|
+
}
|
|
250
|
+
return { basis: basis as ShareBasis, note: clean(input["note"], 300), by: clean(input["by"], 60) };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private blank(via: "url" | "upload", url: string, name: string, rights: { basis: ShareBasis; note: string; by: string }, from: string): Share {
|
|
254
|
+
const id = randomBytes(6).toString("hex");
|
|
255
|
+
const ext = extname(name).toLowerCase();
|
|
256
|
+
const safe = name.split(/[\\/]/).pop()!.replace(/[^\w .()-]/g, "_").replace(/^\.+/, "").slice(0, 120) || "track";
|
|
257
|
+
return {
|
|
258
|
+
id, token: randomBytes(16).toString("base64url"), via, url, name: safe,
|
|
259
|
+
file: join(this.deps.root, id, isAudio(`x${ext}`) ? safe : `${safe}.mp3`),
|
|
260
|
+
bytes: 0, sha256: "", state: "fetching", reason: "", title: "", artist: "", genre: "", duration: 0,
|
|
261
|
+
rights: { basis: rights.basis, note: rights.note }, by: rights.by, from,
|
|
262
|
+
check: { model: "", verdict: "", reason: "" }, reports: [], createdAt: this.now(), updatedAt: this.now(),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** A link to a music file. Answers at once; the fetch and the check carry on. */
|
|
267
|
+
fromUrl(input: Record<string, unknown>, caller: string): Share {
|
|
268
|
+
const rights = this.declared(input);
|
|
269
|
+
const link = clean(input["url"], 2000);
|
|
270
|
+
let parsed: URL;
|
|
271
|
+
try {
|
|
272
|
+
parsed = new URL(link);
|
|
273
|
+
} catch {
|
|
274
|
+
throw new StationError("invalid_url", "paste a link to a music file (https://.../song.mp3)");
|
|
275
|
+
}
|
|
276
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") throw new StationError("invalid_url", "links are http or https");
|
|
277
|
+
if ([...this.shares.values()].some((one) => one.url === link && one.state !== "failed" && one.state !== "withdrawn")) {
|
|
278
|
+
throw new StationError("already_shared", "that link has been shared already", 409);
|
|
279
|
+
}
|
|
280
|
+
const from = this.admit(caller);
|
|
281
|
+
const share = this.blank("url", link, decodeURIComponent(parsed.pathname.split("/").pop() || "track.mp3"), rights, from);
|
|
282
|
+
this.shares.set(share.id, share);
|
|
283
|
+
this.save();
|
|
284
|
+
void this.fetchAndCheck(share);
|
|
285
|
+
return share;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** The file itself, as the request body. Resolves once it is all here; the check carries on. */
|
|
289
|
+
async fromUpload(request: IncomingMessage, input: Record<string, unknown>, caller: string): Promise<Share> {
|
|
290
|
+
const rights = this.declared(input);
|
|
291
|
+
const name = clean(input["name"], 200) || "upload.mp3";
|
|
292
|
+
const type = String(request.headers["content-type"] ?? "");
|
|
293
|
+
if (!type.startsWith("audio/") && !isAudio(name)) throw new StationError("not_audio", "share a music file (MP3, OGG, FLAC, M4A, WAV)");
|
|
294
|
+
const declaredLength = Number(request.headers["content-length"] ?? "0");
|
|
295
|
+
if (declaredLength > this.settings.maxBytes) throw new StationError("too_big", `files up to ${Math.round(this.settings.maxBytes / 1048576)} MB`, 413);
|
|
296
|
+
const from = this.admit(caller);
|
|
297
|
+
const share = this.blank("upload", "", name, rights, from);
|
|
298
|
+
this.shares.set(share.id, share);
|
|
299
|
+
try {
|
|
300
|
+
await this.write(share, request);
|
|
301
|
+
} catch (error) {
|
|
302
|
+
this.fail(share, (error as Error).message);
|
|
303
|
+
throw error instanceof StationError ? error : new StationError("upload_failed", share.reason);
|
|
304
|
+
}
|
|
305
|
+
void this.check(share);
|
|
306
|
+
return share;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** The sharer takes it back: off the air and its copy gone. */
|
|
310
|
+
withdraw(share: Share): Share {
|
|
311
|
+
share.state = "withdrawn";
|
|
312
|
+
share.reason = "withdrawn by the person who shared it";
|
|
313
|
+
rmSync(dirname(share.file), { recursive: true, force: true });
|
|
314
|
+
share.updatedAt = this.now();
|
|
315
|
+
this.save();
|
|
316
|
+
void this.sync();
|
|
317
|
+
return share;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** A listener says what is playing should not be: off the air at once, for the owner to look at. */
|
|
321
|
+
report(input: { station?: unknown; share?: unknown; reason?: unknown }, caller: string): { reported: boolean; title: string } {
|
|
322
|
+
let share: Share | undefined;
|
|
323
|
+
if (typeof input.share === "string") share = this.shares.get(input.share);
|
|
324
|
+
else if (typeof input.station === "string") {
|
|
325
|
+
const file = this.deps.nowPlaying(input.station);
|
|
326
|
+
share = [...this.shares.values()].find((one) => one.file === file);
|
|
327
|
+
}
|
|
328
|
+
if (!share || share.state !== "approved") return { reported: false, title: "" };
|
|
329
|
+
share.reports.push({ at: this.now(), reason: clean(input.reason, 300), from: this.fromOf(caller) });
|
|
330
|
+
share.state = "reported";
|
|
331
|
+
share.reason = `reported by a listener${share.reports.at(-1)!.reason ? `: ${share.reports.at(-1)!.reason}` : ""}`;
|
|
332
|
+
share.updatedAt = this.now();
|
|
333
|
+
this.save();
|
|
334
|
+
this.deps.log?.(` shares: "${share.title || share.name}" was reported and is off the air until the owner looks at it`);
|
|
335
|
+
// Off the air now, not when the song ends: the station moves past it.
|
|
336
|
+
const playingOn = typeof input.station === "string" ? input.station : "";
|
|
337
|
+
void this.sync().then(() => { if (playingOn) this.deps.runner.skip(playingOn); });
|
|
338
|
+
return { reported: true, title: share.title || share.name };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** The owner's decision. */
|
|
342
|
+
decide(id: string, decision: "approve" | "reject" | "delete", reason = ""): Share | null {
|
|
343
|
+
const share = this.shares.get(id);
|
|
344
|
+
if (!share) throw new StationError("not_found", "no such share", 404);
|
|
345
|
+
if (decision === "delete") {
|
|
346
|
+
rmSync(dirname(share.file), { recursive: true, force: true });
|
|
347
|
+
this.shares.delete(id);
|
|
348
|
+
} else if (decision === "approve") {
|
|
349
|
+
if (!["held", "rejected", "reported"].includes(share.state)) throw new StationError("not_waiting", `it is ${share.state}`, 409);
|
|
350
|
+
share.state = "approved";
|
|
351
|
+
share.reason = reason || "approved by the owner";
|
|
352
|
+
} else {
|
|
353
|
+
share.state = "rejected";
|
|
354
|
+
share.reason = reason || "not accepted by the owner";
|
|
355
|
+
}
|
|
356
|
+
if (share) share.updatedAt = this.now();
|
|
357
|
+
this.save();
|
|
358
|
+
void this.sync();
|
|
359
|
+
return decision === "delete" ? null : share;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
private fail(share: Share, reason: string): void {
|
|
363
|
+
share.state = "failed";
|
|
364
|
+
share.reason = reason;
|
|
365
|
+
share.updatedAt = this.now();
|
|
366
|
+
rmSync(dirname(share.file), { recursive: true, force: true });
|
|
367
|
+
this.save();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Bytes to the share's file, counted, capped and hashed. */
|
|
371
|
+
private async write(share: Share, body: NodeJS.ReadableStream): Promise<void> {
|
|
372
|
+
mkdirSync(dirname(share.file), { recursive: true });
|
|
373
|
+
const part = `${share.file}.part`;
|
|
374
|
+
const hash = createHash("sha256");
|
|
375
|
+
let bytes = 0;
|
|
376
|
+
const max = this.settings.maxBytes;
|
|
377
|
+
const counter = new Transform({
|
|
378
|
+
transform(chunk: Buffer, _encoding, done) {
|
|
379
|
+
bytes += chunk.length;
|
|
380
|
+
if (bytes > max) return done(new StationError("too_big", `files up to ${Math.round(max / 1048576)} MB`, 413));
|
|
381
|
+
hash.update(chunk);
|
|
382
|
+
done(null, chunk);
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
try {
|
|
386
|
+
await pipeline(body, counter, createWriteStream(part, { mode: 0o640 }));
|
|
387
|
+
} catch (error) {
|
|
388
|
+
rmSync(part, { force: true });
|
|
389
|
+
throw error;
|
|
390
|
+
}
|
|
391
|
+
const sha256 = hash.digest("hex");
|
|
392
|
+
if ([...this.shares.values()].some((one) => one.id !== share.id && one.sha256 === sha256 && one.state !== "withdrawn" && one.state !== "failed")) {
|
|
393
|
+
rmSync(part, { force: true });
|
|
394
|
+
throw new StationError("already_shared", "that track has been shared already", 409);
|
|
395
|
+
}
|
|
396
|
+
renameSync(part, share.file);
|
|
397
|
+
share.bytes = bytes;
|
|
398
|
+
share.sha256 = sha256;
|
|
399
|
+
share.state = "checking";
|
|
400
|
+
share.updatedAt = this.now();
|
|
401
|
+
this.save();
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
private async fetchAndCheck(share: Share): Promise<void> {
|
|
405
|
+
try {
|
|
406
|
+
const send = this.deps.fetcher ?? fetch;
|
|
407
|
+
let at = share.url;
|
|
408
|
+
let response: Response | null = null;
|
|
409
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
410
|
+
const refused = await this.deps.guard(at);
|
|
411
|
+
if (refused) throw new Error(refused);
|
|
412
|
+
response = await send(at, { redirect: "manual", signal: AbortSignal.timeout(5 * 60_000), headers: { "user-agent": "nixamp-shares" } });
|
|
413
|
+
const location = response.headers.get("location");
|
|
414
|
+
if (response.status >= 300 && response.status < 400 && location) {
|
|
415
|
+
at = new URL(location, at).toString();
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
420
|
+
if (!response || !response.ok || !response.body) throw new Error(`the link answered HTTP ${response?.status ?? "nothing"}`);
|
|
421
|
+
const type = (response.headers.get("content-type") ?? "").toLowerCase();
|
|
422
|
+
if (type.startsWith("text/html")) throw new Error("that link is a web page, not a music file; link the .mp3 itself");
|
|
423
|
+
const length = Number(response.headers.get("content-length") ?? "0");
|
|
424
|
+
if (length > this.settings.maxBytes) throw new Error(`the file is over ${Math.round(this.settings.maxBytes / 1048576)} MB`);
|
|
425
|
+
await this.write(share, Readable.fromWeb(response.body as import("node:stream/web").ReadableStream));
|
|
426
|
+
} catch (error) {
|
|
427
|
+
this.fail(share, (error as Error).message);
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
await this.check(share);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** ffprobe, then the model: on the air, waiting, or turned away. */
|
|
434
|
+
private async check(share: Share): Promise<void> {
|
|
435
|
+
try {
|
|
436
|
+
await this.deps.index.scan(this.source(), this.deps.probe).done;
|
|
437
|
+
const item = this.deps.index.itemAt(share.file);
|
|
438
|
+
if (!item || item.state !== "ready") throw new Error(item?.error || "there is no music in that file that a station can play");
|
|
439
|
+
if (item.duration > 0 && item.duration < MIN_SECONDS) throw new Error(`it is ${Math.round(item.duration)} s; a track is at least ${MIN_SECONDS} s`);
|
|
440
|
+
if (item.duration > MAX_SECONDS) throw new Error("it is over 20 minutes; share a track, not a mix or a show");
|
|
441
|
+
share.duration = Math.round(item.duration);
|
|
442
|
+
share.title = item.title || share.name.replace(/\.[a-z0-9]+$/i, "");
|
|
443
|
+
share.artist = item.artist;
|
|
444
|
+
share.genre = genreBuckets(item.genre)[0] ?? "";
|
|
445
|
+
let verdict: Share["check"]["verdict"] = "doubt";
|
|
446
|
+
let reason = "waiting for the owner (no AI check on this server)";
|
|
447
|
+
if ((this.deps.canAsk ?? hasModel)()) {
|
|
448
|
+
try {
|
|
449
|
+
const answer = await (this.deps.ask ?? askJson)({
|
|
450
|
+
system: SYSTEM,
|
|
451
|
+
prompt: JSON.stringify({
|
|
452
|
+
file: share.name, title: item.title, artist: item.artist, album: item.album, genreTag: item.genre, year: item.year,
|
|
453
|
+
seconds: share.duration, from: share.url ? new URL(share.url).host : "uploaded", declared: share.rights,
|
|
454
|
+
}),
|
|
455
|
+
});
|
|
456
|
+
const said = answer.value;
|
|
457
|
+
verdict = said["verdict"] === "clear" || said["verdict"] === "refuse" ? said["verdict"] : "doubt";
|
|
458
|
+
reason = clean(said["reason"], 300) || verdict;
|
|
459
|
+
const genre = SHARE_GENRES.find((one) => one.toLowerCase() === String(said["genre"] ?? "").toLowerCase());
|
|
460
|
+
if (!share.genre && genre) share.genre = genre;
|
|
461
|
+
if (!item.title && clean(said["title"], 200)) share.title = clean(said["title"], 200);
|
|
462
|
+
if (!item.artist && clean(said["artist"], 200)) share.artist = clean(said["artist"], 200);
|
|
463
|
+
share.check = { model: answer.model, verdict, reason };
|
|
464
|
+
} catch (error) {
|
|
465
|
+
reason = `waiting for the owner (the AI check failed: ${(error as Error).message})`;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
share.genre ||= "Mixed";
|
|
469
|
+
if (verdict === "refuse") {
|
|
470
|
+
share.state = "rejected";
|
|
471
|
+
share.reason = reason;
|
|
472
|
+
rmSync(dirname(share.file), { recursive: true, force: true });
|
|
473
|
+
} else if (verdict === "clear" && this.settings.moderation === "ai") {
|
|
474
|
+
share.state = "approved";
|
|
475
|
+
share.reason = "";
|
|
476
|
+
} else {
|
|
477
|
+
share.state = "held";
|
|
478
|
+
share.reason = verdict === "clear" ? "waiting for the owner" : reason;
|
|
479
|
+
}
|
|
480
|
+
share.updatedAt = this.now();
|
|
481
|
+
this.save();
|
|
482
|
+
this.deps.log?.(` shares: "${share.title}" (${share.genre}) is ${share.state}${share.reason ? `: ${share.reason}` : ""}`);
|
|
483
|
+
await this.sync();
|
|
484
|
+
} catch (error) {
|
|
485
|
+
this.fail(share, (error as Error).message);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Each genre's shared station, with exactly the approved tracks; one with none goes quiet. */
|
|
490
|
+
sync(): Promise<void> {
|
|
491
|
+
if (this.syncing) return this.syncing.then(() => this.sync());
|
|
492
|
+
this.syncing = (async () => {
|
|
493
|
+
const byGenre = new Map<string, string[]>();
|
|
494
|
+
for (const share of this.shares.values()) {
|
|
495
|
+
if (share.state !== "approved") continue;
|
|
496
|
+
byGenre.set(share.genre, [...(byGenre.get(share.genre) ?? []), share.file]);
|
|
497
|
+
}
|
|
498
|
+
const genres = new Set([...byGenre.keys(), ...this.deps.store.list().filter((one) => one.auto.startsWith("share:")).map((one) => one.auto.slice(6))]);
|
|
499
|
+
for (const genre of genres) {
|
|
500
|
+
const files = (byGenre.get(genre) ?? []).filter((file) => this.deps.index.itemAt(file)?.state === "ready");
|
|
501
|
+
const existing = this.deps.store.byAuto(`share:${genre}`);
|
|
502
|
+
if (files.length === 0) {
|
|
503
|
+
if (existing && existing.desired === "running") this.deps.runner.stop(existing.id);
|
|
504
|
+
if (existing && existing.listed) this.deps.store.setAccess(existing.id, { listed: false }, this.deps.owner || "shares");
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
let station = existing;
|
|
508
|
+
if (!station) {
|
|
509
|
+
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);
|
|
510
|
+
this.deps.store.markAuto(station.id, `share:${genre}`);
|
|
511
|
+
} else {
|
|
512
|
+
this.deps.store.update(station.id, {}, files);
|
|
513
|
+
}
|
|
514
|
+
if (this.settings.publish) {
|
|
515
|
+
this.deps.store.setAccess(station.id, {
|
|
516
|
+
visibility: "public",
|
|
517
|
+
listed: true,
|
|
518
|
+
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." },
|
|
519
|
+
}, this.deps.owner || "shares");
|
|
520
|
+
} else if (station.visibility === "public") {
|
|
521
|
+
this.deps.store.setAccess(station.id, { visibility: "unlisted" }, this.deps.owner || "shares");
|
|
522
|
+
}
|
|
523
|
+
if (station.desired !== "running" && this.deps.mayStart()) await this.deps.runner.start(station.id).catch(() => undefined);
|
|
524
|
+
}
|
|
525
|
+
})().finally(() => { this.syncing = null; });
|
|
526
|
+
return this.syncing;
|
|
527
|
+
}
|
|
528
|
+
}
|
package/src/playlist-input.ts
CHANGED
|
@@ -39,7 +39,7 @@ export interface PlaylistInput {
|
|
|
39
39
|
* `next` answers a file to play (an advert, an ident) or null for no break
|
|
40
40
|
* this time. The break is outside the rotation and the station's history.
|
|
41
41
|
*/
|
|
42
|
-
breaks?: { everySeconds: number; next: () => Promise<string | null> };
|
|
42
|
+
breaks?: { everySeconds: number | (() => number); next: () => Promise<string | null> };
|
|
43
43
|
/** Told when an entry is passed over because it would not play. */
|
|
44
44
|
onFailure?: (event: { at: number; file: string; reason: string }) => void;
|
|
45
45
|
}
|
|
@@ -138,7 +138,8 @@ export class PlaylistInputStream {
|
|
|
138
138
|
// A break, when one is due: in place of the next entry, which stays
|
|
139
139
|
// next. Not at the very start, and never inside an entry.
|
|
140
140
|
let breakFile: string | null = null;
|
|
141
|
-
|
|
141
|
+
const every = typeof o.breaks?.everySeconds === "function" ? o.breaks.everySeconds() : (o.breaks?.everySeconds ?? 0);
|
|
142
|
+
if (o.breaks && every > 0 && !first && offset - lastBreak >= every) {
|
|
142
143
|
lastBreak = offset;
|
|
143
144
|
breakFile = await o.breaks.next().catch(() => null);
|
|
144
145
|
if (signal.aborted) return;
|
package/src/server.ts
CHANGED
|
@@ -77,6 +77,9 @@ import { LibraryIndex, probeItem, trackOf, type Probed } from "./library-index.t
|
|
|
77
77
|
import { ListenerStore } from "./library-user.ts";
|
|
78
78
|
import { Importer } from "./handoff.ts";
|
|
79
79
|
import { handleHandoffs } from "./handoff-routes.ts";
|
|
80
|
+
import { handleAiRoutes, isPublicSharePath } from "./ai-routes.ts";
|
|
81
|
+
import { AiRadio } from "./ai-radio.ts";
|
|
82
|
+
import { ListenerShares } from "./listener-shares.ts";
|
|
80
83
|
import { STATION_PAGE, STATION_PAGE_CSP, STATION_PAGE_HTML } from "./station-page.ts";
|
|
81
84
|
import { GUIDE_CSP, guideHosts, renderGuide } from "./station-guide.ts";
|
|
82
85
|
import { StationRunner } from "./station-runner.ts";
|
|
@@ -1728,6 +1731,12 @@ export interface HandlerOptions {
|
|
|
1728
1731
|
auto?: { sync: (input: Record<string, unknown>) => Promise<unknown>; setting: () => unknown };
|
|
1729
1732
|
/** This server's public https address, once it is known. */
|
|
1730
1733
|
publicBase?: () => string;
|
|
1734
|
+
/** Stations whose music is composed by AI. */
|
|
1735
|
+
ai?: AiRadio;
|
|
1736
|
+
/** Tracks anybody may share, sorted onto the air by AI. */
|
|
1737
|
+
shares?: ListenerShares;
|
|
1738
|
+
/** Proxies (by address) whose X-Forwarded-For is believed, for per-sharer limits. */
|
|
1739
|
+
trustedProxies?: () => Set<string>;
|
|
1731
1740
|
};
|
|
1732
1741
|
web: string | null;
|
|
1733
1742
|
/** Branded clients on this same backend, keyed by an explicitly configured host. */
|
|
@@ -2667,6 +2676,8 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2667
2676
|
!isSignInPath(path) &&
|
|
2668
2677
|
// A station's link is its own key, checked by the station.
|
|
2669
2678
|
!isStationListenPath(path) &&
|
|
2679
|
+
// Sharing a track and reporting one are for anybody the guide reaches.
|
|
2680
|
+
!isPublicSharePath(path) &&
|
|
2670
2681
|
!STATION_PAGE.test(path) &&
|
|
2671
2682
|
!isGuidePage &&
|
|
2672
2683
|
!eventChannelRequest
|
|
@@ -2799,6 +2810,29 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2799
2810
|
return;
|
|
2800
2811
|
}
|
|
2801
2812
|
|
|
2813
|
+
if (options.stations && (isPublicSharePath(path) || path.startsWith("/api/v1/stations/ai") || path.startsWith("/api/v1/stations/shares"))) {
|
|
2814
|
+
const control = await controlOf();
|
|
2815
|
+
// Behind a proxy we trust (dev2 for live.streaming), the sharer is the
|
|
2816
|
+
// first address it names; otherwise whoever connected.
|
|
2817
|
+
const socket = (request.socket.remoteAddress ?? "").replace(/^::ffff:/, "");
|
|
2818
|
+
const trusted = behindProxy || (options.stations.trustedProxies?.().has(socket) ?? false);
|
|
2819
|
+
const caller = callerOf(request.headers, socket, trusted);
|
|
2820
|
+
const handled = await handleAiRoutes(request, response, path, url, {
|
|
2821
|
+
...(options.stations.ai ? { ai: options.stations.ai } : {}),
|
|
2822
|
+
...(options.stations.shares ? { shares: options.stations.shares } : {}),
|
|
2823
|
+
canControl: control.allowed,
|
|
2824
|
+
caller,
|
|
2825
|
+
json,
|
|
2826
|
+
readBody,
|
|
2827
|
+
headers: CORS,
|
|
2828
|
+
throttle: () => {
|
|
2829
|
+
const verdict = guard.check(`shares:${caller}`, STATION_CONTROL_LIMIT);
|
|
2830
|
+
return verdict.ok ? null : { retryAfter: verdict.retryAfter };
|
|
2831
|
+
},
|
|
2832
|
+
});
|
|
2833
|
+
if (handled) return;
|
|
2834
|
+
}
|
|
2835
|
+
|
|
2802
2836
|
if (options.stations && (path === "/api/v1/stations" || path.startsWith("/api/v1/stations/"))) {
|
|
2803
2837
|
const control = await controlOf();
|
|
2804
2838
|
const encrypted = (request.socket as { encrypted?: boolean }).encrypted === true;
|
|
@@ -6796,8 +6830,9 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
6796
6830
|
return { started: true, settings };
|
|
6797
6831
|
},
|
|
6798
6832
|
};
|
|
6799
|
-
const stations = {
|
|
6833
|
+
const stations: NonNullable<HandlerOptions["stations"]> = {
|
|
6800
6834
|
store: stationStore, runner: stationRunner, usage: stationUsage, limits: stationLimits, auto: stationAuto,
|
|
6835
|
+
trustedProxies: () => new Set([...(process.env["NIXAMP_TRUSTED_PROXIES"] ?? "").split(","), ...(readConfig().trustedProxies ?? [])].map((one) => one.trim()).filter(Boolean)),
|
|
6801
6836
|
// Known once the server has worked out its addresses, after it is listening.
|
|
6802
6837
|
publicBase: () => (publishable_?.url ?? "").replace(/\/+$/, ""),
|
|
6803
6838
|
};
|
|
@@ -6811,6 +6846,39 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
6811
6846
|
log: (line) => console.log(line),
|
|
6812
6847
|
});
|
|
6813
6848
|
importer.resume();
|
|
6849
|
+
const mayStartStation = (): boolean =>
|
|
6850
|
+
stationLimits.runningStations === 0 || stationStore.list().filter((one) => one.desired === "running").length < stationLimits.runningStations;
|
|
6851
|
+
// AI radio: music composed for its stations, a few tracks a day, in a folder of our own.
|
|
6852
|
+
const aiRadio = new AiRadio(AiRadio.fileFor(stateDir(), options.port), {
|
|
6853
|
+
index: libraryIndex,
|
|
6854
|
+
store: stationStore,
|
|
6855
|
+
runner: stationRunner,
|
|
6856
|
+
probe: probeFile,
|
|
6857
|
+
ffmpeg: tools.ffmpeg,
|
|
6858
|
+
root: join(stateDir(), "ai-music"),
|
|
6859
|
+
owner: options.owner || "",
|
|
6860
|
+
mayStart: mayStartStation,
|
|
6861
|
+
log: (line) => console.log(line),
|
|
6862
|
+
});
|
|
6863
|
+
aiRadio.startSchedule();
|
|
6864
|
+
// Tracks anybody shares from the guide, checked by AI before they air.
|
|
6865
|
+
const listenerShares = new ListenerShares(ListenerShares.fileFor(stateDir(), options.port), {
|
|
6866
|
+
index: libraryIndex,
|
|
6867
|
+
store: stationStore,
|
|
6868
|
+
runner: stationRunner,
|
|
6869
|
+
probe: probeFile,
|
|
6870
|
+
guard: (url) => refusedDestination(url),
|
|
6871
|
+
root: join(stateDir(), "shares"),
|
|
6872
|
+
owner: options.owner || "",
|
|
6873
|
+
mayStart: mayStartStation,
|
|
6874
|
+
nowPlaying: (id) => {
|
|
6875
|
+
const info = channels.info(channelOf(id));
|
|
6876
|
+
return info?.playlist?.[info.playlistAt ?? 0] ?? "";
|
|
6877
|
+
},
|
|
6878
|
+
log: (line) => console.log(line),
|
|
6879
|
+
});
|
|
6880
|
+
stations.ai = aiRadio;
|
|
6881
|
+
stations.shares = listenerShares;
|
|
6814
6882
|
// Channels as HLS, on demand, for Safari on a phone: one ffmpeg copying a
|
|
6815
6883
|
// channel's fragments into short files while somebody is asking for them.
|
|
6816
6884
|
// What things are, from nichedb.dev, remembered beside the keys so a
|