engine-dj-mcp 0.9.2 → 0.10.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.
@@ -0,0 +1,240 @@
1
+ import { type EngineError } from "./errors.js";
2
+ import type { QueryProcess } from "./proc/query-client.js";
3
+ /**
4
+ * Engine stores both playlist order and playlist-entry order as singly
5
+ * linked lists, not as a position column:
6
+ *
7
+ * Playlist.nextListId — the next sibling under the same parentListId
8
+ * PlaylistEntity.nextEntityId — the next entry in the same listId
9
+ *
10
+ * Both terminate at 0.
11
+ *
12
+ * The schema also ships a `PlaylistPath` view carrying a column literally
13
+ * called `position`, which is the obvious thing to reach for and is wrong.
14
+ * Its `OrderedList` CTE anchors on `WHERE nextListId = 0` and counts
15
+ * *upwards from the tail*, then orders by that count ascending — so its
16
+ * `position` runs backwards. Measured on the reference library (16
17
+ * playlists): walking the chain yields
18
+ * ACID Beach, KatyaBar, Electro 1, Old Fasion 1A 120-130, Playlist, ...
19
+ * which is what Engine DJ shows in its sidebar, while the view's position
20
+ * order is that list exactly reversed (asserted in tests/playlists.test.ts
21
+ * against the same shape). The chain is the source of truth; the view is
22
+ * never read by this project.
23
+ *
24
+ * Everything here therefore walks the chain — defensively. These are linked
25
+ * lists inside a file this server does not own and never writes: a
26
+ * half-completed Engine write, a sync conflict or a partially restored
27
+ * backup can leave a cycle, a link to a row that is gone, or two
28
+ * disconnected runs. None of those may hang the walk, and none may come
29
+ * back as a silently short list that reads like a complete one.
30
+ */
31
+ /**
32
+ * The predicate that connects one `PlaylistEntity` (`e`) to its `Track` (`t`).
33
+ *
34
+ * A playlist entry does not name a local row id. It carries
35
+ * `(databaseUuid, trackId)`, which identifies the track **in the library the
36
+ * entry was made in**, and `Track` preserves that same identity in
37
+ * `(originDatabaseUuid, originTrackId)` — a pair that need not equal, and
38
+ * usually does not equal, `Track.id`. Engine's own schema says as much: the
39
+ * pair carries `CONSTRAINT C_originDatabaseUuid_originTrackId UNIQUE`, making
40
+ * it the track's natural key across drives, and a pair of triggers
41
+ * (`trigger_after_insert_Track_fix_origin` and its update twin) stamps it
42
+ * from `Information.uuid` whenever a row arrives without one. Engine's
43
+ * application binary embeds the same join verbatim:
44
+ *
45
+ * SELECT COUNT(DISTINCT databaseUuid || trackId) FROM PlaylistEntity
46
+ * JOIN Track ON (originDatabaseUuid, originTrackId) = (databaseUuid, trackId)
47
+ *
48
+ * Joining on `e.trackId = t.id` instead reads naturally and is wrong in both
49
+ * directions. Measured on the reference USB library (257 tracks, 16
50
+ * playlists, 202 entries): the id join reports 105 of 202 entries as orphans
51
+ * where the pair join finds 0, because 178 entries name a *third* library
52
+ * (`33be3313-…`) that is neither of the two attached and only 91 of the 257
53
+ * tracks carry this database's own uuid — a healthy library reported as
54
+ * riddled with holes, and one 43-entry playlist reduced to a single playable
55
+ * track. In the other direction it silently answers with the *wrong track*
56
+ * whenever a foreign entry's `trackId` happens to collide with a local row
57
+ * id, and calls a genuinely missing entry present.
58
+ *
59
+ * Written once and shared, so the three places that ask the question cannot
60
+ * drift apart. It names the aliases `t` and `e`; every call site uses them.
61
+ */
62
+ export declare const ENTRY_TRACK_MATCH = "t.originDatabaseUuid = e.databaseUuid AND t.originTrackId = e.trackId";
63
+ /** One node of an Engine linked list. `next` is 0 at the end of the chain. */
64
+ export interface Linked {
65
+ id: number;
66
+ next: number;
67
+ }
68
+ export interface ChainOrder<T> {
69
+ order: T[];
70
+ warnings: string[];
71
+ }
72
+ /**
73
+ * Orders one linked-list group, and says so when it could not.
74
+ *
75
+ * The contract that matters: `order` always contains **every** input node,
76
+ * exactly once. A chain defect degrades the *order* and raises a warning; it
77
+ * never drops a node, because a caller cannot tell a truncated list from a
78
+ * short one, and `get_playlist_tracks` returning 30 of 43 entries with no
79
+ * complaint is a worse answer than returning 43 in a partly-guessed order
80
+ * with an explanation attached.
81
+ *
82
+ * Walk termination does not rely on a magic iteration cap: every step either
83
+ * marks one previously unseen node visited or stops, so the walk is bounded
84
+ * by the group size by construction. Anything the walk could not reach is
85
+ * appended in id order.
86
+ *
87
+ * `subject` prefixes the warnings, so a caller reading a whole tree can tell
88
+ * which playlist was malformed.
89
+ */
90
+ export declare function orderByChain<T extends Linked>(nodes: readonly T[], subject: string): ChainOrder<T>;
91
+ /** A `Playlist` row, as this module needs it. */
92
+ export interface PlaylistRow extends Linked {
93
+ title: string;
94
+ parent: number;
95
+ isPersisted: boolean;
96
+ entryCount: number;
97
+ missingCount: number;
98
+ }
99
+ /**
100
+ * One playlist as reported to a caller.
101
+ *
102
+ * `is_folder` is derived, not stored: the schema has no folder flag, and
103
+ * Engine's own folders are simply `Playlist` rows that other `Playlist` rows
104
+ * name as their `parentListId`. So this means "has at least one child list",
105
+ * which is exactly what Engine draws as a folder -- with one honest
106
+ * consequence: a folder a DJ has emptied is indistinguishable from a
107
+ * playlist with no tracks, and is reported as the latter.
108
+ *
109
+ * `track_count` is entries in this list itself, never a total rolled up from
110
+ * children, so it matches the number Engine shows beside the playlist.
111
+ * `missing_count` is how many of those entries name a track that is not in
112
+ * this library -- see `get_playlist_tracks` for why that is routine rather
113
+ * than a corruption.
114
+ */
115
+ export interface PlaylistItem {
116
+ id: number;
117
+ name: string;
118
+ /** Full path from the top of the tree, `/`-separated. Unique across the tree. */
119
+ path: string;
120
+ parent_id: number | null;
121
+ depth: number;
122
+ is_folder: boolean;
123
+ is_persisted: boolean;
124
+ track_count: number;
125
+ missing_count: number;
126
+ }
127
+ /**
128
+ * Flattens the playlist forest into Engine's own display order: each sibling
129
+ * group in `nextListId` chain order, each list immediately followed by its
130
+ * own children (pre-order), which is exactly what an expanded Engine sidebar
131
+ * shows top to bottom.
132
+ *
133
+ * Flat-plus-`depth`-plus-`path` rather than nested `children` arrays: the
134
+ * order Engine displays is then readable in a single pass down the array
135
+ * without reconstructing a traversal, the result truncates to a genuine
136
+ * prefix of the sidebar, and `path` gives every list a unique handle that a
137
+ * bare name does not (see `findPlaylistByName`).
138
+ */
139
+ export declare function buildPlaylistTree(rows: readonly PlaylistRow[]): {
140
+ items: PlaylistItem[];
141
+ warnings: string[];
142
+ };
143
+ export interface PlaylistTree {
144
+ items: PlaylistItem[];
145
+ warnings: string[];
146
+ /** Playlists in the library, even when more were found than were returned. */
147
+ total: number;
148
+ truncated: boolean;
149
+ }
150
+ /**
151
+ * Reads every playlist plus its entry counts, and orders the result.
152
+ *
153
+ * The counts come from one grouped pass over `PlaylistEntity` rather than a
154
+ * correlated subquery per playlist, and `missing_count` is computed in the
155
+ * same pass: "how many of these can I actually play" is not a rare question
156
+ * and must not cost a second round trip per list.
157
+ *
158
+ * `missing` is an EXISTS semi-join on the natural key (see
159
+ * ENTRY_TRACK_MATCH), not a LEFT JOIN to Track. Membership is the whole
160
+ * question, and a semi-join cannot inflate `n`: a joined row set would count
161
+ * one entry twice if two tracks ever answered to the same origin key, which
162
+ * Engine's UNIQUE constraint forbids but this server has no way to enforce
163
+ * on a file it does not own.
164
+ */
165
+ export declare function loadPlaylistTree(qp: QueryProcess): Promise<PlaylistTree | EngineError>;
166
+ /**
167
+ * Every list matching a caller-supplied name, most specific interpretation
168
+ * first.
169
+ *
170
+ * `Playlist` is unique on `(title, parentListId)` only, so a bare name is
171
+ * genuinely ambiguous the moment a DJ uses folders -- "House" under
172
+ * *Warmup* and "House" under *Peak* are two different playlists and neither
173
+ * is the obvious winner. A full path is unique by construction, so it is
174
+ * tried first and gives a caller a way to say precisely which one they mean;
175
+ * a bare title is tried next and may legitimately return several, which the
176
+ * caller turns into an error rather than an arbitrary pick.
177
+ *
178
+ * Case-insensitive matching is a fallback tier, not the primary rule: a
179
+ * library containing both "Peak" and "peak" as siblings is legal, and an
180
+ * exact match must win outright rather than being reported as ambiguous
181
+ * against its own differently-cased neighbour.
182
+ */
183
+ export declare function findPlaylistByName(items: readonly PlaylistItem[], name: string): PlaylistItem[];
184
+ export interface PlaylistSelector {
185
+ id?: number;
186
+ name?: string;
187
+ }
188
+ /** The argument names to quote back in an error, so each tool blames its own. */
189
+ export interface SelectorNames {
190
+ id: string;
191
+ name: string;
192
+ }
193
+ export interface ResolvedPlaylist {
194
+ playlist: PlaylistItem;
195
+ /** Chain warnings raised while building the tree this playlist came from. */
196
+ warnings: string[];
197
+ }
198
+ /**
199
+ * Turns "id or name" into one specific playlist, or an actionable error.
200
+ *
201
+ * Ambiguity is never resolved by picking: two playlists really can share a
202
+ * name, and quietly answering about the wrong one is the failure mode this
203
+ * whole function exists to prevent. The error names every candidate with its
204
+ * id and its full path, so the retry is a copy-paste rather than a guess.
205
+ *
206
+ * Deliberately reuses `invalid_argument` rather than adding an error code:
207
+ * the taxonomy is closed (see errors.ts), and "the playlist you named is not
208
+ * in this library" is a problem with the argument, reported the same way an
209
+ * unknown field name is -- with the recognised values in `detail`.
210
+ */
211
+ export declare function resolvePlaylist(qp: QueryProcess, sel: PlaylistSelector, names?: SelectorNames): Promise<ResolvedPlaylist | EngineError>;
212
+ /** One entry of a playlist, in playlist order. */
213
+ export interface OrderedEntry extends Linked {
214
+ /**
215
+ * `PlaylistEntity.trackId` — the track's `originTrackId` in the library
216
+ * `databaseUuid` names, **not** a local `Track.id`. The two halves only
217
+ * mean anything together (see ENTRY_TRACK_MATCH), and together they need
218
+ * not name a track this library holds.
219
+ */
220
+ trackId: number;
221
+ /** The library this entry was made in. Null only in a malformed row. */
222
+ databaseUuid: string | null;
223
+ /** 1-based position within the playlist. */
224
+ position: number;
225
+ }
226
+ export interface PlaylistEntries {
227
+ entries: OrderedEntry[];
228
+ warnings: string[];
229
+ }
230
+ /**
231
+ * Every entry of one playlist, in `nextEntityId` chain order.
232
+ *
233
+ * The whole playlist is read before a page is cut from it, because position
234
+ * *is* the ordering: there is no indexed column to seek on, so a page can
235
+ * only be taken from an already-ordered list. That is affordable -- the
236
+ * reference library's largest playlist is 43 entries and `PlaylistEntity` is
237
+ * three integers wide -- and it is bounded by MAX_ENTRIES rather than by
238
+ * hope.
239
+ */
240
+ export declare function loadPlaylistEntries(qp: QueryProcess, playlist: PlaylistItem): Promise<PlaylistEntries | EngineError>;
@@ -0,0 +1,447 @@
1
+ // src/playlists.ts
2
+ import { err, isEngineError } from "./errors.js";
3
+ /**
4
+ * Engine stores both playlist order and playlist-entry order as singly
5
+ * linked lists, not as a position column:
6
+ *
7
+ * Playlist.nextListId — the next sibling under the same parentListId
8
+ * PlaylistEntity.nextEntityId — the next entry in the same listId
9
+ *
10
+ * Both terminate at 0.
11
+ *
12
+ * The schema also ships a `PlaylistPath` view carrying a column literally
13
+ * called `position`, which is the obvious thing to reach for and is wrong.
14
+ * Its `OrderedList` CTE anchors on `WHERE nextListId = 0` and counts
15
+ * *upwards from the tail*, then orders by that count ascending — so its
16
+ * `position` runs backwards. Measured on the reference library (16
17
+ * playlists): walking the chain yields
18
+ * ACID Beach, KatyaBar, Electro 1, Old Fasion 1A 120-130, Playlist, ...
19
+ * which is what Engine DJ shows in its sidebar, while the view's position
20
+ * order is that list exactly reversed (asserted in tests/playlists.test.ts
21
+ * against the same shape). The chain is the source of truth; the view is
22
+ * never read by this project.
23
+ *
24
+ * Everything here therefore walks the chain — defensively. These are linked
25
+ * lists inside a file this server does not own and never writes: a
26
+ * half-completed Engine write, a sync conflict or a partially restored
27
+ * backup can leave a cycle, a link to a row that is gone, or two
28
+ * disconnected runs. None of those may hang the walk, and none may come
29
+ * back as a silently short list that reads like a complete one.
30
+ */
31
+ /**
32
+ * The predicate that connects one `PlaylistEntity` (`e`) to its `Track` (`t`).
33
+ *
34
+ * A playlist entry does not name a local row id. It carries
35
+ * `(databaseUuid, trackId)`, which identifies the track **in the library the
36
+ * entry was made in**, and `Track` preserves that same identity in
37
+ * `(originDatabaseUuid, originTrackId)` — a pair that need not equal, and
38
+ * usually does not equal, `Track.id`. Engine's own schema says as much: the
39
+ * pair carries `CONSTRAINT C_originDatabaseUuid_originTrackId UNIQUE`, making
40
+ * it the track's natural key across drives, and a pair of triggers
41
+ * (`trigger_after_insert_Track_fix_origin` and its update twin) stamps it
42
+ * from `Information.uuid` whenever a row arrives without one. Engine's
43
+ * application binary embeds the same join verbatim:
44
+ *
45
+ * SELECT COUNT(DISTINCT databaseUuid || trackId) FROM PlaylistEntity
46
+ * JOIN Track ON (originDatabaseUuid, originTrackId) = (databaseUuid, trackId)
47
+ *
48
+ * Joining on `e.trackId = t.id` instead reads naturally and is wrong in both
49
+ * directions. Measured on the reference USB library (257 tracks, 16
50
+ * playlists, 202 entries): the id join reports 105 of 202 entries as orphans
51
+ * where the pair join finds 0, because 178 entries name a *third* library
52
+ * (`33be3313-…`) that is neither of the two attached and only 91 of the 257
53
+ * tracks carry this database's own uuid — a healthy library reported as
54
+ * riddled with holes, and one 43-entry playlist reduced to a single playable
55
+ * track. In the other direction it silently answers with the *wrong track*
56
+ * whenever a foreign entry's `trackId` happens to collide with a local row
57
+ * id, and calls a genuinely missing entry present.
58
+ *
59
+ * Written once and shared, so the three places that ask the question cannot
60
+ * drift apart. It names the aliases `t` and `e`; every call site uses them.
61
+ */
62
+ export const ENTRY_TRACK_MATCH = "t.originDatabaseUuid = e.databaseUuid AND t.originTrackId = e.trackId";
63
+ /**
64
+ * Orders one linked-list group, and says so when it could not.
65
+ *
66
+ * The contract that matters: `order` always contains **every** input node,
67
+ * exactly once. A chain defect degrades the *order* and raises a warning; it
68
+ * never drops a node, because a caller cannot tell a truncated list from a
69
+ * short one, and `get_playlist_tracks` returning 30 of 43 entries with no
70
+ * complaint is a worse answer than returning 43 in a partly-guessed order
71
+ * with an explanation attached.
72
+ *
73
+ * Walk termination does not rely on a magic iteration cap: every step either
74
+ * marks one previously unseen node visited or stops, so the walk is bounded
75
+ * by the group size by construction. Anything the walk could not reach is
76
+ * appended in id order.
77
+ *
78
+ * `subject` prefixes the warnings, so a caller reading a whole tree can tell
79
+ * which playlist was malformed.
80
+ */
81
+ export function orderByChain(nodes, subject) {
82
+ const warnings = [];
83
+ if (nodes.length <= 1)
84
+ return { order: [...nodes], warnings };
85
+ const byId = new Map();
86
+ for (const n of nodes)
87
+ byId.set(n.id, n);
88
+ // A head is a node nothing else in this group points at. A self-link
89
+ // (next === id) is not a reference to a *different* node, so it must not
90
+ // hide its own node from the head search -- otherwise a single self-linked
91
+ // row leaves the group headless and falls back to id order for no reason.
92
+ const referenced = new Set();
93
+ for (const n of nodes) {
94
+ if (n.next !== n.id && byId.has(n.next))
95
+ referenced.add(n.next);
96
+ }
97
+ const selfLinked = nodes.filter((n) => n.next === n.id);
98
+ if (selfLinked.length) {
99
+ warnings.push(`${subject}: ${selfLinked.length === 1 ? "entry" : "entries"} ` +
100
+ `${selfLinked.map((n) => `#${n.id}`).join(", ")} link to themselves; ` +
101
+ `the chain is treated as ending there`);
102
+ }
103
+ const heads = nodes.filter((n) => !referenced.has(n.id)).sort((a, b) => a.id - b.id);
104
+ if (heads.length === 0) {
105
+ warnings.push(`${subject}: the link chain has no start -- every element is pointed at by another, ` +
106
+ `so it is a closed loop. All ${nodes.length} are listed in id order instead.`);
107
+ }
108
+ else if (heads.length > 1) {
109
+ warnings.push(`${subject}: the link chain is broken into ${heads.length} disconnected runs; ` +
110
+ `they are listed one after another, each run starting from its lowest-numbered element.`);
111
+ }
112
+ const order = [];
113
+ const visited = new Set();
114
+ for (const head of heads) {
115
+ let cur = head;
116
+ while (cur) {
117
+ if (visited.has(cur.id)) {
118
+ // Reachable two ways: a genuine cycle, or two runs converging on a
119
+ // shared tail. Either way the remainder of this run is already
120
+ // placed, so stop rather than re-emitting it.
121
+ warnings.push(`${subject}: following the chain reached #${cur.id} a second time; ` +
122
+ `ordering of that run stopped there`);
123
+ break;
124
+ }
125
+ visited.add(cur.id);
126
+ order.push(cur);
127
+ if (cur.next === 0 || cur.next === cur.id)
128
+ break;
129
+ const next = byId.get(cur.next);
130
+ if (!next) {
131
+ warnings.push(`${subject}: #${cur.id} links to #${cur.next}, which is not in this list; ` +
132
+ `ordering of that run stopped there`);
133
+ break;
134
+ }
135
+ cur = next;
136
+ }
137
+ }
138
+ const leftover = nodes.filter((n) => !visited.has(n.id)).sort((a, b) => a.id - b.id);
139
+ if (leftover.length) {
140
+ if (heads.length > 0) {
141
+ warnings.push(`${subject}: ${leftover.length} of ${nodes.length} could not be reached by following ` +
142
+ `the chain; they are listed after it, in id order`);
143
+ }
144
+ order.push(...leftover);
145
+ }
146
+ return { order, warnings };
147
+ }
148
+ /**
149
+ * Recursion bound for the folder tree. A parent cycle cannot get past the
150
+ * `placed` set below, so this only guards a legitimately -- absurdly -- deep
151
+ * tree from exhausting the JS stack.
152
+ */
153
+ const MAX_DEPTH = 64;
154
+ /** Playlists read in one call. Beyond this the tree is reported truncated. */
155
+ const MAX_PLAYLISTS = 2000;
156
+ /** Entries ordered in one call, per playlist. */
157
+ const MAX_ENTRIES = 50_000;
158
+ /**
159
+ * Flattens the playlist forest into Engine's own display order: each sibling
160
+ * group in `nextListId` chain order, each list immediately followed by its
161
+ * own children (pre-order), which is exactly what an expanded Engine sidebar
162
+ * shows top to bottom.
163
+ *
164
+ * Flat-plus-`depth`-plus-`path` rather than nested `children` arrays: the
165
+ * order Engine displays is then readable in a single pass down the array
166
+ * without reconstructing a traversal, the result truncates to a genuine
167
+ * prefix of the sidebar, and `path` gives every list a unique handle that a
168
+ * bare name does not (see `findPlaylistByName`).
169
+ */
170
+ export function buildPlaylistTree(rows) {
171
+ const warnings = [];
172
+ const byId = new Map();
173
+ for (const r of rows)
174
+ byId.set(r.id, r);
175
+ // Grouped by parent. A row whose parentListId names no existing playlist
176
+ // is a real possibility (the parent folder was deleted without its
177
+ // children, or only part of a library was restored) and is surfaced at the
178
+ // top level rather than dropped -- an invisible playlist is the one
179
+ // outcome worth avoiding here.
180
+ const ROOT = 0;
181
+ const children = new Map();
182
+ for (const r of rows) {
183
+ let parent = r.parent;
184
+ if (parent === r.id) {
185
+ warnings.push(`Playlist #${r.id} "${r.title}" is its own parent; it is listed at the top level`);
186
+ parent = ROOT;
187
+ }
188
+ else if (parent !== ROOT && !byId.has(parent)) {
189
+ warnings.push(`Playlist #${r.id} "${r.title}" names parent #${parent}, which does not exist; ` +
190
+ `it is listed at the top level`);
191
+ parent = ROOT;
192
+ }
193
+ const group = children.get(parent);
194
+ if (group)
195
+ group.push(r);
196
+ else
197
+ children.set(parent, [r]);
198
+ }
199
+ const items = [];
200
+ const placed = new Set();
201
+ const walk = (parentId, depth, prefix) => {
202
+ const group = children.get(parentId);
203
+ if (!group)
204
+ return;
205
+ const subject = parentId === ROOT ? "Top-level playlists" : `Playlist #${parentId}`;
206
+ const { order, warnings: chainWarnings } = orderByChain(group, subject);
207
+ warnings.push(...chainWarnings);
208
+ for (const row of order) {
209
+ if (placed.has(row.id))
210
+ continue;
211
+ placed.add(row.id);
212
+ const path = prefix ? `${prefix}/${row.title}` : row.title;
213
+ const kids = children.get(row.id);
214
+ items.push({
215
+ id: row.id,
216
+ name: row.title,
217
+ path,
218
+ parent_id: parentId === ROOT ? null : parentId,
219
+ depth,
220
+ is_folder: Boolean(kids && kids.length),
221
+ is_persisted: row.isPersisted,
222
+ track_count: row.entryCount,
223
+ missing_count: row.missingCount,
224
+ });
225
+ if (!kids || !kids.length)
226
+ continue;
227
+ if (depth + 1 >= MAX_DEPTH) {
228
+ warnings.push(`Playlist #${row.id} "${row.title}" nests deeper than ${MAX_DEPTH} levels; ` +
229
+ `its children are listed at the top level instead`);
230
+ continue;
231
+ }
232
+ walk(row.id, depth + 1, path);
233
+ }
234
+ };
235
+ walk(ROOT, 0, "");
236
+ // Anything still unplaced sits in a parent cycle (A's parent is B, B's
237
+ // parent is A), so no root traversal can reach it. Surface it flat.
238
+ const unreachable = rows.filter((r) => !placed.has(r.id)).sort((a, b) => a.id - b.id);
239
+ if (unreachable.length) {
240
+ warnings.push(`${unreachable.length} playlist(s) are inside a parentListId loop and belong to no top-level ` +
241
+ `branch; they are listed at the end, in id order`);
242
+ for (const row of unreachable) {
243
+ placed.add(row.id);
244
+ items.push({
245
+ id: row.id,
246
+ name: row.title,
247
+ path: row.title,
248
+ parent_id: row.parent === 0 ? null : row.parent,
249
+ depth: 0,
250
+ is_folder: Boolean(children.get(row.id)?.length),
251
+ is_persisted: row.isPersisted,
252
+ track_count: row.entryCount,
253
+ missing_count: row.missingCount,
254
+ });
255
+ }
256
+ }
257
+ return { items, warnings };
258
+ }
259
+ /**
260
+ * Reads every playlist plus its entry counts, and orders the result.
261
+ *
262
+ * The counts come from one grouped pass over `PlaylistEntity` rather than a
263
+ * correlated subquery per playlist, and `missing_count` is computed in the
264
+ * same pass: "how many of these can I actually play" is not a rare question
265
+ * and must not cost a second round trip per list.
266
+ *
267
+ * `missing` is an EXISTS semi-join on the natural key (see
268
+ * ENTRY_TRACK_MATCH), not a LEFT JOIN to Track. Membership is the whole
269
+ * question, and a semi-join cannot inflate `n`: a joined row set would count
270
+ * one entry twice if two tracks ever answered to the same origin key, which
271
+ * Engine's UNIQUE constraint forbids but this server has no way to enforce
272
+ * on a file it does not own.
273
+ */
274
+ export async function loadPlaylistTree(qp) {
275
+ const res = await qp.run(`SELECT p.id, p.title, p.parentListId, p.isPersisted, p.nextListId,
276
+ COALESCE(c.n, 0) AS entry_count, COALESCE(c.missing, 0) AS missing_count
277
+ FROM main.Playlist p
278
+ LEFT JOIN (SELECT e.listId AS listId, COUNT(*) AS n,
279
+ SUM(CASE WHEN EXISTS (SELECT 1 FROM main.Track t
280
+ WHERE ${ENTRY_TRACK_MATCH})
281
+ THEN 0 ELSE 1 END) AS missing
282
+ FROM main.PlaylistEntity e
283
+ GROUP BY e.listId) c ON c.listId = p.id
284
+ ORDER BY p.id
285
+ LIMIT ?`,
286
+ // One over the cap, so "there are more" is observed rather than inferred
287
+ // from a full page.
288
+ [MAX_PLAYLISTS + 1]);
289
+ if (isEngineError(res))
290
+ return res;
291
+ const idx = Object.fromEntries(res.columns.map((c, i) => [c, i]));
292
+ const truncated = res.rows.length > MAX_PLAYLISTS;
293
+ const kept = truncated ? res.rows.slice(0, MAX_PLAYLISTS) : res.rows;
294
+ const rows = kept.map((r) => ({
295
+ id: Number(r[idx.id]),
296
+ // Engine's column is nullable; a nameless playlist must still be
297
+ // listed (and addressable by id) rather than crash the projection.
298
+ title: r[idx.title] === null || r[idx.title] === undefined ? "" : String(r[idx.title]),
299
+ parent: Number(r[idx.parentListId] ?? 0),
300
+ isPersisted: Boolean(Number(r[idx.isPersisted] ?? 0)),
301
+ next: Number(r[idx.nextListId] ?? 0),
302
+ entryCount: Number(r[idx.entry_count] ?? 0),
303
+ missingCount: Number(r[idx.missing_count] ?? 0),
304
+ }));
305
+ const { items, warnings } = buildPlaylistTree(rows);
306
+ if (truncated) {
307
+ warnings.unshift(`This library holds more than ${MAX_PLAYLISTS} playlists; only the first ${MAX_PLAYLISTS} ` +
308
+ `by id were read, so the reported order and nesting are incomplete`);
309
+ }
310
+ return { items, warnings, total: rows.length, truncated };
311
+ }
312
+ /**
313
+ * Every list matching a caller-supplied name, most specific interpretation
314
+ * first.
315
+ *
316
+ * `Playlist` is unique on `(title, parentListId)` only, so a bare name is
317
+ * genuinely ambiguous the moment a DJ uses folders -- "House" under
318
+ * *Warmup* and "House" under *Peak* are two different playlists and neither
319
+ * is the obvious winner. A full path is unique by construction, so it is
320
+ * tried first and gives a caller a way to say precisely which one they mean;
321
+ * a bare title is tried next and may legitimately return several, which the
322
+ * caller turns into an error rather than an arbitrary pick.
323
+ *
324
+ * Case-insensitive matching is a fallback tier, not the primary rule: a
325
+ * library containing both "Peak" and "peak" as siblings is legal, and an
326
+ * exact match must win outright rather than being reported as ambiguous
327
+ * against its own differently-cased neighbour.
328
+ */
329
+ export function findPlaylistByName(items, name) {
330
+ const wanted = name.trim();
331
+ if (!wanted)
332
+ return [];
333
+ const fold = (s) => s.trim().toLowerCase();
334
+ const folded = fold(wanted);
335
+ const tiers = [
336
+ items.filter((i) => i.path === wanted),
337
+ items.filter((i) => i.name === wanted),
338
+ items.filter((i) => fold(i.path) === folded),
339
+ items.filter((i) => fold(i.name) === folded),
340
+ ];
341
+ for (const tier of tiers)
342
+ if (tier.length)
343
+ return tier;
344
+ return [];
345
+ }
346
+ /** How many playlists an error message lists before summarising the rest. */
347
+ const NAMED_IN_ERROR = 25;
348
+ function describe(items) {
349
+ const shown = items.slice(0, NAMED_IN_ERROR).map((i) => `${i.id} -- ${i.path}`).join("; ");
350
+ return items.length > NAMED_IN_ERROR
351
+ ? `${shown}; and ${items.length - NAMED_IN_ERROR} more (call get_playlists to see them all)`
352
+ : shown;
353
+ }
354
+ /**
355
+ * Turns "id or name" into one specific playlist, or an actionable error.
356
+ *
357
+ * Ambiguity is never resolved by picking: two playlists really can share a
358
+ * name, and quietly answering about the wrong one is the failure mode this
359
+ * whole function exists to prevent. The error names every candidate with its
360
+ * id and its full path, so the retry is a copy-paste rather than a guess.
361
+ *
362
+ * Deliberately reuses `invalid_argument` rather than adding an error code:
363
+ * the taxonomy is closed (see errors.ts), and "the playlist you named is not
364
+ * in this library" is a problem with the argument, reported the same way an
365
+ * unknown field name is -- with the recognised values in `detail`.
366
+ */
367
+ export async function resolvePlaylist(qp, sel, names = { id: "playlist_id", name: "playlist_name" }) {
368
+ const hasId = sel.id !== undefined && sel.id !== null;
369
+ const hasName = typeof sel.name === "string" && sel.name.trim() !== "";
370
+ if (hasId && hasName) {
371
+ return err("invalid_argument", `Pass ${names.id} or ${names.name}, not both`, {
372
+ detail: "They can name different playlists, so there is no safe way to combine them.",
373
+ });
374
+ }
375
+ if (!hasId && !hasName) {
376
+ return err("invalid_argument", `Name the playlist: pass ${names.id} or ${names.name}`, {
377
+ detail: "call get_playlists to see the ids and names in this library.",
378
+ });
379
+ }
380
+ const tree = await loadPlaylistTree(qp);
381
+ if (isEngineError(tree))
382
+ return tree;
383
+ if (hasId) {
384
+ const found = tree.items.find((i) => i.id === sel.id);
385
+ if (!found) {
386
+ return err("invalid_argument", `No playlist with ${names.id} ${sel.id} in this library`, {
387
+ detail: tree.items.length
388
+ ? `Playlists (id -- path): ${describe(tree.items)}`
389
+ : "This library has no playlists.",
390
+ });
391
+ }
392
+ return { playlist: found, warnings: tree.warnings };
393
+ }
394
+ const matches = findPlaylistByName(tree.items, sel.name);
395
+ if (matches.length === 1)
396
+ return { playlist: matches[0], warnings: tree.warnings };
397
+ if (matches.length === 0) {
398
+ return err("invalid_argument", `No playlist named "${sel.name}" in this library`, {
399
+ detail: tree.items.length
400
+ ? `Playlists (id -- path): ${describe(tree.items)}`
401
+ : "This library has no playlists.",
402
+ });
403
+ }
404
+ return err("invalid_argument", `"${sel.name}" names ${matches.length} playlists in this library`, {
405
+ detail: `Playlist names are unique only within a folder. Pass ${names.id}, or pass the full ` +
406
+ `path as ${names.name}: ${describe(matches)}`,
407
+ });
408
+ }
409
+ /**
410
+ * Every entry of one playlist, in `nextEntityId` chain order.
411
+ *
412
+ * The whole playlist is read before a page is cut from it, because position
413
+ * *is* the ordering: there is no indexed column to seek on, so a page can
414
+ * only be taken from an already-ordered list. That is affordable -- the
415
+ * reference library's largest playlist is 43 entries and `PlaylistEntity` is
416
+ * three integers wide -- and it is bounded by MAX_ENTRIES rather than by
417
+ * hope.
418
+ */
419
+ export async function loadPlaylistEntries(qp, playlist) {
420
+ const res = await qp.run(`SELECT e.id, e.trackId, e.databaseUuid, e.nextEntityId
421
+ FROM main.PlaylistEntity e
422
+ WHERE e.listId = ?
423
+ ORDER BY e.id
424
+ LIMIT ?`, [playlist.id, MAX_ENTRIES + 1]);
425
+ if (isEngineError(res))
426
+ return res;
427
+ const idx = Object.fromEntries(res.columns.map((c, i) => [c, i]));
428
+ const truncated = res.rows.length > MAX_ENTRIES;
429
+ const kept = truncated ? res.rows.slice(0, MAX_ENTRIES) : res.rows;
430
+ const nodes = kept.map((r) => ({
431
+ id: Number(r[idx.id]),
432
+ trackId: Number(r[idx.trackId] ?? 0),
433
+ databaseUuid: r[idx.databaseUuid] === null || r[idx.databaseUuid] === undefined
434
+ ? null
435
+ : String(r[idx.databaseUuid]),
436
+ next: Number(r[idx.nextEntityId] ?? 0),
437
+ }));
438
+ const { order, warnings } = orderByChain(nodes, `Playlist "${playlist.name}"`);
439
+ if (truncated) {
440
+ warnings.unshift(`Playlist "${playlist.name}" holds more than ${MAX_ENTRIES} entries; only the first ` +
441
+ `${MAX_ENTRIES} by id were read, so the reported order is incomplete`);
442
+ }
443
+ return {
444
+ entries: order.map((e, i) => ({ ...e, position: i + 1 })),
445
+ warnings,
446
+ };
447
+ }