nixamp 0.30.3 → 0.33.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/channels.d.ts +11 -2
- package/dist/channels.js +26 -4
- package/dist/library-cli.d.ts +3 -0
- package/dist/library-cli.js +198 -0
- package/dist/library-index.d.ts +190 -0
- package/dist/library-index.js +643 -0
- package/dist/library-mcp.d.ts +230 -0
- package/dist/library-mcp.js +139 -0
- package/dist/library-routes.d.ts +34 -0
- package/dist/library-routes.js +258 -0
- package/dist/library-usage.d.ts +3 -0
- package/dist/library-usage.js +20 -0
- package/dist/library-user.d.ts +67 -0
- package/dist/library-user.js +204 -0
- package/dist/main.js +8 -0
- package/dist/mcp.js +5 -2
- package/dist/server.d.ts +9 -0
- package/dist/server.js +87 -13
- package/dist/station-cli.js +4 -3
- package/dist/station-mcp.d.ts +17 -0
- package/dist/station-mcp.js +25 -19
- package/dist/station-page.d.ts +1 -1
- package/dist/station-page.js +25 -4
- package/dist/station-routes.js +54 -1
- package/dist/station-usage.d.ts +1 -1
- package/dist/station-usage.js +1 -1
- package/package.json +1 -1
- package/src/channels.ts +23 -4
- package/src/library-cli.ts +175 -0
- package/src/library-index.ts +709 -0
- package/src/library-mcp.ts +132 -0
- package/src/library-routes.ts +282 -0
- package/src/library-usage.ts +21 -0
- package/src/library-user.ts +218 -0
- package/src/main.ts +7 -0
- package/src/mcp.ts +5 -2
- package/src/server.ts +86 -14
- package/src/station-cli.ts +4 -3
- package/src/station-mcp.ts +22 -15
- package/src/station-page.ts +25 -4
- package/src/station-routes.ts +53 -1
- package/src/station-usage.ts +1 -1
- package/web/dist/assets/ar-4kD-mTIA.js +1 -0
- package/web/dist/assets/cs-CziXiWNY.js +1 -0
- package/web/dist/assets/da-CyjJ8UOr.js +1 -0
- package/web/dist/assets/de-DxwYa667.js +1 -0
- package/web/dist/assets/es-QgMTJeSc.js +1 -0
- package/web/dist/assets/fi-Clj-jHhR.js +1 -0
- package/web/dist/assets/fr-nH_gzSFv.js +1 -0
- package/web/dist/assets/hi-D7djQ6_C.js +1 -0
- package/web/dist/assets/hls-3VKVEQE3-BaWVouMg.js +1 -0
- package/web/dist/assets/hu-H5Q3L8bD.js +1 -0
- package/web/dist/assets/id-40z_EQdf.js +1 -0
- package/web/dist/assets/it-C9Fcgywl.js +1 -0
- package/web/dist/assets/{index-CDBIr8Tz.js → main-BJUyBy0h.js} +2 -2
- package/web/dist/assets/{mpegts-63ylMmge.js → mpegts-BlmyNlBC.js} +1 -1
- package/web/dist/assets/mpegts-LO6RVLD6-CAuth8C5.js +1 -0
- package/web/dist/assets/nl-CrL4Psk9.js +1 -0
- package/web/dist/assets/radio-B58WgC1j.js +1 -0
- package/web/dist/assets/radio-CC8ihiO_.css +1 -0
- package/web/dist/assets/ru-C84JS0ch.js +1 -0
- package/web/dist/assets/styles-D70tSrIe.js +1 -0
- package/web/dist/assets/styles-qC62Gona.css +1 -0
- package/web/dist/assets/sv-DtMcl0sf.js +1 -0
- package/web/dist/assets/uk-dOLf99vZ.js +1 -0
- package/web/dist/assets/vi-BWeQVRZ8.js +1 -0
- package/web/dist/assets/zh-Cnv3Qpux.js +1 -0
- package/web/dist/index.html +4 -2
- package/web/dist/radio.html +134 -0
- package/web/dist/sw.js +26 -23
- package/web/dist/assets/ar-ClCIWtVb.js +0 -1
- package/web/dist/assets/cs-CVG4U823.js +0 -1
- package/web/dist/assets/da-igdiQUk8.js +0 -1
- package/web/dist/assets/de-CHPsc6lc.js +0 -1
- package/web/dist/assets/es-AvbMspQX.js +0 -1
- package/web/dist/assets/fi-5JGUOm3W.js +0 -1
- package/web/dist/assets/fr-YfMwk1FR.js +0 -1
- package/web/dist/assets/hi-BUZuQTkp.js +0 -1
- package/web/dist/assets/hls-3VKVEQE3-BCXawNaW.js +0 -1
- package/web/dist/assets/hu-C7on-yUl.js +0 -1
- package/web/dist/assets/id-pIrzVeOi.js +0 -1
- package/web/dist/assets/index-Bo3utcDw.css +0 -1
- package/web/dist/assets/it-Bl9xk5B4.js +0 -1
- package/web/dist/assets/mpegts-LO6RVLD6-DBakOXU5.js +0 -1
- package/web/dist/assets/nl-b5BUih3i.js +0 -1
- package/web/dist/assets/ru-BlAkbEPp.js +0 -1
- package/web/dist/assets/sv-BEKvqELG.js +0 -1
- package/web/dist/assets/uk-JY8Dh56p.js +0 -1
- package/web/dist/assets/vi-DbtxuQPL.js +0 -1
- package/web/dist/assets/zh-4x0DI5jO.js +0 -1
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** `nixamp library` subcommands and usage, apart so help does not load the client. */
|
|
2
|
+
export const LIBRARY_COMMANDS = new Set(["status", "sources", "add", "scan", "search", "show", "fix", "remove", "playlists", "playlist"]);
|
|
3
|
+
export const LIBRARY_USAGE = `nixamp library — what this server has, indexed
|
|
4
|
+
|
|
5
|
+
nixamp library PATH the folder the daemon serves (set once)
|
|
6
|
+
nixamp library status items by state, sources, last scans
|
|
7
|
+
nixamp library sources
|
|
8
|
+
nixamp library add PATH index another folder (inside your home, not hidden)
|
|
9
|
+
nixamp library scan [SOURCE] [--force] [--wait]
|
|
10
|
+
nixamp library search WORDS [--artist A] [--album A] [--state failed] [--limit 20]
|
|
11
|
+
nixamp library show ITEM
|
|
12
|
+
nixamp library fix ITEM --title T [--artist A] [--album A] [--genre G] [--track N] [--year N]
|
|
13
|
+
nixamp library remove SOURCE forget a folder; its files are not touched
|
|
14
|
+
nixamp library playlists
|
|
15
|
+
nixamp library playlist new NAME [ITEM...]
|
|
16
|
+
nixamp library playlist show|delete PLAYLIST
|
|
17
|
+
nixamp library playlist add PLAYLIST ITEM...
|
|
18
|
+
|
|
19
|
+
--json machine-readable output
|
|
20
|
+
--url, --key another server, as with \`nixamp admin\``;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export interface Playlist {
|
|
2
|
+
id: string;
|
|
3
|
+
owner: string;
|
|
4
|
+
name: string;
|
|
5
|
+
items: string[];
|
|
6
|
+
revision: number;
|
|
7
|
+
createdAt: number;
|
|
8
|
+
updatedAt: number;
|
|
9
|
+
}
|
|
10
|
+
export type Repeat = "off" | "all" | "one";
|
|
11
|
+
export interface Queue {
|
|
12
|
+
owner: string;
|
|
13
|
+
items: string[];
|
|
14
|
+
/** Index into items of what is on. */
|
|
15
|
+
at: number;
|
|
16
|
+
/** Seconds into it. */
|
|
17
|
+
position: number;
|
|
18
|
+
shuffle: boolean;
|
|
19
|
+
repeat: Repeat;
|
|
20
|
+
/** Which device moved it last, as it named itself. */
|
|
21
|
+
device: string;
|
|
22
|
+
revision: number;
|
|
23
|
+
updatedAt: number;
|
|
24
|
+
}
|
|
25
|
+
export declare class ListenerStore {
|
|
26
|
+
private readonly file;
|
|
27
|
+
private readonly io;
|
|
28
|
+
private readonly now;
|
|
29
|
+
private playlists;
|
|
30
|
+
private queues;
|
|
31
|
+
private timer;
|
|
32
|
+
constructor(file: string, io?: {
|
|
33
|
+
read: (path: string) => string | null;
|
|
34
|
+
write: (path: string, body: string) => void;
|
|
35
|
+
}, now?: () => number);
|
|
36
|
+
static fileFor(stateDir: string, port: number): string;
|
|
37
|
+
private touch;
|
|
38
|
+
flush(): void;
|
|
39
|
+
playlistsOf(owner: string): Playlist[];
|
|
40
|
+
/** Somebody's own playlist; anybody else's is the same "not found". */
|
|
41
|
+
playlist(owner: string, id: string): Playlist;
|
|
42
|
+
private items;
|
|
43
|
+
private name;
|
|
44
|
+
create(owner: string, input: {
|
|
45
|
+
name?: unknown;
|
|
46
|
+
items?: unknown;
|
|
47
|
+
}, known: (id: string) => boolean): Playlist;
|
|
48
|
+
/** Rename, reorder or replace the items; `revision` must be the one the caller saw. */
|
|
49
|
+
update(owner: string, id: string, input: {
|
|
50
|
+
name?: unknown;
|
|
51
|
+
items?: unknown;
|
|
52
|
+
revision?: unknown;
|
|
53
|
+
}, known: (id: string) => boolean): Playlist;
|
|
54
|
+
/** Items added, at the end or at a place. */
|
|
55
|
+
add(owner: string, id: string, input: {
|
|
56
|
+
items?: unknown;
|
|
57
|
+
at?: unknown;
|
|
58
|
+
}, known: (id: string) => boolean): Playlist;
|
|
59
|
+
remove(owner: string, id: string): void;
|
|
60
|
+
queueOf(owner: string): Queue;
|
|
61
|
+
/**
|
|
62
|
+
* A new queue (items given) or where playback has got to (at/position).
|
|
63
|
+
* Last writer wins for where you are -- that is what moving to another
|
|
64
|
+
* device means -- but a whole new list names the revision it replaces.
|
|
65
|
+
*/
|
|
66
|
+
setQueue(owner: string, input: Record<string, unknown>, known: (id: string) => boolean): Queue;
|
|
67
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What belongs to one listener of a library: their playlists, and where
|
|
3
|
+
* they are in what they were playing.
|
|
4
|
+
*
|
|
5
|
+
* A listener here is somebody this server can recognise next time from
|
|
6
|
+
* another device: a nixamp.com account, or the owner holding the control key
|
|
7
|
+
* (who is "owner"). Somebody with only the listen link is not anybody in
|
|
8
|
+
* particular, so they get a player and no memory, which is what the PRD means
|
|
9
|
+
* by resuming "after authentication".
|
|
10
|
+
*
|
|
11
|
+
* Playlists hold item ids, not paths: a playlist survives a rescan, and an
|
|
12
|
+
* item that went missing stays in it, shown as unavailable, until somebody
|
|
13
|
+
* takes it out. Every change bumps a revision; an edit that names an older
|
|
14
|
+
* revision is refused rather than quietly overwriting what another device
|
|
15
|
+
* just did.
|
|
16
|
+
*
|
|
17
|
+
* The queue is one per listener, written whenever playback moves on, so a
|
|
18
|
+
* phone picks up where the laptop stopped.
|
|
19
|
+
*/
|
|
20
|
+
import { randomBytes } from "node:crypto";
|
|
21
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { dirname, join } from "node:path";
|
|
23
|
+
import { LibraryError } from "./library-index.js";
|
|
24
|
+
const MAX_PLAYLISTS = 500;
|
|
25
|
+
const MAX_ITEMS = 10_000;
|
|
26
|
+
export class ListenerStore {
|
|
27
|
+
file;
|
|
28
|
+
io;
|
|
29
|
+
now;
|
|
30
|
+
playlists = new Map();
|
|
31
|
+
queues = new Map();
|
|
32
|
+
timer = null;
|
|
33
|
+
constructor(file, io = {
|
|
34
|
+
read: (path) => { try {
|
|
35
|
+
return readFileSync(path, "utf8");
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
} },
|
|
40
|
+
write: (path, body) => {
|
|
41
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
42
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
43
|
+
writeFileSync(temporary, body, { mode: 0o600 });
|
|
44
|
+
renameSync(temporary, path);
|
|
45
|
+
},
|
|
46
|
+
}, now = Date.now) {
|
|
47
|
+
this.file = file;
|
|
48
|
+
this.io = io;
|
|
49
|
+
this.now = now;
|
|
50
|
+
const body = io.read(file);
|
|
51
|
+
if (body === null)
|
|
52
|
+
return;
|
|
53
|
+
try {
|
|
54
|
+
const parsed = JSON.parse(body);
|
|
55
|
+
for (const one of parsed.playlists ?? [])
|
|
56
|
+
if (one?.id)
|
|
57
|
+
this.playlists.set(one.id, one);
|
|
58
|
+
for (const one of parsed.queues ?? [])
|
|
59
|
+
if (one?.owner)
|
|
60
|
+
this.queues.set(one.owner, one);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// Started over.
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
static fileFor(stateDir, port) {
|
|
67
|
+
return join(stateDir, `listeners-${port}.json`);
|
|
68
|
+
}
|
|
69
|
+
touch() {
|
|
70
|
+
if (this.timer)
|
|
71
|
+
return;
|
|
72
|
+
this.timer = setTimeout(() => this.flush(), 1_000);
|
|
73
|
+
this.timer.unref?.();
|
|
74
|
+
}
|
|
75
|
+
flush() {
|
|
76
|
+
if (this.timer)
|
|
77
|
+
clearTimeout(this.timer);
|
|
78
|
+
this.timer = null;
|
|
79
|
+
this.io.write(this.file, JSON.stringify({ version: 1, playlists: [...this.playlists.values()], queues: [...this.queues.values()] }));
|
|
80
|
+
}
|
|
81
|
+
playlistsOf(owner) {
|
|
82
|
+
return [...this.playlists.values()].filter((one) => one.owner === owner).sort((a, b) => a.createdAt - b.createdAt);
|
|
83
|
+
}
|
|
84
|
+
/** Somebody's own playlist; anybody else's is the same "not found". */
|
|
85
|
+
playlist(owner, id) {
|
|
86
|
+
const found = this.playlists.get(id);
|
|
87
|
+
if (!found || found.owner !== owner)
|
|
88
|
+
throw new LibraryError("not_found", "no such playlist", 404);
|
|
89
|
+
return found;
|
|
90
|
+
}
|
|
91
|
+
items(value, known) {
|
|
92
|
+
if (!Array.isArray(value) || value.some((one) => typeof one !== "string"))
|
|
93
|
+
throw new LibraryError("invalid_items", "items is a list of item ids");
|
|
94
|
+
if (value.length > MAX_ITEMS)
|
|
95
|
+
throw new LibraryError("too_many_items", `a list holds at most ${MAX_ITEMS} items`);
|
|
96
|
+
const unknown = value.filter((one) => !known(one));
|
|
97
|
+
if (unknown.length)
|
|
98
|
+
throw new LibraryError("unknown_items", `${unknown.length} of those items are not in this library`);
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
101
|
+
name(value) {
|
|
102
|
+
const name = typeof value === "string" ? value.trim().replace(/[\u0000-\u001f\u007f]/g, " ").slice(0, 120) : "";
|
|
103
|
+
if (!name)
|
|
104
|
+
throw new LibraryError("name_required", "a playlist needs a name");
|
|
105
|
+
return name;
|
|
106
|
+
}
|
|
107
|
+
create(owner, input, known) {
|
|
108
|
+
if (this.playlistsOf(owner).length >= MAX_PLAYLISTS)
|
|
109
|
+
throw new LibraryError("too_many_playlists", `at most ${MAX_PLAYLISTS} playlists each`, 409);
|
|
110
|
+
const now = this.now();
|
|
111
|
+
const playlist = {
|
|
112
|
+
id: randomBytes(8).toString("hex"),
|
|
113
|
+
owner,
|
|
114
|
+
name: this.name(input.name),
|
|
115
|
+
items: input.items === undefined ? [] : this.items(input.items, known),
|
|
116
|
+
revision: 1,
|
|
117
|
+
createdAt: now,
|
|
118
|
+
updatedAt: now,
|
|
119
|
+
};
|
|
120
|
+
this.playlists.set(playlist.id, playlist);
|
|
121
|
+
this.touch();
|
|
122
|
+
return playlist;
|
|
123
|
+
}
|
|
124
|
+
/** Rename, reorder or replace the items; `revision` must be the one the caller saw. */
|
|
125
|
+
update(owner, id, input, known) {
|
|
126
|
+
const playlist = this.playlist(owner, id);
|
|
127
|
+
if (input.revision !== undefined && input.revision !== playlist.revision) {
|
|
128
|
+
throw new LibraryError("stale_revision", `the playlist changed (revision ${playlist.revision}); fetch it and try again`, 409);
|
|
129
|
+
}
|
|
130
|
+
if (input.name !== undefined)
|
|
131
|
+
playlist.name = this.name(input.name);
|
|
132
|
+
if (input.items !== undefined)
|
|
133
|
+
playlist.items = this.items(input.items, known);
|
|
134
|
+
playlist.revision += 1;
|
|
135
|
+
playlist.updatedAt = this.now();
|
|
136
|
+
this.touch();
|
|
137
|
+
return playlist;
|
|
138
|
+
}
|
|
139
|
+
/** Items added, at the end or at a place. */
|
|
140
|
+
add(owner, id, input, known) {
|
|
141
|
+
const playlist = this.playlist(owner, id);
|
|
142
|
+
const added = this.items(input.items, known);
|
|
143
|
+
const at = typeof input.at === "number" && Number.isInteger(input.at) ? Math.max(0, Math.min(playlist.items.length, input.at)) : playlist.items.length;
|
|
144
|
+
if (playlist.items.length + added.length > MAX_ITEMS)
|
|
145
|
+
throw new LibraryError("too_many_items", `a list holds at most ${MAX_ITEMS} items`);
|
|
146
|
+
playlist.items.splice(at, 0, ...added);
|
|
147
|
+
playlist.revision += 1;
|
|
148
|
+
playlist.updatedAt = this.now();
|
|
149
|
+
this.touch();
|
|
150
|
+
return playlist;
|
|
151
|
+
}
|
|
152
|
+
remove(owner, id) {
|
|
153
|
+
this.playlist(owner, id);
|
|
154
|
+
this.playlists.delete(id);
|
|
155
|
+
this.touch();
|
|
156
|
+
}
|
|
157
|
+
queueOf(owner) {
|
|
158
|
+
return this.queues.get(owner) ?? { owner, items: [], at: 0, position: 0, shuffle: false, repeat: "off", device: "", revision: 0, updatedAt: 0 };
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* A new queue (items given) or where playback has got to (at/position).
|
|
162
|
+
* Last writer wins for where you are -- that is what moving to another
|
|
163
|
+
* device means -- but a whole new list names the revision it replaces.
|
|
164
|
+
*/
|
|
165
|
+
setQueue(owner, input, known) {
|
|
166
|
+
const queue = { ...this.queueOf(owner) };
|
|
167
|
+
if (input["items"] !== undefined) {
|
|
168
|
+
if (input["revision"] !== undefined && input["revision"] !== queue.revision) {
|
|
169
|
+
throw new LibraryError("stale_revision", `the queue changed on another device (revision ${queue.revision})`, 409);
|
|
170
|
+
}
|
|
171
|
+
queue.items = this.items(input["items"], known);
|
|
172
|
+
queue.at = 0;
|
|
173
|
+
queue.position = 0;
|
|
174
|
+
}
|
|
175
|
+
if (input["at"] !== undefined) {
|
|
176
|
+
if (!Number.isInteger(input["at"]) || input["at"] < 0 || input["at"] >= Math.max(1, queue.items.length)) {
|
|
177
|
+
throw new LibraryError("invalid_value", "at is a place in the queue");
|
|
178
|
+
}
|
|
179
|
+
queue.at = input["at"];
|
|
180
|
+
if (input["position"] === undefined)
|
|
181
|
+
queue.position = 0;
|
|
182
|
+
}
|
|
183
|
+
if (input["position"] !== undefined) {
|
|
184
|
+
const position = Number(input["position"]);
|
|
185
|
+
if (!Number.isFinite(position) || position < 0)
|
|
186
|
+
throw new LibraryError("invalid_value", "position is seconds into the item");
|
|
187
|
+
queue.position = Math.round(position * 10) / 10;
|
|
188
|
+
}
|
|
189
|
+
if (input["shuffle"] !== undefined)
|
|
190
|
+
queue.shuffle = input["shuffle"] === true;
|
|
191
|
+
if (input["repeat"] !== undefined) {
|
|
192
|
+
if (!["off", "all", "one"].includes(String(input["repeat"])))
|
|
193
|
+
throw new LibraryError("invalid_value", "repeat is off, all or one");
|
|
194
|
+
queue.repeat = input["repeat"];
|
|
195
|
+
}
|
|
196
|
+
if (typeof input["device"] === "string")
|
|
197
|
+
queue.device = input["device"].slice(0, 80);
|
|
198
|
+
queue.revision += 1;
|
|
199
|
+
queue.updatedAt = this.now();
|
|
200
|
+
this.queues.set(owner, queue);
|
|
201
|
+
this.touch();
|
|
202
|
+
return queue;
|
|
203
|
+
}
|
|
204
|
+
}
|
package/dist/main.js
CHANGED
|
@@ -8,6 +8,7 @@ import { t as uiMessage, i18n, terminalUiLanguage, uiLanguage } from "./i18n.js"
|
|
|
8
8
|
* ffmpeg decodes; we read every sample on its way to the speakers and draw it.
|
|
9
9
|
*/
|
|
10
10
|
import { USAGE as STATION_USAGE } from "./station-usage.js";
|
|
11
|
+
import { LIBRARY_COMMANDS, LIBRARY_USAGE } from "./library-usage.js";
|
|
11
12
|
import { createApp, themes } from "@profullstack/hqtui";
|
|
12
13
|
import { fileURLToPath } from "node:url";
|
|
13
14
|
import { resolve } from "node:path";
|
|
@@ -371,6 +372,8 @@ q or d leaves; neither stops anything. \`nixamp daemon stop\` is what stops it.
|
|
|
371
372
|
export function helpFor(topic) {
|
|
372
373
|
if (topic === "station" || topic === "stations")
|
|
373
374
|
return STATION_USAGE;
|
|
375
|
+
if (topic === "library")
|
|
376
|
+
return LIBRARY_USAGE;
|
|
374
377
|
return (topic ? TOPICS[topic] : undefined) ?? HELP;
|
|
375
378
|
}
|
|
376
379
|
/**
|
|
@@ -621,6 +624,11 @@ export async function main() {
|
|
|
621
624
|
process.exitCode = await syncCommand(rest);
|
|
622
625
|
return;
|
|
623
626
|
}
|
|
627
|
+
if (first === "library" && LIBRARY_COMMANDS.has(rest[0] ?? "")) {
|
|
628
|
+
const { libraryTool } = await import("./library-cli.js");
|
|
629
|
+
process.exitCode = await libraryTool(rest);
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
624
632
|
if (first === "library") {
|
|
625
633
|
const { libraryCommand } = await import("./library.js");
|
|
626
634
|
process.exitCode = await libraryCommand(rest);
|
package/dist/mcp.js
CHANGED
|
@@ -31,6 +31,7 @@ import { describeFile, keepFile } from "./media-local.js";
|
|
|
31
31
|
import { fileFingerprint, idFrom, languageCode, mediaOfUrl, transcriptIdOf } from "./transcripts.js";
|
|
32
32
|
import { personaLines, readPersona, readVoices, writePersona } from "./profile.js";
|
|
33
33
|
import { STATION_TOOLS, callStationTool, isStationTool } from "./station-mcp.js";
|
|
34
|
+
import { LIBRARY_TOOLS, callLibraryTool, isLibraryTool } from "./library-mcp.js";
|
|
34
35
|
export const PROTOCOL_VERSION = "2025-06-18";
|
|
35
36
|
const STRING = { type: "string" };
|
|
36
37
|
export const TOOLS = [
|
|
@@ -217,6 +218,7 @@ export const TOOLS = [
|
|
|
217
218
|
},
|
|
218
219
|
},
|
|
219
220
|
...STATION_TOOLS,
|
|
221
|
+
...LIBRARY_TOOLS,
|
|
220
222
|
];
|
|
221
223
|
function said(line) {
|
|
222
224
|
return `${line.createdAt} ${line.handle}: ${line.body}`;
|
|
@@ -249,8 +251,9 @@ function describe(row) {
|
|
|
249
251
|
*/
|
|
250
252
|
export async function callTool(name, args, options = {}) {
|
|
251
253
|
// Stations first: on this machine they need the daemon, not a sign-in.
|
|
252
|
-
if (isStationTool(name)) {
|
|
253
|
-
|
|
254
|
+
if (isStationTool(name) || isLibraryTool(name)) {
|
|
255
|
+
const local = { ...(options.fetcher ? { fetcher: options.fetcher } : {}), ...(options.session !== undefined ? { session: options.session } : {}), ...(options.stations ?? {}) };
|
|
256
|
+
return isStationTool(name) ? callStationTool(name, args, local) : callLibraryTool(name, args, local);
|
|
254
257
|
}
|
|
255
258
|
const session = options.session === undefined ? readSession() : options.session;
|
|
256
259
|
if (!session) {
|
package/dist/server.d.ts
CHANGED
|
@@ -21,6 +21,8 @@ import { Directory, type LineupEntry } from "./directory.ts";
|
|
|
21
21
|
import { ArtCache } from "./art.ts";
|
|
22
22
|
import { PartyLine } from "./partyline.ts";
|
|
23
23
|
import { HlsPackagers } from "./hls.ts";
|
|
24
|
+
import { LibraryIndex, type Probed } from "./library-index.ts";
|
|
25
|
+
import { ListenerStore } from "./library-user.ts";
|
|
24
26
|
import { StationRunner } from "./station-runner.ts";
|
|
25
27
|
import { StationStore } from "./stations.ts";
|
|
26
28
|
import { CompressionService } from "./compression/service.ts";
|
|
@@ -566,6 +568,13 @@ export declare function joinSubject(url: URL, options: Pick<HandlerOptions, "dir
|
|
|
566
568
|
/** The shell, titled for what a join link opens, with its picture on the card. */
|
|
567
569
|
export declare function joinDocument(shell: string, subject: JoinSubject, site: string): string;
|
|
568
570
|
export interface HandlerOptions {
|
|
571
|
+
/** The library as an index: sources, items, search. */
|
|
572
|
+
library?: {
|
|
573
|
+
index: LibraryIndex;
|
|
574
|
+
probe: (path: string) => Promise<Probed>;
|
|
575
|
+
retag: (tracks: Track[]) => void;
|
|
576
|
+
listeners?: ListenerStore;
|
|
577
|
+
};
|
|
569
578
|
/** This server's stations and what keeps them on the air. */
|
|
570
579
|
stations?: {
|
|
571
580
|
store: StationStore;
|
package/dist/server.js
CHANGED
|
@@ -55,6 +55,9 @@ import { PartyLine, telnyxSms } from "./partyline.js";
|
|
|
55
55
|
import { HlsPackagers, segmentType, withKey } from "./hls.js";
|
|
56
56
|
import { refusedDestination } from "./destinations.js";
|
|
57
57
|
import { handleStations, isStationListenPath } from "./station-routes.js";
|
|
58
|
+
import { handleLibrary } from "./library-routes.js";
|
|
59
|
+
import { LibraryIndex, probeItem, trackOf } from "./library-index.js";
|
|
60
|
+
import { ListenerStore } from "./library-user.js";
|
|
58
61
|
import { STATION_PAGE, STATION_PAGE_CSP, STATION_PAGE_HTML } from "./station-page.js";
|
|
59
62
|
import { StationRunner } from "./station-runner.js";
|
|
60
63
|
import { StationError, StationStore, stationOfChannel } from "./stations.js";
|
|
@@ -2201,6 +2204,29 @@ export function createHandler(engine, options) {
|
|
|
2201
2204
|
response.end(request.method === "HEAD" ? undefined : STATION_PAGE_HTML);
|
|
2202
2205
|
return;
|
|
2203
2206
|
}
|
|
2207
|
+
// Past the key gate is past it: the owner, or somebody given the listen
|
|
2208
|
+
// link, may read the library; only the owner may change it.
|
|
2209
|
+
if (options.library && (path === "/api/v1/library" || path.startsWith("/api/v1/library/"))) {
|
|
2210
|
+
const control = await controlOf();
|
|
2211
|
+
// Who this is: the owner's account, "owner" for the control key, or
|
|
2212
|
+
// any other signed-in nixamp.com account that has the link.
|
|
2213
|
+
const session = tokenFrom(request.headers) || url.searchParams.get("session") || "";
|
|
2214
|
+
const principal = control.account || (control.allowed ? "owner" : (session && options.owner ? (await options.owner.accountFor(session)) || null : null));
|
|
2215
|
+
await handleLibrary(request, response, path, url, {
|
|
2216
|
+
index: options.library.index,
|
|
2217
|
+
canRead: true,
|
|
2218
|
+
canWrite: control.allowed,
|
|
2219
|
+
account: control.account,
|
|
2220
|
+
principal,
|
|
2221
|
+
...(options.library.listeners ? { listeners: options.library.listeners } : {}),
|
|
2222
|
+
probe: options.library.probe,
|
|
2223
|
+
retag: options.library.retag,
|
|
2224
|
+
sendFile,
|
|
2225
|
+
json,
|
|
2226
|
+
readBody,
|
|
2227
|
+
});
|
|
2228
|
+
return;
|
|
2229
|
+
}
|
|
2204
2230
|
if (options.stations && (path === "/api/v1/stations" || path.startsWith("/api/v1/stations/"))) {
|
|
2205
2231
|
const control = await controlOf();
|
|
2206
2232
|
const encrypted = request.socket.encrypted === true;
|
|
@@ -2212,7 +2238,24 @@ export function createHandler(engine, options) {
|
|
|
2212
2238
|
channels: options.channels,
|
|
2213
2239
|
canControl: control.allowed,
|
|
2214
2240
|
account: control.account,
|
|
2215
|
-
select: (body) =>
|
|
2241
|
+
select: (body) => {
|
|
2242
|
+
// A playlist of the owner's, as the station's files: its ready items, in its order.
|
|
2243
|
+
if (typeof body.playlist === "string" && options.library?.listeners) {
|
|
2244
|
+
const owner = control.account || "owner";
|
|
2245
|
+
let list;
|
|
2246
|
+
try {
|
|
2247
|
+
list = options.library.listeners.playlist(owner, body.playlist);
|
|
2248
|
+
}
|
|
2249
|
+
catch (error) {
|
|
2250
|
+
throw new StationError("playlist_not_found", error.message, 404);
|
|
2251
|
+
}
|
|
2252
|
+
const files = list.items.map((id) => options.library.index.item(id)).filter((one) => one?.state === "ready").map((one) => one.path);
|
|
2253
|
+
if (files.length === 0)
|
|
2254
|
+
throw new StationError("empty_playlist", "none of that playlist's items are ready to play");
|
|
2255
|
+
return files;
|
|
2256
|
+
}
|
|
2257
|
+
return selectStationFiles(engine, body);
|
|
2258
|
+
},
|
|
2216
2259
|
trackFor: (file) => trackFor(engine, file),
|
|
2217
2260
|
base: `${scheme}://${request.headers.host ?? "localhost"}`,
|
|
2218
2261
|
json,
|
|
@@ -5547,7 +5590,10 @@ export function createHandler(engine, options) {
|
|
|
5547
5590
|
return;
|
|
5548
5591
|
}
|
|
5549
5592
|
let file = null;
|
|
5550
|
-
|
|
5593
|
+
// The library and radio console is a page of its own, at its own names.
|
|
5594
|
+
if (/^\/(radio|library)\/?$/.test(path) && isFile(join(web, "radio.html")))
|
|
5595
|
+
file = join(web, "radio.html");
|
|
5596
|
+
else if (isFile(direct))
|
|
5551
5597
|
file = direct;
|
|
5552
5598
|
else if (isFile(join(direct, "index.html")))
|
|
5553
5599
|
file = join(direct, "index.html");
|
|
@@ -5910,7 +5956,7 @@ function isFile(path) {
|
|
|
5910
5956
|
return false;
|
|
5911
5957
|
}
|
|
5912
5958
|
}
|
|
5913
|
-
function sendFile(request, response, file) {
|
|
5959
|
+
function sendFile(request, response, file, cache) {
|
|
5914
5960
|
let size;
|
|
5915
5961
|
try {
|
|
5916
5962
|
size = statSync(file).size;
|
|
@@ -5928,7 +5974,7 @@ function sendFile(request, response, file) {
|
|
|
5928
5974
|
};
|
|
5929
5975
|
// The shell must never be cached by a service worker's fetch fallback, but
|
|
5930
5976
|
// hashed assets and audio can be.
|
|
5931
|
-
headers["cache-control"] = type.startsWith("text/html") ? "no-cache" : "public, max-age=3600";
|
|
5977
|
+
headers["cache-control"] = cache ?? (type.startsWith("text/html") ? "no-cache" : "public, max-age=3600");
|
|
5932
5978
|
if (range) {
|
|
5933
5979
|
headers["content-range"] = `bytes ${range.start}-${range.end}/${size}`;
|
|
5934
5980
|
headers["content-length"] = String(range.end - range.start + 1);
|
|
@@ -6067,6 +6113,25 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
6067
6113
|
stationRunner?.onChannelEnd(info);
|
|
6068
6114
|
},
|
|
6069
6115
|
});
|
|
6116
|
+
// The library as an index kept beside the keys: what each file is, learned
|
|
6117
|
+
// once and asked about again only when it changes. The folder this server
|
|
6118
|
+
// was started on is its first source.
|
|
6119
|
+
const libraryIndex = new LibraryIndex(LibraryIndex.fileFor(stateDir(), options.port));
|
|
6120
|
+
const homeSource = (() => {
|
|
6121
|
+
try {
|
|
6122
|
+
return !isRemote(root) && statSync(root).isDirectory() ? libraryIndex.ensureSource(root, options.owner || "") : null;
|
|
6123
|
+
}
|
|
6124
|
+
catch {
|
|
6125
|
+
return null;
|
|
6126
|
+
}
|
|
6127
|
+
})();
|
|
6128
|
+
const probeFile = (path) => probeItem(tools, path);
|
|
6129
|
+
const retagPlayer = (tracks) => {
|
|
6130
|
+
if (tracks.length > 0)
|
|
6131
|
+
engine.retag(tracks, root);
|
|
6132
|
+
};
|
|
6133
|
+
const listeners = new ListenerStore(ListenerStore.fileFor(stateDir(), options.port));
|
|
6134
|
+
const library = { index: libraryIndex, probe: probeFile, retag: retagPlayer, listeners };
|
|
6070
6135
|
const stationStore = new StationStore(StationStore.fileFor(stateDir(), options.port));
|
|
6071
6136
|
stationRunner = new StationRunner({
|
|
6072
6137
|
store: stationStore,
|
|
@@ -6078,7 +6143,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
6078
6143
|
audioBitrate: known.audioBitrate,
|
|
6079
6144
|
})),
|
|
6080
6145
|
// The library as it is now, or as it was chosen while it is still being read.
|
|
6081
|
-
roots: () => [engine.snapshot(false).root || chosen].filter((one) => one !== ""),
|
|
6146
|
+
roots: () => [...new Set([engine.snapshot(false).root || chosen, ...libraryIndex.roots()])].filter((one) => one !== ""),
|
|
6082
6147
|
log: (line) => console.log(line),
|
|
6083
6148
|
});
|
|
6084
6149
|
const stations = { store: stationStore, runner: stationRunner };
|
|
@@ -6661,6 +6726,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
6661
6726
|
owner,
|
|
6662
6727
|
channels,
|
|
6663
6728
|
stations,
|
|
6729
|
+
library,
|
|
6664
6730
|
rememberChannels: remembering,
|
|
6665
6731
|
catalogs,
|
|
6666
6732
|
publishUrls: () => publishUrls,
|
|
@@ -6939,14 +7005,22 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
6939
7005
|
console.log(`nixamp serve — ${found.length} tracks under ${root}`);
|
|
6940
7006
|
if (isRemote(root))
|
|
6941
7007
|
return;
|
|
6942
|
-
|
|
6943
|
-
|
|
6944
|
-
|
|
6945
|
-
|
|
6946
|
-
|
|
6947
|
-
|
|
6948
|
-
|
|
6949
|
-
|
|
7008
|
+
if (!homeSource) {
|
|
7009
|
+
// A playlist file or a single file: tagged as it always was.
|
|
7010
|
+
return loadTagged(tools, root, probeAsync, found.map((track) => track.path))
|
|
7011
|
+
.then((tagged) => engine.retag(tagged, root))
|
|
7012
|
+
.catch(() => undefined);
|
|
7013
|
+
}
|
|
7014
|
+
// What is already known, at once: a restart has its tags before the
|
|
7015
|
+
// first listener arrives. Then only new and changed files are asked
|
|
7016
|
+
// about, and their tags reach the player a couple of hundred at a time
|
|
7017
|
+
// rather than all at the end.
|
|
7018
|
+
retagPlayer(libraryIndex.tracksFor(found.map((track) => track.path)));
|
|
7019
|
+
const { done } = libraryIndex.scan(homeSource.id, probeFile, {
|
|
7020
|
+
onProgress: (changed) => retagPlayer(changed.filter((one) => one.state === "ready").map(trackOf)),
|
|
7021
|
+
});
|
|
7022
|
+
return done.then((report) => {
|
|
7023
|
+
console.log(`nixamp serve — library: ${report.unchanged} unchanged, ${report.probed} read${report.failed ? `, ${report.failed} would not read` : ""}${report.missing ? `, ${report.missing} gone` : ""}${report.outside ? `, ${report.outside} links outside left out` : ""}.`);
|
|
6950
7024
|
});
|
|
6951
7025
|
})
|
|
6952
7026
|
.catch((error) => {
|
package/dist/station-cli.js
CHANGED
|
@@ -115,14 +115,15 @@ export async function station(argv) {
|
|
|
115
115
|
if (root && folder.startsWith(root))
|
|
116
116
|
folder = folder.slice(root.length);
|
|
117
117
|
}
|
|
118
|
-
|
|
119
|
-
|
|
118
|
+
const playlist = flag(argv, "--playlist");
|
|
119
|
+
if (items === undefined && folder === undefined && playlist === undefined) {
|
|
120
|
+
console.error("nixamp station create needs --folder PATH, --playlist ID or --items 0,1,2");
|
|
120
121
|
return 2;
|
|
121
122
|
}
|
|
122
123
|
const bitrate = flag(argv, "--bitrate");
|
|
123
124
|
const answer = await call("POST", "/api/v1/stations", {
|
|
124
125
|
name,
|
|
125
|
-
...(items !== undefined ? { items: items.split(",").map((one) => Number(one.trim())) } : { folder }),
|
|
126
|
+
...(playlist !== undefined ? { playlist } : items !== undefined ? { items: items.split(",").map((one) => Number(one.trim())) } : { folder }),
|
|
126
127
|
shuffle: has(argv, "--shuffle"),
|
|
127
128
|
repeat: !has(argv, "--no-repeat"),
|
|
128
129
|
...(bitrate ? { bitrate: Number(bitrate) } : {}),
|
package/dist/station-mcp.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ export declare const STATION_TOOLS: ({
|
|
|
8
8
|
type: "string";
|
|
9
9
|
description: string;
|
|
10
10
|
};
|
|
11
|
+
playlist?: undefined;
|
|
11
12
|
idempotencyKey?: undefined;
|
|
12
13
|
name?: undefined;
|
|
13
14
|
description?: undefined;
|
|
@@ -40,6 +41,7 @@ export declare const STATION_TOOLS: ({
|
|
|
40
41
|
type: "string";
|
|
41
42
|
description: string;
|
|
42
43
|
};
|
|
44
|
+
playlist?: undefined;
|
|
43
45
|
idempotencyKey?: undefined;
|
|
44
46
|
name?: undefined;
|
|
45
47
|
description?: undefined;
|
|
@@ -81,6 +83,10 @@ export declare const STATION_TOOLS: ({
|
|
|
81
83
|
};
|
|
82
84
|
description: string;
|
|
83
85
|
};
|
|
86
|
+
playlist: {
|
|
87
|
+
type: "string";
|
|
88
|
+
description: string;
|
|
89
|
+
};
|
|
84
90
|
description: {
|
|
85
91
|
readonly type: "string";
|
|
86
92
|
};
|
|
@@ -115,6 +121,7 @@ export declare const STATION_TOOLS: ({
|
|
|
115
121
|
inputSchema: {
|
|
116
122
|
type: string;
|
|
117
123
|
properties: {
|
|
124
|
+
playlist?: undefined;
|
|
118
125
|
idempotencyKey?: undefined;
|
|
119
126
|
server: {
|
|
120
127
|
type: "string";
|
|
@@ -166,6 +173,7 @@ export declare const STATION_TOOLS: ({
|
|
|
166
173
|
inputSchema: {
|
|
167
174
|
type: string;
|
|
168
175
|
properties: {
|
|
176
|
+
playlist?: undefined;
|
|
169
177
|
idempotencyKey?: undefined;
|
|
170
178
|
name?: undefined;
|
|
171
179
|
description?: undefined;
|
|
@@ -200,6 +208,7 @@ export declare const STATION_TOOLS: ({
|
|
|
200
208
|
inputSchema: {
|
|
201
209
|
type: string;
|
|
202
210
|
properties: {
|
|
211
|
+
playlist?: undefined;
|
|
203
212
|
idempotencyKey?: undefined;
|
|
204
213
|
name?: undefined;
|
|
205
214
|
description?: undefined;
|
|
@@ -242,6 +251,7 @@ export declare const STATION_TOOLS: ({
|
|
|
242
251
|
inputSchema: {
|
|
243
252
|
type: string;
|
|
244
253
|
properties: {
|
|
254
|
+
playlist?: undefined;
|
|
245
255
|
idempotencyKey?: undefined;
|
|
246
256
|
name?: undefined;
|
|
247
257
|
description?: undefined;
|
|
@@ -289,6 +299,13 @@ export interface StationToolOptions {
|
|
|
289
299
|
token: string;
|
|
290
300
|
} | null;
|
|
291
301
|
}
|
|
302
|
+
/** Where to send a tool's request, and with which credential. */
|
|
303
|
+
export declare function connect(args: Record<string, unknown>, options: StationToolOptions): {
|
|
304
|
+
base: string;
|
|
305
|
+
headers: Record<string, string>;
|
|
306
|
+
} | {
|
|
307
|
+
error: string;
|
|
308
|
+
};
|
|
292
309
|
export declare function isStationTool(name: string): boolean;
|
|
293
310
|
export declare function callStationTool(name: string, args: Record<string, unknown>, options?: StationToolOptions): Promise<Result>;
|
|
294
311
|
export {};
|