nixamp 0.29.1 → 0.30.3
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/channels.d.ts +12 -0
- package/dist/channels.js +12 -1
- package/dist/directory.d.ts +3 -1
- package/dist/directory.js +8 -2
- package/dist/main.d.ts +0 -8
- package/dist/main.js +9 -0
- package/dist/mcp.d.ts +3 -0
- package/dist/mcp.js +7 -1
- package/dist/playlist-input.d.ts +10 -0
- package/dist/playlist-input.js +9 -0
- package/dist/server.d.ts +27 -1
- package/dist/server.js +162 -6
- package/dist/station-cli.d.ts +3 -0
- package/dist/station-cli.js +201 -0
- package/dist/station-mcp.d.ts +294 -0
- package/dist/station-mcp.js +173 -0
- package/dist/station-page.d.ts +5 -0
- package/dist/station-page.js +138 -0
- package/dist/station-routes.d.ts +60 -0
- package/dist/station-routes.js +250 -0
- package/dist/station-runner.d.ts +67 -0
- package/dist/station-runner.js +219 -0
- package/dist/station-usage.d.ts +2 -0
- package/dist/station-usage.js +20 -0
- package/dist/stations.d.ts +148 -0
- package/dist/stations.js +331 -0
- package/package.json +1 -1
- package/src/channels.ts +14 -1
- package/src/directory.ts +10 -2
- package/src/main.ts +8 -0
- package/src/mcp.ts +9 -1
- package/src/playlist-input.ts +9 -0
- package/src/server.ts +169 -7
- package/src/station-cli.ts +195 -0
- package/src/station-mcp.ts +183 -0
- package/src/station-page.ts +145 -0
- package/src/station-routes.ts +302 -0
- package/src/station-runner.ts +244 -0
- package/src/station-usage.ts +20 -0
- package/src/stations.ts +407 -0
- package/web/dist/assets/{ar-D6_ZFDan.js → ar-ClCIWtVb.js} +1 -1
- package/web/dist/assets/{cs-DEPGOGEV.js → cs-CVG4U823.js} +1 -1
- package/web/dist/assets/{da-CQw7SPC9.js → da-igdiQUk8.js} +1 -1
- package/web/dist/assets/{de-OWnobbah.js → de-CHPsc6lc.js} +1 -1
- package/web/dist/assets/{es-qMpyH9bM.js → es-AvbMspQX.js} +1 -1
- package/web/dist/assets/{fi-DpqqwJTD.js → fi-5JGUOm3W.js} +1 -1
- package/web/dist/assets/{fr-BiO_ceen.js → fr-YfMwk1FR.js} +1 -1
- package/web/dist/assets/{hi-D-q5wZuY.js → hi-BUZuQTkp.js} +1 -1
- package/web/dist/assets/{hls-3VKVEQE3-D2W4K8VR.js → hls-3VKVEQE3-BCXawNaW.js} +1 -1
- package/web/dist/assets/{hu-FIjHyhVC.js → hu-C7on-yUl.js} +1 -1
- package/web/dist/assets/{id-gmB6lu56.js → id-pIrzVeOi.js} +1 -1
- package/web/dist/assets/{index-UmGvzuun.js → index-CDBIr8Tz.js} +2 -2
- package/web/dist/assets/{it-Dxfo713A.js → it-Bl9xk5B4.js} +1 -1
- package/web/dist/assets/{mpegts-BT4XGr_E.js → mpegts-63ylMmge.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-CpZnlq0W.js → mpegts-LO6RVLD6-DBakOXU5.js} +1 -1
- package/web/dist/assets/{nl-mk5eN3Io.js → nl-b5BUih3i.js} +1 -1
- package/web/dist/assets/{ru-B2psU0kN.js → ru-BlAkbEPp.js} +1 -1
- package/web/dist/assets/{sv-Cg6waoNZ.js → sv-BEKvqELG.js} +1 -1
- package/web/dist/assets/{uk-BcFkukLd.js → uk-JY8Dh56p.js} +1 -1
- package/web/dist/assets/{vi-CnqIC1Sz.js → vi-DbtxuQPL.js} +1 -1
- package/web/dist/assets/{zh-CvnXIFvN.js → zh-4x0DI5jO.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +22 -22
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nixamp station`: stations from a terminal, a script or a cron job.
|
|
3
|
+
*
|
|
4
|
+
* Talks to the daemon on this machine with its control key, or to another
|
|
5
|
+
* server with --url and --key, exactly as `nixamp admin` does. Every command
|
|
6
|
+
* takes --json for a script; without it, a line a person can read. Exit codes
|
|
7
|
+
* a script can branch on: 0 done, 1 the server said no (the reason and a
|
|
8
|
+
* stable code are printed), 2 the command was wrong, 3 the server could not
|
|
9
|
+
* be reached.
|
|
10
|
+
*
|
|
11
|
+
* Nothing here publishes as a side effect. `create` and `start` leave a
|
|
12
|
+
* station private; `publish` is its own command and says on what basis.
|
|
13
|
+
*/
|
|
14
|
+
import { resolveTarget } from "./admin.js";
|
|
15
|
+
import { readState } from "./daemon.js";
|
|
16
|
+
import { KEY_HEADER } from "./share.js";
|
|
17
|
+
import { USAGE } from "./station-usage.js";
|
|
18
|
+
export { USAGE };
|
|
19
|
+
function flag(argv, name) {
|
|
20
|
+
const at = argv.indexOf(name);
|
|
21
|
+
return at === -1 ? undefined : argv[at + 1];
|
|
22
|
+
}
|
|
23
|
+
function has(argv, name) {
|
|
24
|
+
return argv.includes(name);
|
|
25
|
+
}
|
|
26
|
+
class Unreachable extends Error {
|
|
27
|
+
}
|
|
28
|
+
export async function station(argv) {
|
|
29
|
+
const [command, id] = argv;
|
|
30
|
+
const json = has(argv, "--json");
|
|
31
|
+
if (!command || command === "help" || has(argv, "--help")) {
|
|
32
|
+
console.log(USAGE);
|
|
33
|
+
return command ? 0 : 2;
|
|
34
|
+
}
|
|
35
|
+
let target;
|
|
36
|
+
try {
|
|
37
|
+
target = resolveTarget(argv);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
console.error(error.message);
|
|
41
|
+
return 3;
|
|
42
|
+
}
|
|
43
|
+
const headers = { "content-type": "application/json", ...(target.key ? { [KEY_HEADER]: target.key } : {}) };
|
|
44
|
+
const call = async (method, path, body) => {
|
|
45
|
+
let response;
|
|
46
|
+
try {
|
|
47
|
+
response = await fetch(`${target.url}${path}`, { method, headers, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
throw new Unreachable(`could not reach ${target.url}: ${error.message}`);
|
|
51
|
+
}
|
|
52
|
+
const text = await response.text();
|
|
53
|
+
let parsed = {};
|
|
54
|
+
try {
|
|
55
|
+
parsed = JSON.parse(text);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
parsed = { error: text };
|
|
59
|
+
}
|
|
60
|
+
return { status: response.status, body: parsed };
|
|
61
|
+
};
|
|
62
|
+
const needId = () => {
|
|
63
|
+
if (!id || id.startsWith("--")) {
|
|
64
|
+
console.error(`nixamp station ${command} needs a station id. \`nixamp station list\` shows them.`);
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
return id;
|
|
68
|
+
};
|
|
69
|
+
const show = (answer, line) => {
|
|
70
|
+
if (json) {
|
|
71
|
+
console.log(JSON.stringify(answer.body, null, 2));
|
|
72
|
+
}
|
|
73
|
+
else if (answer.status < 400) {
|
|
74
|
+
console.log(line(answer.body));
|
|
75
|
+
}
|
|
76
|
+
if (answer.status >= 400) {
|
|
77
|
+
if (!json)
|
|
78
|
+
console.error(`nixamp: ${String(answer.body["error"] ?? "refused")} (${String(answer.body["code"] ?? answer.status)})`);
|
|
79
|
+
return 1;
|
|
80
|
+
}
|
|
81
|
+
return 0;
|
|
82
|
+
};
|
|
83
|
+
const describe = (station) => {
|
|
84
|
+
const links = (station["links"] ?? {});
|
|
85
|
+
return [
|
|
86
|
+
`${String(station["name"])} [${String(station["id"])}]`,
|
|
87
|
+
` state ${String(station["state"])} (wanted ${String(station["desired"])})`,
|
|
88
|
+
` visibility ${String(station["visibility"])}${station["listed"] ? ", listed in the directory" : ""}`,
|
|
89
|
+
` rotation ${station["shuffle"] ? "shuffle" : "in order"}${station["repeat"] ? ", repeating" : ", once"}; ${station["items"]?.length ?? 0} files at ${String(station["bitrate"])} kbps`,
|
|
90
|
+
...(station["error"] ? [` problem ${String(station["error"])}`] : []),
|
|
91
|
+
...(station["excluded"]?.length ? [` left out ${station["excluded"].length} files (nixamp station retry)`] : []),
|
|
92
|
+
...(station["visibility"] !== "private" && links["page"] ? [` listen ${links["page"]}`] : []),
|
|
93
|
+
].join("\n");
|
|
94
|
+
};
|
|
95
|
+
try {
|
|
96
|
+
switch (command) {
|
|
97
|
+
case "list": {
|
|
98
|
+
const answer = await call("GET", "/api/v1/stations?limit=200");
|
|
99
|
+
return show(answer, (body) => {
|
|
100
|
+
const all = body["stations"] ?? [];
|
|
101
|
+
return all.length === 0 ? "No stations yet. nixamp station create --name NAME --folder PATH" : all.map(describe).join("\n\n");
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
case "create": {
|
|
105
|
+
const name = flag(argv, "--name");
|
|
106
|
+
if (!name) {
|
|
107
|
+
console.error("nixamp station create needs --name");
|
|
108
|
+
return 2;
|
|
109
|
+
}
|
|
110
|
+
const items = flag(argv, "--items");
|
|
111
|
+
let folder = flag(argv, "--folder");
|
|
112
|
+
if (folder !== undefined) {
|
|
113
|
+
// A path under the library is taken relative to it.
|
|
114
|
+
const root = readState()?.source ?? "";
|
|
115
|
+
if (root && folder.startsWith(root))
|
|
116
|
+
folder = folder.slice(root.length);
|
|
117
|
+
}
|
|
118
|
+
if (items === undefined && folder === undefined) {
|
|
119
|
+
console.error("nixamp station create needs --folder PATH or --items 0,1,2");
|
|
120
|
+
return 2;
|
|
121
|
+
}
|
|
122
|
+
const bitrate = flag(argv, "--bitrate");
|
|
123
|
+
const answer = await call("POST", "/api/v1/stations", {
|
|
124
|
+
name,
|
|
125
|
+
...(items !== undefined ? { items: items.split(",").map((one) => Number(one.trim())) } : { folder }),
|
|
126
|
+
shuffle: has(argv, "--shuffle"),
|
|
127
|
+
repeat: !has(argv, "--no-repeat"),
|
|
128
|
+
...(bitrate ? { bitrate: Number(bitrate) } : {}),
|
|
129
|
+
});
|
|
130
|
+
return show(answer, (body) => `Made (private, stopped):\n${describe(body["station"])}`);
|
|
131
|
+
}
|
|
132
|
+
case "show":
|
|
133
|
+
case "status": {
|
|
134
|
+
const station = needId();
|
|
135
|
+
if (!station)
|
|
136
|
+
return 2;
|
|
137
|
+
return show(await call("GET", `/api/v1/stations/${station}`), (body) => describe(body["station"]));
|
|
138
|
+
}
|
|
139
|
+
case "start":
|
|
140
|
+
case "stop":
|
|
141
|
+
case "skip":
|
|
142
|
+
case "retry": {
|
|
143
|
+
const station = needId();
|
|
144
|
+
if (!station)
|
|
145
|
+
return 2;
|
|
146
|
+
return show(await call("POST", `/api/v1/stations/${station}/${command}`, {}), (body) => describe(body["station"]));
|
|
147
|
+
}
|
|
148
|
+
case "publish": {
|
|
149
|
+
const station = needId();
|
|
150
|
+
if (!station)
|
|
151
|
+
return 2;
|
|
152
|
+
const basis = flag(argv, "--basis");
|
|
153
|
+
const unlisted = has(argv, "--unlisted");
|
|
154
|
+
const access = {
|
|
155
|
+
visibility: unlisted ? "unlisted" : "public",
|
|
156
|
+
listed: !unlisted && !has(argv, "--no-list"),
|
|
157
|
+
...(basis ? { rights: { basis, note: flag(argv, "--note") ?? "" } } : {}),
|
|
158
|
+
};
|
|
159
|
+
return show(await call("POST", `/api/v1/stations/${station}/access`, access), (body) => describe(body["station"]));
|
|
160
|
+
}
|
|
161
|
+
case "unpublish": {
|
|
162
|
+
const station = needId();
|
|
163
|
+
if (!station)
|
|
164
|
+
return 2;
|
|
165
|
+
const access = has(argv, "--private") ? { visibility: "private" } : { listed: false };
|
|
166
|
+
return show(await call("POST", `/api/v1/stations/${station}/access`, access), (body) => {
|
|
167
|
+
const dropped = Number(body["dropped"] ?? 0);
|
|
168
|
+
return `${describe(body["station"])}${dropped ? `\n ${dropped} listener(s) disconnected; old links no longer work.` : ""}`;
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
case "link": {
|
|
172
|
+
const station = needId();
|
|
173
|
+
if (!station)
|
|
174
|
+
return 2;
|
|
175
|
+
const answer = has(argv, "--rotate")
|
|
176
|
+
? await call("POST", `/api/v1/stations/${station}/rotate-link`, {})
|
|
177
|
+
: await call("GET", `/api/v1/stations/${station}`);
|
|
178
|
+
return show(answer, (body) => {
|
|
179
|
+
const links = (body["station"]["links"] ?? {});
|
|
180
|
+
return [`page ${links["page"]}`, `stream ${links["stream"]}`, `m3u ${links["m3u"]}`].join("\n");
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
case "delete": {
|
|
184
|
+
const station = needId();
|
|
185
|
+
if (!station)
|
|
186
|
+
return 2;
|
|
187
|
+
return show(await call("DELETE", `/api/v1/stations/${station}`), () => `Deleted ${station}. Its files were not touched.`);
|
|
188
|
+
}
|
|
189
|
+
default:
|
|
190
|
+
console.error(`nixamp station: no command "${command}".\n\n${USAGE}`);
|
|
191
|
+
return 2;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
if (error instanceof Unreachable) {
|
|
196
|
+
console.error(`nixamp: ${error.message}`);
|
|
197
|
+
return 3;
|
|
198
|
+
}
|
|
199
|
+
throw error;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
export declare const STATION_TOOLS: ({
|
|
2
|
+
name: string;
|
|
3
|
+
description: string;
|
|
4
|
+
inputSchema: {
|
|
5
|
+
type: string;
|
|
6
|
+
properties: {
|
|
7
|
+
server: {
|
|
8
|
+
type: "string";
|
|
9
|
+
description: string;
|
|
10
|
+
};
|
|
11
|
+
idempotencyKey?: undefined;
|
|
12
|
+
name?: undefined;
|
|
13
|
+
description?: undefined;
|
|
14
|
+
folder?: undefined;
|
|
15
|
+
items?: undefined;
|
|
16
|
+
shuffle?: undefined;
|
|
17
|
+
repeat?: undefined;
|
|
18
|
+
bitrate?: undefined;
|
|
19
|
+
action?: undefined;
|
|
20
|
+
visibility?: undefined;
|
|
21
|
+
listed?: undefined;
|
|
22
|
+
basis?: undefined;
|
|
23
|
+
note?: undefined;
|
|
24
|
+
id?: undefined;
|
|
25
|
+
private?: undefined;
|
|
26
|
+
};
|
|
27
|
+
required?: undefined;
|
|
28
|
+
};
|
|
29
|
+
} | {
|
|
30
|
+
name: string;
|
|
31
|
+
description: string;
|
|
32
|
+
inputSchema: {
|
|
33
|
+
type: string;
|
|
34
|
+
properties: {
|
|
35
|
+
server: {
|
|
36
|
+
type: "string";
|
|
37
|
+
description: string;
|
|
38
|
+
};
|
|
39
|
+
id: {
|
|
40
|
+
type: "string";
|
|
41
|
+
description: string;
|
|
42
|
+
};
|
|
43
|
+
idempotencyKey?: undefined;
|
|
44
|
+
name?: undefined;
|
|
45
|
+
description?: undefined;
|
|
46
|
+
folder?: undefined;
|
|
47
|
+
items?: undefined;
|
|
48
|
+
shuffle?: undefined;
|
|
49
|
+
repeat?: undefined;
|
|
50
|
+
bitrate?: undefined;
|
|
51
|
+
action?: undefined;
|
|
52
|
+
visibility?: undefined;
|
|
53
|
+
listed?: undefined;
|
|
54
|
+
basis?: undefined;
|
|
55
|
+
note?: undefined;
|
|
56
|
+
private?: undefined;
|
|
57
|
+
};
|
|
58
|
+
required: string[];
|
|
59
|
+
};
|
|
60
|
+
} | {
|
|
61
|
+
name: string;
|
|
62
|
+
description: string;
|
|
63
|
+
inputSchema: {
|
|
64
|
+
type: string;
|
|
65
|
+
properties: {
|
|
66
|
+
server: {
|
|
67
|
+
type: "string";
|
|
68
|
+
description: string;
|
|
69
|
+
};
|
|
70
|
+
name: {
|
|
71
|
+
readonly type: "string";
|
|
72
|
+
};
|
|
73
|
+
folder: {
|
|
74
|
+
type: "string";
|
|
75
|
+
description: string;
|
|
76
|
+
};
|
|
77
|
+
items: {
|
|
78
|
+
type: string;
|
|
79
|
+
items: {
|
|
80
|
+
type: string;
|
|
81
|
+
};
|
|
82
|
+
description: string;
|
|
83
|
+
};
|
|
84
|
+
description: {
|
|
85
|
+
readonly type: "string";
|
|
86
|
+
};
|
|
87
|
+
shuffle: {
|
|
88
|
+
type: string;
|
|
89
|
+
};
|
|
90
|
+
repeat: {
|
|
91
|
+
type: string;
|
|
92
|
+
description: string;
|
|
93
|
+
};
|
|
94
|
+
bitrate: {
|
|
95
|
+
type: string;
|
|
96
|
+
description: string;
|
|
97
|
+
};
|
|
98
|
+
idempotencyKey: {
|
|
99
|
+
type: "string";
|
|
100
|
+
description: string;
|
|
101
|
+
};
|
|
102
|
+
action?: undefined;
|
|
103
|
+
visibility?: undefined;
|
|
104
|
+
listed?: undefined;
|
|
105
|
+
basis?: undefined;
|
|
106
|
+
note?: undefined;
|
|
107
|
+
id?: undefined;
|
|
108
|
+
private?: undefined;
|
|
109
|
+
};
|
|
110
|
+
required: string[];
|
|
111
|
+
};
|
|
112
|
+
} | {
|
|
113
|
+
name: string;
|
|
114
|
+
description: string;
|
|
115
|
+
inputSchema: {
|
|
116
|
+
type: string;
|
|
117
|
+
properties: {
|
|
118
|
+
idempotencyKey?: undefined;
|
|
119
|
+
server: {
|
|
120
|
+
type: "string";
|
|
121
|
+
description: string;
|
|
122
|
+
};
|
|
123
|
+
id: {
|
|
124
|
+
type: "string";
|
|
125
|
+
description: string;
|
|
126
|
+
};
|
|
127
|
+
name: {
|
|
128
|
+
readonly type: "string";
|
|
129
|
+
};
|
|
130
|
+
description: {
|
|
131
|
+
readonly type: "string";
|
|
132
|
+
};
|
|
133
|
+
folder: {
|
|
134
|
+
readonly type: "string";
|
|
135
|
+
};
|
|
136
|
+
items: {
|
|
137
|
+
description?: undefined;
|
|
138
|
+
type: string;
|
|
139
|
+
items: {
|
|
140
|
+
type: string;
|
|
141
|
+
};
|
|
142
|
+
};
|
|
143
|
+
shuffle: {
|
|
144
|
+
type: string;
|
|
145
|
+
};
|
|
146
|
+
repeat: {
|
|
147
|
+
description?: undefined;
|
|
148
|
+
type: string;
|
|
149
|
+
};
|
|
150
|
+
bitrate: {
|
|
151
|
+
description?: undefined;
|
|
152
|
+
type: string;
|
|
153
|
+
};
|
|
154
|
+
action?: undefined;
|
|
155
|
+
visibility?: undefined;
|
|
156
|
+
listed?: undefined;
|
|
157
|
+
basis?: undefined;
|
|
158
|
+
note?: undefined;
|
|
159
|
+
private?: undefined;
|
|
160
|
+
};
|
|
161
|
+
required: string[];
|
|
162
|
+
};
|
|
163
|
+
} | {
|
|
164
|
+
name: string;
|
|
165
|
+
description: string;
|
|
166
|
+
inputSchema: {
|
|
167
|
+
type: string;
|
|
168
|
+
properties: {
|
|
169
|
+
idempotencyKey?: undefined;
|
|
170
|
+
name?: undefined;
|
|
171
|
+
description?: undefined;
|
|
172
|
+
folder?: undefined;
|
|
173
|
+
items?: undefined;
|
|
174
|
+
shuffle?: undefined;
|
|
175
|
+
repeat?: undefined;
|
|
176
|
+
bitrate?: undefined;
|
|
177
|
+
server: {
|
|
178
|
+
type: "string";
|
|
179
|
+
description: string;
|
|
180
|
+
};
|
|
181
|
+
id: {
|
|
182
|
+
type: "string";
|
|
183
|
+
description: string;
|
|
184
|
+
};
|
|
185
|
+
action: {
|
|
186
|
+
type: string;
|
|
187
|
+
enum: string[];
|
|
188
|
+
};
|
|
189
|
+
visibility?: undefined;
|
|
190
|
+
listed?: undefined;
|
|
191
|
+
basis?: undefined;
|
|
192
|
+
note?: undefined;
|
|
193
|
+
private?: undefined;
|
|
194
|
+
};
|
|
195
|
+
required: string[];
|
|
196
|
+
};
|
|
197
|
+
} | {
|
|
198
|
+
name: string;
|
|
199
|
+
description: string;
|
|
200
|
+
inputSchema: {
|
|
201
|
+
type: string;
|
|
202
|
+
properties: {
|
|
203
|
+
idempotencyKey?: undefined;
|
|
204
|
+
name?: undefined;
|
|
205
|
+
description?: undefined;
|
|
206
|
+
folder?: undefined;
|
|
207
|
+
items?: undefined;
|
|
208
|
+
shuffle?: undefined;
|
|
209
|
+
repeat?: undefined;
|
|
210
|
+
bitrate?: undefined;
|
|
211
|
+
action?: undefined;
|
|
212
|
+
server: {
|
|
213
|
+
type: "string";
|
|
214
|
+
description: string;
|
|
215
|
+
};
|
|
216
|
+
id: {
|
|
217
|
+
type: "string";
|
|
218
|
+
description: string;
|
|
219
|
+
};
|
|
220
|
+
visibility: {
|
|
221
|
+
type: string;
|
|
222
|
+
enum: string[];
|
|
223
|
+
};
|
|
224
|
+
listed: {
|
|
225
|
+
type: string;
|
|
226
|
+
description: string;
|
|
227
|
+
};
|
|
228
|
+
basis: {
|
|
229
|
+
type: string;
|
|
230
|
+
enum: string[];
|
|
231
|
+
};
|
|
232
|
+
note: {
|
|
233
|
+
readonly type: "string";
|
|
234
|
+
};
|
|
235
|
+
private?: undefined;
|
|
236
|
+
};
|
|
237
|
+
required: string[];
|
|
238
|
+
};
|
|
239
|
+
} | {
|
|
240
|
+
name: string;
|
|
241
|
+
description: string;
|
|
242
|
+
inputSchema: {
|
|
243
|
+
type: string;
|
|
244
|
+
properties: {
|
|
245
|
+
idempotencyKey?: undefined;
|
|
246
|
+
name?: undefined;
|
|
247
|
+
description?: undefined;
|
|
248
|
+
folder?: undefined;
|
|
249
|
+
items?: undefined;
|
|
250
|
+
shuffle?: undefined;
|
|
251
|
+
repeat?: undefined;
|
|
252
|
+
bitrate?: undefined;
|
|
253
|
+
action?: undefined;
|
|
254
|
+
visibility?: undefined;
|
|
255
|
+
listed?: undefined;
|
|
256
|
+
basis?: undefined;
|
|
257
|
+
note?: undefined;
|
|
258
|
+
server: {
|
|
259
|
+
type: "string";
|
|
260
|
+
description: string;
|
|
261
|
+
};
|
|
262
|
+
id: {
|
|
263
|
+
type: "string";
|
|
264
|
+
description: string;
|
|
265
|
+
};
|
|
266
|
+
private: {
|
|
267
|
+
type: string;
|
|
268
|
+
};
|
|
269
|
+
};
|
|
270
|
+
required: string[];
|
|
271
|
+
};
|
|
272
|
+
})[];
|
|
273
|
+
interface Result {
|
|
274
|
+
content: {
|
|
275
|
+
type: "text";
|
|
276
|
+
text: string;
|
|
277
|
+
}[];
|
|
278
|
+
isError?: boolean;
|
|
279
|
+
}
|
|
280
|
+
export interface StationToolOptions {
|
|
281
|
+
fetcher?: typeof fetch;
|
|
282
|
+
/** Where the local daemon is and its key; the tests hand these in. */
|
|
283
|
+
local?: {
|
|
284
|
+
url: string;
|
|
285
|
+
key: string | null;
|
|
286
|
+
} | null;
|
|
287
|
+
session?: {
|
|
288
|
+
site: string;
|
|
289
|
+
token: string;
|
|
290
|
+
} | null;
|
|
291
|
+
}
|
|
292
|
+
export declare function isStationTool(name: string): boolean;
|
|
293
|
+
export declare function callStationTool(name: string, args: Record<string, unknown>, options?: StationToolOptions): Promise<Result>;
|
|
294
|
+
export {};
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stations as MCP tools: what `nixamp station` does, for an agent.
|
|
3
|
+
*
|
|
4
|
+
* The same API and the same rules as the CLI and the page. On this machine
|
|
5
|
+
* the tools use the daemon's control key, which is what being on the machine
|
|
6
|
+
* means; for another server they send this machine's nixamp.com session,
|
|
7
|
+
* which the server accepts only if that account owns it. Publishing is its
|
|
8
|
+
* own tool and asks for the basis, exactly as for a person.
|
|
9
|
+
*/
|
|
10
|
+
import { daemonUrl, readState } from "./daemon.js";
|
|
11
|
+
import { readSession } from "./session.js";
|
|
12
|
+
import { KEY_HEADER } from "./share.js";
|
|
13
|
+
const STRING = { type: "string" };
|
|
14
|
+
const ID = { ...STRING, description: "The station's id (10 characters), from station_list." };
|
|
15
|
+
const SERVER = { ...STRING, description: "A nixamp server's address; this machine's daemon when omitted." };
|
|
16
|
+
export const STATION_TOOLS = [
|
|
17
|
+
{
|
|
18
|
+
name: "station_list",
|
|
19
|
+
description: "List this server's stations: id, name, state (live, stopped, offline...), visibility and listening link.",
|
|
20
|
+
inputSchema: { type: "object", properties: { server: SERVER } },
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: "station_get",
|
|
24
|
+
description: "One station in full: files, rotation, state, any problem, and its links.",
|
|
25
|
+
inputSchema: { type: "object", properties: { server: SERVER, id: ID }, required: ["id"] },
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: "station_create",
|
|
29
|
+
description: "Make a station from a folder in the library (path relative to it, \"\" for all of it) or from track indexes. It starts private and stopped; nothing is published.",
|
|
30
|
+
inputSchema: {
|
|
31
|
+
type: "object",
|
|
32
|
+
properties: {
|
|
33
|
+
server: SERVER,
|
|
34
|
+
name: STRING,
|
|
35
|
+
folder: { ...STRING, description: "A folder under the library, e.g. \"Albums/Late Night\"." },
|
|
36
|
+
items: { type: "array", items: { type: "integer" }, description: "Track indexes, as /api/state lists them." },
|
|
37
|
+
description: STRING,
|
|
38
|
+
shuffle: { type: "boolean" },
|
|
39
|
+
repeat: { type: "boolean", description: "Round and round (default) or once." },
|
|
40
|
+
bitrate: { type: "integer", description: "kbps: 64, 96, 128 (default), 160, 192, 256 or 320." },
|
|
41
|
+
idempotencyKey: { ...STRING, description: "Asking again with the same key returns the same station." },
|
|
42
|
+
},
|
|
43
|
+
required: ["name"],
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: "station_update",
|
|
48
|
+
description: "Rename, describe, change files or rotation. Edits to the files land on the next track.",
|
|
49
|
+
inputSchema: {
|
|
50
|
+
type: "object",
|
|
51
|
+
properties: { server: SERVER, id: ID, name: STRING, description: STRING, folder: STRING, items: { type: "array", items: { type: "integer" } }, shuffle: { type: "boolean" }, repeat: { type: "boolean" }, bitrate: { type: "integer" } },
|
|
52
|
+
required: ["id"],
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: "station_control",
|
|
57
|
+
description: "start, stop or skip a station, or retry the files it left out. Starting does not publish.",
|
|
58
|
+
inputSchema: {
|
|
59
|
+
type: "object",
|
|
60
|
+
properties: { server: SERVER, id: ID, action: { type: "string", enum: ["start", "stop", "skip", "retry"] } },
|
|
61
|
+
required: ["id", "action"],
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: "station_publish",
|
|
66
|
+
description: "Make a station unlisted (a link) or public (and listed in the nixamp.com directory). Public needs the basis on which it may be broadcast: owned, permission, licensed or public-domain. A declaration, not legal clearance.",
|
|
67
|
+
inputSchema: {
|
|
68
|
+
type: "object",
|
|
69
|
+
properties: {
|
|
70
|
+
server: SERVER,
|
|
71
|
+
id: ID,
|
|
72
|
+
visibility: { type: "string", enum: ["unlisted", "public"] },
|
|
73
|
+
listed: { type: "boolean", description: "In the directory (public only). Default true for public." },
|
|
74
|
+
basis: { type: "string", enum: ["owned", "permission", "licensed", "public-domain"] },
|
|
75
|
+
note: STRING,
|
|
76
|
+
},
|
|
77
|
+
required: ["id", "visibility"],
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
name: "station_unpublish",
|
|
82
|
+
description: "Take a station out of the directory, or make it private: its links stop working and listeners are disconnected.",
|
|
83
|
+
inputSchema: { type: "object", properties: { server: SERVER, id: ID, private: { type: "boolean" } }, required: ["id"] },
|
|
84
|
+
},
|
|
85
|
+
];
|
|
86
|
+
const say = (value, isError = false) => ({ content: [{ type: "text", text: value }], ...(isError ? { isError: true } : {}) });
|
|
87
|
+
export function isStationTool(name) {
|
|
88
|
+
return STATION_TOOLS.some((tool) => tool.name === name);
|
|
89
|
+
}
|
|
90
|
+
function line(station) {
|
|
91
|
+
const links = (station["links"] ?? {});
|
|
92
|
+
const problem = station["error"] ? `; problem: ${String(station["error"])}` : "";
|
|
93
|
+
const link = station["visibility"] !== "private" && links["page"] ? `\n listen: ${links["page"]}` : "";
|
|
94
|
+
return `${String(station["name"])} [${String(station["id"])}] ${String(station["state"])}, ${String(station["visibility"])}${station["listed"] ? " and listed" : ""}${problem}${link}`;
|
|
95
|
+
}
|
|
96
|
+
export async function callStationTool(name, args, options = {}) {
|
|
97
|
+
const send = options.fetcher ?? fetch;
|
|
98
|
+
const server = typeof args["server"] === "string" ? args["server"].replace(/\/+$/, "") : "";
|
|
99
|
+
let base = server;
|
|
100
|
+
const headers = { "content-type": "application/json" };
|
|
101
|
+
if (server === "") {
|
|
102
|
+
const local = options.local === undefined ? (() => { const state = readState(); return state ? { url: daemonUrl(state), key: state.key } : null; })() : options.local;
|
|
103
|
+
if (!local)
|
|
104
|
+
return say("No nixamp daemon runs on this machine. Start one (nixamp daemon start ~/Music) or pass `server`.", true);
|
|
105
|
+
base = local.url;
|
|
106
|
+
if (local.key)
|
|
107
|
+
headers[KEY_HEADER] = local.key;
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
const session = options.session === undefined ? readSession() : options.session;
|
|
111
|
+
if (!session)
|
|
112
|
+
return say("Another server's stations need this machine signed in as its owner: nixamp login.", true);
|
|
113
|
+
headers["authorization"] = `Bearer ${session.token}`;
|
|
114
|
+
}
|
|
115
|
+
const id = typeof args["id"] === "string" ? args["id"] : "";
|
|
116
|
+
const call = async (method, path, body) => {
|
|
117
|
+
const response = await send(`${base}${path}`, { method, headers, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
|
118
|
+
const parsed = (await response.json().catch(() => ({})));
|
|
119
|
+
return { ok: response.ok, body: parsed };
|
|
120
|
+
};
|
|
121
|
+
const refused = (body) => say(`${String(body["error"] ?? "refused")} (${String(body["code"] ?? "error")})`, true);
|
|
122
|
+
const pick = (keys) => Object.fromEntries(keys.filter((key) => args[key] !== undefined).map((key) => [key, args[key]]));
|
|
123
|
+
try {
|
|
124
|
+
if (name === "station_list") {
|
|
125
|
+
const answer = await call("GET", "/api/v1/stations?limit=200");
|
|
126
|
+
if (!answer.ok)
|
|
127
|
+
return refused(answer.body);
|
|
128
|
+
const all = answer.body["stations"] ?? [];
|
|
129
|
+
return say(all.length === 0 ? "No stations on this server yet." : all.map(line).join("\n"));
|
|
130
|
+
}
|
|
131
|
+
if (!/^[a-z0-9]{10}$/.test(id) && name !== "station_create")
|
|
132
|
+
return say("Which station? Pass its id from station_list.", true);
|
|
133
|
+
if (name === "station_get") {
|
|
134
|
+
const answer = await call("GET", `/api/v1/stations/${id}`);
|
|
135
|
+
return answer.ok ? say(JSON.stringify(answer.body["station"], null, 2)) : refused(answer.body);
|
|
136
|
+
}
|
|
137
|
+
if (name === "station_create") {
|
|
138
|
+
const answer = await call("POST", "/api/v1/stations", pick(["name", "folder", "items", "description", "shuffle", "repeat", "bitrate", "idempotencyKey"]));
|
|
139
|
+
return answer.ok ? say(`Made, private and stopped: ${line(answer.body["station"])}`) : refused(answer.body);
|
|
140
|
+
}
|
|
141
|
+
if (name === "station_update") {
|
|
142
|
+
const answer = await call("PATCH", `/api/v1/stations/${id}`, pick(["name", "description", "folder", "items", "shuffle", "repeat", "bitrate"]));
|
|
143
|
+
return answer.ok ? say(line(answer.body["station"])) : refused(answer.body);
|
|
144
|
+
}
|
|
145
|
+
if (name === "station_control") {
|
|
146
|
+
const action = String(args["action"] ?? "");
|
|
147
|
+
if (!["start", "stop", "skip", "retry"].includes(action))
|
|
148
|
+
return say("action is start, stop, skip or retry", true);
|
|
149
|
+
const answer = await call("POST", `/api/v1/stations/${id}/${action}`, {});
|
|
150
|
+
return answer.ok ? say(line(answer.body["station"])) : refused(answer.body);
|
|
151
|
+
}
|
|
152
|
+
if (name === "station_publish") {
|
|
153
|
+
const visibility = args["visibility"] === "public" ? "public" : "unlisted";
|
|
154
|
+
const answer = await call("POST", `/api/v1/stations/${id}/access`, {
|
|
155
|
+
visibility,
|
|
156
|
+
listed: visibility === "public" && args["listed"] !== false,
|
|
157
|
+
...(typeof args["basis"] === "string" ? { rights: { basis: args["basis"], note: typeof args["note"] === "string" ? args["note"] : "" } } : {}),
|
|
158
|
+
});
|
|
159
|
+
return answer.ok ? say(line(answer.body["station"])) : refused(answer.body);
|
|
160
|
+
}
|
|
161
|
+
if (name === "station_unpublish") {
|
|
162
|
+
const answer = await call("POST", `/api/v1/stations/${id}/access`, args["private"] === true ? { visibility: "private" } : { listed: false });
|
|
163
|
+
if (!answer.ok)
|
|
164
|
+
return refused(answer.body);
|
|
165
|
+
const dropped = Number(answer.body["dropped"] ?? 0);
|
|
166
|
+
return say(`${line(answer.body["station"])}${dropped ? `\n${dropped} listener(s) disconnected.` : ""}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
return say(`Could not reach ${base}: ${error.message}`, true);
|
|
171
|
+
}
|
|
172
|
+
return say(`No such tool: ${name}`, true);
|
|
173
|
+
}
|