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.
Files changed (90) hide show
  1. package/dist/channels.d.ts +11 -2
  2. package/dist/channels.js +26 -4
  3. package/dist/library-cli.d.ts +3 -0
  4. package/dist/library-cli.js +198 -0
  5. package/dist/library-index.d.ts +190 -0
  6. package/dist/library-index.js +643 -0
  7. package/dist/library-mcp.d.ts +230 -0
  8. package/dist/library-mcp.js +139 -0
  9. package/dist/library-routes.d.ts +34 -0
  10. package/dist/library-routes.js +258 -0
  11. package/dist/library-usage.d.ts +3 -0
  12. package/dist/library-usage.js +20 -0
  13. package/dist/library-user.d.ts +67 -0
  14. package/dist/library-user.js +204 -0
  15. package/dist/main.js +8 -0
  16. package/dist/mcp.js +5 -2
  17. package/dist/server.d.ts +9 -0
  18. package/dist/server.js +87 -13
  19. package/dist/station-cli.js +4 -3
  20. package/dist/station-mcp.d.ts +17 -0
  21. package/dist/station-mcp.js +25 -19
  22. package/dist/station-page.d.ts +1 -1
  23. package/dist/station-page.js +25 -4
  24. package/dist/station-routes.js +54 -1
  25. package/dist/station-usage.d.ts +1 -1
  26. package/dist/station-usage.js +1 -1
  27. package/package.json +1 -1
  28. package/src/channels.ts +23 -4
  29. package/src/library-cli.ts +175 -0
  30. package/src/library-index.ts +709 -0
  31. package/src/library-mcp.ts +132 -0
  32. package/src/library-routes.ts +282 -0
  33. package/src/library-usage.ts +21 -0
  34. package/src/library-user.ts +218 -0
  35. package/src/main.ts +7 -0
  36. package/src/mcp.ts +5 -2
  37. package/src/server.ts +86 -14
  38. package/src/station-cli.ts +4 -3
  39. package/src/station-mcp.ts +22 -15
  40. package/src/station-page.ts +25 -4
  41. package/src/station-routes.ts +53 -1
  42. package/src/station-usage.ts +1 -1
  43. package/web/dist/assets/ar-4kD-mTIA.js +1 -0
  44. package/web/dist/assets/cs-CziXiWNY.js +1 -0
  45. package/web/dist/assets/da-CyjJ8UOr.js +1 -0
  46. package/web/dist/assets/de-DxwYa667.js +1 -0
  47. package/web/dist/assets/es-QgMTJeSc.js +1 -0
  48. package/web/dist/assets/fi-Clj-jHhR.js +1 -0
  49. package/web/dist/assets/fr-nH_gzSFv.js +1 -0
  50. package/web/dist/assets/hi-D7djQ6_C.js +1 -0
  51. package/web/dist/assets/hls-3VKVEQE3-BaWVouMg.js +1 -0
  52. package/web/dist/assets/hu-H5Q3L8bD.js +1 -0
  53. package/web/dist/assets/id-40z_EQdf.js +1 -0
  54. package/web/dist/assets/it-C9Fcgywl.js +1 -0
  55. package/web/dist/assets/{index-CDBIr8Tz.js → main-BJUyBy0h.js} +2 -2
  56. package/web/dist/assets/{mpegts-63ylMmge.js → mpegts-BlmyNlBC.js} +1 -1
  57. package/web/dist/assets/mpegts-LO6RVLD6-CAuth8C5.js +1 -0
  58. package/web/dist/assets/nl-CrL4Psk9.js +1 -0
  59. package/web/dist/assets/radio-B58WgC1j.js +1 -0
  60. package/web/dist/assets/radio-CC8ihiO_.css +1 -0
  61. package/web/dist/assets/ru-C84JS0ch.js +1 -0
  62. package/web/dist/assets/styles-D70tSrIe.js +1 -0
  63. package/web/dist/assets/styles-qC62Gona.css +1 -0
  64. package/web/dist/assets/sv-DtMcl0sf.js +1 -0
  65. package/web/dist/assets/uk-dOLf99vZ.js +1 -0
  66. package/web/dist/assets/vi-BWeQVRZ8.js +1 -0
  67. package/web/dist/assets/zh-Cnv3Qpux.js +1 -0
  68. package/web/dist/index.html +4 -2
  69. package/web/dist/radio.html +134 -0
  70. package/web/dist/sw.js +26 -23
  71. package/web/dist/assets/ar-ClCIWtVb.js +0 -1
  72. package/web/dist/assets/cs-CVG4U823.js +0 -1
  73. package/web/dist/assets/da-igdiQUk8.js +0 -1
  74. package/web/dist/assets/de-CHPsc6lc.js +0 -1
  75. package/web/dist/assets/es-AvbMspQX.js +0 -1
  76. package/web/dist/assets/fi-5JGUOm3W.js +0 -1
  77. package/web/dist/assets/fr-YfMwk1FR.js +0 -1
  78. package/web/dist/assets/hi-BUZuQTkp.js +0 -1
  79. package/web/dist/assets/hls-3VKVEQE3-BCXawNaW.js +0 -1
  80. package/web/dist/assets/hu-C7on-yUl.js +0 -1
  81. package/web/dist/assets/id-pIrzVeOi.js +0 -1
  82. package/web/dist/assets/index-Bo3utcDw.css +0 -1
  83. package/web/dist/assets/it-Bl9xk5B4.js +0 -1
  84. package/web/dist/assets/mpegts-LO6RVLD6-DBakOXU5.js +0 -1
  85. package/web/dist/assets/nl-b5BUih3i.js +0 -1
  86. package/web/dist/assets/ru-BlAkbEPp.js +0 -1
  87. package/web/dist/assets/sv-BEKvqELG.js +0 -1
  88. package/web/dist/assets/uk-JY8Dh56p.js +0 -1
  89. package/web/dist/assets/vi-DbtxuQPL.js +0 -1
  90. 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
- return callStationTool(name, args, { ...(options.fetcher ? { fetcher: options.fetcher } : {}), ...(options.session !== undefined ? { session: options.session } : {}), ...(options.stations ?? {}) });
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) => selectStationFiles(engine, 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
- if (isFile(direct))
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
- // Handed the files we already found. Tagging used to walk the whole
6943
- // library a second time to discover the same paths, and that second walk
6944
- // was the one that ran with the port already open.
6945
- return loadTagged(tools, root, probeAsync, found.map((track) => track.path))
6946
- .then((tagged) => engine.retag(tagged, root))
6947
- .catch(() => {
6948
- // Filenames are a working player. A failure here is worth nothing
6949
- // but titles that stay as they are.
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) => {
@@ -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
- if (items === undefined && folder === undefined) {
119
- console.error("nixamp station create needs --folder PATH or --items 0,1,2");
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) } : {}),
@@ -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 {};