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.
Files changed (69) hide show
  1. package/dist/channels.d.ts +12 -0
  2. package/dist/channels.js +8 -0
  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.d.ts +3 -0
  17. package/dist/mcp.js +10 -1
  18. package/dist/playlist-input.d.ts +10 -0
  19. package/dist/playlist-input.js +9 -0
  20. package/dist/server.d.ts +9 -0
  21. package/dist/server.js +83 -12
  22. package/dist/station-cli.js +4 -3
  23. package/dist/station-mcp.d.ts +311 -0
  24. package/dist/station-mcp.js +179 -0
  25. package/dist/station-page.d.ts +1 -1
  26. package/dist/station-page.js +2 -1
  27. package/dist/station-routes.js +2 -1
  28. package/dist/station-usage.d.ts +1 -1
  29. package/dist/station-usage.js +1 -1
  30. package/package.json +1 -1
  31. package/src/channels.ts +10 -0
  32. package/src/library-cli.ts +175 -0
  33. package/src/library-index.ts +709 -0
  34. package/src/library-mcp.ts +132 -0
  35. package/src/library-routes.ts +282 -0
  36. package/src/library-usage.ts +21 -0
  37. package/src/library-user.ts +218 -0
  38. package/src/main.ts +7 -0
  39. package/src/mcp.ts +12 -1
  40. package/src/playlist-input.ts +9 -0
  41. package/src/server.ts +83 -13
  42. package/src/station-cli.ts +4 -3
  43. package/src/station-mcp.ts +190 -0
  44. package/src/station-page.ts +2 -1
  45. package/src/station-routes.ts +2 -1
  46. package/src/station-usage.ts +1 -1
  47. package/web/dist/assets/{ar-CTUDxy1J.js → ar-C_4gIadE.js} +1 -1
  48. package/web/dist/assets/{cs-m4JTaO3o.js → cs-B18R5adY.js} +1 -1
  49. package/web/dist/assets/{da-DHnJElYB.js → da-B6w5URQ2.js} +1 -1
  50. package/web/dist/assets/{de-DkxPohnf.js → de-Dlhr9ikG.js} +1 -1
  51. package/web/dist/assets/{es-oY_G83d7.js → es-BIpArFOC.js} +1 -1
  52. package/web/dist/assets/{fi-fCO8XRdg.js → fi-o9W4XS3X.js} +1 -1
  53. package/web/dist/assets/{fr-CSupf-IN.js → fr-BgVwhPsP.js} +1 -1
  54. package/web/dist/assets/{hi-Dve7PJsC.js → hi-zIXqrXNC.js} +1 -1
  55. package/web/dist/assets/{hls-3VKVEQE3-D5-AkKSR.js → hls-3VKVEQE3-5pAjDDLr.js} +1 -1
  56. package/web/dist/assets/{hu-D1KYamzV.js → hu-Dpn_DIRi.js} +1 -1
  57. package/web/dist/assets/{id-Cofp_4aQ.js → id-Cu1V6gAm.js} +1 -1
  58. package/web/dist/assets/{index-7mCQZGNF.js → index-D-PZ4nGC.js} +2 -2
  59. package/web/dist/assets/{it-62iXQWQD.js → it-BNcoJEYQ.js} +1 -1
  60. package/web/dist/assets/{mpegts-BT5ESacM.js → mpegts-CabjhxTI.js} +1 -1
  61. package/web/dist/assets/{mpegts-LO6RVLD6-BgHd8PFT.js → mpegts-LO6RVLD6-CRSxiR1J.js} +1 -1
  62. package/web/dist/assets/{nl-CzRRG-CN.js → nl-gvivlDEE.js} +1 -1
  63. package/web/dist/assets/{ru-OykC1LJQ.js → ru-BxQnVrHu.js} +1 -1
  64. package/web/dist/assets/{sv-B8FXBDHm.js → sv-BP1JhAR8.js} +1 -1
  65. package/web/dist/assets/{uk-DhAJBjwH.js → uk-ChYhgzlf.js} +1 -1
  66. package/web/dist/assets/{vi-CdBSRReC.js → vi-CD7vzLwP.js} +1 -1
  67. package/web/dist/assets/{zh-wGWNyBEZ.js → zh-BmGS-RcE.js} +1 -1
  68. package/web/dist/index.html +1 -1
  69. 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
  }
@@ -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<{
@@ -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) => 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,
@@ -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
- // 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.
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) => {
@@ -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) } : {}),