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,759 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { recordTitle } from "../classify/folder-name.js";
4
+ import { discMarker, pairKey } from "../classify/roles.js";
5
+ import { clearIssues } from "../db/issue.js";
6
+ import { current, ledgerEntry, unmoved } from "../db/ledger.js";
7
+ import { PROBE_METHOD, probeFile } from "../probe/ffprobe.js";
8
+ import { CERTAIN, CONFIDENT, decodeText } from "../text/encoding.js";
9
+ import { isSiteName } from "../text/site-name.js";
10
+ import { basenameOf, folderOf, rootBasenameOf } from "../util/names.js";
11
+ import { audioNamedBy, chooseCue } from "./match.js";
12
+ import { parseCue } from "./parse.js";
13
+ import { planAlbum } from "./plan.js";
14
+ /** This stage's name in `issue.stage`. Bound to the insert and to the clear. */
15
+ const STAGE = 'cues';
16
+ function toWalkedFile(row, folderRelPath) {
17
+ return {
18
+ relPath: row.rel_path,
19
+ folderRelPath,
20
+ name: row.name,
21
+ kind: row.kind,
22
+ ext: row.ext,
23
+ size: row.size,
24
+ mtimeMs: row.mtime_ms,
25
+ };
26
+ }
27
+ /**
28
+ * Read the cues, work out what each album's tracks are, and write them down.
29
+ *
30
+ * An album is identified by a path, and that path means one of two things:
31
+ * a *folder* (an ordinary album, or a disc that is a CD subfolder) or a single
32
+ * *audio file* — a disc of a release ripped flat, where each disc is one image
33
+ * plus its own cue. Both are handled here, and for the file case the cue is the
34
+ * one sharing the image's stem.
35
+ *
36
+ * Probing stays narrow: ffprobe is spawned only for the image behind a cue
37
+ * split, whose closing track no cue can bound. A folder of separate files needs
38
+ * no probe at all, so a 50k-file scan does not spawn 50k processes to learn
39
+ * something it already knows.
40
+ *
41
+ * The whole pass runs in one transaction. A single malformed cue must not leave
42
+ * a half-written meta layer behind for the next stage to read.
43
+ *
44
+ * The ledger buys back the probe, not the read: a cue is still read and decoded
45
+ * on every pass. That is a deliberate line rather than an oversight — cues are
46
+ * small text files, while ffprobe is a process spawn per album, and the spawn
47
+ * was the cost worth avoiding. Widening the ledger to cover the read would mean
48
+ * trusting stored parse results against a file the walk did not re-read.
49
+ */
50
+ export function applyCues(db, deps = {}) {
51
+ const probe = deps.probe ?? ((absPath) => probeFile(absPath));
52
+ // Bytes, not a string: reading a CP1251 cue as UTF-8 yields replacement
53
+ // characters and a plausible-looking album that is quietly wrong.
54
+ const readBytes = deps.readBytes ?? ((absPath) => readFileSync(absPath));
55
+ const byShape = { 'image-cue': 0, 'tracks-cue': 0, 'tracks-only': 0 };
56
+ const counters = {
57
+ albums: 0,
58
+ cues: 0,
59
+ tracks: 0,
60
+ probed: 0,
61
+ probesReused: 0,
62
+ probeFailures: 0,
63
+ issues: 0,
64
+ };
65
+ const roots = db.prepare('SELECT id, path FROM root ORDER BY id').all();
66
+ const latestRun = db.prepare('SELECT MAX(id) AS id FROM scan_run').get()
67
+ .id;
68
+ const selectAlbums = db.prepare('SELECT rel_path FROM album WHERE root_id = ? ORDER BY rel_path');
69
+ // Every cue the root holds, so the ones no album ever read can be told apart
70
+ // from the ones it did — see the reports after the album loop.
71
+ const selectRootCues = db.prepare("SELECT rel_path FROM file WHERE root_id = ? AND kind = 'cue' ORDER BY rel_path");
72
+ // An album whose path names an actual file is a disc of a flat release; one
73
+ // naming a folder is an ordinary album. The test is "is there a file here",
74
+ // NOT "is there a folder here" — a root that is itself an album has rel_path
75
+ // '', and the walk never creates a folder row for the root.
76
+ const selectFileRow = db.prepare('SELECT 1 AS present FROM file WHERE root_id = ? AND rel_path = ?');
77
+ const selectFolderFiles = db.prepare(`SELECT rel_path, name, kind, ext, size, mtime_ms
78
+ FROM file WHERE root_id = ? AND folder_rel_path = ? ORDER BY rel_path`);
79
+ // The files that are an album's own identity. A flat box keys each disc on the
80
+ // image it lives in, and the box's own album must not claim those images a
81
+ // second time — see where a folder's audio is collected below.
82
+ const selectAlbumFiles = db.prepare("SELECT rel_path FROM album WHERE root_id = ? AND rel_path <> ''");
83
+ const selectAlbumId = db.prepare('SELECT id, release_id, title_source FROM album WHERE root_id = ? AND rel_path = ?');
84
+ const selectReleaseSource = db.prepare('SELECT title_source FROM release WHERE id = ?');
85
+ const selectFileId = db.prepare('SELECT id FROM file WHERE root_id = ? AND rel_path = ?');
86
+ // What a file says its own title is. Read one value per name: a file with two
87
+ // TITLE lines has stated two answers to one question, and the first is the
88
+ // one rippers write and the one a reader expects.
89
+ const selectTagValue = db.prepare('SELECT value FROM file_tag WHERE file_id = ? AND name = ? ORDER BY position LIMIT 1');
90
+ /**
91
+ * A whole-file track's length, taken from whatever already measured the file.
92
+ *
93
+ * Trusted when it was re-derived on this run, or when the filesystem says the
94
+ * file has not moved since it was. That is deliberately looser than the
95
+ * ledger rule guarding the *closing* segment of a cue split, and the
96
+ * difference is blast radius: a wrong bound there truncates audio silently,
97
+ * while a wrong whole-file length misreports a number and is corrected by the
98
+ * next scan.
99
+ */
100
+ const selectFileDuration = db.prepare(`SELECT ap.duration_ms AS duration_ms
101
+ FROM audio_probe ap
102
+ JOIN file f ON f.id = ap.file_id
103
+ LEFT JOIN scan_state ss ON ${ledgerEntry('f', 'ss')}
104
+ WHERE ap.file_id = ? AND ap.probe_ok = 1 AND ap.probe_method = ?
105
+ AND (f.tags_read_run_id = ? OR ${unmoved('ss')})`);
106
+ // A cue names the record it belongs to. For an ordinary album that is the
107
+ // album; for a disc of a release it names the *release*, while the disc keeps
108
+ // the name its own file carries — three ASOT discs share one cue TITLE, so
109
+ // taking it for each disc would name all three identically.
110
+ const updateAlbumTitle = db.prepare('UPDATE album SET title = ?, title_source = ? WHERE id = ?');
111
+ const updateReleaseTitle = db.prepare('UPDATE release SET title = ?, title_source = ? WHERE id = ?');
112
+ /**
113
+ * Is this name still the placeholder the classifier put there?
114
+ *
115
+ * The rule the whole priority chain rests on. An album title is never empty,
116
+ * so "write it if there is nothing there" can never fire — and a name a cue
117
+ * or a tag has already given is not something a *later* stage may overwrite.
118
+ * A tag fills `folder` and nothing else.
119
+ */
120
+ const placeholder = (source) => source === null || source === undefined || source === 'folder';
121
+ /**
122
+ * The track a cue names, written where its id already is.
123
+ *
124
+ * `track` is keyed `UNIQUE (album_id, ordinal)` — the record and the place on
125
+ * it — so a second run updates the row the first one wrote instead of deleting
126
+ * it and writing a new one. That is not tidiness: a client keeps its playlist,
127
+ * its stars and its play counts by track id, and a stage that renumbered every
128
+ * track on every scan broke all three without saying anything. Artists, albums
129
+ * and folders were already written this way (`ON CONFLICT DO UPDATE`); the
130
+ * tracks were the ones the cue stage rewrote from nothing.
131
+ */
132
+ const upsertTrack = db.prepare(`INSERT INTO track (album_id, ordinal, title, title_source, file_id, segment_start_ms, segment_end_ms, duration_ms)
133
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
134
+ ON CONFLICT (album_id, ordinal) DO UPDATE SET
135
+ title = excluded.title,
136
+ title_source = excluded.title_source,
137
+ file_id = excluded.file_id,
138
+ segment_start_ms = excluded.segment_start_ms,
139
+ segment_end_ms = excluded.segment_end_ms,
140
+ duration_ms = excluded.duration_ms`);
141
+ const deleteTracks = db.prepare('DELETE FROM track WHERE album_id = ?');
142
+ /**
143
+ * The tracks of a record this pass no longer names.
144
+ *
145
+ * Swept by the ordinals to *keep*, not by "everything past the last one": a
146
+ * plan drops a track whose file the scan could not name (`trackFile ===
147
+ * undefined` below), which leaves a hole in the numbering rather than a
148
+ * shorter list — and a hole is exactly what an `ordinal > n` sweep would
149
+ * leave standing. The statement is cached by arity because the number of
150
+ * ordinals is the only thing that varies between records.
151
+ */
152
+ const sweeps = new Map();
153
+ const deleteStaleTracks = (albumId, ordinals) => {
154
+ if (ordinals.length === 0) {
155
+ deleteTracks.run(albumId);
156
+ return;
157
+ }
158
+ let sweep = sweeps.get(ordinals.length);
159
+ if (sweep === undefined) {
160
+ sweep = db.prepare(`DELETE FROM track
161
+ WHERE album_id = ?
162
+ AND ordinal NOT IN (${ordinals.map(() => '?').join(', ')})`);
163
+ sweeps.set(ordinals.length, sweep);
164
+ }
165
+ sweep.run(albumId, ...ordinals);
166
+ };
167
+ const upsertCue = db.prepare(`INSERT INTO cue (file_id, audio_file_id, catalog, rem_json, encoding, encoding_confidence, performer, title)
168
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
169
+ ON CONFLICT (file_id) DO UPDATE SET
170
+ audio_file_id = excluded.audio_file_id,
171
+ catalog = excluded.catalog,
172
+ rem_json = excluded.rem_json,
173
+ encoding = excluded.encoding,
174
+ encoding_confidence = excluded.encoding_confidence,
175
+ performer = excluded.performer,
176
+ title = excluded.title`);
177
+ const clearCue = db.prepare('UPDATE cue SET audio_file_id = NULL WHERE file_id = ?');
178
+ const selectCueId = db.prepare('SELECT id FROM cue WHERE file_id = ?');
179
+ const deleteCueTracks = db.prepare('DELETE FROM cue_track WHERE cue_id = ?');
180
+ const insertCueTrack = db.prepare(`INSERT INTO cue_track (cue_id, file_index, ordinal, title, performer, index00_ms, index01_ms,
181
+ index00_file_index, index01_file_index)
182
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`);
183
+ // The answer here is always ffprobe's: this stage has nothing else to ask,
184
+ // and it asks only where the tag stage left no usable row. Naming it is what
185
+ // keeps the column's two values apart — `'container'` is a statement the
186
+ // file's own bytes make, and a number ffprobe produced is not one, whether it
187
+ // read a length the container states or worked one out from the size and the
188
+ // bitrate (task:2724).
189
+ const upsertProbe = db.prepare(`INSERT INTO audio_probe (file_id, duration_ms, codec, sample_rate, channels, bitrate, probe_ok, probe_err, duration_source, probe_method)
190
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
191
+ ON CONFLICT (file_id) DO UPDATE SET
192
+ duration_ms = excluded.duration_ms,
193
+ codec = excluded.codec,
194
+ sample_rate = excluded.sample_rate,
195
+ channels = excluded.channels,
196
+ bitrate = excluded.bitrate,
197
+ probe_ok = excluded.probe_ok,
198
+ probe_err = excluded.probe_err,
199
+ duration_source = excluded.duration_source,
200
+ probe_method = excluded.probe_method`);
201
+ // A duration already measured is still true while the file it describes has
202
+ // not moved — which is what the ledger's verdict is for. It is asked for with
203
+ // its currency: a stored whole-file length is forgiven a stale verdict, but
204
+ // this one bounds a cue split, where being wrong truncates audio.
205
+ const selectUsableProbe = db.prepare(`SELECT ap.duration_ms AS duration_ms
206
+ FROM audio_probe ap
207
+ JOIN file f ON f.id = ap.file_id
208
+ JOIN scan_state ss ON ${ledgerEntry('f', 'ss')}
209
+ WHERE f.root_id = ? AND f.rel_path = ?
210
+ AND ${unmoved('ss')}
211
+ AND ${current('ss')}
212
+ AND ap.probe_ok = 1
213
+ AND ap.probe_method = ?`);
214
+ const insertIssue = db.prepare(`INSERT INTO issue (scan_run_id, stage, root_id, rel_path, kind, severity, detail)
215
+ VALUES (?, ?, ?, ?, ?, ?, ?)`);
216
+ const recordIssue = (rootId, relPath, kind, detail, severity = 'warn') => {
217
+ insertIssue.run(latestRun, STAGE, rootId, relPath, kind, severity, detail);
218
+ counters.issues += 1;
219
+ };
220
+ // A cue sitting above the audio it describes is a candidate for every album
221
+ // underneath it, so a per-match report would restate one inference once per
222
+ // album. Keyed by root as well as path, since the same relative path under
223
+ // two roots is two different files.
224
+ const encodingReported = new Set();
225
+ try {
226
+ // `IMMEDIATE`, so that what this transaction happens to do first cannot
227
+ // decide whether it waits — the first statement below is a write, which
228
+ // would consult the busy handler under a deferred `BEGIN`, but that is a
229
+ // property of the order of the statements rather than of this line, and the
230
+ // order is what the next edit changes. `db/index.ts` has the rule and
231
+ // `search/index.ts` the case where it already matters (task:2871).
232
+ db.exec('BEGIN IMMEDIATE');
233
+ // Every root is walked again on every run, so last run's rows describe a
234
+ // present that has been replaced. See `db/issue.ts`.
235
+ clearIssues(db, STAGE);
236
+ for (const root of roots) {
237
+ const albums = selectAlbums.all(root.id).map((r) => r.rel_path);
238
+ const albumFiles = new Set(selectAlbumFiles.all(root.id).map((r) => r.rel_path));
239
+ // Three questions about a cue that the per-album loop cannot answer, held
240
+ // until the whole root has been walked. Two of them are the same question
241
+ // asked late: a cue is a candidate for every album beneath it, so "did
242
+ // this cue describe anything at all?" is not answerable while the albums
243
+ // are still being read.
244
+ const rootCues = selectRootCues.all(root.id).map((r) => r.rel_path);
245
+ const readCues = new Set();
246
+ const matchedCues = new Set();
247
+ const parentCues = new Set();
248
+ // Cues the stage tried to read and could not. They are not in `readCues`
249
+ // either, and the difference matters: "nothing read it" is a second,
250
+ // false reason to give about a file whose real one is already stated
251
+ // (`cue-unreadable`) — task:2727.
252
+ const unreadableCues = new Set();
253
+ // Cues whose lines the parser could not place, reported once per root for
254
+ // the same reason the encoding guess is: a cue above the audio is a
255
+ // candidate for every album beneath it, and the lines do not become
256
+ // stranger with repetition.
257
+ const strayReported = new Set();
258
+ /**
259
+ * Say out loud what a cue wrote and the parser could not place.
260
+ *
261
+ * The parser answers with the lines; whether they matter is decided here,
262
+ * because only this stage knows what is built out of them. A line opening
263
+ * with a cue command and not parsing is a statement that was lost — the
264
+ * `FILE My Album.flac FLAC` of finding 2 takes its file reference with it
265
+ * and leaves the TRACKs after it counted against the file before it, so
266
+ * every offset they carry points at the wrong file. A line that is not cue
267
+ * syntax is reported too, and as information rather than warning: it is
268
+ * usually something a ripper left behind (task:2756).
269
+ */
270
+ const reportStrayLines = (rootId, relPath, strays) => {
271
+ if (strays.length === 0)
272
+ return;
273
+ const key = `${rootId}:${relPath}`;
274
+ if (strayReported.has(key))
275
+ return;
276
+ strayReported.add(key);
277
+ const shown = strays
278
+ .slice(0, 3)
279
+ .map((stray) => `line ${stray.line} "${stray.text}"`)
280
+ .join('; ');
281
+ const rest = strays.length > 3 ? `; and ${strays.length - 3} more` : '';
282
+ const lost = strays.some((stray) => stray.malformed);
283
+ recordIssue(rootId, relPath, 'cue-unrecognized-line', `${strays.length} line(s) matched no cue command — ${shown}${rest}. ${lost
284
+ ? 'A cue command that will not read takes what it declared with it'
285
+ : 'Nothing here reads them'}`, lost ? 'warn' : 'info');
286
+ };
287
+ // What each release's discs say it is called, gathered before any of them
288
+ // is allowed to say it. A release is named once, for the whole box.
289
+ const releaseCueTitles = new Map();
290
+ // The same gathering for the other source that names a release: the ALBUM
291
+ // tag each disc's files carry. Held apart from the cue's because the two
292
+ // are read at different points of the walk, and because the tag has the
293
+ // first say — an EAC disc label is no name for a box, while a tag is.
294
+ const releaseTagTitles = new Map();
295
+ for (const albumRelPath of albums) {
296
+ const asFile = selectFileRow.get(root.id, albumRelPath) !== undefined;
297
+ const sourceFolder = asFile ? folderOf(albumRelPath) : albumRelPath;
298
+ const rows = selectFolderFiles.all(root.id, sourceFolder);
299
+ let audioFiles;
300
+ /** Every audio file the folder holds, this album's or another's. */
301
+ let folderAudio;
302
+ let cueRows;
303
+ if (asFile) {
304
+ // A disc of a flat release: one image, and the cue named after it.
305
+ const image = rows.find((r) => r.rel_path === albumRelPath && r.kind === 'audio');
306
+ if (image === undefined) {
307
+ recordIssue(root.id, albumRelPath, 'album-without-audio', 'album names a file that holds no audio');
308
+ continue;
309
+ }
310
+ audioFiles = [toWalkedFile(image, sourceFolder)];
311
+ folderAudio = audioFiles;
312
+ // The same pairing the classifier made when it called this folder a
313
+ // release — `pairKey` is shared with it precisely so the two cannot
314
+ // disagree about which cue belongs to this image. Reading the name
315
+ // here by `stemOf` alone knew `… - Pulse.cue` and not `… CD1.flac.cue`,
316
+ // so the disc was recognised and then left without its cue, and its
317
+ // declared tracks became the one track the image holds (task:2708).
318
+ const imageKey = pairKey(image);
319
+ cueRows = rows.filter((r) => r.kind === 'cue' && pairKey(r) === imageKey);
320
+ }
321
+ else {
322
+ // A file that is an album of its own is not also a track of the album
323
+ // whose folder holds it. The two meet in one place: a flat box, where
324
+ // each disc is the image it lives in and the folder can hold loose
325
+ // audio beside them (task:2719). Without this the box's own album read
326
+ // every disc as one of its tracks too — three files, three tracks, two
327
+ // of them already playing from an album of their own.
328
+ const audioRows = rows.filter((r) => r.kind === 'audio');
329
+ folderAudio = audioRows.map((r) => toWalkedFile(r, sourceFolder));
330
+ audioFiles = audioRows
331
+ .filter((r) => !albumFiles.has(r.rel_path))
332
+ .map((r) => toWalkedFile(r, sourceFolder));
333
+ cueRows = rows.filter((r) => r.kind === 'cue');
334
+ }
335
+ if (audioFiles.length === 0)
336
+ continue;
337
+ // A cue can sit one level above the audio it describes — a whole-album
338
+ // cue in the root over `Album/01.flac`. Those count as candidates, but
339
+ // they are not this album's cues: reporting an unmatched one would fire
340
+ // once for every album underneath it.
341
+ const parentFolder = folderOf(sourceFolder);
342
+ const parentCueRows = parentFolder === sourceFolder
343
+ ? []
344
+ : selectFolderFiles.all(root.id, parentFolder).filter((row) => row.kind === 'cue');
345
+ const ownCuePaths = new Set(cueRows.map((row) => row.rel_path));
346
+ const candidates = [];
347
+ for (const row of [...cueRows, ...parentCueRows]) {
348
+ try {
349
+ const decoded = decodeText(readBytes(join(root.path, row.rel_path)));
350
+ const doc = parseCue(decoded.text);
351
+ candidates.push({ relPath: row.rel_path, doc, decoded });
352
+ reportStrayLines(root.id, row.rel_path, doc.unrecognized);
353
+ }
354
+ catch (err) {
355
+ unreadableCues.add(row.rel_path);
356
+ recordIssue(root.id, row.rel_path, 'cue-unreadable', err.message);
357
+ }
358
+ }
359
+ for (const candidate of candidates) {
360
+ readCues.add(candidate.relPath);
361
+ if (!ownCuePaths.has(candidate.relPath))
362
+ parentCues.add(candidate.relPath);
363
+ }
364
+ const match = chooseCue(candidates, audioFiles);
365
+ if (match !== null)
366
+ matchedCues.add(match.cue.relPath);
367
+ // A cue of this folder's own that lost is not one report but three, and
368
+ // only that cue's own references tell them apart — asking the album's
369
+ // match answers about a different cue. The `WAV.cue` of an EAC image rip
370
+ // names the same audio the winning `FLAC.cue` names (the stem rule reads
371
+ // it so), so it lost on score and the reader has nothing to do. A cue
372
+ // naming audio this folder does not hold is the lost rip the report
373
+ // exists for; one naming a *second* file of the folder is a binding the
374
+ // model dropped. Reporting all three as a warning is what teaches a
375
+ // reader to skim warnings.
376
+ for (const candidate of candidates) {
377
+ if (candidate === match?.cue || !ownCuePaths.has(candidate.relPath))
378
+ continue;
379
+ const named = audioNamedBy(candidate, folderAudio);
380
+ // A cue naming a file that is an album of its own belongs to that
381
+ // album, and is reported there if it is reported at all. Repeating it
382
+ // here would restate one cue's situation once per folder that can see
383
+ // the file — and would say it wrongly, since the folder does hold what
384
+ // the cue names. The album's own file is exempt: a second cue
385
+ // describing *this* album's image is exactly the lost rip this loop is
386
+ // for.
387
+ if (named !== null && named.audio.relPath !== albumRelPath && albumFiles.has(named.audio.relPath)) {
388
+ continue;
389
+ }
390
+ let report;
391
+ if (named !== null && match !== null && named.audio.relPath === match.audio.relPath) {
392
+ report = {
393
+ detail: 'another cue describes this audio; this one was not used',
394
+ severity: 'info',
395
+ };
396
+ }
397
+ else if (named === null) {
398
+ report = { detail: 'cue describes no audio file in this folder', severity: 'warn' };
399
+ }
400
+ else {
401
+ report = {
402
+ detail: 'cue names an audio file this album was not matched to',
403
+ severity: 'warn',
404
+ };
405
+ }
406
+ recordIssue(root.id, candidate.relPath, 'cue-unmatched', report.detail, report.severity);
407
+ }
408
+ const probeFor = (file) => {
409
+ // Only the last split track needs this, and it needs it exactly once
410
+ // per album — but a collection of image+cue rips would spawn one
411
+ // ffprobe per album on every run. A stored answer for a file the
412
+ // filesystem reports unmoved is the same answer.
413
+ const stored = selectUsableProbe.get(root.id, file.relPath, PROBE_METHOD);
414
+ if (stored !== undefined) {
415
+ counters.probesReused += 1;
416
+ return stored.duration_ms;
417
+ }
418
+ const result = probe(join(root.path, file.relPath));
419
+ counters.probed += 1;
420
+ if (!result.ok)
421
+ counters.probeFailures += 1;
422
+ const fileRow = selectFileId.get(root.id, file.relPath);
423
+ if (fileRow !== undefined) {
424
+ upsertProbe.run(fileRow.id, result.durationMs, result.codec, result.sampleRate, result.channels, result.bitrate, result.ok ? 1 : 0, result.err, 'ffprobe', PROBE_METHOD);
425
+ }
426
+ return result.ok ? result.durationMs : null;
427
+ };
428
+ // Memoised per album: a title is asked for once per track, and a
429
+ // folder's files are asked about more than once across the plan.
430
+ const tagMemo = new Map();
431
+ const tagValue = (relPath, name) => {
432
+ // A NUL between the two fields, because it is the one character
433
+ // neither can contain — a space would collide the moment a path or a
434
+ // tag name carried one. Written as an escape rather than as the byte
435
+ // itself: a raw 0x00 in the source is enough for `rg`, `grep` and
436
+ // every other tool to call this file binary and skip it (task:2722).
437
+ const key = `${relPath}\u0000${name}`;
438
+ if (!tagMemo.has(key)) {
439
+ const fileRow = selectFileId.get(root.id, relPath);
440
+ const tag = fileRow === undefined
441
+ ? undefined
442
+ : selectTagValue.get(fileRow.id, name);
443
+ tagMemo.set(key, tag?.value ?? null);
444
+ }
445
+ return tagMemo.get(key) ?? null;
446
+ };
447
+ const plan = planAlbum(audioFiles, match?.cue.doc ?? null, {
448
+ // The matcher resolved the cue's `FILE` tags from the cue's own
449
+ // folder; the plan has to read them from the same place, or it would
450
+ // be asking whether the cue describes files it never named.
451
+ cuePath: match?.cue.relPath ?? null,
452
+ durationMs: probeFor,
453
+ titleOf: (file) => tagValue(file.relPath, 'title'),
454
+ // The album's own name, for the one thing that reads it: a file name
455
+ // whose leading field the folder also states is the album's, not the
456
+ // track's. An album with no folder of its own is the root, and its
457
+ // name is then the name of the directory the root points at.
458
+ albumName: sourceFolder === '' ? rootBasenameOf(root.path) : basenameOf(sourceFolder),
459
+ });
460
+ if (match !== null) {
461
+ const cueRow = selectFileId.get(root.id, match.cue.relPath);
462
+ const audioRow = selectFileId.get(root.id, match.audio.relPath);
463
+ if (cueRow !== undefined) {
464
+ const { decoded } = match.cue;
465
+ upsertCue.run(cueRow.id, audioRow?.id ?? null, match.cue.doc.rem['CATALOG'] ?? null, JSON.stringify(match.cue.doc.rem), decoded.encoding, decoded.confidence, match.cue.doc.performer, match.cue.doc.title);
466
+ // The reader normalises to UTF-8, but when it had to *infer* the
467
+ // source encoding, that inference is itself a finding. Once per
468
+ // cue: a parent cue describes every album beneath it, and the
469
+ // inference does not become truer — or noisier — with repetition.
470
+ const cueKey = `${root.id}:${match.cue.relPath}`;
471
+ if (decoded.confidence < CERTAIN && !encodingReported.has(cueKey)) {
472
+ encodingReported.add(cueKey);
473
+ recordIssue(root.id, match.cue.relPath, 'cue-encoding-guessed', decoded.basis, decoded.confidence >= CONFIDENT ? 'info' : 'warn');
474
+ }
475
+ const stored = selectCueId.get(cueRow.id);
476
+ if (stored !== undefined) {
477
+ deleteCueTracks.run(stored.id);
478
+ // Cue numbering is not guaranteed unique — a cue spanning several
479
+ // files restarts at TRACK 01 for each. Recording the duplicate
480
+ // beats throwing away the whole scan on a UNIQUE violation.
481
+ const seen = new Set();
482
+ for (const entry of match.cue.doc.tracks) {
483
+ const key = `${entry.fileIndex}:${entry.ordinal}`;
484
+ if (seen.has(key)) {
485
+ recordIssue(root.id, match.cue.relPath, 'cue-duplicate-track', `TRACK ${entry.ordinal} declared twice in file ${entry.fileIndex}`);
486
+ continue;
487
+ }
488
+ seen.add(key);
489
+ insertCueTrack.run(stored.id, entry.fileIndex, entry.ordinal, entry.title, entry.performer, entry.index00Ms, entry.index01Ms, entry.index00FileIndex, entry.index01FileIndex);
490
+ }
491
+ }
492
+ counters.cues += 1;
493
+ }
494
+ }
495
+ else if (cueRows.length > 0) {
496
+ // Nothing matched. Clear any stale binding so the cue does not keep
497
+ // claiming an audio file that is no longer there.
498
+ for (const row of cueRows) {
499
+ const cueRow = selectFileId.get(root.id, row.rel_path);
500
+ if (cueRow !== undefined) {
501
+ clearCue.run(cueRow.id);
502
+ const stored = selectCueId.get(cueRow.id);
503
+ if (stored !== undefined)
504
+ deleteCueTracks.run(stored.id);
505
+ }
506
+ }
507
+ }
508
+ const album = selectAlbumId.get(root.id, albumRelPath);
509
+ const cueTitle = match?.cue.doc.title ?? null;
510
+ if (album !== undefined) {
511
+ const releaseId = album.release_id;
512
+ if (cueTitle !== null && releaseId === null) {
513
+ if (isSiteName(cueTitle)) {
514
+ // The cue names where the rip came from, not the record — the
515
+ // rule below on the tag path, applied to the source that speaks
516
+ // first and loudest. Written, it would put a wrong word in the
517
+ // meta layer under `'cue'`, the value that carries the most
518
+ // authority, and say nothing about it (task:2725). Refused, the
519
+ // record keeps the name its folder has, exactly as it does when a
520
+ // tag is refused.
521
+ recordIssue(root.id, albumRelPath, 'title-cue-declined', `the cue states where the rip came from, not the record — "${cueTitle}" was not used as a name; the record keeps the name its folder has`, 'info');
522
+ }
523
+ else {
524
+ updateAlbumTitle.run(cueTitle, 'cue', album.id);
525
+ }
526
+ }
527
+ else {
528
+ // Either no cue names this record, or it names a *disc* of one and a
529
+ // disc's label is not the record's name. The files themselves get
530
+ // their say instead — the ordinary case for a folder of tagged
531
+ // files, and the reason a tag beats the folder at all. Every ALBUM
532
+ // the files state, not the first one. A record whose files state two
533
+ // titles has stated two answers to one question, and taking whichever
534
+ // sorted first is the confident guess this project already refuses to
535
+ // make elsewhere: `albumArtistOf` in `artist/apply.ts` declines the
536
+ // credit when an album's files disagree, for exactly this reason. One
537
+ // rule, not two.
538
+ const stated = new Set();
539
+ for (const file of audioFiles) {
540
+ const value = tagValue(file.relPath, 'album');
541
+ if (value !== null)
542
+ stated.add(value);
543
+ }
544
+ const tagTitles = [...stated].sort();
545
+ if (releaseId !== null) {
546
+ // Where a *release* is concerned the name belongs to the release,
547
+ // exactly as the cue's does above: three discs of a box share one
548
+ // album title, and a disc's tag may no more name the box than its
549
+ // cue's label may. What the discs say is gathered here and decided
550
+ // once for the whole box below, after every disc has been read.
551
+ // Deciding it per disc is what named the box after whichever disc
552
+ // came last, and left a box whose discs disagreed entirely silent
553
+ // (task:2728).
554
+ const seen = releaseTagTitles.get(releaseId) ?? {
555
+ titles: new Set(),
556
+ relPath: albumRelPath,
557
+ };
558
+ for (const title of tagTitles)
559
+ seen.titles.add(title);
560
+ releaseTagTitles.set(releaseId, seen);
561
+ }
562
+ else {
563
+ const fromTag = tagTitles.length === 1 ? tagTitles[0] : null;
564
+ // Said out loud rather than dropped: the files disagree, so the
565
+ // tag has no answer, and the record keeps the name its folder has
566
+ // until somebody decides which of the two it is.
567
+ if (tagTitles.length > 1) {
568
+ recordIssue(root.id, albumRelPath, 'title-tag-ambiguous', `its files state ${tagTitles.length} different ALBUM tags — ${tagTitles
569
+ .map((title) => `"${title}"`)
570
+ .join(', ')}; none was used, and the record keeps the name its folder has`, 'info');
571
+ }
572
+ if (fromTag !== null && isSiteName(fromTag)) {
573
+ // The tag names where the rip came from, not the record — see
574
+ // `text/site-name.ts`. It is not written as knowledge, and the
575
+ // refusal is said out loud.
576
+ recordIssue(root.id, albumRelPath, 'title-tag-declined', `the tag states where the rip came from, not the record — "${fromTag}" was not used as a name; the record keeps the name its folder has`, 'info');
577
+ }
578
+ else if (fromTag !== null) {
579
+ if (placeholder(album.title_source)) {
580
+ updateAlbumTitle.run(fromTag, 'tag', album.id);
581
+ }
582
+ }
583
+ }
584
+ }
585
+ // A cue's claim on a *release* is not settled here. It is gathered and
586
+ // decided once for the whole box after every disc has been read — see
587
+ // below. Deciding it per cue was the first attempt and it was wrong:
588
+ // one sibling cue carrying no marker would still name the record after
589
+ // its own disc, and which cue came last decided the winner.
590
+ if (cueTitle !== null && releaseId !== null) {
591
+ const seen = releaseCueTitles.get(releaseId) ?? { titles: new Set(), relPath: albumRelPath };
592
+ seen.titles.add(cueTitle);
593
+ releaseCueTitles.set(releaseId, seen);
594
+ }
595
+ }
596
+ if (album !== undefined) {
597
+ // Which places on this record the pass still names. Everything else
598
+ // the record holds is left over from a cue that has changed, and is
599
+ // swept once the writes are done — a track whose file could not be
600
+ // named is *not* kept, so its old row does not outlive it.
601
+ const kept = [];
602
+ for (const track of plan.tracks) {
603
+ const trackFile = selectFileId.get(root.id, track.file.relPath);
604
+ if (trackFile === undefined)
605
+ continue;
606
+ // A cue split states its own length; a whole-file track has only
607
+ // what the file itself measured — which, for a FLAC, arrived in the
608
+ // same pass without spawning anything.
609
+ const duration = track.segmentStartMs !== null && track.segmentEndMs !== null
610
+ ? track.segmentEndMs - track.segmentStartMs
611
+ : (selectFileDuration.get(trackFile.id, PROBE_METHOD, latestRun)?.duration_ms ?? null);
612
+ upsertTrack.run(album.id, track.ordinal, track.title, track.titleSource, trackFile.id, track.segmentStartMs, track.segmentEndMs, duration);
613
+ kept.push(track.ordinal);
614
+ counters.tracks += 1;
615
+ }
616
+ deleteStaleTracks(album.id, kept);
617
+ }
618
+ // A net no current path falls into. `planAlbum` answers with at least one
619
+ // track for any album that reaches here, and an album with no audio at
620
+ // all never does — `audioFiles.length === 0` above says so. It is kept
621
+ // because the shape it catches is one this stage has already produced
622
+ // once (an album emptied by a later pass), because the contract forbids
623
+ // losing it without a word, and because the check is what makes that
624
+ // impossible rather than merely unlikely. `test/multidisc.test.ts` pins
625
+ // the invariant it enforces whether or not it fires.
626
+ if (plan.tracks.length === 0) {
627
+ recordIssue(root.id, albumRelPath, 'album-without-tracks', `${audioFiles.length} audio file(s) yielded no tracks`);
628
+ }
629
+ for (const issue of plan.issues) {
630
+ recordIssue(root.id, albumRelPath, issue.kind, issue.detail, issue.severity ?? 'warn');
631
+ }
632
+ byShape[plan.shape] += 1;
633
+ counters.albums += 1;
634
+ }
635
+ // A cue sitting above the albums that named audio in none of them.
636
+ //
637
+ // The per-album loop reports a cue of the folder's own and stops there:
638
+ // a parent cue is a candidate for every album beneath it, so reporting it
639
+ // there would restate one cue's situation once per album — which is the
640
+ // repetition the filter exists to prevent, and it was taken as a reason to
641
+ // say nothing at all. Waiting until the root is walked is what makes a
642
+ // single report possible, and it is the only place that knows.
643
+ for (const relPath of parentCues) {
644
+ if (matchedCues.has(relPath))
645
+ continue;
646
+ recordIssue(root.id, relPath, 'cue-unmatched', 'cue sits above these albums and names none of their audio', 'warn');
647
+ }
648
+ // A cue in a folder that holds no album. The stage walks albums, and a
649
+ // folder with no audio is not one, so nothing read this cue, nothing
650
+ // bonded it, and nothing said so — the one shape of cue that left no trace
651
+ // anywhere in the meta layer.
652
+ for (const relPath of rootCues) {
653
+ // Read, or tried and failed. A cue that could not be opened has its
654
+ // reason already, and "nothing read it" is not it — this loop is about
655
+ // the folder, not about the attempt.
656
+ if (readCues.has(relPath) || unreadableCues.has(relPath))
657
+ continue;
658
+ recordIssue(root.id, relPath, 'cue-unmatched', 'cue is in a folder with no album; nothing read it', 'warn');
659
+ }
660
+ // The tag's half of the same rule, and the same reason for waiting: a box
661
+ // is named once, by what its discs agree on. A disc's ALBUM tag named the
662
+ // release the moment it was read, so the box took the title of whichever
663
+ // disc came last, and a box whose discs stated different titles said
664
+ // nothing at all about it — while the album level already refuses to pick
665
+ // between files that disagree (task:2728).
666
+ //
667
+ // It runs before the cues below rather than after, because the tag has the
668
+ // first say: a file's own ALBUM names the record, where a cue's TITLE on a
669
+ // disc is the label EAC wrote for that disc. A release the tags have named
670
+ // is one the cue pass finds no placeholder to fill.
671
+ for (const [releaseId, seen] of releaseTagTitles) {
672
+ const titles = [...seen.titles].sort();
673
+ const [title] = titles;
674
+ if (title === undefined)
675
+ continue;
676
+ // A cue already named this record, and a tag never overrides a cue
677
+ // (`006_tags.sql`). The pass below is the other half of the same rule —
678
+ // it skips a record a tag has named — and without this one the two only
679
+ // agreed because of the order they happened to run in, which holds for a
680
+ // record seen for the first time and not for one a previous run left
681
+ // named by its cue.
682
+ const named = selectReleaseSource.get(releaseId);
683
+ if (!placeholder(named?.title_source))
684
+ continue;
685
+ if (titles.length > 1) {
686
+ recordIssue(root.id, seen.relPath, 'title-tag-ambiguous', `its discs state ${titles.length} different ALBUM tags — ${titles
687
+ .map((each) => `"${each}"`)
688
+ .join(', ')}; none was used, and the box keeps the name its folder states`, 'info');
689
+ continue;
690
+ }
691
+ if (isSiteName(title)) {
692
+ // The tag names where the rip came from, not the record — see
693
+ // `text/site-name.ts`. One report for the box rather than one per
694
+ // disc, which is the scope the finding is about: every disc of a box
695
+ // carries the same site.
696
+ recordIssue(root.id, seen.relPath, 'title-tag-declined', `the tag states where the rip came from, not the record — "${title}" was not used as a name; the box keeps the name its folder states`, 'info');
697
+ continue;
698
+ }
699
+ // `recordTitle`, not the tag verbatim: a name a file states is shown to a
700
+ // client exactly as a name a folder states, so it is read the same way.
701
+ // The live collection had `1989 Звезда по имени Солнце (2019, Maschina
702
+ // Records, MKM891CD, 3CD)` from a tag beside `Группа крови (Maschina
703
+ // Records, MKK881CD, 3CD)` from a folder, under one artist, and only the
704
+ // second looked like the rest of the records.
705
+ updateReleaseTitle.run(recordTitle(title), 'tag', releaseId);
706
+ }
707
+ // A release is named by its cues only when the box speaks with one voice
708
+ // and that voice is not a disc's.
709
+ //
710
+ // Both cues of the Wall say `The Wall [Disc 1]` and `The Wall [1994
711
+ // Remaster](Disc 2)`, and writing each onto the one release row in turn
712
+ // left the box named after whichever disc came last — EAC writes a disc's
713
+ // label into `TITLE`, and here it stood in for the album's name with
714
+ // nothing to show it happened.
715
+ for (const [releaseId, seen] of releaseCueTitles) {
716
+ const titles = [...seen.titles].sort();
717
+ const [title] = titles;
718
+ if (title === undefined)
719
+ continue;
720
+ const release = selectReleaseSource.get(releaseId);
721
+ // A tag already named this record. That is the priority chain working,
722
+ // not a cue being turned away, so it is not reported.
723
+ if (!placeholder(release?.title_source))
724
+ continue;
725
+ // Three ways a box's cues fail to name the record, and none is allowed
726
+ // to happen quietly: its discs disagree about what they are, they agree
727
+ // on a label that names one disc rather than the record, or they agree
728
+ // on a host — the site the rip came from, which is the one shape no
729
+ // agreement check can tell from a name (task:2725).
730
+ const why = titles.length > 1
731
+ ? 'its discs disagree'
732
+ : discMarker(title) !== null
733
+ ? `"${title}" names a disc`
734
+ : isSiteName(title)
735
+ ? `"${title}" names where the rip came from`
736
+ : null;
737
+ if (why === null) {
738
+ // The same read the tag path uses, and for the same reason.
739
+ updateReleaseTitle.run(recordTitle(title), 'cue', releaseId);
740
+ continue;
741
+ }
742
+ // Nothing keeps a turned-away title — `cue` has no `title` column — so
743
+ // the refusal is recorded rather than left to vanish (task:2697).
744
+ recordIssue(root.id, seen.relPath, 'release-title-declined', `cue titles not used — ${why}: ${titles.map((each) => `"${each}"`).join(', ')}; the release keeps its folder name`, 'info');
745
+ }
746
+ }
747
+ db.exec('COMMIT');
748
+ }
749
+ catch (err) {
750
+ try {
751
+ db.exec('ROLLBACK');
752
+ }
753
+ catch {
754
+ // Already unwound; the original error is what matters.
755
+ }
756
+ throw err;
757
+ }
758
+ return { ...counters, byShape };
759
+ }