funoteka 0.1.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 (162) hide show
  1. package/.env.example +90 -0
  2. package/CHANGELOG.md +104 -0
  3. package/DEPLOY.md +375 -0
  4. package/LICENSE +21 -0
  5. package/README.md +138 -0
  6. package/deploy/answers.schema.json +151 -0
  7. package/deploy/build.mjs +88 -0
  8. package/deploy/check-publishable.mjs +246 -0
  9. package/deploy/smoke.mjs +681 -0
  10. package/deploy/systemd/funoteka.env.example +41 -0
  11. package/deploy/systemd/funoteka.service +77 -0
  12. package/deploy/windows/funoteka-service.xml +58 -0
  13. package/deploy/windows/install-service.ps1 +158 -0
  14. package/dist/annotation/store.js +67 -0
  15. package/dist/api/admin-export.js +227 -0
  16. package/dist/api/admin-guard.js +181 -0
  17. package/dist/api/admin-library.js +113 -0
  18. package/dist/api/admin.js +1162 -0
  19. package/dist/api/annotation.js +193 -0
  20. package/dist/api/artistinfo.js +140 -0
  21. package/dist/api/audit.js +32 -0
  22. package/dist/api/auth.js +145 -0
  23. package/dist/api/bookmark.js +108 -0
  24. package/dist/api/browse.js +1241 -0
  25. package/dist/api/config-file.js +202 -0
  26. package/dist/api/config.js +285 -0
  27. package/dist/api/cover.js +304 -0
  28. package/dist/api/download.js +112 -0
  29. package/dist/api/envelope.js +162 -0
  30. package/dist/api/extensions.js +43 -0
  31. package/dist/api/genre.js +81 -0
  32. package/dist/api/health.js +35 -0
  33. package/dist/api/history.js +374 -0
  34. package/dist/api/idempotency.js +66 -0
  35. package/dist/api/keys.js +90 -0
  36. package/dist/api/log-file.js +65 -0
  37. package/dist/api/meta.js +1410 -0
  38. package/dist/api/playlist.js +311 -0
  39. package/dist/api/rescan.js +196 -0
  40. package/dist/api/roots.js +161 -0
  41. package/dist/api/router.js +133 -0
  42. package/dist/api/scanner.js +163 -0
  43. package/dist/api/search.js +82 -0
  44. package/dist/api/server.js +643 -0
  45. package/dist/api/settings.js +67 -0
  46. package/dist/api/stream.js +442 -0
  47. package/dist/api/stubs.js +183 -0
  48. package/dist/api/transcode.js +678 -0
  49. package/dist/api/user.js +73 -0
  50. package/dist/api/virtual.js +362 -0
  51. package/dist/api/visibility.js +21 -0
  52. package/dist/artist/apply.js +752 -0
  53. package/dist/artist/credit.js +95 -0
  54. package/dist/artist/folder.js +65 -0
  55. package/dist/artist/name.js +205 -0
  56. package/dist/artist/nfo.js +101 -0
  57. package/dist/artist/translit.js +79 -0
  58. package/dist/bookmark/store.js +54 -0
  59. package/dist/classify/classify.js +405 -0
  60. package/dist/classify/collision-name.js +109 -0
  61. package/dist/classify/folder-name.js +466 -0
  62. package/dist/classify/roles.js +277 -0
  63. package/dist/classify/shelf-name.js +178 -0
  64. package/dist/classify/tree.js +43 -0
  65. package/dist/cli/args.js +169 -0
  66. package/dist/cli/daemon.js +192 -0
  67. package/dist/cli/entry.js +25 -0
  68. package/dist/cli/keys.js +52 -0
  69. package/dist/cli.js +638 -0
  70. package/dist/cover/pick.js +58 -0
  71. package/dist/cover/picture.js +111 -0
  72. package/dist/cue/engine.js +759 -0
  73. package/dist/cue/match.js +142 -0
  74. package/dist/cue/parse.js +182 -0
  75. package/dist/cue/plan.js +286 -0
  76. package/dist/cue/track-name.js +120 -0
  77. package/dist/db/index.js +163 -0
  78. package/dist/db/issue.js +73 -0
  79. package/dist/db/ledger.js +84 -0
  80. package/dist/db/migrations/001_init.sql +196 -0
  81. package/dist/db/migrations/002_cue_track_flexible.sql +35 -0
  82. package/dist/db/migrations/003_cue_metadata.sql +14 -0
  83. package/dist/db/migrations/004_cue_performer.sql +12 -0
  84. package/dist/db/migrations/005_incremental.sql +23 -0
  85. package/dist/db/migrations/006_tags.sql +57 -0
  86. package/dist/db/migrations/007_artist_credit.sql +35 -0
  87. package/dist/db/migrations/008_issue_stage.sql +39 -0
  88. package/dist/db/migrations/009_cue_title.sql +20 -0
  89. package/dist/db/migrations/010_duration_source.sql +31 -0
  90. package/dist/db/migrations/011_track_title_source.sql +28 -0
  91. package/dist/db/migrations/012_file_container.sql +25 -0
  92. package/dist/db/migrations/013_cover_art.sql +28 -0
  93. package/dist/db/migrations/014_album_year.sql +17 -0
  94. package/dist/db/migrations/015_probe_method.sql +14 -0
  95. package/dist/db/migrations/016_tags_method.sql +20 -0
  96. package/dist/db/migrations/017_cover_indirect.sql +23 -0
  97. package/dist/db/migrations/018_release_year.sql +14 -0
  98. package/dist/db/migrations/019_file_tag_name_file_value.sql +20 -0
  99. package/dist/db/migrations/020_analyze_after_index.sql +16 -0
  100. package/dist/db/migrations/021_cue_index_files.sql +15 -0
  101. package/dist/db/migrations/022_sidecar_text.sql +19 -0
  102. package/dist/db/migrations/023_track_credit.sql +23 -0
  103. package/dist/db/migrations/024_playlists.sql +63 -0
  104. package/dist/db/migrations/025_playlist_sequence.sql +27 -0
  105. package/dist/db/migrations/026_playlist_source_file.sql +19 -0
  106. package/dist/db/migrations/027_lookup_indexes.sql +30 -0
  107. package/dist/db/migrations/028_annotations.sql +47 -0
  108. package/dist/db/migrations/029_scan_state_root_run.sql +28 -0
  109. package/dist/db/migrations/030_history.sql +108 -0
  110. package/dist/db/migrations/031_queue_position_and_plays.sql +46 -0
  111. package/dist/db/migrations/032_bookmarks.sql +27 -0
  112. package/dist/db/migrations/033_bookmark_order.sql +14 -0
  113. package/dist/db/migrations/034_junk.sql +33 -0
  114. package/dist/db/migrations/035_api_key.sql +38 -0
  115. package/dist/db/migrations/036_file_tag_first.sql +88 -0
  116. package/dist/db/migrations/037_admin_idempotency.sql +31 -0
  117. package/dist/db/migrations/038_cue_audio_index.sql +24 -0
  118. package/dist/db/sweep.js +109 -0
  119. package/dist/history/store.js +151 -0
  120. package/dist/inventory/inventory.js +294 -0
  121. package/dist/junk/marks.js +143 -0
  122. package/dist/junk/rule.js +93 -0
  123. package/dist/mcp/client.js +48 -0
  124. package/dist/mcp/server.js +175 -0
  125. package/dist/mcp/tools.js +348 -0
  126. package/dist/playlist/files.js +116 -0
  127. package/dist/playlist/import.js +260 -0
  128. package/dist/playlist/store.js +240 -0
  129. package/dist/probe/ffprobe.js +179 -0
  130. package/dist/run.js +120 -0
  131. package/dist/scan/kinds.js +49 -0
  132. package/dist/scan/scan.js +503 -0
  133. package/dist/scan/schedule.js +132 -0
  134. package/dist/scan/settle.js +172 -0
  135. package/dist/scan/walk.js +132 -0
  136. package/dist/search/index.js +41 -0
  137. package/dist/search/query.js +28 -0
  138. package/dist/stream/flac.js +541 -0
  139. package/dist/stream/mpeg.js +179 -0
  140. package/dist/stream/recode.js +393 -0
  141. package/dist/stream/rewrite.js +163 -0
  142. package/dist/stream/segment.js +624 -0
  143. package/dist/tags/apply.js +703 -0
  144. package/dist/tags/encode.js +164 -0
  145. package/dist/tags/first.js +130 -0
  146. package/dist/tags/flac.js +196 -0
  147. package/dist/tags/genres.js +123 -0
  148. package/dist/tags/id3v1.js +209 -0
  149. package/dist/tags/id3v2.js +754 -0
  150. package/dist/tags/mp4.js +534 -0
  151. package/dist/tags/mpeg.js +355 -0
  152. package/dist/tags/ogg.js +463 -0
  153. package/dist/tags/picture.js +71 -0
  154. package/dist/tags/read.js +269 -0
  155. package/dist/tags/types.js +51 -0
  156. package/dist/tags/vorbis-comment.js +155 -0
  157. package/dist/text/encoding.js +384 -0
  158. package/dist/text/site-name.js +37 -0
  159. package/dist/util/child.js +21 -0
  160. package/dist/util/names.js +97 -0
  161. package/funoteka.json.example +27 -0
  162. package/package.json +55 -0
@@ -0,0 +1,1410 @@
1
+ import { splitCredit } from "../artist/credit.js";
2
+ import { artistName, artistOwning } from "../artist/name.js";
3
+ import { basenameOf } from "../util/names.js";
4
+ /**
5
+ * The meta layer, as the API reads it.
6
+ *
7
+ * Routes answer in the protocol's shapes; the SQL that reaches the classified
8
+ * collection lives here. That split is what lets a route's file be about the
9
+ * protocol and this one about the library, and it is why the API's reads can be
10
+ * read in one place when the question is "what does the server know".
11
+ *
12
+ * Read-only, and it stays that way. The classified model has one author — the
13
+ * scanner — and a second writer of *that* would make the database describe two
14
+ * collections. The listener's own rows are not the model and are not written
15
+ * here: playlists are `src/playlist/store.ts` and the marks are
16
+ * `src/annotation/store.ts` — the two writers this server has.
17
+ *
18
+ * Identity is the path, so nothing here merges: two rows are two rows, and two
19
+ * albums that read the same are still two albums, exactly as the contract's
20
+ * first rule requires.
21
+ */
22
+ /**
23
+ * Rows in the shape this module declares them.
24
+ *
25
+ * The driver hands back an untyped record per row, and the two casts below are
26
+ * the whole of the seam: every declared shape is a claim about the SQL right
27
+ * beside it, checkable by reading the two together, and nothing else in the API
28
+ * has to know the driver's type.
29
+ */
30
+ function rows(db, sql, ...args) {
31
+ return db.prepare(sql).all(...args);
32
+ }
33
+ function row(db, sql, ...args) {
34
+ return db.prepare(sql).get(...args);
35
+ }
36
+ /**
37
+ * One query for a page of ids, answered in the order the ids were asked for.
38
+ *
39
+ * A page of ids is ranked already — a search's by relevance, a listing's by its
40
+ * own sort — and asking for them one at a time is not that request repeated: it
41
+ * is a separate execution of the whole select per id. `ALBUM_SELECT` carries the
42
+ * genre window table, so on a search page that difference is measured in
43
+ * seconds. See `albumsById`.
44
+ *
45
+ * SQLite answers a set, so the order comes back from the caller's list, which is
46
+ * the only place it is known. An id that names nothing drops out rather than
47
+ * becoming a hole — but *what* names nothing is the caller's query and not this
48
+ * function's. `albumsById` matches a row's own `id`, while `album` resolves the
49
+ * group, so a box's second disc opens the record through one and drops out of
50
+ * the other. Both are answering the question they were asked; a caller that
51
+ * wants a disc's id to open its record asks the way `album` does.
52
+ */
53
+ function byIds(ids, query) {
54
+ if (ids.length === 0)
55
+ return [];
56
+ const found = query(ids.map(() => '?').join(', '));
57
+ const byId = new Map(found.map((found) => [found.id, found]));
58
+ return ids.flatMap((id) => {
59
+ const found = byId.get(id);
60
+ return found === undefined ? [] : [found];
61
+ });
62
+ }
63
+ /**
64
+ * Whether a scan is running, and how big the library is.
65
+ *
66
+ * `count` is the audio files the meta layer holds rather than every file the
67
+ * walk met: the covers, cue sheets and logs beside a record are the scanner's
68
+ * business and never become a song a client could ask for, so a count that
69
+ * included them would describe a library larger than the one the client sees.
70
+ *
71
+ * `scanning` reads the last run's status rather than answering `false`: a scan
72
+ * that died without settling its row would otherwise be reported as finished,
73
+ * and a client that polled this to decide when to refresh would wait forever.
74
+ */
75
+ export function scanStatus(db) {
76
+ const run = row(db, 'SELECT status FROM scan_run ORDER BY id DESC LIMIT 1');
77
+ return {
78
+ scanning: run?.status === 'running',
79
+ count: count(db, "SELECT COUNT(*) AS n FROM file WHERE kind = 'audio'"),
80
+ };
81
+ }
82
+ export function count(db, sql, ...args) {
83
+ return row(db, sql, ...args)?.n ?? 0;
84
+ }
85
+ /**
86
+ * The roles an artist has in this library, as OpenSubsonic lists them.
87
+ *
88
+ * `ArtistID3.roles` is "the list of all roles this artist has in the library",
89
+ * and a client groups its views by it: the operator's Symfonium has an "album
90
+ * artists" view and a "composers" one, and the second came up empty — because
91
+ * this server sent no roles at all, so no artist was a composer or anything
92
+ * else (task:2896).
93
+ *
94
+ * **Two roles are claimed, and only two are derivable honestly.** `albumartist`
95
+ * for an artist a record is credited to — owning it, or credited on it beside
96
+ * somebody else, which is what `artist_credit` is for — and `artist` for one
97
+ * credited on a track. In this model a record's artist *is* its tracks' artist,
98
+ * so the second follows from the first and there is no second lookup behind it.
99
+ *
100
+ * **`composer` is deliberately not among them.** This collection states a
101
+ * composer on 80 files of 3350, in sixteen distinct names, and **one** of those
102
+ * names is an artist this library knows: the rest are people who would have to
103
+ * exist as artists before a role could put them in a list. Matching a composer
104
+ * tag's *value* — a credit string, `Виктор Цой & Кино` — against artist names is
105
+ * the kind of guessing `artist/credit.ts` exists to avoid, and one wrong match
106
+ * here does not lose a picture, it moves an artist into a view it is not in.
107
+ *
108
+ * Asked once for a whole page rather than once per artist: a listing is a page,
109
+ * and a query per row is the shape `search3` was already fixed for.
110
+ */
111
+ export function rolesOf(db, ids) {
112
+ const roles = new Map();
113
+ if (ids.length === 0)
114
+ return roles;
115
+ const placeholders = ids.map(() => '?').join(', ');
116
+ const stated = rows(db, `SELECT stated.artist_id, stated.role FROM (
117
+ SELECT artist_id, 'albumartist' AS role FROM album WHERE artist_id IS NOT NULL
118
+ UNION SELECT artist_id, 'albumartist' FROM artist_credit
119
+ UNION SELECT artist_id, 'artist' FROM track_credit
120
+ UNION SELECT artist_id, 'artist' FROM album WHERE artist_id IS NOT NULL
121
+ ) stated WHERE stated.artist_id IN (${placeholders})`, ...ids);
122
+ for (const { artist_id, role } of stated) {
123
+ const held = roles.get(artist_id);
124
+ if (held === undefined)
125
+ roles.set(artist_id, [role]);
126
+ else if (!held.includes(role))
127
+ held.push(role);
128
+ }
129
+ // One order for one set of roles, so that two listings of the same artist
130
+ // cannot differ in a field a client compares.
131
+ for (const held of roles.values())
132
+ held.sort();
133
+ return roles;
134
+ }
135
+ /** The same rows with their roles attached, in one query for the page. */
136
+ function attachRoles(db, all) {
137
+ const roles = rolesOf(db, all.map((one) => one.id));
138
+ return all.map((one) => ({ ...one, roles: roles.get(one.id) ?? [] }));
139
+ }
140
+ /**
141
+ * The artists a client can browse to, which is those owning at least one record.
142
+ *
143
+ * An artist known only from a credit — a guest on a compilation, the audience on
144
+ * a live disc — is a real credit and a dead end: a client that opened them would
145
+ * find nothing to play. `artist_credit` keeps them; the index does not offer
146
+ * them.
147
+ *
148
+ * The count is of *records*, through the group's representative, so it is the
149
+ * number of entries `albumsOfArtist` will actually list — a box counts once, not
150
+ * once per disc.
151
+ */
152
+ export function artists(db, rootId, visibility = 'records') {
153
+ const all = attachRoles(db, rows(db, `SELECT ar.id, ar.name, ar.name_key, ar.sort_key, aa.starred_at, aa.rating,
154
+ COUNT(*) AS album_count
155
+ FROM artist ar
156
+ LEFT JOIN artist_annotation aa ON aa.artist_id = ar.id
157
+ JOIN (
158
+ SELECT COALESCE(rel.artist_id, rep.artist_id) AS artist_id
159
+ FROM ${ALBUM_GROUPS} grp
160
+ JOIN album rep ON rep.id = grp.id
161
+ LEFT JOIN release rel ON rel.id = rep.release_id
162
+ WHERE grp.rn = 1${recorded(visibility)}
163
+ ) record ON record.artist_id = ar.id
164
+ GROUP BY ar.id
165
+ ORDER BY COALESCE(ar.sort_key, ar.name), ar.id`));
166
+ const owned = ownedArtistKeys(db, all, rootId);
167
+ for (const key of albumArtistKeys(db, rootId))
168
+ owned.add(key);
169
+ return all.filter((row) => owned.has(row.name_key));
170
+ }
171
+ /**
172
+ * The artists the collection's own `albumartist` tags name.
173
+ *
174
+ * This is a second way onto the list, and it was the operator's call after the
175
+ * first one left out somebody real. `Виктор Цой` leads Кино, his records are
176
+ * filed in Кино's folder, and no rule reading folders will ever offer him — but
177
+ * the files themselves say `albumartist = Виктор Цой`, and that tag is the
178
+ * statement "this record is his".
179
+ *
180
+ * What buys it its place is what it *refuses*. The rule it replaces would have
181
+ * admitted every artist who is the only credit on a record, and that admits
182
+ * `All Out Life` — a single whose folder name parsed as an artist while its
183
+ * files say `albumartist = Slipknot`. It would have admitted `Merzbow`, whose
184
+ * split carries no `albumartist` at all, only `artist = Merzbow & Cock E.S.P.`:
185
+ * a track credit saying who plays, not whose record it is. Neither has the tag.
186
+ *
187
+ * The operator weighed the cost and took it: the list grows by the tags' own
188
+ * words, which is how `Various Artists` and the series' names arrive beside the
189
+ * performers. That is the trade this makes — the folder still decides, and now
190
+ * a tag may also nominate. See `wiki:3590` for the boundary argument this
191
+ * replaces, and [[task:2822]] for the decision to cross it.
192
+ */
193
+ function albumArtistKeys(db, rootId) {
194
+ const values = rootId === undefined
195
+ ? rows(db, `SELECT DISTINCT ft.value AS value
196
+ FROM file_tag ft JOIN file f ON f.id = ft.file_id
197
+ WHERE ft.name = 'albumartist'`)
198
+ : rows(db, `SELECT DISTINCT ft.value AS value
199
+ FROM file_tag ft JOIN file f ON f.id = ft.file_id
200
+ WHERE ft.name = 'albumartist' AND f.root_id = ?`, rootId);
201
+ const keys = new Set();
202
+ for (const { value } of values) {
203
+ for (const name of namesInTag(value)) {
204
+ const key = artistName(name).key;
205
+ if (key !== '')
206
+ keys.add(key);
207
+ }
208
+ }
209
+ return keys;
210
+ }
211
+ /**
212
+ * The names one tag value holds.
213
+ *
214
+ * `splitCredit` reads the joiners a track credit uses — the symbols, and the
215
+ * whole words — and a comma is not among them. What a comma would break is
216
+ * `Cure, The`, where the article has moved to the tail and the name is still
217
+ * one name; that rule lives in `name.ts`, not in `credit.ts`, and an earlier
218
+ * version of this comment credited `splitCredit` with a decision it never made.
219
+ *
220
+ * A tag that lists two artists writes them with a comma, and the operator
221
+ * confirmed both count: `Аквариум, Kyiv Virtuosi` is two artists, not one
222
+ * called that. So this reader splits them, after `splitCredit` has taken the
223
+ * joiners — having a tag that is a list rather than a name.
224
+ *
225
+ * The split is unguarded, and `Earth, Wind & Fire` would come out as two
226
+ * artists. Nothing in this collection is spelled that way — the one comma among
227
+ * the fourteen values is a list — but the rule is not safe beyond the collection
228
+ * that produced it, and a test pinning a `Cure, The`-shaped tag would say so.
229
+ */
230
+ function namesInTag(value) {
231
+ return splitCredit(value)
232
+ .flatMap((entry) => entry.name.split(','))
233
+ .map((name) => name.trim())
234
+ .filter((name) => name !== '');
235
+ }
236
+ /**
237
+ * The artists that own a folder of their own.
238
+ *
239
+ * One of the two ways onto the list — `albumArtistKeys` is the other, and
240
+ * `artists` unions them. This one is the older and the stricter: a folder named
241
+ * for the artist is the artist's, and so is a folder with a shelf's tail on it,
242
+ * `Slipknot AAC 320`.
243
+ *
244
+ * On its own it left out somebody real, and the arguments above it are where
245
+ * that used to be justified. This collection credits eleven different acts
246
+ * across the splits filed in `Cock E.S.P/`, one Slipknot single to `All Out
247
+ * Life`, and four Кино records to `Виктор Цой` — and every one of those is
248
+ * reachable through the folder their record sits in, which is why the rule read
249
+ * as sufficient. `Виктор Цой` is the case that showed it was not: he leads
250
+ * Кино, his records are filed in Кино's folder, and the files say
251
+ * `albumartist = Виктор Цой`. A tag offers him; this function never would.
252
+ *
253
+ * What it still decides is everything it always did — which folders fold into
254
+ * which artist's node, and so which folder names can offer an artist at all.
255
+ */
256
+ function ownedArtistKeys(db, all, rootId) {
257
+ const keys = all.map((row) => row.name_key);
258
+ const tops = rootId === undefined
259
+ ? rows(db, `SELECT rel_path FROM folder WHERE parent_rel_path = '' AND role <> 'empty'`)
260
+ : rows(db, `SELECT rel_path FROM folder WHERE root_id = ? AND parent_rel_path = '' AND role <> 'empty'`, rootId);
261
+ const owned = new Set();
262
+ for (const top of tops) {
263
+ const owner = artistOwning(basenameOf(top.rel_path), keys);
264
+ if (owner !== null)
265
+ owned.add(owner);
266
+ }
267
+ return owned;
268
+ }
269
+ /**
270
+ * The folders that are an artist's own — the ones named for them.
271
+ *
272
+ * Whose a folder is follows the rule `ownedArtistKeys` above and `virtualNodes`
273
+ * keep: a folder whose name opens with the artist's name belongs to that artist.
274
+ * Here it comes with an order. `Slipknot`, `Slipknot AAC 320` and `Slipknot
275
+ * ALAC` all belong to Slipknot, and the one spelled exactly like the artist
276
+ * comes first — it is the one a picture of the artist, or a note about them,
277
+ * would have been put in; the shelves behind it are asked only when it holds
278
+ * nothing.
279
+ *
280
+ * Two readers want this and neither owns it: `cover.ts` asks it where an
281
+ * artist's picture is, and `artistinfo.ts` where their `artist.nfo` is. It is
282
+ * the same folder both times, and the same rule `ownedArtistKeys` already
283
+ * states — so it is stated once, here, rather than once per reader.
284
+ */
285
+ export function artistOwnFolders(db, artistId) {
286
+ const row = artist(db, artistId);
287
+ if (row === undefined)
288
+ return [];
289
+ const exact = [];
290
+ const shelved = [];
291
+ for (const root of roots(db)) {
292
+ for (const folder of childFolders(db, root.id, '')) {
293
+ if (folder.role === 'empty')
294
+ continue;
295
+ const name = basenameOf(folder.rel_path);
296
+ if (artistOwning(name, [row.name_key]) !== row.name_key)
297
+ continue;
298
+ const where = { rootId: root.id, relPath: folder.rel_path };
299
+ if (artistName(name).key === row.name_key)
300
+ exact.push(where);
301
+ else
302
+ shelved.push(where);
303
+ }
304
+ }
305
+ return [...exact, ...shelved];
306
+ }
307
+ /**
308
+ * The `artist.nfo` sitting in a folder, when the collection has one there.
309
+ *
310
+ * The scan records the file and never parses it, which is what makes this a
311
+ * path rather than a column: what the document says is read where it is asked
312
+ * for, and the meta layer's part is only to say which file that is.
313
+ */
314
+ export function artistNfoIn(db, where) {
315
+ const found = row(db, `SELECT f.rel_path, r.path AS root_path
316
+ FROM file f JOIN root r ON r.id = f.root_id
317
+ WHERE f.root_id = ? AND f.folder_rel_path = ? AND f.name = 'artist.nfo'`, where.rootId, where.relPath);
318
+ return found === undefined ? null : { rootPath: found.root_path, relPath: found.rel_path };
319
+ }
320
+ /** One artist row, by whatever names them — an id, or the fold of their name. */
321
+ function artistWhere(db, column, value, visibility = 'records') {
322
+ const found = row(db, `SELECT ar.id, ar.name, ar.name_key, ar.sort_key, aa.starred_at, aa.rating,
323
+ COUNT(grp.id) AS album_count
324
+ FROM artist ar
325
+ LEFT JOIN artist_annotation aa ON aa.artist_id = ar.id
326
+ LEFT JOIN album al ON al.artist_id = ar.id
327
+ LEFT JOIN ${ALBUM_GROUPS} grp ON grp.id = al.id AND grp.rn = 1${recorded(visibility)}
328
+ WHERE ${column} = ?
329
+ GROUP BY ar.id`, value);
330
+ if (found === undefined)
331
+ return undefined;
332
+ return { ...found, roles: rolesOf(db, [found.id]).get(found.id) ?? [] };
333
+ }
334
+ export function artist(db, id) {
335
+ return artistWhere(db, 'ar.id', id);
336
+ }
337
+ /**
338
+ * The artist a folded name belongs to, when the collection has one.
339
+ *
340
+ * Offering and answering are different questions, and this is the second one.
341
+ * `artists` above is who a client can browse to; this answers about a name some
342
+ * other row already holds — a credit on a record, or the drawer an artist's
343
+ * folder gathers — whether or not that name is offered.
344
+ *
345
+ * `Виктор Цой` needed both, and needed only the second before the albumartist
346
+ * tag earned him a place in the list. That is the difference worth keeping
347
+ * straight: being answerable never depended on being offered.
348
+ */
349
+ export function artistByKey(db, nameKey) {
350
+ return artistWhere(db, 'ar.name_key', nameKey);
351
+ }
352
+ /**
353
+ * The same answer for a page of ids, in one query.
354
+ *
355
+ * The plural of `artist`, for the reason `byIds` gives: a search's artist
356
+ * section is twenty ranked ids, and twenty executions of the join above is
357
+ * twenty of the same answer.
358
+ *
359
+ * **The count goes through the group, and leaving it out is not a smaller
360
+ * version of the same answer.** Counting `album.id` counted every disc of every
361
+ * box: a search page said `albumCount: 2` beside an artist whose page listed one
362
+ * record, and a client that reconciled the two had no way to tell which was
363
+ * lying. Both numbers being the same ordinal in the same payload is the whole
364
+ * contract between the search and the artist page, so this reads exactly what
365
+ * `artist` above reads.
366
+ */
367
+ export function artistsById(db, ids) {
368
+ return attachRoles(db, byIds(ids, (placeholders) => rows(db, `SELECT ar.id, ar.name, ar.name_key, ar.sort_key, aa.starred_at, aa.rating,
369
+ COUNT(grp.id) AS album_count
370
+ FROM artist ar
371
+ LEFT JOIN artist_annotation aa ON aa.artist_id = ar.id
372
+ LEFT JOIN album al ON al.artist_id = ar.id
373
+ LEFT JOIN ${ALBUM_GROUPS} grp ON grp.id = al.id AND grp.rn = 1
374
+ WHERE ar.id IN (${placeholders})
375
+ GROUP BY ar.id`, ...ids)));
376
+ }
377
+ /**
378
+ * The characters taken off the edge of a genre value, for a named alias.
379
+ *
380
+ * **`TRIM(X)` is not enough and this was wrong at first.** SQLite's one-argument
381
+ * `TRIM` removes space and nothing else — not a tab, not a newline, not a
382
+ * non-breaking space — so a file stating `"Rock\t"` would still list as a second
383
+ * genre beside `"Rock"`, and a value that was *only* a tab would pass the
384
+ * emptiness guard and be listed as a genre whose name is invisible. The set is
385
+ * spelled out here so that the claim the comments make is the claim the code
386
+ * keeps.
387
+ *
388
+ * A function of the alias rather than one string, because the same expression
389
+ * has to hold in more than one place at once — the list's group key, both
390
+ * lookups, and the album predicate — and the agreement between those is the
391
+ * whole contract the two methods have: a client hands back a value it was given,
392
+ * and a genre that lists but cannot be opened is the failure that prevents.
393
+ */
394
+ const trimmed = (alias) => `TRIM(${alias}.value, ' ' || CHAR(9) || CHAR(10) || CHAR(13) || CHAR(160) || CHAR(12288))`;
395
+ /**
396
+ * Which record an album row belongs to, as one value two rows compare equal by.
397
+ *
398
+ * A function of the alias because three queries group by it and they have to
399
+ * agree; a fourth spelling of the same rule is how a listing and a count come to
400
+ * disagree.
401
+ *
402
+ * **The prefix is not decoration.** `album.id` and `release.id` are two
403
+ * independent `INTEGER PRIMARY KEY` sequences, so a bare
404
+ * `COALESCE(release_id, id)` puts the standalone album `al:9` in the group of
405
+ * release 9 — different tables, same integer, and nothing in the value says
406
+ * which one it came from. It happened: measured on this collection, a Cock
407
+ * E.S.P record (album 9) was listed as the record of a Kino box (release 9) and
408
+ * the box itself disappeared from its artist, because the row standing for the
409
+ * group was the one with an artist of its own. Both id spaces are in use, so the
410
+ * key carries which one it is.
411
+ */
412
+ const grouped = (alias) => `CASE WHEN ${alias}.release_id IS NULL THEN 'a' || ${alias}.id ELSE 'r' || ${alias}.release_id END`;
413
+ /**
414
+ * Every record's genre, as a table to be joined rather than asked per record.
415
+ *
416
+ * **This was a correlated subquery and it took the server down.** Written the
417
+ * obvious way — a `SELECT … LIMIT 1` inside `ALBUM_SELECT` — it was evaluated
418
+ * once per album *before* the listing's `ORDER BY` and `LIMIT` could discard
419
+ * anything, so every album listing in the whole API paid it 268 times: measured
420
+ * at 1321 ms for the collection, against 24 ms for this. The operator's client
421
+ * stopped answering, which is what a browse call that takes seconds and a genre
422
+ * call that takes thirty looks like from a sofa.
423
+ *
424
+ * The cost was not the work but its shape. A correlated subquery here cannot use
425
+ * the index that would make it cheap: its two conditions came from the *album*
426
+ * row. Computed once, the same question is a single pass over the genre rows.
427
+ *
428
+ * `ROW_NUMBER() OVER (PARTITION BY …)` is what keeps the rule: the first file
429
+ * that states a genre answers for the record, in path order — and within one
430
+ * file, the first genre tag by position. Files of one record do disagree — this
431
+ * collection has compilations whose tracks each carry their own — and some
432
+ * answer has to be first.
433
+ *
434
+ * **Keyed by record, not by folder, and reached through the track.** A record is
435
+ * a box with its discs (see `ALBUM_GROUPS`), so its genre is the first file by
436
+ * path across *all* its discs — which a per-folder key cannot express. The file
437
+ * reaches its record through the track it became rather than through the folder
438
+ * it sits in, because a disc of a flat rip is keyed on the image *file* and no
439
+ * folder names it: binding by folder loses those. Measured on this collection,
440
+ * that is 6 genre-tagged files, and the binding gains nothing — every
441
+ * genre-tagged file here has a track, and no file's tracks belong to more than
442
+ * one record (both measured).
443
+ *
444
+ * What is left costs about 15 ms on this collection, measured 2026-09-13 warm
445
+ * with the baseline interleaved: the joins are about 8 of it and the window
446
+ * function the rest. A cheaper form was measured rather than assumed — grouping
447
+ * with `MIN()` over a concatenated `rel_path` and `position` is about 12 ms — and
448
+ * it was declined, **but not on price**. The figures this paragraph used to quote
449
+ * (150 ms against 25) were true when they were written and are not any more; it
450
+ * is the same rows several times faster. What still holds is the reason: that
451
+ * form reads the bare `value` column out of whichever row the `MIN()` chose,
452
+ * which SQLite guarantees only while the subquery holds *exactly one* aggregate —
453
+ * so a later `COUNT(*)` added beside it would turn the genre into an arbitrary
454
+ * row's genre, silently, with a test that catches it only half the time. Six
455
+ * milliseconds is not worth that, and the `MIN()` figure above is the grouping
456
+ * alone: fetching the winning row's value safely needs a join back, which nobody
457
+ * has measured.
458
+ */
459
+ const albumGenres = (scope) => `
460
+ (SELECT group_id, value FROM (
461
+ SELECT ${grouped('a')} AS group_id,
462
+ ${trimmed('ft')} AS value,
463
+ ROW_NUMBER() OVER (PARTITION BY ${grouped('a')}
464
+ ORDER BY f.rel_path, ft.position) AS rn
465
+ FROM file f
466
+ JOIN track t ON t.file_id = f.id
467
+ JOIN album a ON a.id = t.album_id
468
+ JOIN file_tag ft ON ft.file_id = f.id
469
+ WHERE ft.name = 'genre' AND ${trimmed('ft')} <> '' ${scope}
470
+ ) WHERE rn = 1)`;
471
+ /**
472
+ * The same, for every record at once — see `albumTotals` on why it is a function.
473
+ *
474
+ * The window function is what the join cannot plan around, and it is kept for the
475
+ * reason stated at length above: the cheaper form reads the value out of whichever
476
+ * row it happened to choose.
477
+ */
478
+ const ALBUM_GENRES = albumGenres('');
479
+ /**
480
+ * Which record each album row belongs to, and which row stands for it.
481
+ *
482
+ * A box is a `release` with one `album` row per disc, so the unit a client
483
+ * opens — an album — is the *group*, not the row. This is the one place that
484
+ * grouping is decided; everything below reads it rather than repeating the rule.
485
+ *
486
+ * The row that stands for a group is its **first disc**: lowest stated disc
487
+ * number, then path, then id. Deliberately not `release.id`, because an `al:` id
488
+ * carries an `album.id` and the two tables number themselves independently — an
489
+ * `al:5` that sometimes meant a release would be a client handing back an id it
490
+ * was given and being shown a different record. A representative keeps the id
491
+ * in the namespace it has always been in, and keeps it stable: disc 1 is disc 1
492
+ * as long as the box is on disk, whatever the scan does to the other rows.
493
+ *
494
+ * `rep_id` rides on every row of the group rather than only on the
495
+ * representative, so a song can name the record it is on without a second join.
496
+ */
497
+ /**
498
+ * Which row of a group stands for it: the lowest disc, then path, then id.
499
+ *
500
+ * **A row with no disc number goes last, not first.** `COALESCE(disc_number, 0)`
501
+ * reads as "no number is zero", and a zero sorts in front of disc 1 — so a row
502
+ * that is not a disc at all could represent the record, and everything a client
503
+ * is shown for it comes off that row: the `al:` id, the name, the year. Measured
504
+ * on the live collection there is no such row — 0 albums carry a release without
505
+ * a disc number, and no album's path lies inside a disc folder — so this orders
506
+ * nothing differently today; it is there so that the ordering is not the thing
507
+ * that decides it when the shape appears (task:2843).
508
+ *
509
+ * What a group with *only* unnumbered rows does is unchanged: the lowest of them
510
+ * by path is still the representative. There is nothing else it could be.
511
+ */
512
+ const REPRESENTATIVE_ORDER = `(a.disc_number IS NULL), a.disc_number, a.rel_path, a.id`;
513
+ const ALBUM_GROUPS = `
514
+ (SELECT a.id, ${grouped('a')} AS group_id,
515
+ FIRST_VALUE(a.id) OVER (PARTITION BY ${grouped('a')}
516
+ ORDER BY ${REPRESENTATIVE_ORDER}) AS rep_id,
517
+ ROW_NUMBER() OVER (PARTITION BY ${grouped('a')}
518
+ ORDER BY ${REPRESENTATIVE_ORDER}) AS rn,
519
+ a.junk_reason AS junk_reason
520
+ FROM album a)`;
521
+ /**
522
+ * The clause that keeps what is not a record out of a listing.
523
+ *
524
+ * Every listing in this file already joins the record group by `grp`, so the
525
+ * answer is carried there once (`ALBUM_GROUPS` above) and read here — rather than
526
+ * a subquery per row, which is the shape that took the server down once already
527
+ * (`ALBUM_GENRES`). A caller told to show everything gets the empty string, and
528
+ * an empty string in a `WHERE` changes nothing.
529
+ *
530
+ * **The line this draws, stated once so no listing has to guess it.** Hiding is
531
+ * about what the server *offers*: the listings a client browses, the tree, what
532
+ * a search turns up, what a shuffle draws. It is not about what the listener
533
+ * already holds — a playlist or a star that names a hidden album keeps naming it,
534
+ * because a client whose own list silently lost an entry is worse off than one
535
+ * shown something it marked. And it is not about a record reached by an id
536
+ * already in hand: `album`, `song` and their plurals answer what they are asked,
537
+ * so a client with a stale entry gets a cover rather than an error. What is
538
+ * hidden is that nobody is *offered* it.
539
+ */
540
+ function recorded(visibility, alias = 'grp') {
541
+ return visibility === 'all' ? '' : ` AND ${alias}.junk_reason IS NULL`;
542
+ }
543
+ /**
544
+ * What a record holds, summed over its discs.
545
+ *
546
+ * Its own pass rather than a subquery per row, for the reason above, and
547
+ * aggregated *before* it is joined so a record with no songs still comes back as
548
+ * itself — which an inner join to `track` would not do.
549
+ */
550
+ const albumTotals = (scope) => `
551
+ (SELECT ${grouped('a')} AS group_id,
552
+ COUNT(*) AS song_count,
553
+ SUM(t.duration_ms) AS duration_ms
554
+ FROM track t JOIN album a ON a.id = t.album_id
555
+ ${scope}
556
+ GROUP BY ${grouped('a')})`;
557
+ /**
558
+ * The same, for every record at once.
559
+ *
560
+ * A function of the scope rather than one string, because a reader that already
561
+ * knows which record it wants can say so — and then this pass touches that
562
+ * record's tracks instead of the collection's. The body is written once; only the
563
+ * `WHERE` differs, which is how `grouped` and `trimmed` above are built too.
564
+ */
565
+ const ALBUM_TOTALS = albumTotals('');
566
+ /**
567
+ * Every genre the collection states, and how much of it states each.
568
+ *
569
+ * Counted over `track` rather than over files, because a song is what a client
570
+ * is being offered: a genre list whose numbers were files would overcount every
571
+ * cue image, which is one file holding an album's worth of tracks.
572
+ *
573
+ * There is no list of known genres here and there should not be — the files
574
+ * name their own, and this project has one table of genre names only because
575
+ * ID3 lets a tag be a *number* that points into one (`tags/genres.ts`). What a
576
+ * file states in words is what this returns, trimmed and nothing else.
577
+ */
578
+ export function genres(db, visibility = 'records') {
579
+ // A song on a record nobody is offered is not part of what the collection
580
+ // states: a genre whose every file lives in a folder of screenshots would
581
+ // otherwise be listed with nothing behind it. `al` is joined `LEFT` because a
582
+ // song's album may be missing, and such a song is kept — it is not something
583
+ // that was hidden, it is something that was never classified.
584
+ const dropJunk = visibility === 'all' ? '' : 'AND (al.id IS NULL OR al.junk_reason IS NULL)';
585
+ return rows(db, `SELECT ${trimmed('ft')} AS value,
586
+ COUNT(DISTINCT t.id) AS song_count,
587
+ COUNT(DISTINCT ${grouped('al')}) AS album_count
588
+ FROM file_tag ft
589
+ JOIN track t ON t.file_id = ft.file_id
590
+ LEFT JOIN album al ON al.id = t.album_id
591
+ WHERE ft.name = 'genre' AND ${trimmed('ft')} <> '' ${dropJunk}
592
+ GROUP BY ${trimmed('ft')}
593
+ ORDER BY ${trimmed('ft')} COLLATE NOCASE`);
594
+ }
595
+ /**
596
+ * The genre predicate, written once.
597
+ *
598
+ * `EXISTS` rather than a join to `file_tag` so a genre cannot multiply a song
599
+ * into several rows — a file is free to state a genre twice, and a listing that
600
+ * showed it twice would be a listing that invented a duplicate. Two queries ask
601
+ * it now (one genre's songs, and a random draw filtered by genre), and the two
602
+ * have to agree about what "this song is that genre" means.
603
+ */
604
+ function inGenre(fileAlias) {
605
+ return `EXISTS (SELECT 1 FROM file_tag ft
606
+ WHERE ft.file_id = ${fileAlias}.id AND ft.name = 'genre' AND ${trimmed('ft')} = ?)`;
607
+ }
608
+ /**
609
+ * A random draw of songs, from what the filters name.
610
+ *
611
+ * **`ORDER BY RANDOM()` reads every matching row and sorts it**, which is why
612
+ * this shape is usually the wrong one — and it is written here with the number
613
+ * in hand rather than by habit. The alternative, picking a random rowid, cannot
614
+ * honour a filter: `genre` is a tag on the file, `year` belongs to the record,
615
+ * and a folder is a root — none of them is a column of `track`. So the choice is
616
+ * between reading the rows and refusing the parameters, and the protocol has the
617
+ * parameters.
618
+ *
619
+ * **Measured on the live collection** (11 541 files, 4 969 songs), warmed,
620
+ * against the running daemon: a default draw of ten is **15–33 ms**, five
621
+ * hundred songs **45 ms**, a genre filter **20 ms** and a year filter **23 ms**.
622
+ * The scan and the sort are the whole of it.
623
+ *
624
+ * **Two of those numbers have since been taken again, and one of them was read
625
+ * wrong here.** The year filter is genuinely about free — 17.2 ms against an
626
+ * unfiltered 16.3 ms — but the *genre* filter is **twice** the unfiltered draw:
627
+ * 34.0 ms against 16.3, medians of thirty warm samples each, on a machine whose
628
+ * `ping` floor was 20 ms in the same window (task:2869). The plan says why, and
629
+ * it is the opposite of what this comment used to claim: `genre` is a tag on the
630
+ * *file*, so the filter is an `EXISTS` evaluated for every one of the 4 972
631
+ * tracks — a covering-index search each, but a search *per row*, before
632
+ * `ORDER BY RANDOM()` sorts what is left. Fewer rows reach the sort; more work
633
+ * happens getting them there. The old sentence — "a filter is *cheaper* than no
634
+ * filter because there are fewer rows left to sort" — was true of the year and
635
+ * false of the genre, and it was written as though it were true of both.
636
+ *
637
+ * **Warmed is the word that matters.** The first version of this comment gave
638
+ * 76–100 ms, taken moments after a restart: a cold page cache is not what the
639
+ * method costs, it is what the disk costs, and the two were reported as the
640
+ * same number until a reviewer measured it again (task:2864).
641
+ *
642
+ * What it costs the *other* clients is the loop, not the call: on one thread
643
+ * eight draws back to back take `ping` from a half-millisecond to 25 ms, because
644
+ * every draw holds the thread for its whole scan.
645
+ *
646
+ * `size` is chosen by the caller, which caps it at the protocol's five hundred;
647
+ * the `LIMIT` itself is this function's.
648
+ */
649
+ export function randomSongs(db, wanted) {
650
+ const filters = [];
651
+ const args = [];
652
+ const junk = recorded(wanted.visibility ?? 'records');
653
+ if (wanted.genre !== undefined) {
654
+ filters.push(inGenre('f'));
655
+ args.push(wanted.genre);
656
+ }
657
+ // The record's year, spelled the way the select above reads it — a release's
658
+ // when there is one, the album's otherwise — so a filter and the field it
659
+ // filters cannot disagree about which year a song has.
660
+ if (wanted.fromYear !== undefined) {
661
+ filters.push('COALESCE(rel.year, rep.year) >= ?');
662
+ args.push(wanted.fromYear);
663
+ }
664
+ if (wanted.toYear !== undefined) {
665
+ filters.push('COALESCE(rel.year, rep.year) <= ?');
666
+ args.push(wanted.toYear);
667
+ }
668
+ if (wanted.rootId !== undefined) {
669
+ filters.push('r.id = ?');
670
+ args.push(wanted.rootId);
671
+ }
672
+ return rows(db, `${SONG_SELECT}
673
+ WHERE 1 = 1${filters.length === 0 ? '' : ` AND ${filters.join(' AND ')}`}${junk}
674
+ ORDER BY RANDOM()
675
+ LIMIT ?`, ...args, wanted.size);
676
+ }
677
+ /**
678
+ * The songs one genre names, in a stable order.
679
+ *
680
+ * A song is listed under *every* genre its file states, while the `genre` in its
681
+ * own payload is the first one by position. Those two answers would disagree for
682
+ * a file stating two different genres, and no file in this collection does
683
+ * (measured: not one has even two genre rows). Left unreconciled rather than
684
+ * guessed at, because reconciling means choosing — a song under one genre only,
685
+ * or a payload that cannot say which of several it is — and that choice should
686
+ * be made on a file that has the shape.
687
+ */
688
+ export function songsByGenre(db, genre, count, offset, visibility = 'records') {
689
+ return rows(db, `${SONG_SELECT}
690
+ WHERE ${inGenre('f')}${recorded(visibility)}
691
+ ORDER BY al.rel_path, t.ordinal, t.id
692
+ LIMIT ? OFFSET ?`, genre, count, offset);
693
+ }
694
+ /**
695
+ * The records at least one of whose files states this genre.
696
+ *
697
+ * Matched through `track`, which is the same relation `genres()` counts its
698
+ * records by, so the number and the listing are one answer rather than two that
699
+ * happen to agree. The hit is a derived table joined on the *group*, so a box is
700
+ * listed once however many of its discs carry the genre, and no record is
701
+ * multiplied by the genre rows that found it — a file is free to state a genre
702
+ * twice.
703
+ */
704
+ export function albumListByGenre(db, genre, size, offset, rootId, visibility = 'records') {
705
+ const confined = rootId === undefined ? '' : 'WHERE COALESCE(rel.root_id, al.root_id) = ?';
706
+ const junk = rootId === undefined ? `WHERE 1 = 1${recorded(visibility)}` : recorded(visibility);
707
+ return rows(db, `${ALBUM_SELECT}
708
+ JOIN (SELECT DISTINCT ${grouped('a')} AS group_id
709
+ FROM track t
710
+ JOIN album a ON a.id = t.album_id
711
+ JOIN file_tag ft ON ft.file_id = t.file_id
712
+ WHERE ft.name = 'genre' AND ${trimmed('ft')} = ?) hit ON hit.group_id = grp.group_id
713
+ ${confined}${junk}
714
+ ORDER BY COALESCE(rel.rel_path, al.rel_path), al.id
715
+ LIMIT ? OFFSET ?`, ...(rootId === undefined ? [genre, size, offset] : [genre, rootId, size, offset]));
716
+ }
717
+ /**
718
+ * The records whose year falls between two the client named, inclusive.
719
+ *
720
+ * The second of the protocol's two filters, shaped like the genre one beside it:
721
+ * a client names the axis and the bounds, and the bounds are required arguments
722
+ * rather than a page — defaulting them would answer "everything" to a client that
723
+ * asked for a decade and forgot to say which.
724
+ *
725
+ * The year is the record's own, the release's where it has one and its folder's
726
+ * otherwise, which is the value `albumId3` shows. So a record lists under the
727
+ * year it displays, and a range that finds it is the range that shows it.
728
+ */
729
+ export function albumListByYear(db, fromYear, toYear, size, offset, rootId, visibility = 'records') {
730
+ const confine = rootId === undefined ? '' : 'AND COALESCE(rel.root_id, al.root_id) = ?';
731
+ return rows(db, `${ALBUM_SELECT}
732
+ WHERE COALESCE(rel.year, al.year) BETWEEN ? AND ? ${confine}${recorded(visibility)}
733
+ ORDER BY COALESCE(rel.year, al.year), COALESCE(rel.rel_path, al.rel_path), al.id
734
+ LIMIT ? OFFSET ?`, ...(rootId === undefined
735
+ ? [fromYear, toYear, size, offset]
736
+ : [fromYear, toYear, rootId, size, offset]));
737
+ }
738
+ /**
739
+ * What a record is, however it was reached — by artist, by id, by listing.
740
+ *
741
+ * **One row per record, not per album row.** A box is several `album` rows and
742
+ * one record, so the select reads the representative of each group
743
+ * (`ALBUM_GROUPS`) and answers for the whole of it: the title is the release's,
744
+ * the counts are summed over its discs, and `disc_number` is null because a
745
+ * record is not a disc. Without this a client is shown `CD1 ● Альбом`,
746
+ * `CD2 ● …`, `CD3 ● …` as three albums and the record's own name — the one its
747
+ * folder states — nowhere at all. Measured on this collection: 61 disc rows
748
+ * standing in for 19 records.
749
+ *
750
+ * `rel_path` and `root_id` are the *record's* folder, which for a box is the
751
+ * release's — where its cover is, and where its genre and year come from. The
752
+ * representative's own path is still reachable as `al.rel_path`, which is what
753
+ * the orderings use: a deterministic order does not care which of the two
754
+ * prefixes it sorts by, and the queries that do care say which they mean.
755
+ *
756
+ * The counts are a left join to a table aggregated before the join, rather than
757
+ * a subquery per row: a record with no songs still comes back as itself, and
758
+ * nothing multiplies — while a correlated subquery here is what took the server
759
+ * down once already (see `ALBUM_GENRES`).
760
+ */
761
+ /**
762
+ * The rows a record is read from, before the columns are chosen.
763
+ *
764
+ * Split out because other queries want these rows and none of the columns:
765
+ * `recordsUnderCount` counts what `ALBUM_SELECT` reads, and
766
+ * `albumPlacesOfArtist` wants only where a record is. The one invariant all
767
+ * three depend on — no join here multiplies a record — is not something any of
768
+ * them can check. Writing the `FROM` clause once is the nearest thing to a
769
+ * guard: a join added for a column stays below, and a join that changed *which*
770
+ * rows there are cannot be.
771
+ */
772
+ const ALBUM_ROWS = `
773
+ FROM album al
774
+ JOIN ${ALBUM_GROUPS} grp ON grp.id = al.id AND grp.rn = 1
775
+ LEFT JOIN release rel ON rel.id = al.release_id`;
776
+ const albumSelect = (totals, genres) => `
777
+ SELECT al.id,
778
+ aa.starred_at, aa.rating,
779
+ COALESCE(rel.root_id, al.root_id) AS root_id,
780
+ COALESCE(rel.rel_path, al.rel_path) AS rel_path,
781
+ COALESCE(rel.title, al.title) AS title,
782
+ COALESCE(rel.artist_id, al.artist_id) AS artist_id,
783
+ ar.name AS artist_name, ar.sort_key AS artist_sort,
784
+ rel.title AS release_title,
785
+ CASE WHEN al.release_id IS NULL THEN al.disc_number END AS disc_number,
786
+ COALESCE(rel.year, al.year) AS year,
787
+ g.value AS genre,
788
+ COALESCE(tot.song_count, 0) AS song_count,
789
+ tot.duration_ms AS duration_ms
790
+ ${ALBUM_ROWS}
791
+ LEFT JOIN artist ar ON ar.id = COALESCE(rel.artist_id, al.artist_id)
792
+ LEFT JOIN album_annotation aa ON aa.album_id = al.id
793
+ LEFT JOIN ${totals} tot ON tot.group_id = grp.group_id
794
+ LEFT JOIN ${genres} g ON g.group_id = grp.group_id
795
+ `;
796
+ /**
797
+ * The columns are written once and the two aggregate clauses are handed in.
798
+ *
799
+ * Which keeps the property `ALBUM_ROWS` above is written once to keep: a reader
800
+ * that knows which record it wants and one that lists them all answer with the
801
+ * same columns from the same rows, and only the scope of the aggregates differs.
802
+ */
803
+ const ALBUM_SELECT = albumSelect(ALBUM_TOTALS, ALBUM_GENRES);
804
+ /**
805
+ * The same columns for a reader that already knows which record it wants.
806
+ *
807
+ * Both aggregates walk the whole collection — every track for the counts, every
808
+ * genre-tagged file for the genre — and neither is narrowed by whatever `WHERE` or
809
+ * `LIMIT` a caller puts after them: the plan materialises them, and only then does
810
+ * the outer query choose rows. Measured on this collection the floor is about
811
+ * 25 ms and it is flat: `getAlbumList2` cost 24.3 ms at `size=10` and 31.7 ms at
812
+ * `size=500`. A listing pays that once for hundreds of records; opening one album
813
+ * paid it for one.
814
+ *
815
+ * So the aggregates are told which record to build, through a `target` named once
816
+ * and read by both. The rules do not fork — `albumTotals` and `albumGenres` above
817
+ * write the bodies, and this hands each a `WHERE`.
818
+ */
819
+ const ONE_GROUP = `${grouped('a')} = (SELECT group_id FROM target)`;
820
+ const ALBUM_SELECT_ONE = `WITH target(group_id) AS (
821
+ SELECT ${grouped('b')} FROM album b WHERE b.id = ?
822
+ )
823
+ ${albumSelect(albumTotals(`WHERE ${ONE_GROUP}`), albumGenres(`AND ${ONE_GROUP}`))}
824
+ WHERE grp.group_id = (SELECT group_id FROM target)`;
825
+ /**
826
+ * The records an artist is credited on, or only those in one root.
827
+ *
828
+ * `rootId` is what a client asking for one music folder means. Without it this
829
+ * answers about the whole collection, which is right for an artist page and
830
+ * wrong for a confined one: `getArtists` narrows its list to the folder asked
831
+ * about, so a page that ignored the same parameter would list records from a
832
+ * folder the client is not looking at.
833
+ */
834
+ export function albumsOfArtist(db, artistId, rootId, visibility = 'records') {
835
+ const confined = rootId === undefined ? '' : 'AND al.root_id = ?';
836
+ return rows(db, `${ALBUM_SELECT} WHERE COALESCE(rel.artist_id, al.artist_id) = ? ${confined}${recorded(visibility)}
837
+ ORDER BY COALESCE(al.disc_number, 0), al.rel_path, al.id`, ...(rootId === undefined ? [artistId] : [artistId, rootId]));
838
+ }
839
+ export function albumPlacesOfArtist(db, artistId, visibility = 'records') {
840
+ return rows(db, `SELECT al.id,
841
+ COALESCE(rel.root_id, al.root_id) AS root_id,
842
+ COALESCE(rel.rel_path, al.rel_path) AS rel_path
843
+ ${ALBUM_ROWS}
844
+ WHERE COALESCE(rel.artist_id, al.artist_id) = ?${recorded(visibility)}
845
+ ORDER BY COALESCE(al.disc_number, 0), al.rel_path, al.id`, artistId);
846
+ }
847
+ /**
848
+ * The folders that are a *disc* of a release rather than a record of their own.
849
+ *
850
+ * The tree lists what is directly inside a folder, and a box's discs are not
851
+ * that: `Slipknot AAC 320/2014 - .5 The Gray Chapter - CD 1 [JP - WPCR-16130]`
852
+ * is the first disc of the record keyed on `Slipknot AAC 320` itself, and a
853
+ * listing that showed it as a folder would offer the same record twice — once
854
+ * as itself and once as its own first CD.
855
+ */
856
+ export function discPaths(db, rootId, paths) {
857
+ if (paths.length === 0)
858
+ return [];
859
+ const under = paths
860
+ .map(() => `(al.rel_path = ? OR al.rel_path LIKE ? || '/%')`)
861
+ .join(' OR ');
862
+ const args = [];
863
+ for (const path of paths)
864
+ args.push(path, path);
865
+ return rows(db, `SELECT al.rel_path FROM album al
866
+ WHERE al.root_id = ? AND al.release_id IS NOT NULL AND (${under})`, rootId, ...args).map((row) => row.rel_path);
867
+ }
868
+ /**
869
+ * The records lying under a set of physical folders.
870
+ *
871
+ * This is the question the virtual tree asks and `albumsOfArtist` cannot: a
872
+ * record belongs to the folder it was filed in, not to whoever its tags credit.
873
+ * `Cock E.S.P` holds eleven splits its files credit to Merzbow, Aube and nine
874
+ * others, and the operator's word on that is that the folder decides — the
875
+ * person knows what they put where, and everything else is navigation or
876
+ * search.
877
+ *
878
+ * A folder's records are the ones whose own path *is* it (a record folder at
879
+ * the top of a root) or lies beneath it.
880
+ *
881
+ * Ordered by year, and deliberately not by path. Path order is the collector's
882
+ * *shelves* — `Compilations/`, `Deluxe Editions/`, `Live Albums/` — so an
883
+ * artist listed every compilation from 1983 to 2004 and then every deluxe
884
+ * edition from 2004 on, with the year jumping at each boundary. What a person
885
+ * reads a shelf of records for is when they came out. A record with no year
886
+ * goes last rather than first, which is where a missing number belongs.
887
+ */
888
+ export function recordsUnder(db, rootId, paths, visibility = 'records') {
889
+ if (paths.length === 0)
890
+ return [];
891
+ const under = underPaths(paths);
892
+ return rows(db, `${ALBUM_SELECT} WHERE COALESCE(rel.root_id, al.root_id) = ? AND (${under.sql})${recorded(visibility)}
893
+ ORDER BY COALESCE(rel.year, al.year) IS NULL, COALESCE(rel.year, al.year),
894
+ COALESCE(rel.title, al.title), COALESCE(rel.rel_path, al.rel_path), al.id`, rootId, ...under.args);
895
+ }
896
+ /**
897
+ * The predicate that says a record lies under one of these paths.
898
+ *
899
+ * Written once because two queries now read it — the one that fetches the
900
+ * records and the one that only counts them — and a path predicate that drifted
901
+ * between the two would make a list and its own length disagree, which is the
902
+ * defect this project keeps finding.
903
+ */
904
+ function underPaths(paths) {
905
+ const sql = paths
906
+ .map(() => `(COALESCE(rel.rel_path, al.rel_path) = ? OR COALESCE(rel.rel_path, al.rel_path) LIKE ? || '/%')`)
907
+ .join(' OR ');
908
+ const args = [];
909
+ for (const path of paths)
910
+ args.push(path, path);
911
+ return { sql, args };
912
+ }
913
+ /**
914
+ * How many records lie under a set of folders, without reading one of them.
915
+ *
916
+ * The same question `recordsUnder` answers, asked for its size alone — and the
917
+ * difference is not a smaller version of the same query. `ALBUM_SELECT` carries
918
+ * `ALBUM_GENRES` and `ALBUM_TOTALS`, aggregates over `file`, `track` and
919
+ * `file_tag` built for *every* album before the join narrows anything; a caller
920
+ * that wants only the length pays for all of it and reads one number.
921
+ *
922
+ * `getIndexes` is that caller, and it asks once per node: thirty-one nodes at
923
+ * roughly twenty milliseconds each was most of a second, on a single-threaded
924
+ * server, spent producing a list of counts. What a record *is* is not needed to
925
+ * count it, so the genres and the totals are not joined here — only
926
+ * `ALBUM_GROUPS`, which decides how many records there are rather than what
927
+ * they hold.
928
+ */
929
+ export function recordsUnderCount(db, rootId, paths, visibility = 'records') {
930
+ if (paths.length === 0)
931
+ return 0;
932
+ const under = underPaths(paths);
933
+ const found = row(db, `SELECT COUNT(*) AS n
934
+ ${ALBUM_ROWS}
935
+ WHERE COALESCE(rel.root_id, al.root_id) = ? AND (${under.sql})${recorded(visibility)}`, rootId, ...under.args);
936
+ return found?.n ?? 0;
937
+ }
938
+ /**
939
+ * The same counts, all at once.
940
+ *
941
+ * Asking `recordsUnderCount` per group is what `getIndexes` did, and each call
942
+ * built `ALBUM_GROUPS` — a window function over every album — for its own
943
+ * handful of paths. Twenty-five of those was most of the 104 ms that route
944
+ * still cost after the records stopped being read. One pass over the album rows
945
+ * with the groups as a `VALUES` list gives the same numbers; measured on a copy
946
+ * of the collection, 63–144 ms became 4–8.
947
+ *
948
+ * The scope is a `VALUES` list in a CTE rather than a temporary table: SQLite
949
+ * has to be told about temp tables, and `WITH s(...) AS (VALUES ...)` is a
950
+ * literal the planner sees. It is written this way and not as a
951
+ * `(VALUES ...)` join because SQLite refuses a bare `VALUES` as a table
952
+ * expression — measured, `near "(": syntax error` — while a CTE over one is
953
+ * accepted. The paths are bound, never interpolated: they are folder names, and
954
+ * a folder name is a thing a person chose.
955
+ *
956
+ * Groups whose paths overlap count a record once for each, which is the answer
957
+ * the per-group calls gave as well, so nothing here depends on overlap being
958
+ * absent.
959
+ */
960
+ export function recordsUnderCounts(db, scope, visibility = 'records') {
961
+ const counts = new Array(scope.length).fill(0);
962
+ if (scope.length === 0)
963
+ return counts;
964
+ const bound = [];
965
+ const values = [];
966
+ scope.forEach((group, at) => {
967
+ for (const path of group.paths) {
968
+ values.push('(?, ?, ?)');
969
+ bound.push(at, group.rootId, path);
970
+ }
971
+ });
972
+ if (values.length === 0)
973
+ return counts;
974
+ // The path predicate is spelled out here rather than taken from `underPaths`,
975
+ // and the reason is that the two are not the same shape: `underPaths` builds a
976
+ // disjunction over N *bound* paths, one comparison each, while this compares
977
+ // one path *column* per joined row. A shared form would have to take the
978
+ // expression as an argument, which is a parameter whose only two values are
979
+ // `?` and `s.path` — more machinery than the rule it would protect, and a
980
+ // reader of either is still looking at the same three conditions.
981
+ const found = rows(db, `WITH s(at, root_id, path) AS (VALUES ${values.join(', ')})
982
+ SELECT s.at AS at, COUNT(*) AS n
983
+ ${ALBUM_ROWS}
984
+ JOIN s ON COALESCE(rel.root_id, al.root_id) = s.root_id
985
+ AND (COALESCE(rel.rel_path, al.rel_path) = s.path
986
+ OR COALESCE(rel.rel_path, al.rel_path) LIKE s.path || '/%')
987
+ WHERE 1 = 1${recorded(visibility)}
988
+ GROUP BY s.at`, ...bound);
989
+ for (const { at, n } of found)
990
+ counts[at] = n;
991
+ return counts;
992
+ }
993
+ /**
994
+ * The record an id names, whichever of its rows the id belongs to.
995
+ *
996
+ * The id is resolved through the group rather than matched, so a client that
997
+ * stored `al:` for the second disc of a box — which is what every client did
998
+ * while the discs were the albums — opens the record rather than being told
999
+ * there is no such album. An id naming nothing resolves to nothing: the inner
1000
+ * select is empty, the comparison is null, and no row matches.
1001
+ */
1002
+ export function album(db, id) {
1003
+ return row(db, ALBUM_SELECT_ONE, id);
1004
+ }
1005
+ /**
1006
+ * The same answer for a page of ids, in one query.
1007
+ *
1008
+ * This is where asking one at a time stopped being affordable. `ALBUM_SELECT`
1009
+ * computes `ALBUM_GENRES` — a window function over every genre row — and the
1010
+ * plan is built per execution, so a search page of 250 albums ran it 250 times:
1011
+ * measured on the live collection at about **2 s**, against a few milliseconds
1012
+ * for the single `IN` below. The listing routes never had the problem, because
1013
+ * they ask once and let `LIMIT` decide; only the search resolved ids it already
1014
+ * held.
1015
+ */
1016
+ export function albumsById(db, ids) {
1017
+ return byIds(ids, (placeholders) => rows(db, `${ALBUM_SELECT} WHERE al.id IN (${placeholders})`, ...ids));
1018
+ }
1019
+ /** Where the listener's own marks and plays are read from — see the migrations. */
1020
+ const MARKS = '(SELECT aa.starred_at FROM album_annotation aa WHERE aa.album_id = al.id)';
1021
+ const RATING = '(SELECT aa.rating FROM album_annotation aa WHERE aa.album_id = al.id)';
1022
+ const LAST_PLAY = '(SELECT MAX(tp.played_at) FROM track_play tp JOIN track t ON t.id = tp.track_id WHERE t.album_id = al.id)';
1023
+ const PLAYS = '(SELECT SUM(tp.play_count) FROM track_play tp JOIN track t ON t.id = tp.track_id WHERE t.album_id = al.id)';
1024
+ const ORDERS = {
1025
+ // The *record's* name, which for a box is its release folder — the row's own
1026
+ // title is the disc's (`CD1 ● Альбом`), and sorting the collection by that
1027
+ // would file every box under `C`, beside the alphabet's other disc ones.
1028
+ alphabeticalByName: { by: 'COALESCE(rel.title, al.title, al.rel_path), al.id' },
1029
+ alphabeticalByArtist: {
1030
+ by: 'COALESCE(ar.sort_key, ar.name), COALESCE(rel.title, al.title, al.rel_path), al.id',
1031
+ },
1032
+ // The id rises with the order the scan met a folder in, which is the nearest
1033
+ // thing to "recently added" a derived layer can offer: nothing records when a
1034
+ // record was acquired, only when it was first seen.
1035
+ newest: { by: 'al.id DESC' },
1036
+ random: { by: 'RANDOM()' },
1037
+ // The albums the listener marked, most recently marked first.
1038
+ starred: { by: `${MARKS} DESC`, where: `AND ${MARKS} IS NOT NULL` },
1039
+ // A record is rated as a record — the rating of its songs is a different
1040
+ // question — and one nobody rated is not "highly rated".
1041
+ highest: { by: `${RATING} DESC, al.id`, where: `AND ${RATING} IS NOT NULL` },
1042
+ // And the two the history answers: when anything on the record was last
1043
+ // played, and what the plays on it add up to.
1044
+ recent: { by: `${LAST_PLAY} DESC`, where: `AND ${LAST_PLAY} IS NOT NULL` },
1045
+ frequent: { by: `${PLAYS} DESC, al.id`, where: `AND COALESCE(${PLAYS}, 0) > 0` },
1046
+ };
1047
+ export function albumList(db, order, size, offset, rootId, visibility = 'records') {
1048
+ const confined = rootId === undefined ? '' : 'COALESCE(rel.root_id, al.root_id) = ?';
1049
+ const ordered = ORDERS[order];
1050
+ const where = `WHERE 1 = 1 ${confined === '' ? '' : `AND ${confined}`}${recorded(visibility)} ` +
1051
+ `${ordered.where ?? ''}`;
1052
+ const args = rootId === undefined ? [size, offset] : [rootId, size, offset];
1053
+ return rows(db, `${ALBUM_SELECT} ${where} ORDER BY ${ordered.by} LIMIT ? OFFSET ?`, ...args);
1054
+ }
1055
+ /**
1056
+ * A song, with the album it is on and the artist who owns that album.
1057
+ *
1058
+ * The artist is the album's and not the song's, because the meta layer keeps no
1059
+ * per-song artist: `track.artist_id` is left empty on purpose (see
1060
+ * `artist/apply.ts`), and the cue's own PERFORMER is a credit — a string a cue
1061
+ * wrote — which the inventory dump shows beside the track rather than in place
1062
+ * of the record's artist.
1063
+ *
1064
+ * **The eight tag fields come off a joined row and are not asked of `file_tag`
1065
+ * here.** They used to be eight correlated subqueries, which was not wrong so
1066
+ * much as eight seeks a song: a page of five hundred costs 10.04 ms that way and
1067
+ * 6.37 ms off this row, measured, with every alternative shape worse
1068
+ * (`tags/first.ts` has the numbers and the alternatives). `tg` is one row a file,
1069
+ * written by the stage that writes the tags it is derived from, so it cannot
1070
+ * disagree with them — and a file nobody has read has no row, which is the null
1071
+ * the subqueries answered too.
1072
+ */
1073
+ const SONG_SELECT = `
1074
+ SELECT t.id, t.ordinal, t.title, grp.rep_id AS album_id,
1075
+ ta.starred_at, ta.rating,
1076
+ tp.play_count, tp.played_at,
1077
+ COALESCE(rel.title, rep.title) AS album_title,
1078
+ COALESCE(rel.artist_id, rep.artist_id) AS artist_id,
1079
+ ar.name AS artist_name, al.disc_number,
1080
+ COALESCE(rel.year, rep.year) AS album_year,
1081
+ tg.genre AS genre, tg.track_artist AS track_artist,
1082
+ tg.advisory_itunes AS advisory_itunes, tg.advisory_mp4 AS advisory_mp4,
1083
+ tg.rg_track_gain AS rg_track_gain,
1084
+ tg.rg_album_gain AS rg_album_gain,
1085
+ tg.rg_track_peak AS rg_track_peak,
1086
+ tg.rg_album_peak AS rg_album_peak,
1087
+ t.duration_ms, t.segment_start_ms, t.segment_end_ms,
1088
+ r.id AS root_id, r.path AS root_path, f.rel_path, f.folder_rel_path, f.size, f.ext,
1089
+ p.codec AS codec, p.sample_rate AS sample_rate, p.channels AS channels,
1090
+ p.bitrate AS bitrate
1091
+ FROM track t
1092
+ JOIN file f ON f.id = t.file_id
1093
+ JOIN root r ON r.id = f.root_id
1094
+ LEFT JOIN album al ON al.id = t.album_id
1095
+ LEFT JOIN ${ALBUM_GROUPS} grp ON grp.id = al.id
1096
+ LEFT JOIN album rep ON rep.id = grp.rep_id
1097
+ LEFT JOIN release rel ON rel.id = rep.release_id
1098
+ LEFT JOIN artist ar ON ar.id = COALESCE(rep.artist_id, rel.artist_id)
1099
+ LEFT JOIN audio_probe p ON p.file_id = f.id
1100
+ LEFT JOIN file_tag_first tg ON tg.file_id = f.id
1101
+ LEFT JOIN track_annotation ta ON ta.track_id = t.id
1102
+ LEFT JOIN track_play tp ON tp.track_id = t.id
1103
+ `;
1104
+ /**
1105
+ * Every song of a record, in playing order — disc by disc, then within a disc.
1106
+ *
1107
+ * The id is resolved through the group, so opening *any* disc of a box opens the
1108
+ * record, and what comes back is all of it. Each song still says which disc it
1109
+ * is on: that is the field the protocol gives a client to draw its separators
1110
+ * with, and the reason the discs do not have to be albums of their own.
1111
+ */
1112
+ export function songsOfAlbum(db, albumId) {
1113
+ return rows(db, `${SONG_SELECT}
1114
+ WHERE al.id IN (SELECT id FROM album a
1115
+ WHERE ${grouped('a')} =
1116
+ (SELECT ${grouped('b')} FROM album b WHERE b.id = ?))
1117
+ ORDER BY COALESCE(al.disc_number, 0), t.ordinal, t.id`, albumId);
1118
+ }
1119
+ /**
1120
+ * Which of these artists have a record in this root.
1121
+ *
1122
+ * The one question a music-folder filter can ask about an artist: an artist is
1123
+ * not *in* a folder — their records are, and they may sit in several — so the
1124
+ * artists a folder holds are the ones it has a record of, credited either on
1125
+ * the record itself or on the release a box belongs to.
1126
+ *
1127
+ * Chunked for the reason `knownTrackIds` is: one placeholder per id, and SQLite
1128
+ * caps how many a statement may carry.
1129
+ */
1130
+ export function artistIdsInRoot(db, ids, rootId) {
1131
+ const found = new Set();
1132
+ const CHUNK = 500;
1133
+ for (let at = 0; at < ids.length; at += CHUNK) {
1134
+ const chunk = ids.slice(at, at + CHUNK);
1135
+ const matched = rows(db, `SELECT DISTINCT COALESCE(rel.artist_id, al.artist_id) AS id
1136
+ FROM album al
1137
+ LEFT JOIN release rel ON rel.id = al.release_id
1138
+ WHERE al.root_id = ? AND COALESCE(rel.artist_id, al.artist_id) IN (${chunk.map(() => '?').join(', ')})`, rootId, ...chunk);
1139
+ for (const row of matched)
1140
+ if (row.id !== null)
1141
+ found.add(row.id);
1142
+ }
1143
+ return found;
1144
+ }
1145
+ /**
1146
+ * The songs of a playlist, in the order the playlist holds them.
1147
+ *
1148
+ * The one listing in this API whose order was chosen by a person rather than by
1149
+ * the collection: `position` is the whole of what a playlist says beyond which
1150
+ * songs are in it, so the `ORDER BY` is the answer and not a convenience.
1151
+ *
1152
+ * Read here rather than in the playlist module so that a song in a playlist is
1153
+ * the same song as everywhere else — one select, one shape, and no second
1154
+ * opinion about what a song is.
1155
+ */
1156
+ export function songsOfPlaylist(db, playlistId) {
1157
+ return rows(db, `${SONG_SELECT}
1158
+ JOIN playlist_track pt ON pt.track_id = t.id
1159
+ WHERE pt.playlist_id = ?
1160
+ ORDER BY pt.position`, playlistId);
1161
+ }
1162
+ /**
1163
+ * Which of these ids name a song the collection still has.
1164
+ *
1165
+ * The question `createPlaylist` and `updatePlaylist` ask before they write
1166
+ * anything, and asked of the collection rather than of the playlist module for
1167
+ * the reason the reading above lives here: whether a track exists is a fact
1168
+ * about the classified model, and the model has one reader.
1169
+ *
1170
+ * Asked in chunks, and the chunking is not tidiness. The ids are bound one
1171
+ * placeholder per song, and SQLite caps how many parameters a statement may
1172
+ * carry — 32766 in the build this runs on — so a client saving a very long
1173
+ * playlist would be answered with an internal error rather than with an answer
1174
+ * about songs. A chunk well under that ceiling keeps every statement ordinary,
1175
+ * and the protocol sets no limit on how many songs a playlist may hold.
1176
+ *
1177
+ * An id that names nothing is simply absent from the answer: which of them did
1178
+ * is the caller's to report, since only the caller knows what it asked for.
1179
+ */
1180
+ export function knownTrackIds(db, ids) {
1181
+ const known = new Set();
1182
+ const CHUNK = 500;
1183
+ for (let at = 0; at < ids.length; at += CHUNK) {
1184
+ const chunk = ids.slice(at, at + CHUNK);
1185
+ const found = rows(db, `SELECT id FROM track WHERE id IN (${chunk.map(() => '?').join(', ')})`, ...chunk);
1186
+ for (const { id } of found)
1187
+ known.add(id);
1188
+ }
1189
+ return known;
1190
+ }
1191
+ /**
1192
+ * Where the files of several records sit, for all of them at once.
1193
+ *
1194
+ * `songsOfAlbum` answers "what is on this record", and reads it through the
1195
+ * group so a box's other discs come with it. A caller that wants only *where*
1196
+ * the files are paid that whole select per record: the cover route asking about
1197
+ * an artist ran it once per record of the artist — measured on the live
1198
+ * collection at 30 executions for `getCoverArt?id=ar:15`, and 277 ms of a route
1199
+ * that serves no other request while it runs. The question needs no order of
1200
+ * tracks and no titles, only `track → file`, which is one statement either way.
1201
+ *
1202
+ * Keyed by the record each row belongs to, and within a record in the order
1203
+ * `songsOfAlbum` would have given it, so a caller that walks its own records in
1204
+ * order and appends what it finds gets the list it would have built one record
1205
+ * at a time. That order is not decoration: it decides which folder answers when
1206
+ * no picture's *name* says which side it is.
1207
+ */
1208
+ export function fileFoldersOfAlbums(db, albumIds) {
1209
+ const found = new Map();
1210
+ if (albumIds.length === 0)
1211
+ return found;
1212
+ const asked = albumIds.map(() => '?').join(', ');
1213
+ const where = rows(db, `SELECT grp.rep_id AS album_id, r.id AS root_id, f.folder_rel_path AS rel_path
1214
+ FROM track t
1215
+ JOIN file f ON f.id = t.file_id
1216
+ JOIN root r ON r.id = f.root_id
1217
+ LEFT JOIN album al ON al.id = t.album_id
1218
+ JOIN ${ALBUM_GROUPS} grp ON grp.id = al.id
1219
+ WHERE grp.rep_id IN (${asked})
1220
+ ORDER BY grp.rep_id, COALESCE(al.disc_number, 0), t.ordinal, t.id`, ...albumIds);
1221
+ for (const { album_id: albumId, root_id: rootId, rel_path: relPath } of where) {
1222
+ const already = found.get(albumId);
1223
+ const folder = { rootId, relPath };
1224
+ if (already === undefined)
1225
+ found.set(albumId, [folder]);
1226
+ else
1227
+ already.push(folder);
1228
+ }
1229
+ return found;
1230
+ }
1231
+ /**
1232
+ * The discs of a record, in order, for `discTitles`.
1233
+ *
1234
+ * A disc folder called `CD2 ● Ранний вариант` says what that disc *is* — an
1235
+ * early version, a bonus disc, a live set — and the protocol has a field for
1236
+ * exactly that. The title comes back verbatim; stripping the number in front of
1237
+ * it is `discSubtitle`'s job, and a disc whose folder names nothing but its
1238
+ * number is reported as it is and dropped by the caller.
1239
+ */
1240
+ export function discsOfAlbum(db, albumId) {
1241
+ return rows(db, `SELECT al.disc_number AS disc, al.title AS title FROM album al
1242
+ WHERE al.disc_number IS NOT NULL
1243
+ AND ${grouped('al')} = (SELECT ${grouped('b')} FROM album b WHERE b.id = ?)
1244
+ ORDER BY al.disc_number, al.rel_path, al.id`, albumId);
1245
+ }
1246
+ export function song(db, trackId) {
1247
+ return row(db, `${SONG_SELECT} WHERE t.id = ?`, trackId);
1248
+ }
1249
+ /**
1250
+ * The same answer for a page of ids, in one query.
1251
+ *
1252
+ * The plural of `song`, and the one the starred listing needs: what the listener
1253
+ * starred is a set of ids with no order the collection knows, and asking for
1254
+ * them one at a time is one execution of `SONG_SELECT` per song.
1255
+ */
1256
+ export function songsByIds(db, ids) {
1257
+ return byIds(ids, (placeholders) => rows(db, `${SONG_SELECT} WHERE t.id IN (${placeholders})`, ...ids));
1258
+ }
1259
+ /** The songs held in one folder, which is one album's worth by construction. */
1260
+ export function songsInFolder(db, rootId, relPath) {
1261
+ return rows(db, `${SONG_SELECT} WHERE al.root_id = ? AND al.rel_path = ? ORDER BY t.ordinal, t.id`, rootId, relPath);
1262
+ }
1263
+ export function roots(db) {
1264
+ return rows(db, 'SELECT id, path, alias FROM root ORDER BY id');
1265
+ }
1266
+ export function root(db, id) {
1267
+ return row(db, 'SELECT id, path, alias FROM root WHERE id = ?', id);
1268
+ }
1269
+ export function folder(db, id) {
1270
+ return row(db, 'SELECT id, root_id, rel_path, parent_rel_path, role FROM folder WHERE id = ?', id);
1271
+ }
1272
+ export function picturesInFolder(db, rootId, relPath) {
1273
+ return rows(db, `SELECT f.rel_path, f.ext, r.path AS root_path
1274
+ FROM file f
1275
+ JOIN root r ON r.id = f.root_id
1276
+ WHERE f.root_id = ? AND f.folder_rel_path = ? AND f.kind = 'image'
1277
+ ORDER BY f.rel_path`, rootId, relPath);
1278
+ }
1279
+ /**
1280
+ * The songs a search finds, best first.
1281
+ *
1282
+ * The rank comes from the index rather than from this query: how well a row
1283
+ * answers is the index's to say, and the id beside it is only there so that two
1284
+ * equally good answers come back in the same order twice.
1285
+ */
1286
+ export function searchSongs(db, page) {
1287
+ // The root, when one was asked for, is a condition on every one of the three
1288
+ // sections — a search confined to a library that answered about another one
1289
+ // would be answering about the wrong music.
1290
+ const inRoot = page.rootId === undefined ? '' : ' AND r.id = ?';
1291
+ const root = page.rootId === undefined ? [] : [page.rootId];
1292
+ const junk = recorded(page.visibility ?? 'records');
1293
+ if (page.match === null) {
1294
+ return rows(db, `${SONG_SELECT} WHERE 1 = 1${inRoot}${junk} ORDER BY t.id LIMIT ? OFFSET ?`, ...root, page.size, page.offset);
1295
+ }
1296
+ return rows(db, `${SONG_SELECT}
1297
+ JOIN track_fts ON track_fts.rowid = t.id
1298
+ WHERE track_fts MATCH ?${inRoot}${junk}
1299
+ ORDER BY track_fts.rank, t.id
1300
+ LIMIT ? OFFSET ?`, page.match, ...root, page.size, page.offset);
1301
+ }
1302
+ /**
1303
+ * The records a query finds, as the ids `albumsById` expects: the
1304
+ * representative of each group, never a disc.
1305
+ *
1306
+ * A disc id here would be a second answer to a question `getAlbumList2` answers
1307
+ * with records — a search that offered `CD1 ● Альбом` while the browse tab
1308
+ * offered the box is the disagreement this file exists to avoid. The ranking is
1309
+ * kept: a match is ordered by its best rank, everything else by the record's
1310
+ * name.
1311
+ */
1312
+ export function searchAlbumIds(db, page) {
1313
+ const inRoot = page.rootId === undefined ? '' : ' AND al.root_id = ?';
1314
+ const root = page.rootId === undefined ? [] : [page.rootId];
1315
+ const junk = recorded(page.visibility ?? 'records');
1316
+ const ids = page.match === null
1317
+ ? rows(db, `SELECT grp.rep_id AS id FROM album al
1318
+ JOIN ${ALBUM_GROUPS} grp ON grp.id = al.id AND grp.rn = 1
1319
+ LEFT JOIN release rel ON rel.id = al.release_id
1320
+ WHERE 1 = 1${inRoot}${junk}
1321
+ ORDER BY COALESCE(rel.title, al.title, al.rel_path), grp.rep_id
1322
+ LIMIT ? OFFSET ?`, ...root, page.size, page.offset)
1323
+ : rows(db, `SELECT MIN(grp.rep_id) AS id, MIN(track_fts.rank) AS best
1324
+ FROM track_fts
1325
+ JOIN track t ON t.id = track_fts.rowid
1326
+ JOIN album al ON al.id = t.album_id
1327
+ JOIN ${ALBUM_GROUPS} grp ON grp.id = al.id
1328
+ WHERE track_fts MATCH ? AND t.album_id IS NOT NULL${inRoot}${junk}
1329
+ GROUP BY grp.group_id
1330
+ ORDER BY best, MIN(grp.rep_id)
1331
+ LIMIT ? OFFSET ?`, page.match, ...root, page.size, page.offset);
1332
+ return ids.map((row) => row.id);
1333
+ }
1334
+ export function searchArtistIds(db, page) {
1335
+ const inRoot = page.rootId === undefined ? '' : ' AND al.root_id = ?';
1336
+ const root = page.rootId === undefined ? [] : [page.rootId];
1337
+ // Spelled out rather than `recorded()`, because that reads the clause off the
1338
+ // record group's alias `grp` and this query has no group to join — it is
1339
+ // answering "which artists have a song", which is a question about `album`
1340
+ // rows, and joining the group would multiply every row it counts.
1341
+ const junk = page.visibility === 'all' ? '' : ' AND al.junk_reason IS NULL';
1342
+ const ids = page.match === null
1343
+ ? rows(db, `SELECT ar.id FROM artist ar
1344
+ JOIN album al ON al.artist_id = ar.id
1345
+ WHERE 1 = 1${inRoot}${junk}
1346
+ GROUP BY ar.id
1347
+ ORDER BY COALESCE(ar.sort_key, ar.name), ar.id
1348
+ LIMIT ? OFFSET ?`, ...root, page.size, page.offset)
1349
+ : rows(db, `SELECT al.artist_id AS id, MIN(track_fts.rank) AS best
1350
+ FROM track_fts
1351
+ JOIN track t ON t.id = track_fts.rowid
1352
+ JOIN album al ON al.id = t.album_id
1353
+ WHERE track_fts MATCH ? AND al.artist_id IS NOT NULL${inRoot}${junk}
1354
+ GROUP BY al.artist_id
1355
+ ORDER BY best, al.artist_id
1356
+ LIMIT ? OFFSET ?`, page.match, ...root, page.size, page.offset);
1357
+ return ids.map((row) => row.id);
1358
+ }
1359
+ export function embeddedCoverInFolder(db, rootId, relPath) {
1360
+ return row(db, `SELECT f.rel_path, r.path AS root_path, c.mime, c.offset, c.length,
1361
+ c.kind, f.tags_container AS container
1362
+ FROM file f
1363
+ JOIN root r ON r.id = f.root_id
1364
+ JOIN cover_art c ON c.file_id = f.id
1365
+ WHERE f.root_id = ? AND f.folder_rel_path = ?
1366
+ ORDER BY f.rel_path
1367
+ LIMIT 1`, rootId, relPath);
1368
+ }
1369
+ /**
1370
+ * The folders directly inside one folder.
1371
+ *
1372
+ * The row that stands for a root has `''` for both its path and its parent, so
1373
+ * the third condition is what keeps the root from being listed among its own
1374
+ * children. It is written as a comparison rather than as a special case for `''`
1375
+ * because it says the general thing: a folder is never its own child.
1376
+ */
1377
+ export function childFolders(db, rootId, parentRelPath, visibility = 'records') {
1378
+ // A folder the scanner called junk is not offered as a place to go.
1379
+ //
1380
+ // **This is what makes hiding complete, and it is not the album filter
1381
+ // repeated.** A record is hidden by the clause every listing carries, but the
1382
+ // *tree* is built from folders, not from records: the node for `Telegram
1383
+ // Desktop` would still be built, still be listed by `getIndexes`, and still
1384
+ // open onto an empty directory — a row reading nought, which is the shape the
1385
+ // operator would still see in his client. Dropping the folder drops the node
1386
+ // with it, everywhere, because every tree in this file is built from here.
1387
+ //
1388
+ // What it does not cover, stated because the rule is not general: a folder
1389
+ // that is *both* junk and a shelf over other records takes those records with
1390
+ // it. No folder in this collection is both — a dumping ground holds no
1391
+ // records, and the one that does is the root itself, which is never a child of
1392
+ // anything — and a rule that had to tell the two apart would have to walk the
1393
+ // subtree to answer.
1394
+ //
1395
+ // **The subquery is correlated, which this file otherwise refuses.** The shape
1396
+ // is the one that took the server down once (`ALBUM_GENRES`), and it is kept
1397
+ // here because its cost is bounded by something the other had not: this runs
1398
+ // once per *child folder* of one folder, not once per album in the collection,
1399
+ // and it reads `album (root_id, rel_path)`, which is that table's UNIQUE index
1400
+ // — a lookup, not a scan. `getIndexes` walks the whole tree and is measured;
1401
+ // it did not move.
1402
+ const hideJunk = visibility === 'all'
1403
+ ? ''
1404
+ : `AND NOT EXISTS (SELECT 1 FROM album al
1405
+ WHERE al.root_id = f.root_id AND al.rel_path = f.rel_path
1406
+ AND al.junk_reason IS NOT NULL)`;
1407
+ return rows(db, `SELECT f.id, f.root_id, f.rel_path, f.parent_rel_path, f.role FROM folder f
1408
+ WHERE f.root_id = ? AND f.parent_rel_path = ? AND f.rel_path <> ? ${hideJunk}
1409
+ ORDER BY f.rel_path`, rootId, parentRelPath, parentRelPath);
1410
+ }