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,405 @@
1
+ import { basename } from 'node:path';
2
+ import { withTransaction } from "../db/index.js";
3
+ import { prepareSweep, SWEPT_BY_CLASSIFY } from "../db/sweep.js";
4
+ import { junkReason } from "../junk/rule.js";
5
+ import { compareNatural, stemOf } from "../util/names.js";
6
+ import { parseFolderName, recordTitle, splitFields } from "./folder-name.js";
7
+ import { assignRoles, discMarker, discMarkerIndex, discNumber, flatDiscPairs, isDiscName, } from "./roles.js";
8
+ import { buildTree } from "./tree.js";
9
+ const ALL_ROLES = ['album', 'disc', 'box', 'category', 'empty'];
10
+ function toWalkedFile(row) {
11
+ return {
12
+ relPath: row.rel_path,
13
+ folderRelPath: row.folder_rel_path,
14
+ name: row.name,
15
+ kind: row.kind,
16
+ ext: row.ext,
17
+ size: row.size,
18
+ mtimeMs: row.mtime_ms,
19
+ };
20
+ }
21
+ /**
22
+ * Provisional album name.
23
+ *
24
+ * The contract says metadata comes from the folder, the cue and the .nfo
25
+ * (requirements:39 §4). Only the folder is available at this stage, so the name
26
+ * stands in until the cue and tag readers can override it.
27
+ *
28
+ * A collector's folder carries more than the title, though — the year, the
29
+ * artist, the Discogs format note — and a release called
30
+ * `1996 - Greatest Dicks (CD, Comp)` is called `Greatest Dicks`. `parseFolderName`
31
+ * reads the name once for all three tasks that want a piece of it, and the
32
+ * title is this stage's piece. The verbatim name is the fallback: when there is
33
+ * nothing to strip, or stripping leaves nothing (`2009 - Twodeadsluts
34
+ * Onegoodfuck + Cock E.S.P. (Cass, Ltd, C5)` is a credit and a format with no
35
+ * title between them), a noisy name still beats an empty one.
36
+ */
37
+ function displayName(node, rootPath) {
38
+ const name = node.relPath === '' ? basename(rootPath) : node.name;
39
+ return parseFolderName(name).title ?? name;
40
+ }
41
+ /**
42
+ * The name a *record* is shown by — see `recordTitle`, which owns the rule.
43
+ *
44
+ * Only a release reads it, and it parts from `displayName` twice, both times
45
+ * over what a person is shown. It takes the year out of the name — a record's
46
+ * year is the one a box states on its own folder, and `folderYear` reads it for
47
+ * an album either way — and it *keeps* the edition note that `displayName`
48
+ * strips. `1988 ● Группа крови (2019, Maschina Records, MKK881CD, 3CD)` and
49
+ * `Группа Крови (Gold Castle Rec.)` are one album by one band, and a client
50
+ * shown `Группа крови` twice has nothing to choose between them.
51
+ *
52
+ * The note is not said again in `AlbumID3.version`: the API offers that field
53
+ * only when the name does not already carry the note, which for a release named
54
+ * here it always does. See `albumId3`.
55
+ */
56
+ function releaseName(node, rootPath) {
57
+ return recordTitle(node.relPath === '' ? basename(rootPath) : node.name);
58
+ }
59
+ /**
60
+ * The year the folder's name states, or nothing.
61
+ *
62
+ * The same read as the name above, and the same source: the year slot a
63
+ * collector puts in front of a title. It was parsed and thrown away until a
64
+ * client asked for a release date — 236 of the 241 albums in the live
65
+ * collection state one, and all of them were being discarded on the line above.
66
+ */
67
+ function folderYear(node, rootPath) {
68
+ const name = node.relPath === '' ? basename(rootPath) : node.name;
69
+ return parseFolderName(name).year;
70
+ }
71
+ /** `01. x - Pulse` -> `1`; null when the name carries no disc number. */
72
+ function leadingNumber(name) {
73
+ const match = /^(\d{1,2})\s*[.\-_)\]]/.exec(name.trim());
74
+ const digits = match?.[1];
75
+ return digits === undefined ? null : Number.parseInt(digits, 10);
76
+ }
77
+ /** Whether a folder holds audio of its own — the only thing a disc can be. */
78
+ function hasAudioHere(node) {
79
+ return node.files.some((file) => file.kind === 'audio');
80
+ }
81
+ /**
82
+ * The children of a folder that are discs by their *name*, whatever their role.
83
+ *
84
+ * `assignRoles` promotes a child to `disc` only where the folder around it came
85
+ * out a box, and a shelf is not one. So the discs of a shelf carry the role of
86
+ * an ordinary album, and the only thing left that says what they are is their
87
+ * name — the same `isDiscName` the role rule reads.
88
+ */
89
+ function namedDiscs(node) {
90
+ return node.children.filter((child) => isDiscName(child.name) && hasAudioHere(child));
91
+ }
92
+ /**
93
+ * Those children gathered into the sets they name.
94
+ *
95
+ * A disc's set is everything in front of its marker: `2014 - .5 The Gray
96
+ * Chapter - CD 1 [JP - WPCR-16130]` and `… CD 2 [JP - WPCR-16131]` are one set
97
+ * although their catalogue numbers differ, because the difference comes *after*
98
+ * the marker. Bare `CD 1` and `CD 2` are one set too — nothing in front of the
99
+ * marker, both times.
100
+ *
101
+ * The distinction matters because a shelf may hold two sets, and they are two
102
+ * records. Pooling them is not a naming problem: the second set stops existing,
103
+ * its songs joining the first record's totals ([[task:2811]], finding 1).
104
+ */
105
+ function discSets(node) {
106
+ const byName = new Map();
107
+ for (const child of namedDiscs(node)) {
108
+ const at = discMarkerIndex(child.name);
109
+ const prefix = at === -1 ? child.name : child.name.slice(0, at);
110
+ const key = prefix.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
111
+ byName.set(key, [...(byName.get(key) ?? []), child]);
112
+ }
113
+ return [...byName.values()];
114
+ }
115
+ /**
116
+ * The name a shelf's release starts out as: what its discs are called.
117
+ *
118
+ * The discs are the record — the shelf's own name is a statement about the rip,
119
+ * not about the music (`Slipknot AAC 320` names a format). Both discs of a pair
120
+ * are named after the record, so either answers; the lowest disc number is
121
+ * taken, which is the same choice `ALBUM_GROUPS` makes about which row stands
122
+ * for a group, made here for the same reason.
123
+ *
124
+ * What tells one shelf from the next is *not* added here. That needs the
125
+ * artist, and this stage writes no artists — `shelf-name.ts` runs last and
126
+ * appends it, to this name or to whatever a tag or cue made of it.
127
+ */
128
+ function shelfReleaseName(discs, rootPath) {
129
+ const ordered = [...discs].sort((a, b) => (discNumber(a.name) ?? 0) - (discNumber(b.name) ?? 0) ||
130
+ compareNatural(a.relPath, b.relPath));
131
+ const first = ordered[0];
132
+ return first === undefined ? '' : displayName(first, rootPath);
133
+ }
134
+ /**
135
+ * Cut a shared prefix back to a boundary between two names.
136
+ *
137
+ * Two discs of one record share almost their whole name. `Pink Floyd - The Wall
138
+ * [Disc 1]` and `Pink Floyd - The Wall [1994 Remaster](Disc 2)` agree as far as
139
+ * `Pink Floyd - The Wall [`, and taking that prefix leaves `Disc 1]` and
140
+ * `1994 Remaster](Disc 2)` — titles opening on a closing bracket, which is not a
141
+ * name anybody wrote. A prefix ending inside a bracket is not a boundary between
142
+ * two names, so it walks back to the last one that is.
143
+ *
144
+ * The boundary comes from `splitFields`, the bracket-aware splitter the folder
145
+ * parser already uses, rather than from a second scanner counting depth here.
146
+ * `folder-name.ts` opens by naming that duplication as the Shotgun Surgery this
147
+ * project already has a task for.
148
+ */
149
+ function snapPrefix(prefix) {
150
+ const opened = (prefix.match(/[([]/g) ?? []).length;
151
+ const closed = (prefix.match(/[)\]]/g) ?? []).length;
152
+ // Balanced, so the cut landed between two names and is a boundary already.
153
+ if (opened <= closed)
154
+ return prefix;
155
+ // Inside a bracket. Keep everything before the last boundary; when there is no
156
+ // boundary to walk back to, keep nothing and let the caller fall back to the
157
+ // file's own name rather than serve a fragment.
158
+ const parts = splitFields(prefix);
159
+ return parts.length > 1 ? parts.slice(0, -1).join(' - ') : '';
160
+ }
161
+ /**
162
+ * Name each disc by what survives once the folder's shared prefix is removed,
163
+ * so `VA - A State Of Trance_Ibiza 2026 - Pulse/Frequency/Energy` becomes
164
+ * `Pulse`, `Frequency`, `Energy`. Falls back to the file's own stem if
165
+ * stripping leaves nothing — a name is better than an empty string.
166
+ *
167
+ * What survives has to be a **name**, and that is the whole of what a letter
168
+ * tests. `CD1` and `CD2` agree as far as `CD`, so the strip leaves `1` and `2`:
169
+ * honest, and telling a reader nothing — the album list showed the Kino box as
170
+ * two records called `1` and `2`. A folder box has no such hole, because its
171
+ * discs are *folders* and their names are kept whole (`CD1 ● Группа крови`), and
172
+ * the flat pair's equivalent of that name is the file's own stem, which is what
173
+ * the disc is called (task:2756, finding 7).
174
+ */
175
+ function discTitles(pairs) {
176
+ const stripped = pairs.map((pair) => stemOf(pair.audio.name).replace(/^\d+\s*[.\-_)\]]*\s*/, ''));
177
+ let prefix = stripped[0] ?? '';
178
+ for (const value of stripped) {
179
+ while (prefix !== '' && !value.startsWith(prefix))
180
+ prefix = prefix.slice(0, -1);
181
+ }
182
+ prefix = snapPrefix(prefix);
183
+ return stripped.map((value, index) => {
184
+ const trimmed = value.slice(prefix.length).replace(/^[\s\-_.]+|[\s\-_.]+$/g, '');
185
+ if (/\p{L}/u.test(trimmed))
186
+ return trimmed;
187
+ return stemOf(pairs[index]?.audio.name ?? '');
188
+ });
189
+ }
190
+ /**
191
+ * Classify every folder and materialise the album tree.
192
+ *
193
+ * Album identity is the path — `(root_id, rel_path)` and nothing else. Two
194
+ * pressings of the same record, or one album present under two roots, produce
195
+ * two rows, because the filesystem says they are two things. Tags may enrich a
196
+ * row later; they never merge two of them.
197
+ *
198
+ * Re-running is safe: rows are matched on the same identity and updated.
199
+ *
200
+ * Albums and releases are created, updated, and — where the folder beneath
201
+ * them is gone — dropped. Letting go belongs here rather than in the scan
202
+ * because the scan sweeps folders first: by the time this runs, a folder row
203
+ * that is missing is missing because a walk did not see it. What keeps that
204
+ * from being a mistake is the stamp — a row the latest classification
205
+ * re-derived from a surviving folder carries the run, and a row it did not
206
+ * carries an older one. So a root the latest run never walked keeps everything
207
+ * it had.
208
+ */
209
+ export function classify(db) {
210
+ const byRole = Object.fromEntries(ALL_ROLES.map((role) => [role, 0]));
211
+ let folders = 0;
212
+ let albums = 0;
213
+ let releases = 0;
214
+ const roots = db.prepare('SELECT id, path FROM root ORDER BY id').all();
215
+ // The run this classification belongs to. With no run there is nothing to
216
+ // scope a sweep by — and nothing that rows could be older than.
217
+ const runId = db.prepare('SELECT MAX(id) AS id FROM scan_run').get().id;
218
+ const selectFolders = db.prepare('SELECT rel_path FROM folder WHERE root_id = ?');
219
+ // The hand marks for one root, read once: `junk/rule.ts` owns what they mean.
220
+ const selectMarks = db.prepare('SELECT rel_path, verdict FROM junk_mark WHERE root_id = ?');
221
+ const selectFiles = db.prepare(`SELECT rel_path, folder_rel_path, name, kind, ext, size, mtime_ms
222
+ FROM file WHERE root_id = ?`);
223
+ const updateRole = db.prepare('UPDATE folder SET role = ? WHERE root_id = ? AND rel_path = ?');
224
+ // artist_id is deliberately absent: normalising artists is a later stage, and
225
+ // leaving it out here means reclassifying never clobbers what it wrote.
226
+ //
227
+ // `title_source` is set, and set to the plain truth: this name came from the
228
+ // folder. It is overwritten by whichever later stage has a better one, and it
229
+ // is written unconditionally because the folder name is re-derived on every
230
+ // pass — a stage that wants its own name to survive a rescan has to say so
231
+ // again on that rescan anyway.
232
+ // The year follows the same rule the title does and one more: a folder that
233
+ // names no year must not erase the one a file's own DATE stated, which is the
234
+ // only thing the `COALESCE` and the `CASE` below are for. Everything else is
235
+ // re-derived on every pass, because the folder is.
236
+ // `junk_reason` is written unconditionally, like `title` and unlike `year`:
237
+ // what the folder holds is on disk and is re-derived on every pass, so a folder
238
+ // that stops being a dumping ground stops being junk without anyone saying so.
239
+ // The one thing that outranks it is a hand mark, and `junkReason` is where that
240
+ // is decided — this stage only carries the answer.
241
+ const upsertAlbum = db.prepare(`INSERT INTO album (root_id, rel_path, title, title_source, release_id, disc_number, year, year_source, junk_reason, last_seen_run_id)
242
+ VALUES (?, ?, ?, 'folder', ?, ?, ?, ?, ?, ?)
243
+ ON CONFLICT (root_id, rel_path) DO UPDATE SET
244
+ title = excluded.title,
245
+ title_source = excluded.title_source,
246
+ release_id = excluded.release_id,
247
+ disc_number = excluded.disc_number,
248
+ year = COALESCE(excluded.year, album.year),
249
+ year_source = CASE WHEN excluded.year IS NULL THEN album.year_source ELSE excluded.year_source END,
250
+ junk_reason = excluded.junk_reason,
251
+ last_seen_run_id = excluded.last_seen_run_id`);
252
+ // The release carries the year too, and for the opposite reason the album's
253
+ // `COALESCE` above exists: a box states its year on the *box* folder and its
254
+ // discs state none (`1988 ● Группа крови (…)` over `CD1 ● Альбом`), so the
255
+ // year has nowhere else to live. `ALBUM_SELECT` asks the release first for
256
+ // exactly that reason — a record *is* its release where it has one, and a
257
+ // disc's year is the year of the album that disc holds, which for a box is
258
+ // not the record's. What a tag or a cue writes is the *album's*, and it is
259
+ // still what dates a record with no release above it.
260
+ const upsertRelease = db.prepare(`INSERT INTO release (root_id, rel_path, title, title_source, year, last_seen_run_id)
261
+ VALUES (?, ?, ?, ?, ?, ?)
262
+ ON CONFLICT (root_id, rel_path) DO UPDATE SET
263
+ title = excluded.title,
264
+ title_source = excluded.title_source,
265
+ year = excluded.year,
266
+ last_seen_run_id = excluded.last_seen_run_id`);
267
+ const selectRelease = db.prepare('SELECT id FROM release WHERE root_id = ? AND rel_path = ?');
268
+ // Letting go of albums and releases whose folder is gone. This belongs here
269
+ // because classify is what creates them, and it runs after the scan, so the
270
+ // folder rows for whatever disappeared are already gone by now. How a row is
271
+ // judged, and what has to be released before it goes, is `db/sweep.ts`.
272
+ const sweep = prepareSweep(db, SWEPT_BY_CLASSIFY);
273
+ for (const root of roots) {
274
+ const folderPaths = selectFolders.all(root.id).map((r) => r.rel_path);
275
+ // node:sqlite types a row as Record<string, SQLOutputValue>; the columns are
276
+ // ours, so the bridge through unknown is a naming step, not a conversion.
277
+ const files = selectFiles.all(root.id).map(toWalkedFile);
278
+ const marks = new Map(selectMarks.all(root.id).map((row) => [
279
+ row.rel_path,
280
+ row.verdict,
281
+ ]));
282
+ const tree = buildTree(folderPaths, files);
283
+ assignRoles(tree);
284
+ const persist = (node, releaseId, discOrdinal) => {
285
+ if (node.relPath !== '') {
286
+ updateRole.run(node.role, root.id, node.relPath);
287
+ folders += 1;
288
+ if (node.role !== null)
289
+ byRole[node.role] += 1;
290
+ }
291
+ let inheritedRelease = releaseId;
292
+ // When *this* folder's release covers named children rather than all of
293
+ // them: a shelf's covers its disc set and stops there, where a box's
294
+ // covers every child it has, because every child it has is a disc. The
295
+ // set is carried rather than a flag, so a disc-named child that is *not*
296
+ // in the set does not slip in beside it.
297
+ let shelfDiscs = null;
298
+ if (node.role === 'box') {
299
+ upsertRelease.run(root.id, node.relPath, releaseName(node, root.path), 'folder', folderYear(node, root.path), runId);
300
+ inheritedRelease = selectRelease.get(root.id, node.relPath)?.id ?? null;
301
+ releases += 1;
302
+ const pairs = flatDiscPairs(node.files);
303
+ const paired = new Set(pairs.map((pair) => pair.audio.relPath));
304
+ const loose = node.files.filter((file) => file.kind === 'audio' && !paired.has(file.relPath));
305
+ if (pairs.length >= 2) {
306
+ // Flat multi-disc: each pair IS a disc, so it takes its own album row
307
+ // keyed on the image it lives in. Without this every disc past the
308
+ // first loses its tracks entirely.
309
+ const titles = discTitles(pairs);
310
+ pairs.forEach((pair, index) => {
311
+ upsertAlbum.run(root.id, pair.audio.relPath, titles[index] ?? pair.audio.name, inheritedRelease,
312
+ // A stated disc marker outranks a leading number: `01 - The Wall
313
+ // [Disc 2].ape` says which disc it is, and the `01` is the order it
314
+ // was ripped in. Order is the last resort, for names that state
315
+ // nothing.
316
+ discMarker(pair.audio.name) ?? leadingNumber(pair.audio.name) ?? index + 1, folderYear(node, root.path), folderYear(node, root.path) === null ? null : 'folder',
317
+ // The disc's own key is the image it lives in, so the mark is
318
+ // looked up by that and not by the box's folder: a hand edit names
319
+ // the path a client is shown, which is the album's row.
320
+ junkReason(node.files, marks.get(pair.audio.relPath)), runId);
321
+ albums += 1;
322
+ });
323
+ }
324
+ // The box's own audio: the files the discs did not claim.
325
+ //
326
+ // Two shapes reach here and both want the row. With fewer than two pairs
327
+ // nothing was keyed on an image at all, so the folder is the album and
328
+ // every file under it belongs to it. With two or more, the albums went
329
+ // to the discs and `loose` is what they left behind — a bonus track, a
330
+ // rip whose cue never matched. That second shape is the one a condition
331
+ // reading `pairs.length < 2` alone missed, and its cost is total: the
332
+ // file reaches no track at all, without an issue or a counter ever
333
+ // moving.
334
+ if (loose.length > 0 || (pairs.length < 2 && node.files.some((f) => f.kind === 'audio'))) {
335
+ upsertAlbum.run(root.id, node.relPath, displayName(node, root.path), null, null, folderYear(node, root.path), folderYear(node, root.path) === null ? null : 'folder', junkReason(node.files, marks.get(node.relPath)), runId);
336
+ albums += 1;
337
+ }
338
+ }
339
+ else if (node.role === 'category') {
340
+ // A shelf that holds a disc set. The set is a release and the shelf is
341
+ // not: `a9f06a5` settled that, and what it settled is *how far* the
342
+ // release reaches rather than whether one exists. The albums beside the
343
+ // pair stay records of their own — making the shelf a box is what
344
+ // collapsed sixteen releases into two, and the difference between the
345
+ // two shapes is exactly this list.
346
+ //
347
+ // Nothing here asks what tells one shelf from the next. That question
348
+ // needs the artist, which this stage has not got and deliberately does
349
+ // not write; it is answered after every stage that names anything, by
350
+ // `shelf-name.ts`. Grouping a pair is right whether or not a qualifier
351
+ // can be found — a pair inside one shelf collides with itself.
352
+ //
353
+ // One set, and only one: a release is keyed by its folder, so a second
354
+ // set could only be poured into the first one's record. See `discSets`.
355
+ const sets = discSets(node).filter((set) => set.length >= 2);
356
+ const discs = sets.length === 1 ? (sets[0] ?? []) : [];
357
+ if (discs.length >= 2) {
358
+ upsertRelease.run(root.id, node.relPath, shelfReleaseName(discs, root.path), 'folder',
359
+ // No year: a shelf's name states none, and the discs' own years
360
+ // reach the record through its representative row.
361
+ null, runId);
362
+ inheritedRelease = selectRelease.get(root.id, node.relPath)?.id ?? null;
363
+ shelfDiscs = new Set(discs.map((disc) => disc.relPath));
364
+ releases += 1;
365
+ }
366
+ }
367
+ else if (node.role === 'album' || node.role === 'disc') {
368
+ // A disc named `CD2` states its own number; one named after its album
369
+ // does not, and falls back to its position in the release.
370
+ //
371
+ // A shelf's discs arrive here with the role of an ordinary album — the
372
+ // role rule only promotes children of a *box* — so the release it holds
373
+ // is what says they are discs, and a name that states a number is read
374
+ // for the same reason it is read anywhere else.
375
+ const number = node.role === 'disc' || (releaseId !== null && isDiscName(node.name))
376
+ ? (discNumber(node.name) ?? discOrdinal)
377
+ : null;
378
+ upsertAlbum.run(root.id, node.relPath, displayName(node, root.path), inheritedRelease, number, folderYear(node, root.path), folderYear(node, root.path) === null ? null : 'folder', junkReason(node.files, marks.get(node.relPath)), runId);
379
+ albums += 1;
380
+ }
381
+ const discs = node.children.filter((child) => child.role === 'disc');
382
+ for (const child of node.children) {
383
+ const position = discs.indexOf(child);
384
+ // The release a shelf created belongs to its discs and to nothing else.
385
+ // Everything the shelf holds beside them is a record in its own right,
386
+ // which is the whole of what separates a shelf from a box.
387
+ const carriesRelease = shelfDiscs === null ? true : shelfDiscs.has(child.relPath);
388
+ persist(child, carriesRelease ? inheritedRelease : null, position === -1 ? null : position + 1);
389
+ }
390
+ };
391
+ // One transaction per root. Without it every `updateRole` and `upsertAlbum`
392
+ // below is its own commit, and a commit is a disk flush: measured on the
393
+ // live collection, this stage cost **10 356 ms** committing each and **437
394
+ // ms** with the transaction (task:2883). The tree it builds is the same
395
+ // either way — all of the difference was the commits. The sweep goes in the
396
+ // same transaction as the rows it judges, which is the rule `scan.ts` states
397
+ // for the same reason.
398
+ withTransaction(db, () => {
399
+ persist(tree, null, null);
400
+ if (runId !== null)
401
+ sweep.run(root.id, runId);
402
+ });
403
+ }
404
+ return { folders, albums, releases, byRole };
405
+ }
@@ -0,0 +1,109 @@
1
+ import { withTransaction } from "../db/index.js";
2
+ import { basenameOf } from "../util/names.js";
3
+ import { unsaidNote } from "./folder-name.js";
4
+ /**
5
+ * Two records that read the same get a number, so a client shows two.
6
+ *
7
+ * The last resort, and it is deliberately the *last*: every other rule here tries
8
+ * to say what distinguishes a record, and this one gives up on saying it and
9
+ * merely makes the difference visible. Two folders of one Sigur Rós record —
10
+ * eleven tracks against twelve, the Japanese bonus missing from one — carry the
11
+ * same album tag, so every name the stages above could build for them is the same
12
+ * name, and a client lists the record twice with nothing to choose between.
13
+ *
14
+ * `(1)` and `(2)` are not a claim about which is which. They are the honest
15
+ * minimum: these are two, and which one a person keeps is a question about their
16
+ * collection that the server has no business answering. The operator's rule for
17
+ * the whole of this: show what the folder says, and if a person dislikes the
18
+ * result they will go and tidy the folder.
19
+ *
20
+ * **Numbered by record, never by row.** `ALBUM_GROUPS` folds a box's discs into
21
+ * one record, and this stage reads the same two sets that stage does — albums
22
+ * that belong to no release, and releases — because numbering rows would put
23
+ * `(1)` through `(10)` on the discs of one box.
24
+ *
25
+ * Only records an artist owns are numbered. A record with no artist appears on no
26
+ * artist's page, so two of them reading alike is not something a client can be
27
+ * shown; numbering them would invent a difference nobody sees.
28
+ *
29
+ * The order is the collection's own — path, then id — so the same unchanged
30
+ * library numbers the same record `(1)` on every machine.
31
+ */
32
+ export function numberCollidingRecords(db) {
33
+ const counters = { groups: 0, numbered: 0 };
34
+ const albums = db
35
+ .prepare(`SELECT id, title, artist_id, rel_path FROM album
36
+ WHERE release_id IS NULL AND artist_id IS NOT NULL AND title IS NOT NULL`)
37
+ .all();
38
+ const releases = db
39
+ .prepare(`SELECT id, title, artist_id, rel_path FROM release
40
+ WHERE artist_id IS NOT NULL AND title IS NOT NULL`)
41
+ .all();
42
+ const byKey = new Map();
43
+ const add = (kind, id, title, artistId, relPath) => {
44
+ const base = title.replace(NUMBERED, '').trim();
45
+ if (base === '')
46
+ return;
47
+ // Grouped by what a client is *shown*, not by the title a tag states.
48
+ //
49
+ // The two are not the same field, and this stage read the wrong one. A folder
50
+ // writes the pressing beside the record — `1990 - Entreat [1991 issue AU
51
+ // Warner 903174106-2]`, `1994 - … - Split (Cass, C60)` — and that note is
52
+ // part of every name the record is shown by. A tag says `Entreat` three times
53
+ // for three pressings, so this stage saw one name three times and numbered
54
+ // them, and the note arrived afterwards and told them apart anyway. Sixteen
55
+ // of the eighteen numbers it wrote were that: the collection had already
56
+ // answered the question the number was asked to answer (task:2783).
57
+ //
58
+ // The note goes in as `unsaidNote` answers it — the same answer every name of
59
+ // the record is built from — and not as the folder spells it. The folder's
60
+ // spelling is finer: it keeps the pressing's own year, which each name drops,
61
+ // so two pressings of one catalogue number would be told apart here and read
62
+ // identically in the album list, with no number to separate them (task:2845).
63
+ const key = `${artistId}\u0000${base}\u0000${unsaidNote(base, basenameOf(relPath)) ?? ''}`;
64
+ const at = byKey.get(key) ?? [];
65
+ at.push({ kind, id, base, relPath });
66
+ byKey.set(key, at);
67
+ };
68
+ for (const row of albums)
69
+ add('album', row.id, row.title, row.artist_id, row.rel_path);
70
+ for (const row of releases)
71
+ add('release', row.id, row.title, row.artist_id, row.rel_path);
72
+ const renameAlbum = db.prepare('UPDATE album SET title = ?, title_source = ? WHERE id = ?');
73
+ const renameRelease = db.prepare('UPDATE release SET title = ?, title_source = ? WHERE id = ?');
74
+ // One transaction, and it is not a micro-optimisation. Without it every
75
+ // rename below is its own commit, and a commit is a disk flush: measured on
76
+ // the live collection, this stage cost **43 ms** committing each and **5 ms**
77
+ // with the transaction. It renames a handful of records, so the writes were
78
+ // never the cost — the commits were the whole of it (task:2883). `db/index.ts`
79
+ // has the rule and `scan.ts` the same fix at a larger scale.
80
+ withTransaction(db, () => {
81
+ for (const set of byKey.values()) {
82
+ if (set.length < 2)
83
+ continue;
84
+ counters.groups += 1;
85
+ set.sort((a, b) => cmp(a.relPath, b.relPath) || a.id - b.id);
86
+ for (const [at, record] of set.entries()) {
87
+ const title = `${record.base} (${at + 1})`;
88
+ if (record.kind === 'album')
89
+ renameAlbum.run(title, 'collision', record.id);
90
+ else
91
+ renameRelease.run(title, 'collision', record.id);
92
+ counters.numbered += 1;
93
+ }
94
+ }
95
+ });
96
+ return counters;
97
+ }
98
+ function cmp(a, b) {
99
+ return a < b ? -1 : a > b ? 1 : 0;
100
+ }
101
+ /**
102
+ * A number this stage put there on an earlier run.
103
+ *
104
+ * Stripped before numbering, for the reason `qualified` in `shelf-name.ts` gives:
105
+ * a root the walk did not see keeps the rows it had, this stage's among them, and
106
+ * a name that collected a second `(1)` on every run would be a name nobody could
107
+ * read.
108
+ */
109
+ const NUMBERED = / \((\d+)\)$/;