nixamp 0.36.4 → 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.
- 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/server.d.ts +8 -0
- package/dist/server.js +62 -1
- package/dist/station-cli.js +166 -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-usage.d.ts +1 -1
- package/dist/station-usage.js +19 -0
- 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/server.ts +69 -1
- package/src/station-cli.ts +153 -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-usage.ts +19 -0
- package/web/dist/assets/{ar-B9jc6q-w.js → ar-B2m5eyoV.js} +1 -1
- package/web/dist/assets/{cs-lU25OfSM.js → cs-Cc5pOkWw.js} +1 -1
- package/web/dist/assets/{da-CAgdgQoL.js → da-10XVgmQy.js} +1 -1
- package/web/dist/assets/{de-CiusGizF.js → de-DIBX68fo.js} +1 -1
- package/web/dist/assets/{es-DVxl-KYi.js → es-B5wXCbD_.js} +1 -1
- package/web/dist/assets/{fi-C1-78Aib.js → fi-CfqtJU25.js} +1 -1
- package/web/dist/assets/{fr-BcAXoFCf.js → fr-DVpDbDWM.js} +1 -1
- package/web/dist/assets/{hi-DXdG5DSQ.js → hi-iQgB7g2O.js} +1 -1
- package/web/dist/assets/{hls-3VKVEQE3-C3dxJvEy.js → hls-3VKVEQE3-Hp3aQLSm.js} +1 -1
- package/web/dist/assets/{hu-qrAMY3zu.js → hu-cL8uAJW0.js} +1 -1
- package/web/dist/assets/{id-Cgtr-WML.js → id-DAEQSrx8.js} +1 -1
- package/web/dist/assets/{it-kuAXXts8.js → it-DARHJ2-F.js} +1 -1
- package/web/dist/assets/{main-C5IsPzDk.js → main-Bdbkg8vi.js} +2 -2
- package/web/dist/assets/{mpegts-LO6RVLD6-CYjdm0vd.js → mpegts-LO6RVLD6-Be6JzNGd.js} +1 -1
- package/web/dist/assets/{mpegts-BWzEpcbk.js → mpegts-vNdAhc1w.js} +1 -1
- package/web/dist/assets/{nl-CEXpOYtY.js → nl-BNskTvYD.js} +1 -1
- package/web/dist/assets/{ru-v7b_XixQ.js → ru-PA8keJv_.js} +1 -1
- package/web/dist/assets/{sv-CJK5wtih.js → sv-sZu9UVEg.js} +1 -1
- package/web/dist/assets/{uk-qDSjg5zB.js → uk-FmHT0lvK.js} +1 -1
- package/web/dist/assets/{vi-kckrzXyJ.js → vi-BV81HbEM.js} +1 -1
- package/web/dist/assets/{zh-Cbq-3ikH.js → zh-CwzApCu7.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +22 -22
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { AI_KEY_NAMES, aiKeysSet, saveAiKey } from "./ai-keys.js";
|
|
2
|
+
import { SHARE_BASES, SHARE_GENRES, ownerShareView, sharerView } from "./listener-shares.js";
|
|
3
|
+
import { StationError } from "./stations.js";
|
|
4
|
+
const PUBLIC = /^\/api\/v1\/share(?:\/(upload|report|[a-f0-9]{12}))?$/;
|
|
5
|
+
/** Whether this path is one anybody may use without the server's key. */
|
|
6
|
+
export function isPublicSharePath(path) {
|
|
7
|
+
return PUBLIC.test(path);
|
|
8
|
+
}
|
|
9
|
+
async function jsonBody(ctx, request) {
|
|
10
|
+
const raw = await ctx.readBody(request);
|
|
11
|
+
if (raw.trim() === "")
|
|
12
|
+
return {};
|
|
13
|
+
let parsed;
|
|
14
|
+
try {
|
|
15
|
+
parsed = JSON.parse(raw);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
throw new StationError("bad_json", "the body is not JSON");
|
|
19
|
+
}
|
|
20
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
21
|
+
throw new StationError("bad_json", "the body is not a JSON object");
|
|
22
|
+
return parsed;
|
|
23
|
+
}
|
|
24
|
+
/** Answers the paths above; false when the path is not one of them. */
|
|
25
|
+
export async function handleAiRoutes(request, response, path, url, ctx) {
|
|
26
|
+
const method = request.method ?? "GET";
|
|
27
|
+
const publicPath = PUBLIC.exec(path);
|
|
28
|
+
const ownerPath = path === "/api/v1/stations/ai" || path.startsWith("/api/v1/stations/ai/") || path === "/api/v1/stations/shares" || path.startsWith("/api/v1/stations/shares/");
|
|
29
|
+
if (!publicPath && !ownerPath)
|
|
30
|
+
return false;
|
|
31
|
+
try {
|
|
32
|
+
if (publicPath) {
|
|
33
|
+
const shares = ctx.shares;
|
|
34
|
+
if (!shares) {
|
|
35
|
+
ctx.json(response, 404, { error: "this server does not take shared tracks", code: "not_found" });
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
const what = publicPath[1];
|
|
39
|
+
if (what === undefined && method === "GET") {
|
|
40
|
+
const setting = shares.setting();
|
|
41
|
+
ctx.json(response, 200, { open: setting.open, bases: SHARE_BASES, genres: SHARE_GENRES, maxMb: Math.round(setting.maxBytes / 1048576), perHour: setting.perHour });
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
if (method !== "GET" && method !== "DELETE" && ctx.throttle) {
|
|
45
|
+
const wait = ctx.throttle();
|
|
46
|
+
if (wait) {
|
|
47
|
+
response.setHeader("retry-after", String(wait.retryAfter));
|
|
48
|
+
ctx.json(response, 429, { error: "too many at once; wait a moment", code: "rate_limited", retryAfter: wait.retryAfter });
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (what === undefined && method === "POST") {
|
|
53
|
+
const share = shares.fromUrl(await jsonBody(ctx, request), ctx.caller);
|
|
54
|
+
ctx.json(response, 202, { share: sharerView(share), token: share.token, status: `/api/v1/share/${share.id}?t=${share.token}` });
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
if (what === "upload" && (method === "PUT" || method === "POST")) {
|
|
58
|
+
const input = Object.fromEntries(url.searchParams.entries());
|
|
59
|
+
const share = await shares.fromUpload(request, input, ctx.caller);
|
|
60
|
+
ctx.json(response, 202, { share: sharerView(share), token: share.token, status: `/api/v1/share/${share.id}?t=${share.token}` });
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
if (what === "report" && method === "POST") {
|
|
64
|
+
const input = await jsonBody(ctx, request);
|
|
65
|
+
ctx.json(response, 200, shares.report(input, ctx.caller));
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
if (what && /^[a-f0-9]{12}$/.test(what) && (method === "GET" || method === "DELETE")) {
|
|
69
|
+
const share = shares.forSharer(what, url.searchParams.get("t") ?? "");
|
|
70
|
+
if (!share) {
|
|
71
|
+
ctx.json(response, 404, { error: "no such share, or not with that link", code: "not_found" });
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
ctx.json(response, 200, { share: sharerView(method === "DELETE" ? shares.withdraw(share) : share) });
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
ctx.json(response, 405, { error: "not like that", code: "method_not_allowed" });
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
if (!ctx.canControl) {
|
|
81
|
+
ctx.json(response, 403, { error: "only this server's owner manages AI radio and shares", code: "forbidden" });
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
if (path === "/api/v1/stations/shares" || path.startsWith("/api/v1/stations/shares/")) {
|
|
85
|
+
const shares = ctx.shares;
|
|
86
|
+
if (!shares) {
|
|
87
|
+
ctx.json(response, 404, { error: "shares are not available here", code: "not_found" });
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
if (path === "/api/v1/stations/shares") {
|
|
91
|
+
if (method === "GET") {
|
|
92
|
+
const list = shares.list(url.searchParams.get("state") ?? undefined);
|
|
93
|
+
ctx.json(response, 200, { setting: shares.setting(), total: list.length, shares: list.slice(0, 200).map(ownerShareView) });
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
if (method === "POST") {
|
|
97
|
+
ctx.json(response, 200, { setting: shares.configure(await jsonBody(ctx, request)) });
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const match = /^\/api\/v1\/stations\/shares\/([a-f0-9]{12})(?:\/(approve|reject))?$/.exec(path);
|
|
102
|
+
if (match) {
|
|
103
|
+
const [, id, decision] = match;
|
|
104
|
+
if (!decision && method === "GET") {
|
|
105
|
+
const share = shares.get(id);
|
|
106
|
+
if (!share)
|
|
107
|
+
throw new StationError("not_found", "no such share", 404);
|
|
108
|
+
ctx.json(response, 200, { share: ownerShareView(share) });
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
if (!decision && method === "DELETE") {
|
|
112
|
+
shares.decide(id, "delete");
|
|
113
|
+
ctx.json(response, 200, { deleted: id });
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
if (decision && method === "POST") {
|
|
117
|
+
const input = await jsonBody(ctx, request);
|
|
118
|
+
const share = shares.decide(id, decision, typeof input["reason"] === "string" ? input["reason"].slice(0, 300) : "");
|
|
119
|
+
ctx.json(response, 200, { share: share ? ownerShareView(share) : null });
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
ctx.json(response, 404, { error: "no such share route", code: "not_found" });
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
const ai = ctx.ai;
|
|
127
|
+
if (!ai) {
|
|
128
|
+
ctx.json(response, 404, { error: "AI radio is not available here", code: "not_found" });
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
if (path === "/api/v1/stations/ai") {
|
|
132
|
+
if (method === "GET") {
|
|
133
|
+
ctx.json(response, 200, { ...ai.status(), keys: aiKeysSet() });
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
if (method === "POST") {
|
|
137
|
+
ctx.json(response, 200, { settings: ai.configure(await jsonBody(ctx, request)) });
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (path === "/api/v1/stations/ai/keys" && method === "POST") {
|
|
142
|
+
const input = await jsonBody(ctx, request);
|
|
143
|
+
const name = String(input["name"] ?? "");
|
|
144
|
+
if (!AI_KEY_NAMES.includes(name))
|
|
145
|
+
throw new StationError("unknown_key", `keys: ${AI_KEY_NAMES.join(", ")}`);
|
|
146
|
+
saveAiKey(name, typeof input["value"] === "string" ? input["value"] : "");
|
|
147
|
+
ctx.json(response, 200, { keys: aiKeysSet() });
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
if (path === "/api/v1/stations/ai/stations" && method === "POST") {
|
|
151
|
+
ctx.json(response, 201, { station: ai.create(await jsonBody(ctx, request)) });
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
const match = /^\/api\/v1\/stations\/ai\/stations\/([a-f0-9]{10})(?:\/(generate))?$/.exec(path);
|
|
155
|
+
if (match) {
|
|
156
|
+
const [, key, action] = match;
|
|
157
|
+
if (!action && method === "GET") {
|
|
158
|
+
const station = ai.get(key);
|
|
159
|
+
if (!station)
|
|
160
|
+
throw new StationError("not_found", "no such AI station", 404);
|
|
161
|
+
ctx.json(response, 200, { station });
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
if (!action && method === "PATCH") {
|
|
165
|
+
ctx.json(response, 200, { station: ai.update(key, await jsonBody(ctx, request)) });
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
if (!action && method === "DELETE") {
|
|
169
|
+
ai.remove(key, url.searchParams.get("files") === "1");
|
|
170
|
+
ctx.json(response, 200, { deleted: key });
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
if (action === "generate" && method === "POST") {
|
|
174
|
+
// Composing takes a minute or two; answer when it is on the station.
|
|
175
|
+
ctx.json(response, 200, { track: await ai.generate(key) });
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
ctx.json(response, 404, { error: "no such AI radio route", code: "not_found" });
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
if (error instanceof StationError) {
|
|
184
|
+
ctx.json(response, error.status, { error: error.message, code: error.code });
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
ctx.json(response, 500, { error: error instanceof Error ? error.message : String(error), code: "internal" });
|
|
188
|
+
}
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface AskOptions {
|
|
2
|
+
/** What the model is for, said once. */
|
|
3
|
+
system: string;
|
|
4
|
+
/** The question, with what it should answer from. */
|
|
5
|
+
prompt: string;
|
|
6
|
+
/** Milliseconds before giving up (default 30 s). */
|
|
7
|
+
timeoutMs?: number;
|
|
8
|
+
fetcher?: typeof fetch;
|
|
9
|
+
env?: Record<string, string | undefined>;
|
|
10
|
+
}
|
|
11
|
+
export interface Answer {
|
|
12
|
+
/** Which model answered, for the record ("gemini-flash-latest", ...). */
|
|
13
|
+
model: string;
|
|
14
|
+
value: Record<string, unknown>;
|
|
15
|
+
}
|
|
16
|
+
export declare class NoModel extends Error {
|
|
17
|
+
constructor();
|
|
18
|
+
}
|
|
19
|
+
/** The first JSON object in a model's answer. */
|
|
20
|
+
export declare function firstObject(text: string): Record<string, unknown>;
|
|
21
|
+
/** Whether any model can be asked. */
|
|
22
|
+
export declare function hasModel(env?: Record<string, string | undefined>): boolean;
|
|
23
|
+
export declare function askJson(options: AskOptions): Promise<Answer>;
|
package/dist/ai-text.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One question to a language model, one JSON answer.
|
|
3
|
+
*
|
|
4
|
+
* AI radio asks for small things: a title and a prompt for the next track, a
|
|
5
|
+
* genre and a verdict for a shared file. Whichever model has a key answers --
|
|
6
|
+
* Gemini, then OpenAI, then Anthropic -- and when none has, the caller is told
|
|
7
|
+
* so and does without (a rule of its own, or a person). The answer is parsed
|
|
8
|
+
* and handed back as an object; a model that rambles round its JSON is read
|
|
9
|
+
* for the first object in what it said.
|
|
10
|
+
*/
|
|
11
|
+
import { aiKey } from "./ai-keys.js";
|
|
12
|
+
export class NoModel extends Error {
|
|
13
|
+
constructor() {
|
|
14
|
+
super("no language model key is set (GEMINI_API_KEY, OPENAI_API_KEY or ANTHROPIC_API_KEY)");
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** The first JSON object in a model's answer. */
|
|
18
|
+
export function firstObject(text) {
|
|
19
|
+
const cleaned = text.replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/, "");
|
|
20
|
+
try {
|
|
21
|
+
const parsed = JSON.parse(cleaned);
|
|
22
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed))
|
|
23
|
+
return parsed;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// Look inside it instead.
|
|
27
|
+
}
|
|
28
|
+
const start = cleaned.indexOf("{");
|
|
29
|
+
const end = cleaned.lastIndexOf("}");
|
|
30
|
+
if (start !== -1 && end > start) {
|
|
31
|
+
const parsed = JSON.parse(cleaned.slice(start, end + 1));
|
|
32
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed))
|
|
33
|
+
return parsed;
|
|
34
|
+
}
|
|
35
|
+
throw new Error("the model did not answer with a JSON object");
|
|
36
|
+
}
|
|
37
|
+
/** Whether any model can be asked. */
|
|
38
|
+
export function hasModel(env = process.env) {
|
|
39
|
+
return Boolean(aiKey("GEMINI_API_KEY", env) || aiKey("OPENAI_API_KEY", env) || aiKey("ANTHROPIC_API_KEY", env));
|
|
40
|
+
}
|
|
41
|
+
export async function askJson(options) {
|
|
42
|
+
const env = options.env ?? process.env;
|
|
43
|
+
const send = options.fetcher ?? fetch;
|
|
44
|
+
const signal = AbortSignal.timeout(options.timeoutMs ?? 30_000);
|
|
45
|
+
const gemini = aiKey("GEMINI_API_KEY", env);
|
|
46
|
+
const openai = aiKey("OPENAI_API_KEY", env);
|
|
47
|
+
const anthropic = aiKey("ANTHROPIC_API_KEY", env);
|
|
48
|
+
if (gemini) {
|
|
49
|
+
const model = env["NIXAMP_GEMINI_MODEL"] || "gemini-flash-latest";
|
|
50
|
+
const response = await send(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`, {
|
|
51
|
+
method: "POST",
|
|
52
|
+
signal,
|
|
53
|
+
headers: { "content-type": "application/json", "x-goog-api-key": gemini },
|
|
54
|
+
body: JSON.stringify({
|
|
55
|
+
systemInstruction: { parts: [{ text: options.system }] },
|
|
56
|
+
contents: [{ role: "user", parts: [{ text: options.prompt }] }],
|
|
57
|
+
generationConfig: { responseMimeType: "application/json", temperature: 0.9 },
|
|
58
|
+
}),
|
|
59
|
+
});
|
|
60
|
+
if (!response.ok)
|
|
61
|
+
throw new Error(`gemini answered HTTP ${response.status}`);
|
|
62
|
+
const body = (await response.json());
|
|
63
|
+
const text = body.candidates?.[0]?.content?.parts?.map((one) => one.text ?? "").join("") ?? "";
|
|
64
|
+
return { model, value: firstObject(text) };
|
|
65
|
+
}
|
|
66
|
+
if (openai) {
|
|
67
|
+
const model = env["NIXAMP_OPENAI_MODEL"] || "gpt-4.1-mini";
|
|
68
|
+
const response = await send("https://api.openai.com/v1/chat/completions", {
|
|
69
|
+
method: "POST",
|
|
70
|
+
signal,
|
|
71
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${openai}` },
|
|
72
|
+
body: JSON.stringify({
|
|
73
|
+
model,
|
|
74
|
+
response_format: { type: "json_object" },
|
|
75
|
+
messages: [{ role: "system", content: options.system }, { role: "user", content: options.prompt }],
|
|
76
|
+
}),
|
|
77
|
+
});
|
|
78
|
+
if (!response.ok)
|
|
79
|
+
throw new Error(`openai answered HTTP ${response.status}`);
|
|
80
|
+
const body = (await response.json());
|
|
81
|
+
return { model, value: firstObject(body.choices?.[0]?.message?.content ?? "") };
|
|
82
|
+
}
|
|
83
|
+
if (anthropic) {
|
|
84
|
+
const model = env["NIXAMP_ANTHROPIC_MODEL"] || "claude-haiku-5-5";
|
|
85
|
+
const response = await send("https://api.anthropic.com/v1/messages", {
|
|
86
|
+
method: "POST",
|
|
87
|
+
signal,
|
|
88
|
+
headers: { "content-type": "application/json", "x-api-key": anthropic, "anthropic-version": "2023-06-01" },
|
|
89
|
+
body: JSON.stringify({
|
|
90
|
+
model,
|
|
91
|
+
max_tokens: 1024,
|
|
92
|
+
system: `${options.system}\nAnswer with one JSON object and nothing else.`,
|
|
93
|
+
messages: [{ role: "user", content: options.prompt }],
|
|
94
|
+
}),
|
|
95
|
+
});
|
|
96
|
+
if (!response.ok)
|
|
97
|
+
throw new Error(`anthropic answered HTTP ${response.status}`);
|
|
98
|
+
const body = (await response.json());
|
|
99
|
+
return { model, value: firstObject(body.content?.filter((one) => one.type === "text").map((one) => one.text ?? "").join("") ?? "") };
|
|
100
|
+
}
|
|
101
|
+
throw new NoModel();
|
|
102
|
+
}
|
package/dist/library.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export interface Config {
|
|
|
9
9
|
guideHosts?: string[];
|
|
10
10
|
/** Station limits kept across restarts (station-metering.ts). */
|
|
11
11
|
limits?: Record<string, unknown>;
|
|
12
|
+
/** Proxies whose X-Forwarded-For is believed for listener shares (`nixamp station shares trust ADDRESS`). */
|
|
13
|
+
trustedProxies?: string[];
|
|
12
14
|
}
|
|
13
15
|
export declare function configPath(): string;
|
|
14
16
|
export declare function readConfig(): Config;
|
package/dist/library.js
CHANGED
|
@@ -33,6 +33,7 @@ export function readConfig() {
|
|
|
33
33
|
...(typeof record["publish"] === "boolean" ? { publish: record["publish"] } : {}),
|
|
34
34
|
...(typeof record["autoStations"] === "object" && record["autoStations"] !== null ? { autoStations: record["autoStations"] } : {}),
|
|
35
35
|
...(typeof record["limits"] === "object" && record["limits"] !== null ? { limits: record["limits"] } : {}),
|
|
36
|
+
...(Array.isArray(record["trustedProxies"]) ? { trustedProxies: record["trustedProxies"].filter((one) => typeof one === "string") } : {}),
|
|
36
37
|
...(Array.isArray(record["guideHosts"]) ? { guideHosts: record["guideHosts"].filter((one) => typeof one === "string") } : {}),
|
|
37
38
|
};
|
|
38
39
|
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { IncomingMessage } from "node:http";
|
|
2
|
+
import { askJson } from "./ai-text.ts";
|
|
3
|
+
import type { LibraryIndex, Probed } from "./library-index.ts";
|
|
4
|
+
import type { StationRunner } from "./station-runner.ts";
|
|
5
|
+
import { type StationStore } from "./stations.ts";
|
|
6
|
+
export type ShareState = "fetching" | "checking" | "held" | "approved" | "rejected" | "failed" | "reported" | "withdrawn";
|
|
7
|
+
export type ShareBasis = "owned" | "permission" | "licensed" | "creative-commons" | "public-domain";
|
|
8
|
+
export declare const SHARE_BASES: readonly ShareBasis[];
|
|
9
|
+
export declare const SHARE_GENRES: string[];
|
|
10
|
+
export interface Share {
|
|
11
|
+
id: string;
|
|
12
|
+
/** Lets the sharer see how it went, and withdraw it. Never shown to anybody else. */
|
|
13
|
+
token: string;
|
|
14
|
+
via: "url" | "upload";
|
|
15
|
+
/** Where it came from, for the owner. */
|
|
16
|
+
url: string;
|
|
17
|
+
name: string;
|
|
18
|
+
file: string;
|
|
19
|
+
bytes: number;
|
|
20
|
+
sha256: string;
|
|
21
|
+
state: ShareState;
|
|
22
|
+
/** Why it is held, refused, failed or reported, in words. */
|
|
23
|
+
reason: string;
|
|
24
|
+
title: string;
|
|
25
|
+
artist: string;
|
|
26
|
+
genre: string;
|
|
27
|
+
duration: number;
|
|
28
|
+
rights: {
|
|
29
|
+
basis: ShareBasis;
|
|
30
|
+
note: string;
|
|
31
|
+
};
|
|
32
|
+
/** A name the sharer chose to be credited with, or "". */
|
|
33
|
+
by: string;
|
|
34
|
+
/** Who shared it, as a salted hash: enough to count, not to find them. */
|
|
35
|
+
from: string;
|
|
36
|
+
check: {
|
|
37
|
+
model: string;
|
|
38
|
+
verdict: "clear" | "doubt" | "refuse" | "";
|
|
39
|
+
reason: string;
|
|
40
|
+
};
|
|
41
|
+
reports: Array<{
|
|
42
|
+
at: number;
|
|
43
|
+
reason: string;
|
|
44
|
+
from: string;
|
|
45
|
+
}>;
|
|
46
|
+
createdAt: number;
|
|
47
|
+
updatedAt: number;
|
|
48
|
+
}
|
|
49
|
+
export interface ShareSettings {
|
|
50
|
+
/** Whether anybody may share at all. */
|
|
51
|
+
open: boolean;
|
|
52
|
+
/** "ai": a clear verdict goes on the air; "owner": everything waits. */
|
|
53
|
+
moderation: "ai" | "owner";
|
|
54
|
+
/** Shared stations are public and on the guide. */
|
|
55
|
+
publish: boolean;
|
|
56
|
+
maxBytes: number;
|
|
57
|
+
/** Shares an hour from one sharer. */
|
|
58
|
+
perHour: number;
|
|
59
|
+
}
|
|
60
|
+
export declare const SHARE_DEFAULTS: ShareSettings;
|
|
61
|
+
export interface SharesDeps {
|
|
62
|
+
index: LibraryIndex;
|
|
63
|
+
store: StationStore;
|
|
64
|
+
runner: StationRunner;
|
|
65
|
+
probe: (path: string) => Promise<Probed>;
|
|
66
|
+
/** "" when this address may be fetched, else why not (destinations.ts). */
|
|
67
|
+
guard: (url: string) => Promise<string>;
|
|
68
|
+
/** The folder shared files go in: a managed library source. */
|
|
69
|
+
root: string;
|
|
70
|
+
owner: string;
|
|
71
|
+
mayStart: () => boolean;
|
|
72
|
+
/** The file a station is playing now, "" when none. */
|
|
73
|
+
nowPlaying: (stationId: string) => string;
|
|
74
|
+
fetcher?: typeof fetch;
|
|
75
|
+
ask?: typeof askJson;
|
|
76
|
+
canAsk?: () => boolean;
|
|
77
|
+
now?: () => number;
|
|
78
|
+
log?: (line: string) => void;
|
|
79
|
+
}
|
|
80
|
+
/** What a sharer may see of their share. */
|
|
81
|
+
export declare function sharerView(share: Share): Record<string, unknown>;
|
|
82
|
+
/** What the owner sees: all of it but the sharer's token. */
|
|
83
|
+
export declare function ownerShareView(share: Share): Record<string, unknown>;
|
|
84
|
+
export declare class ListenerShares {
|
|
85
|
+
private readonly file;
|
|
86
|
+
private readonly deps;
|
|
87
|
+
private shares;
|
|
88
|
+
private settings;
|
|
89
|
+
private salt;
|
|
90
|
+
private sourceId;
|
|
91
|
+
private recent;
|
|
92
|
+
private syncing;
|
|
93
|
+
constructor(file: string, deps: SharesDeps);
|
|
94
|
+
static fileFor(stateDir: string, port: number): string;
|
|
95
|
+
private now;
|
|
96
|
+
private save;
|
|
97
|
+
private source;
|
|
98
|
+
setting(): ShareSettings & {
|
|
99
|
+
model: boolean;
|
|
100
|
+
};
|
|
101
|
+
configure(input: Record<string, unknown>): ShareSettings;
|
|
102
|
+
list(state?: string): Share[];
|
|
103
|
+
get(id: string): Share | undefined;
|
|
104
|
+
/** The share, if this token is the one its sharer was given. */
|
|
105
|
+
forSharer(id: string, token: string): Share | undefined;
|
|
106
|
+
private fromOf;
|
|
107
|
+
/** Throws when shares are closed or this sharer has shared enough for the hour. */
|
|
108
|
+
private admit;
|
|
109
|
+
private declared;
|
|
110
|
+
private blank;
|
|
111
|
+
/** A link to a music file. Answers at once; the fetch and the check carry on. */
|
|
112
|
+
fromUrl(input: Record<string, unknown>, caller: string): Share;
|
|
113
|
+
/** The file itself, as the request body. Resolves once it is all here; the check carries on. */
|
|
114
|
+
fromUpload(request: IncomingMessage, input: Record<string, unknown>, caller: string): Promise<Share>;
|
|
115
|
+
/** The sharer takes it back: off the air and its copy gone. */
|
|
116
|
+
withdraw(share: Share): Share;
|
|
117
|
+
/** A listener says what is playing should not be: off the air at once, for the owner to look at. */
|
|
118
|
+
report(input: {
|
|
119
|
+
station?: unknown;
|
|
120
|
+
share?: unknown;
|
|
121
|
+
reason?: unknown;
|
|
122
|
+
}, caller: string): {
|
|
123
|
+
reported: boolean;
|
|
124
|
+
title: string;
|
|
125
|
+
};
|
|
126
|
+
/** The owner's decision. */
|
|
127
|
+
decide(id: string, decision: "approve" | "reject" | "delete", reason?: string): Share | null;
|
|
128
|
+
private fail;
|
|
129
|
+
/** Bytes to the share's file, counted, capped and hashed. */
|
|
130
|
+
private write;
|
|
131
|
+
private fetchAndCheck;
|
|
132
|
+
/** ffprobe, then the model: on the air, waiting, or turned away. */
|
|
133
|
+
private check;
|
|
134
|
+
/** Each genre's shared station, with exactly the approved tracks; one with none goes quiet. */
|
|
135
|
+
sync(): Promise<void>;
|
|
136
|
+
}
|