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.
- package/README.md +259 -133
- package/dist/blobs/index.d.ts +18 -12
- package/dist/blobs/index.js +24 -16
- package/dist/playlists.d.ts +240 -0
- package/dist/playlists.js +447 -0
- package/dist/server.js +93 -11
- package/dist/tools/audit.js +12 -1
- package/dist/tools/playlists.d.ts +59 -0
- package/dist/tools/playlists.js +206 -0
- package/dist/tools/search.d.ts +10 -0
- package/dist/tools/search.js +58 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|