nixamp 0.30.1 → 0.32.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 +12 -0
- package/dist/channels.js +8 -0
- 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.d.ts +3 -0
- package/dist/mcp.js +10 -1
- package/dist/playlist-input.d.ts +10 -0
- package/dist/playlist-input.js +9 -0
- package/dist/server.d.ts +9 -0
- package/dist/server.js +83 -12
- package/dist/station-cli.js +4 -3
- package/dist/station-mcp.d.ts +311 -0
- package/dist/station-mcp.js +179 -0
- package/dist/station-page.d.ts +1 -1
- package/dist/station-page.js +2 -1
- package/dist/station-routes.js +2 -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 +10 -0
- 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 +12 -1
- package/src/playlist-input.ts +9 -0
- package/src/server.ts +83 -13
- package/src/station-cli.ts +4 -3
- package/src/station-mcp.ts +190 -0
- package/src/station-page.ts +2 -1
- package/src/station-routes.ts +2 -1
- package/src/station-usage.ts +1 -1
- package/web/dist/assets/{ar-CTUDxy1J.js → ar-C_4gIadE.js} +1 -1
- package/web/dist/assets/{cs-m4JTaO3o.js → cs-B18R5adY.js} +1 -1
- package/web/dist/assets/{da-DHnJElYB.js → da-B6w5URQ2.js} +1 -1
- package/web/dist/assets/{de-DkxPohnf.js → de-Dlhr9ikG.js} +1 -1
- package/web/dist/assets/{es-oY_G83d7.js → es-BIpArFOC.js} +1 -1
- package/web/dist/assets/{fi-fCO8XRdg.js → fi-o9W4XS3X.js} +1 -1
- package/web/dist/assets/{fr-CSupf-IN.js → fr-BgVwhPsP.js} +1 -1
- package/web/dist/assets/{hi-Dve7PJsC.js → hi-zIXqrXNC.js} +1 -1
- package/web/dist/assets/{hls-3VKVEQE3-D5-AkKSR.js → hls-3VKVEQE3-5pAjDDLr.js} +1 -1
- package/web/dist/assets/{hu-D1KYamzV.js → hu-Dpn_DIRi.js} +1 -1
- package/web/dist/assets/{id-Cofp_4aQ.js → id-Cu1V6gAm.js} +1 -1
- package/web/dist/assets/{index-7mCQZGNF.js → index-D-PZ4nGC.js} +2 -2
- package/web/dist/assets/{it-62iXQWQD.js → it-BNcoJEYQ.js} +1 -1
- package/web/dist/assets/{mpegts-BT5ESacM.js → mpegts-CabjhxTI.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-BgHd8PFT.js → mpegts-LO6RVLD6-CRSxiR1J.js} +1 -1
- package/web/dist/assets/{nl-CzRRG-CN.js → nl-gvivlDEE.js} +1 -1
- package/web/dist/assets/{ru-OykC1LJQ.js → ru-BxQnVrHu.js} +1 -1
- package/web/dist/assets/{sv-B8FXBDHm.js → sv-BP1JhAR8.js} +1 -1
- package/web/dist/assets/{uk-DhAJBjwH.js → uk-ChYhgzlf.js} +1 -1
- package/web/dist/assets/{vi-CdBSRReC.js → vi-CD7vzLwP.js} +1 -1
- package/web/dist/assets/{zh-wGWNyBEZ.js → zh-BmGS-RcE.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +22 -22
|
@@ -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.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { wavOf, type Window } from "./transcribe.ts";
|
|
2
2
|
import { type Described } from "./media-local.ts";
|
|
3
|
+
import { type StationToolOptions } from "./station-mcp.ts";
|
|
3
4
|
export declare const PROTOCOL_VERSION = "2025-06-18";
|
|
4
5
|
interface Request {
|
|
5
6
|
jsonrpc: "2.0";
|
|
@@ -15,6 +16,8 @@ interface ToolDefinition {
|
|
|
15
16
|
export declare const TOOLS: ToolDefinition[];
|
|
16
17
|
export interface McpOptions {
|
|
17
18
|
fetcher?: typeof fetch;
|
|
19
|
+
/** Where stations are asked; the tests hand in a local daemon. */
|
|
20
|
+
stations?: StationToolOptions;
|
|
18
21
|
/** Injected by the tests; the session on disk otherwise. */
|
|
19
22
|
session?: {
|
|
20
23
|
site: string;
|
package/dist/mcp.js
CHANGED
|
@@ -30,6 +30,8 @@ import { describeRecord } from "./hash.js";
|
|
|
30
30
|
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
|
+
import { STATION_TOOLS, callStationTool, isStationTool } from "./station-mcp.js";
|
|
34
|
+
import { LIBRARY_TOOLS, callLibraryTool, isLibraryTool } from "./library-mcp.js";
|
|
33
35
|
export const PROTOCOL_VERSION = "2025-06-18";
|
|
34
36
|
const STRING = { type: "string" };
|
|
35
37
|
export const TOOLS = [
|
|
@@ -215,6 +217,8 @@ export const TOOLS = [
|
|
|
215
217
|
required: ["server"],
|
|
216
218
|
},
|
|
217
219
|
},
|
|
220
|
+
...STATION_TOOLS,
|
|
221
|
+
...LIBRARY_TOOLS,
|
|
218
222
|
];
|
|
219
223
|
function said(line) {
|
|
220
224
|
return `${line.createdAt} ${line.handle}: ${line.body}`;
|
|
@@ -246,6 +250,11 @@ function describe(row) {
|
|
|
246
250
|
* pipe, and so the same call is reachable from anywhere else that wants it.
|
|
247
251
|
*/
|
|
248
252
|
export async function callTool(name, args, options = {}) {
|
|
253
|
+
// Stations first: on this machine they need the daemon, not a sign-in.
|
|
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);
|
|
257
|
+
}
|
|
249
258
|
const session = options.session === undefined ? readSession() : options.session;
|
|
250
259
|
if (!session) {
|
|
251
260
|
return failed("This machine is not signed in to nixamp. Run `nixamp login`, or set NIXAMP_TOKEN.");
|
|
@@ -551,7 +560,7 @@ export async function handleMessage(message, options = {}) {
|
|
|
551
560
|
return reply({
|
|
552
561
|
protocolVersion: PROTOCOL_VERSION,
|
|
553
562
|
capabilities: { tools: { listChanged: false } },
|
|
554
|
-
serverInfo: { name: "nixamp", title: "nixamp: watch parties, rooms and transcripts", version: "2" },
|
|
563
|
+
serverInfo: { name: "nixamp", title: "nixamp: stations, watch parties, rooms and transcripts", version: "2" },
|
|
555
564
|
instructions: "Watch parties on nixamp: a party lives on the site hosting the film and is bridged here as a room every nixamp client can join; codes are the ones that site shows, positions are seconds into the film. Rooms: say and read trollbox lines, hear a recording. Transcripts: a file, a link or a live is written down once by nixamp.com's own ear and kept under what it is; ask for it in another language and it is translated once and kept too.",
|
|
556
565
|
});
|
|
557
566
|
}
|
package/dist/playlist-input.d.ts
CHANGED
|
@@ -51,6 +51,16 @@ export declare class PlaylistInputStream {
|
|
|
51
51
|
private entries;
|
|
52
52
|
error: Error | null;
|
|
53
53
|
complete: boolean;
|
|
54
|
+
/**
|
|
55
|
+
* What each entry's own tags say, read when it was probed to be played --
|
|
56
|
+
* so a station can say what is on before the library has got round to
|
|
57
|
+
* reading the file, which on a big library is a while.
|
|
58
|
+
*/
|
|
59
|
+
readonly tags: Map<string, {
|
|
60
|
+
title: string;
|
|
61
|
+
artist: string;
|
|
62
|
+
album: string;
|
|
63
|
+
}>;
|
|
54
64
|
constructor(options: PlaylistInput);
|
|
55
65
|
/** Every entry that has started, in the order it started, with where it sits in the stream. */
|
|
56
66
|
get history(): ReadonlyArray<{
|
package/dist/playlist-input.js
CHANGED
|
@@ -16,6 +16,12 @@ export class PlaylistInputStream {
|
|
|
16
16
|
entries = [];
|
|
17
17
|
error = null;
|
|
18
18
|
complete = false;
|
|
19
|
+
/**
|
|
20
|
+
* What each entry's own tags say, read when it was probed to be played --
|
|
21
|
+
* so a station can say what is on before the library has got round to
|
|
22
|
+
* reading the file, which on a big library is a while.
|
|
23
|
+
*/
|
|
24
|
+
tags = new Map();
|
|
19
25
|
constructor(options) {
|
|
20
26
|
this.options = options;
|
|
21
27
|
}
|
|
@@ -102,6 +108,9 @@ export class PlaylistInputStream {
|
|
|
102
108
|
return;
|
|
103
109
|
if (!codecs.audio && !codecs.video)
|
|
104
110
|
throw new Error(`Cannot decode playlist entry ${at + 1}`);
|
|
111
|
+
if (codecs.tags && (codecs.tags.title || codecs.tags.artist)) {
|
|
112
|
+
this.tags.set(file, { title: codecs.tags.title ?? "", artist: codecs.tags.artist ?? "", album: codecs.tags.album ?? "" });
|
|
113
|
+
}
|
|
105
114
|
const duration = (codecs.duration ?? 0) - position;
|
|
106
115
|
if (duration <= 0)
|
|
107
116
|
throw new Error(`No playable duration for playlist entry ${at + 1}`);
|
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,
|
|
@@ -5910,7 +5953,7 @@ function isFile(path) {
|
|
|
5910
5953
|
return false;
|
|
5911
5954
|
}
|
|
5912
5955
|
}
|
|
5913
|
-
function sendFile(request, response, file) {
|
|
5956
|
+
function sendFile(request, response, file, cache) {
|
|
5914
5957
|
let size;
|
|
5915
5958
|
try {
|
|
5916
5959
|
size = statSync(file).size;
|
|
@@ -5928,7 +5971,7 @@ function sendFile(request, response, file) {
|
|
|
5928
5971
|
};
|
|
5929
5972
|
// The shell must never be cached by a service worker's fetch fallback, but
|
|
5930
5973
|
// hashed assets and audio can be.
|
|
5931
|
-
headers["cache-control"] = type.startsWith("text/html") ? "no-cache" : "public, max-age=3600";
|
|
5974
|
+
headers["cache-control"] = cache ?? (type.startsWith("text/html") ? "no-cache" : "public, max-age=3600");
|
|
5932
5975
|
if (range) {
|
|
5933
5976
|
headers["content-range"] = `bytes ${range.start}-${range.end}/${size}`;
|
|
5934
5977
|
headers["content-length"] = String(range.end - range.start + 1);
|
|
@@ -6067,6 +6110,25 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
6067
6110
|
stationRunner?.onChannelEnd(info);
|
|
6068
6111
|
},
|
|
6069
6112
|
});
|
|
6113
|
+
// The library as an index kept beside the keys: what each file is, learned
|
|
6114
|
+
// once and asked about again only when it changes. The folder this server
|
|
6115
|
+
// was started on is its first source.
|
|
6116
|
+
const libraryIndex = new LibraryIndex(LibraryIndex.fileFor(stateDir(), options.port));
|
|
6117
|
+
const homeSource = (() => {
|
|
6118
|
+
try {
|
|
6119
|
+
return !isRemote(root) && statSync(root).isDirectory() ? libraryIndex.ensureSource(root, options.owner || "") : null;
|
|
6120
|
+
}
|
|
6121
|
+
catch {
|
|
6122
|
+
return null;
|
|
6123
|
+
}
|
|
6124
|
+
})();
|
|
6125
|
+
const probeFile = (path) => probeItem(tools, path);
|
|
6126
|
+
const retagPlayer = (tracks) => {
|
|
6127
|
+
if (tracks.length > 0)
|
|
6128
|
+
engine.retag(tracks, root);
|
|
6129
|
+
};
|
|
6130
|
+
const listeners = new ListenerStore(ListenerStore.fileFor(stateDir(), options.port));
|
|
6131
|
+
const library = { index: libraryIndex, probe: probeFile, retag: retagPlayer, listeners };
|
|
6070
6132
|
const stationStore = new StationStore(StationStore.fileFor(stateDir(), options.port));
|
|
6071
6133
|
stationRunner = new StationRunner({
|
|
6072
6134
|
store: stationStore,
|
|
@@ -6078,7 +6140,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
6078
6140
|
audioBitrate: known.audioBitrate,
|
|
6079
6141
|
})),
|
|
6080
6142
|
// 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 !== ""),
|
|
6143
|
+
roots: () => [...new Set([engine.snapshot(false).root || chosen, ...libraryIndex.roots()])].filter((one) => one !== ""),
|
|
6082
6144
|
log: (line) => console.log(line),
|
|
6083
6145
|
});
|
|
6084
6146
|
const stations = { store: stationStore, runner: stationRunner };
|
|
@@ -6661,6 +6723,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
6661
6723
|
owner,
|
|
6662
6724
|
channels,
|
|
6663
6725
|
stations,
|
|
6726
|
+
library,
|
|
6664
6727
|
rememberChannels: remembering,
|
|
6665
6728
|
catalogs,
|
|
6666
6729
|
publishUrls: () => publishUrls,
|
|
@@ -6939,14 +7002,22 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
6939
7002
|
console.log(`nixamp serve — ${found.length} tracks under ${root}`);
|
|
6940
7003
|
if (isRemote(root))
|
|
6941
7004
|
return;
|
|
6942
|
-
|
|
6943
|
-
|
|
6944
|
-
|
|
6945
|
-
|
|
6946
|
-
|
|
6947
|
-
|
|
6948
|
-
|
|
6949
|
-
|
|
7005
|
+
if (!homeSource) {
|
|
7006
|
+
// A playlist file or a single file: tagged as it always was.
|
|
7007
|
+
return loadTagged(tools, root, probeAsync, found.map((track) => track.path))
|
|
7008
|
+
.then((tagged) => engine.retag(tagged, root))
|
|
7009
|
+
.catch(() => undefined);
|
|
7010
|
+
}
|
|
7011
|
+
// What is already known, at once: a restart has its tags before the
|
|
7012
|
+
// first listener arrives. Then only new and changed files are asked
|
|
7013
|
+
// about, and their tags reach the player a couple of hundred at a time
|
|
7014
|
+
// rather than all at the end.
|
|
7015
|
+
retagPlayer(libraryIndex.tracksFor(found.map((track) => track.path)));
|
|
7016
|
+
const { done } = libraryIndex.scan(homeSource.id, probeFile, {
|
|
7017
|
+
onProgress: (changed) => retagPlayer(changed.filter((one) => one.state === "ready").map(trackOf)),
|
|
7018
|
+
});
|
|
7019
|
+
return done.then((report) => {
|
|
7020
|
+
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
7021
|
});
|
|
6951
7022
|
})
|
|
6952
7023
|
.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) } : {}),
|