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,752 @@
1
+ import { splitCredit } from "./credit.js";
2
+ import { clearIssues } from "../db/issue.js";
3
+ import { parseFolderName } from "../classify/folder-name.js";
4
+ import { basenameOf, rootBasenameOf } from "../util/names.js";
5
+ import { artistFolderOf, qualifiedKey } from "./folder.js";
6
+ import { hasCyrillic, latinSpellingsOf } from "./translit.js";
7
+ import { artistName } from "./name.js";
8
+ /** This stage's name in `issue.stage`. Bound to the insert and to the clear. */
9
+ const STAGE = 'artists';
10
+ /**
11
+ * What a `PERFORMER` string can say that is not a performer.
12
+ *
13
+ * A ripper writing PERFORMER on a live recording is saying who was *audible* in
14
+ * the track, not who is credited for it: `Публика` is the audience, `Ведущий`
15
+ * the compere, and `-` is a ripper's way of writing "nobody". None of them is an
16
+ * artist, and left alone they would become rows in the artist tree and hits in
17
+ * the artist search — which is worse than losing them, because a search that
18
+ * answers `Публика` is answering something nobody asked (task:2729).
19
+ *
20
+ * Matched whole and case-folded, never as a substring: an artist called
21
+ * `Ведущий` is not the case this is for, and `Публика` inside a real name would
22
+ * be a different word.
23
+ */
24
+ const NOT_A_PERFORMER = new Set(['публика', 'ведущий', 'ведущий ~ публика', '-']);
25
+ /**
26
+ * The album a cue's performer belongs to.
27
+ *
28
+ * Three guesses, most specific first. A disc of a release ripped flat is an
29
+ * album keyed on its own image, so the audio file's path fits. An ordinary
30
+ * album is the folder the image sits in. And a cue may sit one level *above*
31
+ * the album it describes (`whole.cue` beside `Album/01.flac`), which is why the
32
+ * cue's own folder is tried last rather than relied on.
33
+ *
34
+ * Missing the middle case is not a near-miss: the artist row is still created,
35
+ * the album simply never points at it, and nothing anywhere says so.
36
+ */
37
+ function albumRelPaths(row) {
38
+ return [row.audio_rel_path, row.audio_folder, row.cue_folder];
39
+ }
40
+ /**
41
+ * The artist an album's own files agree on.
42
+ *
43
+ * ALBUMARTIST is an album-level claim and wins outright. ARTIST is the *track's*
44
+ * artist, so it speaks for the album only when every file in it says the same
45
+ * thing: a compilation whose tracks disagree has no single album artist, and
46
+ * naming it after whichever file sorted first would be confidently wrong about
47
+ * all the others. Null is the honest answer there, and the column already means
48
+ * "not determined".
49
+ *
50
+ * Note what this deliberately does *not* do, on a split: taking the majority
51
+ * would name `2011 - Demonologists + Cock E.S.P.` (10 tracks to 1) after
52
+ * `Cock E.S.P.`, which is exactly the confidently-wrong answer the rule above
53
+ * exists to avoid. A per-track ARTIST names who plays that track, not whose
54
+ * record it is, and no majority fixes that. The album credit comes from the
55
+ * album-level sources instead — the cue, or the folder name.
56
+ *
57
+ * **What it does do is look for a name every track's credit already contains.**
58
+ * A guest is written *inside* the credit — `Sigur Rós & Blanck Mass` on one
59
+ * track of `Kveikur Expanded LP`, `Sigur Rós` on the other two — so the two
60
+ * answers are not two artists disagreeing; one of them is the other plus
61
+ * somebody. Splitting each credit on its joiners and keeping what all of them
62
+ * have in common finds that: `{Sigur Rós}` meets `{Sigur Rós, Blanck Mass}` at
63
+ * `Sigur Rós`, and the album is by Sigur Rós with a guest on one track, which is
64
+ * what `track_artist` is for.
65
+ *
66
+ * It is not the majority rule, and the split above is the proof: `{Demonologists}`
67
+ * and `{Cock E.S.P.}` have nothing in common, so a split still gets no album
68
+ * artist. The rule only ever answers with a name **every** file states, which is
69
+ * a claim about agreement and not about counting — and it is silent unless that
70
+ * name is exactly one, so a collaboration everywhere is still nobody's record.
71
+ */
72
+ function albumArtistOf(entry) {
73
+ const albumArtist = entry.albumArtists[0];
74
+ if (albumArtist !== undefined)
75
+ return albumArtist;
76
+ if (entry.artists.length === 1)
77
+ return entry.artists[0] ?? null;
78
+ const credits = entry.artists.map((name) => new Set(splitCredit(name)
79
+ .map((part) => part.name.trim())
80
+ .filter((part) => part !== '')));
81
+ const first = credits[0];
82
+ if (first === undefined)
83
+ return null;
84
+ const common = [...first].filter((name) => credits.every((credit) => credit.has(name)));
85
+ return common.length === 1 ? (common[0] ?? null) : null;
86
+ }
87
+ /**
88
+ * What a folder is called: the same question `classify` answers for the title.
89
+ *
90
+ * The root is the one path here that did not come from the walk, and on Windows
91
+ * it arrives backslashed — `rootBasenameOf` rather than `basenameOf`, or an
92
+ * album that *is* its root would be named after the whole path, and the credit
93
+ * its folder states would go unclaimed on Windows while working on Linux.
94
+ */
95
+ function folderNameOf(album, rootPaths) {
96
+ if (album.relPath !== '')
97
+ return basenameOf(album.relPath);
98
+ return rootBasenameOf(rootPaths.get(album.rootId) ?? '');
99
+ }
100
+ /**
101
+ * Turn cue performers, folder names and tags into artist rows.
102
+ *
103
+ * Runs after `applyCues`, because that is where a cue's performer first exists
104
+ * and where the tracks a tag's artist is reached through are written.
105
+ *
106
+ * Three sources, in this order, and the order is the whole design:
107
+ *
108
+ * 1. The cue's PERFORMER — a document about *this* record.
109
+ * 2. The folder name — `1994 - Cock E.S.P. + Thirdorgan - Split (Cass, C60)`
110
+ * states the credit outright, and it is the only source that does for a
111
+ * collaboration. This is why the feature exists at all.
112
+ * 3. Tags — ALBUMARTIST, or an ARTIST every file agrees on.
113
+ *
114
+ * Whichever wins is read as a *list* (`splitCredit`), because a credit is not
115
+ * one name: on the sample, 20 of 24 Cock E.S.P folders are collaborations. The
116
+ * list is stored with the phrase that joined each entry, so it rebuilds into
117
+ * the original exactly, and the original is kept beside it.
118
+ *
119
+ * Two things this deliberately does not do:
120
+ *
121
+ * - **It does not touch `track.artist_id`.** Per-track PERFORMER is how a
122
+ * compilation names its artists — a different question (which artist is on
123
+ * *this* track), needing the VA overrides the spec keeps separate. Leaving
124
+ * the column null says "not determined"; filling it with the album artist
125
+ * would say something false about every track of a mix.
126
+ * - **It does not guess from a majority.** See `albumArtistOf`.
127
+ *
128
+ * What it writes, it re-derives every time. Albums, releases and credits are
129
+ * unset before being set again, and artist rows left holding nothing are
130
+ * deleted, so a performer removed from a cue on disk actually leaves. The one
131
+ * thing that is *not* re-derived is history: `cue_track` and the files
132
+ * themselves are other stages' business.
133
+ *
134
+ * Three things it does are guesses, and all three say so. A key that merges
135
+ * names folding *differently* was merged by discarding something a human wrote.
136
+ * A key two artist folders both claim is *split*, because the spec reads
137
+ * different folders as different artists ("разные арт-папки = разные артисты",
138
+ * wiki:3498 §3) — though the collection may equally be one artist filed twice,
139
+ * and only a human or an external identifier can settle that. Each writes its
140
+ * own issue, `artist-ambiguous` and `artist-homonym`, and both mark the rows
141
+ * concerned. The third guesses nothing and marks nothing: two keys that are one
142
+ * name in two alphabets are reported as `artist-transliteration` and left as
143
+ * two rows (`translit.ts` has the argument for why they are not merged).
144
+ */
145
+ export function applyArtists(db) {
146
+ const counters = { artists: 0, ambiguous: 0, homonyms: 0, linked: 0, issues: 0 };
147
+ // The inner join is the point: a cue with no `audio_file_id` describes
148
+ // nothing, and a performer on it names a record that is not in this folder.
149
+ // `applyCues` clears the binding but leaves the row and its performer, so
150
+ // trusting the performer alone keeps an album named after a vanished rip.
151
+ const cueRows = db
152
+ .prepare(`SELECT c.performer AS performer,
153
+ cf.root_id AS root_id,
154
+ cf.folder_rel_path AS cue_folder,
155
+ af.rel_path AS audio_rel_path,
156
+ af.folder_rel_path AS audio_folder
157
+ FROM cue c
158
+ JOIN file cf ON cf.id = c.file_id
159
+ JOIN file af ON af.id = c.audio_file_id
160
+ WHERE c.performer IS NOT NULL AND TRIM(c.performer) <> ''
161
+ ORDER BY c.performer`)
162
+ .all();
163
+ const albumIds = new Map();
164
+ const albumById = new Map();
165
+ const orderedAlbums = [];
166
+ for (const row of db
167
+ .prepare('SELECT id, root_id, rel_path, release_id FROM album ORDER BY id')
168
+ .all()) {
169
+ const album = {
170
+ id: row.id,
171
+ rootId: row.root_id,
172
+ relPath: row.rel_path,
173
+ releaseId: row.release_id,
174
+ };
175
+ albumIds.set(`${row.root_id}:${row.rel_path}`, row.id);
176
+ albumById.set(row.id, album);
177
+ orderedAlbums.push(album);
178
+ }
179
+ const rootPaths = new Map(db.prepare('SELECT id, path FROM root').all().map((row) => [
180
+ row.id,
181
+ row.path,
182
+ ]));
183
+ const credits = new Map();
184
+ const claim = (albumId, raw, source) => {
185
+ // First claim wins, and the order of the three passes below *is* the
186
+ // priority: a cue is claimed before folders are read, so a folder can never
187
+ // displace one.
188
+ if (credits.has(albumId))
189
+ return;
190
+ const album = albumById.get(albumId);
191
+ if (album === undefined)
192
+ return;
193
+ const entries = splitCredit(raw);
194
+ if (entries.length === 0)
195
+ return;
196
+ credits.set(albumId, { album, raw, source, entries });
197
+ };
198
+ // 1. The cue.
199
+ for (const row of cueRows) {
200
+ for (const relPath of albumRelPaths(row)) {
201
+ const albumId = albumIds.get(`${row.root_id}:${relPath}`);
202
+ if (albumId === undefined)
203
+ continue;
204
+ claim(albumId, row.performer, 'cue');
205
+ break;
206
+ }
207
+ }
208
+ // 2. The folder name, for every album no cue named.
209
+ for (const album of orderedAlbums) {
210
+ const parsed = parseFolderName(folderNameOf(album, rootPaths));
211
+ if (parsed.credit === null)
212
+ continue;
213
+ claim(album.id, parsed.credit, 'folder');
214
+ }
215
+ // 3. Tags, reached through `track` — the relation the pipeline already built.
216
+ const tagRows = db
217
+ .prepare(`SELECT t.album_id AS album_id, ft.name AS name, ft.value AS value
218
+ FROM track t
219
+ JOIN file_tag ft ON ft.file_id = t.file_id
220
+ WHERE ft.name IN ('albumartist', 'artist')
221
+ ORDER BY t.album_id, ft.name, ft.position`)
222
+ .all();
223
+ const byAlbum = new Map();
224
+ for (const row of tagRows) {
225
+ let entry = byAlbum.get(row.album_id);
226
+ if (entry === undefined) {
227
+ entry = { albumArtists: [], artists: [] };
228
+ byAlbum.set(row.album_id, entry);
229
+ }
230
+ // A file shared by every track of an image-cue album arrives once per
231
+ // track; the value is the same value, and counting it twice would let one
232
+ // file outvote the others.
233
+ const bucket = row.name === 'albumartist' ? entry.albumArtists : entry.artists;
234
+ if (!bucket.includes(row.value))
235
+ bucket.push(row.value);
236
+ }
237
+ for (const [albumId, entry] of [...byAlbum].sort((a, b) => a[0] - b[0])) {
238
+ const name = albumArtistOf(entry);
239
+ if (name === null)
240
+ continue;
241
+ claim(albumId, name, 'tag');
242
+ }
243
+ // Every chosen credit, read into the rows that will become artists.
244
+ //
245
+ // Grouped by (key, artist folder) rather than by key alone. The key is what
246
+ // merges `The Cure` with `Cure, The`, and it is also what would merge two
247
+ // different bands both called Nirvana — the folder is the only thing that can
248
+ // tell those apart, and the spec hands the deterministic core exactly that
249
+ // half of the signal ("разные арт-папки = разные артисты", wiki:3498 §3).
250
+ const byKey = new Map();
251
+ function remember(key, folder) {
252
+ let groups = byKey.get(key);
253
+ if (groups === undefined) {
254
+ groups = new Map();
255
+ byKey.set(key, groups);
256
+ }
257
+ // Keyed on the folder's path-derived id. Null is its own group: an album
258
+ // that names an artist but sits in no artist folder is not evidence that it
259
+ // belongs to a *different* artist, so it must not manufacture a split.
260
+ const id = folder?.id ?? null;
261
+ let candidate = groups.get(id);
262
+ if (candidate === undefined) {
263
+ candidate = {
264
+ folder,
265
+ spellings: new Map(),
266
+ folded: new Set(),
267
+ stripped: new Set(),
268
+ albums: new Set(),
269
+ };
270
+ groups.set(id, candidate);
271
+ }
272
+ return candidate;
273
+ }
274
+ for (const credit of credits.values()) {
275
+ const rootPath = rootPaths.get(credit.album.rootId) ?? '';
276
+ for (const entry of credit.entries) {
277
+ const parsed = artistName(entry.name);
278
+ if (parsed.key === '')
279
+ continue;
280
+ // Asked per entry, not per album: a collaboration names two artists at
281
+ // once, and each of them may be filed under a folder of its own.
282
+ const folder = artistFolderOf({
283
+ albumRelPath: credit.album.relPath,
284
+ rootPath,
285
+ key: parsed.key,
286
+ });
287
+ const candidate = remember(parsed.key, folder);
288
+ candidate.spellings.set(parsed.name, (candidate.spellings.get(parsed.name) ?? 0) + 1);
289
+ candidate.folded.add(parsed.folded);
290
+ if (parsed.stripped !== null)
291
+ candidate.stripped.add(parsed.stripped);
292
+ candidate.albums.add(credit.album.id);
293
+ }
294
+ }
295
+ /**
296
+ * Root path, then relative path.
297
+ *
298
+ * Never the root id: ids are rowids, reassigned when a root is dropped from
299
+ * the scan and added back, and `upsertRoot` deletes collapsed twins — so
300
+ * ordering by one would hand `#2` to a different folder with no change to the
301
+ * collection on disk.
302
+ */
303
+ function byFolder(a, b) {
304
+ if (a.rootPath !== b.rootPath)
305
+ return a.rootPath < b.rootPath ? -1 : 1;
306
+ return a.relPath < b.relPath ? -1 : a.relPath > b.relPath ? 1 : 0;
307
+ }
308
+ /** The first album a candidate holds, for attributing an issue to a place. */
309
+ function firstAlbumOf(candidate) {
310
+ return [...candidate.albums]
311
+ .sort((a, b) => a - b)
312
+ .map((id) => albumById.get(id))
313
+ .find((album) => album !== undefined);
314
+ }
315
+ const rows = [];
316
+ const homonyms = [];
317
+ /** `${albumId}:${baseKey}` -> the row that album's credit for that name lands on. */
318
+ const finalKeyOf = new Map();
319
+ for (const [key, groups] of byKey) {
320
+ const loose = groups.get(null);
321
+ const foldered = [...groups.values()]
322
+ .filter((candidate) => candidate.folder !== null)
323
+ .sort((a, b) => byFolder(a.folder, b.folder));
324
+ // A split takes two folders to disagree. One folder — or none at all — is
325
+ // the ordinary case and behaves exactly as it did before folders were read,
326
+ // which is what keeps a band filed under a single artist folder whole.
327
+ const targets = foldered.length === 0 ? (loose === undefined ? [] : [loose]) : foldered;
328
+ const split = targets.length > 1;
329
+ // Read before the folder-less albums are merged in below. The issue is about
330
+ // the folders that disagree, so pinning it to an album that belongs to no
331
+ // folder would give a location its own text contradicts.
332
+ const anchor = foldered[0];
333
+ const where = split && anchor !== undefined ? firstAlbumOf(anchor) : undefined;
334
+ let guessed = 0;
335
+ targets.forEach((candidate, index) => {
336
+ const finalKey = qualifiedKey(key, index);
337
+ // Albums no folder claimed go to the first folder. That is a coin flip, so
338
+ // it is counted here and reported below rather than folded in quietly.
339
+ if (index === 0 && foldered.length > 0 && loose !== undefined) {
340
+ for (const [spelling, seen] of loose.spellings) {
341
+ candidate.spellings.set(spelling, (candidate.spellings.get(spelling) ?? 0) + seen);
342
+ }
343
+ for (const folded of loose.folded)
344
+ candidate.folded.add(folded);
345
+ for (const stripped of loose.stripped)
346
+ candidate.stripped.add(stripped);
347
+ for (const albumId of loose.albums)
348
+ candidate.albums.add(albumId);
349
+ guessed = loose.albums.size;
350
+ }
351
+ for (const albumId of candidate.albums)
352
+ finalKeyOf.set(`${albumId}:${key}`, finalKey);
353
+ rows.push({
354
+ key: finalKey,
355
+ baseKey: key,
356
+ candidate,
357
+ ambiguous: candidate.folded.size > 1 || split,
358
+ });
359
+ });
360
+ if (split) {
361
+ homonyms.push({
362
+ spellings: [...new Set(targets.flatMap((target) => [...target.spellings.keys()]))].sort(),
363
+ // When a split happens `targets` *is* `foldered`, so the two walks below
364
+ // cannot fall out of step — which is why the folder and the key it was
365
+ // given are carried as one pair rather than as two aligned arrays.
366
+ owners: foldered.map((candidate, index) => ({
367
+ folder: candidate.folder,
368
+ key: qualifiedKey(key, index),
369
+ })),
370
+ guessed,
371
+ rootId: where?.rootId ?? null,
372
+ relPath: where?.relPath ?? null,
373
+ });
374
+ }
375
+ }
376
+ const upsertArtist = db.prepare(`INSERT INTO artist (name, name_key, sort_key, ambiguous) VALUES (?, ?, ?, ?)
377
+ ON CONFLICT (name_key) DO UPDATE SET
378
+ name = excluded.name,
379
+ sort_key = excluded.sort_key,
380
+ ambiguous = excluded.ambiguous`);
381
+ const selectArtist = db.prepare('SELECT id FROM artist WHERE name_key = ?');
382
+ const linkRelease = db.prepare('UPDATE release SET artist_id = ? WHERE id = ?');
383
+ // A credit is re-derived, so it is cleared rather than merged into: an album
384
+ // whose folder stopped naming a collaborator has to lose them.
385
+ const clearCredits = db.prepare('DELETE FROM artist_credit');
386
+ const resetCredit = db.prepare('UPDATE album SET credit_raw = NULL, credit_source = NULL');
387
+ const setAlbumCredit = db.prepare('UPDATE album SET artist_id = ?, credit_raw = ?, credit_source = ? WHERE id = ?');
388
+ const insertCredit = db.prepare('INSERT INTO artist_credit (album_id, position, artist_id, join_phrase) VALUES (?, ?, ?, ?)');
389
+ // A track's credit is re-derived for the same reason an album's is, and it is
390
+ // the same shape: a list, in the order the source stated, with the word that
391
+ // joined them (task:2729).
392
+ const clearTrackCredits = db.prepare('DELETE FROM track_credit');
393
+ const insertTrackCredit = db.prepare('INSERT INTO track_credit (track_id, position, artist_id, join_phrase) VALUES (?, ?, ?, ?)');
394
+ // Every track a cue states a performer for, with the record it sits in — the
395
+ // same join the dump makes, and for the same reason: a track and its cue_track
396
+ // are one thing seen from two sides, keyed by the audio file and the ordinal.
397
+ const trackPerformerRows = db
398
+ .prepare(`SELECT * FROM (
399
+ SELECT t.id AS track_id, t.album_id AS album_id,
400
+ f.root_id AS root_id, f.rel_path AS rel_path,
401
+ (SELECT ct.performer
402
+ FROM cue c JOIN cue_track ct ON ct.cue_id = c.id
403
+ WHERE c.audio_file_id = t.file_id AND ct.ordinal = t.ordinal
404
+ ORDER BY c.id LIMIT 1) AS performer
405
+ FROM track t JOIN file f ON f.id = t.file_id
406
+ ) WHERE performer IS NOT NULL AND trim(performer) <> ''`)
407
+ .all();
408
+ /** Files holding tracks whose performer nothing here can resolve — per file. */
409
+ const unplaced = new Map();
410
+ /**
411
+ * The artists each record's own credit landed on, in order.
412
+ *
413
+ * Kept so a track can be compared with its record by *identity* rather than by
414
+ * the string the cue wrote. The two are not the same test: `[LINKIN PARK]` and
415
+ * `Linkin Park` are one artist in two spellings — 137 of this collection's
416
+ * stated performers are exactly that — and a string comparison would write a
417
+ * track credit for every one of them, saying nothing the record did not already
418
+ * say (task:2729).
419
+ */
420
+ const albumCreditIds = new Map();
421
+ // An artist named only by a credit is still an artist. Without the last
422
+ // UNION, `pruneArtists` deletes such a row and the cascade takes the credit
423
+ // with it — silently, and only for collaborations, which is exactly the case
424
+ // this whole stage was built for.
425
+ const pruneArtists = db.prepare(`DELETE FROM artist WHERE id NOT IN (
426
+ SELECT artist_id FROM album WHERE artist_id IS NOT NULL
427
+ UNION SELECT artist_id FROM release WHERE artist_id IS NOT NULL
428
+ UNION SELECT artist_id FROM artist_credit
429
+ UNION SELECT artist_id FROM track_credit
430
+ )`);
431
+ const insertIssue = db.prepare(`INSERT INTO issue (scan_run_id, stage, root_id, rel_path, kind, severity, detail)
432
+ VALUES (?, ?, ?, ?, ?, ?, ?)`);
433
+ const latestRun = db.prepare('SELECT MAX(id) AS id FROM scan_run').get()
434
+ .id;
435
+ /**
436
+ * How many accents a spelling carries.
437
+ *
438
+ * Decomposed first, so the two writings of one letter count the same: `ö` as
439
+ * one code point and as `o` plus U+0308 are one accent, and a spelling must
440
+ * not win on the accident of how its tag happened to be stored.
441
+ */
442
+ function marksIn(name) {
443
+ return (name.normalize('NFD').match(/\p{M}/gu) ?? []).length;
444
+ }
445
+ /**
446
+ * How the merged artist is displayed: the spelling that carries the most
447
+ * accents, then the one the collection uses most, and on a tie the briefest —
448
+ * decoration is what tends to be added (`Cure, The` over `The Cure`,
449
+ * `Nirvana (UK)` over `Nirvana`), so the shortest is usually the plainest.
450
+ *
451
+ * The accents come first because they are the one decoration that is not
452
+ * added: they are what a tag *loses*. `Röyksopp` written `Royksopp` is a
453
+ * ripper's umlaut gone missing, and the count would have picked the lossy
454
+ * spelling whenever it happened to be the more common one — which, on the
455
+ * live collection, it is. Code units last rather than `localeCompare`, whose
456
+ * ordering depends on the host's ICU data — the same unchanged collection
457
+ * must not name an artist differently on two machines.
458
+ */
459
+ function displayName(spellings) {
460
+ const ranked = [...spellings.entries()].sort((a, b) => marksIn(b[0]) - marksIn(a[0]) ||
461
+ b[1] - a[1] ||
462
+ a[0].length - b[0].length ||
463
+ (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
464
+ const best = ranked[0];
465
+ return best === undefined ? '' : best[0];
466
+ }
467
+ try {
468
+ // `IMMEDIATE`, so that what this transaction happens to do first cannot
469
+ // decide whether it waits — the first statement below is a write, which
470
+ // would consult the busy handler under a deferred `BEGIN`, but that is a
471
+ // property of the order of the statements rather than of this line, and the
472
+ // order is what the next edit changes. `db/index.ts` has the rule and
473
+ // `search/index.ts` the case where it already matters (task:2871).
474
+ db.exec('BEGIN IMMEDIATE');
475
+ // This stage derives everything from the current meta layer, so its issues
476
+ // describe the present and an earlier run's rows are dead weight. The scope
477
+ // used to be `AND scan_run_id IS ?`, which reads as "the present" and is
478
+ // not: it matched only rows *this* run had already written, so it guarded
479
+ // against running the stage twice in one run and let every run add another
480
+ // set. See `db/issue.ts`.
481
+ clearIssues(db, 'artists');
482
+ // Unset before setting: an album whose cue no longer names anyone has to
483
+ // lose the artist it had, not keep it forever.
484
+ db.prepare('UPDATE album SET artist_id = NULL').run();
485
+ db.prepare('UPDATE release SET artist_id = NULL').run();
486
+ clearCredits.run();
487
+ resetCredit.run();
488
+ clearTrackCredits.run();
489
+ for (const row of rows) {
490
+ const name = displayName(row.candidate.spellings);
491
+ upsertArtist.run(name, row.key, artistName(name).sortKey, row.ambiguous ? 1 : 0);
492
+ if (row.ambiguous)
493
+ counters.ambiguous += 1;
494
+ // A merge of spellings is a guess in its own right and keeps its own
495
+ // issue. A split is reported once per name below, since it spans rows.
496
+ if (row.candidate.folded.size <= 1)
497
+ continue;
498
+ counters.issues += 1;
499
+ // Attributed to the first album that pulled this key in, so the row can
500
+ // be traced back and scoped to a root. `root_id` of null would have made
501
+ // it unfindable in the dump.
502
+ const where = firstAlbumOf(row.candidate);
503
+ const dropped = row.candidate.stripped.size === 0
504
+ ? ''
505
+ : ` by dropping ${[...row.candidate.stripped].sort().join(', ')}`;
506
+ insertIssue.run(latestRun, STAGE, where?.rootId ?? null, where?.relPath ?? null, 'artist-ambiguous', 'warn', `${[...row.candidate.spellings.keys()].sort().join(' / ')} all reduce to "${row.baseKey}"${dropped}`);
507
+ }
508
+ // Said out loud, once per name, because it is a claim the rules cannot prove:
509
+ // two folders named after the artist are two artists — or one artist filed
510
+ // twice, and nothing on disk says which. Only a human or an external
511
+ // identifier can settle that, so it is a warning rather than a decision.
512
+ for (const homonym of homonyms) {
513
+ counters.homonyms += 1;
514
+ counters.issues += 1;
515
+ const described = homonym.owners.map(({ folder }) => folder.relPath === '' ? `the root itself (${folder.rootPath})` : folder.relPath);
516
+ const guessed = homonym.guessed === 0
517
+ ? ''
518
+ : `; ${homonym.guessed} album${homonym.guessed === 1 ? '' : 's'} named the artist but sat in no artist folder, and went to the first`;
519
+ insertIssue.run(latestRun, STAGE, homonym.rootId, homonym.relPath, 'artist-homonym', 'warn', `${homonym.spellings.join(' / ')} came from ${described.length} artist folders (${described.join(', ')}) -> ${homonym.owners.map((owner) => owner.key).join(' / ')}${guessed}`);
520
+ }
521
+ // The credits, written in the order the source stated them. The first name
522
+ // becomes the album's artist — a single displayed artist is what the rest
523
+ // of the meta layer and the API need — while the list keeps the whole
524
+ // truth beside it.
525
+ for (const credit of credits.values()) {
526
+ // Resolved through the group this album's credit actually landed in, not
527
+ // through the bare key. The moment two folders split a name, the bare key
528
+ // is the wrong answer: a collaborator credited inside the second folder
529
+ // would be written onto the first artist's row — right name, wrong band,
530
+ // and the credit list on the album is the only thing that would show it.
531
+ const written = credit.entries
532
+ .map((entry) => ({
533
+ entry,
534
+ key: finalKeyOf.get(`${credit.album.id}:${artistName(entry.name).key}`),
535
+ }))
536
+ .map((pair) => ({
537
+ ...pair,
538
+ id: pair.key === undefined
539
+ ? undefined
540
+ : selectArtist.get(pair.key)?.id,
541
+ }))
542
+ .filter((pair) => pair.id !== undefined);
543
+ if (written.length === 0)
544
+ continue;
545
+ written.forEach((pair, position) => {
546
+ insertCredit.run(credit.album.id, position, pair.id, pair.entry.joinPhrase);
547
+ });
548
+ albumCreditIds.set(credit.album.id, written.map((pair) => pair.id));
549
+ const first = written[0];
550
+ if (first === undefined)
551
+ continue;
552
+ setAlbumCredit.run(first.id, credit.raw, credit.source, credit.album.id);
553
+ counters.linked += 1;
554
+ const releaseId = credit.album.releaseId;
555
+ if (releaseId !== null)
556
+ linkRelease.run(first.id, releaseId);
557
+ // The reading, said out loud. `Smell & Quim` is one artist and this
558
+ // splits it; so is `Florence + the Machine`. The rules cannot tell, so
559
+ // the only honest thing left is to make the guess reviewable.
560
+ //
561
+ // It fires whenever a credit carries a separator, which is the event
562
+ // itself rather than noise — but the names are quoted in the message
563
+ // because the obvious rendering is a tautology: `Cock E.S.P. + Thirdorgan`
564
+ // printed back as `Cock E.S.P. + Thirdorgan` says nothing, since the word
565
+ // that joined them and the word this joined them with are the same one.
566
+ // Quoted, the message shows *where* the split was made, which is the only
567
+ // thing a reader can disagree with (task:2756).
568
+ if (written.length > 1) {
569
+ insertIssue.run(latestRun, STAGE, credit.album.rootId, credit.album.relPath, 'artist-credit-split', 'info', `${credit.raw} -> ${written.map((pair) => `"${pair.entry.name}"`).join(' + ')}`);
570
+ counters.issues += 1;
571
+ }
572
+ }
573
+ // The tracks whose performer is not the record's credit — the second half of
574
+ // what a credit is, and the half `track.artist_id` cannot hold: it holds one
575
+ // artist, and `Кино & Джоанна Стингрей` is two (task:2729).
576
+ //
577
+ // Written were the two disagree. Where they agree the row would only repeat
578
+ // the album's own credit — 1862 of the collection's 2626 stated performers
579
+ // agree — and a row that says nothing new is a row that has to be kept in
580
+ // step for no reason.
581
+ //
582
+ // `NOT_A_PERFORMER` is the rule this starts from: a ripper writing PERFORMER
583
+ // on a live recording is saying who was *audible*, and `Публика` is the
584
+ // audience. Those parts are dropped, and the drop is said out loud — a
585
+ // refusal that keeps quiet is the same loss in a smaller place.
586
+ for (const row of trackPerformerRows) {
587
+ const parts = splitCredit(row.performer);
588
+ const spoken = parts.filter((part) => !NOT_A_PERFORMER.has(part.name.trim().toLowerCase()));
589
+ const dropped = parts.length - spoken.length;
590
+ if (spoken.length === 0) {
591
+ // Nothing but a note about the room. Reported once per track rather than
592
+ // once per run: the thing to go and look at is a track, and 12 of them
593
+ // is not a number worth aggregating away.
594
+ insertIssue.run(latestRun, STAGE, row.root_id, row.rel_path, 'track-performer-declined', 'info', `PERFORMER is ${row.performer}, which names the room rather than a performer`);
595
+ counters.issues += 1;
596
+ continue;
597
+ }
598
+ if (row.album_id === null) {
599
+ // No record to defer to, and no folder for this stage to stand an
600
+ // identity on: the names here are not in any artist folder, so resolving
601
+ // them would be inventing the identity rather than reading it. Counted
602
+ // per file, because that is where the missing thing is (task:2729).
603
+ unplaced.set(`${row.root_id}:${row.rel_path}`, {
604
+ root_id: row.root_id,
605
+ rel_path: row.rel_path,
606
+ tracks: (unplaced.get(`${row.root_id}:${row.rel_path}`)?.tracks ?? 0) + 1,
607
+ });
608
+ continue;
609
+ }
610
+ const written = spoken
611
+ .map((entry) => {
612
+ const key = finalKeyOf.get(`${row.album_id}:${artistName(entry.name).key}`);
613
+ // Through the record's group first — the same rule the album's own
614
+ // credit follows, and for the same reason: two artist folders can hold
615
+ // one spelling, and the bare key would then be the other band's row.
616
+ //
617
+ // A name *only* a track states has no group: it is in no artist folder
618
+ // and on no record's credit, so `finalKeyOf` has never seen it. Those
619
+ // resolve through the name itself, and only to a row that already
620
+ // exists — a guest on this track who appears on another record's credit
621
+ // is a person this stage knows. Creating the row here instead would be
622
+ // inventing an identity with no folder to stand it on, which is the
623
+ // guess this stage refuses to make everywhere else.
624
+ const bare = artistName(entry.name).key;
625
+ const id = key === undefined
626
+ ? selectArtist.get(bare)?.id
627
+ : selectArtist.get(key)?.id;
628
+ return id === undefined ? undefined : { entry, id };
629
+ })
630
+ .filter((pair) => pair !== undefined);
631
+ // The same artists, in the same order, as the record's own credit: this
632
+ // track says nothing the record does not, and a row repeating it would
633
+ // have to be kept in step for no reason. Compared by identity, not by the
634
+ // string the cue wrote — see `albumCreditIds`.
635
+ // A part that resolved to nobody is the one way this can lose a name
636
+ // silently, so it is the one thing said out loud here — and it is asked
637
+ // *before* the comparison below, because a partial credit can look exactly
638
+ // like the record's own: `Кино & Гость` on a record credited `Кино`
639
+ // resolves to just the band, matches, and would have been dropped without
640
+ // ever saying that the guest was lost. Nothing is written in that case
641
+ // either: naming some of a track's people as all of them is worse than
642
+ // naming none (task:2729, and 120 of them in the live collection).
643
+ if (written.length < spoken.length) {
644
+ const unresolved = spoken.filter((entry) => !written.some((pair) => pair.entry === entry));
645
+ insertIssue.run(latestRun, STAGE, row.root_id, row.rel_path, 'track-performer-unresolved', 'info', `${unresolved.map((e) => e.name).join(' + ')} is named only by this track and by no artist folder, so there is no row to attach it to`);
646
+ counters.issues += 1;
647
+ continue;
648
+ }
649
+ // The same artists, in the same order, as the record's own credit: this
650
+ // track says nothing the record does not, and a row repeating it would
651
+ // have to be kept in step for no reason. Compared by identity, not by the
652
+ // string the cue wrote — see `albumCreditIds`.
653
+ const record = albumCreditIds.get(row.album_id) ?? [];
654
+ const sameCredit = written.length === record.length && written.every((pair, at) => pair.id === record[at]);
655
+ if (sameCredit)
656
+ continue;
657
+ written.forEach((pair, position) => {
658
+ insertTrackCredit.run(row.track_id, position, pair.id, pair.entry.joinPhrase);
659
+ });
660
+ if (dropped > 0) {
661
+ insertIssue.run(latestRun, STAGE, row.root_id, row.rel_path, 'track-performer-declined', 'info', `${row.performer} -> ${spoken.map((e) => e.name).join(' + ')} (${dropped} part(s) named the room)`);
662
+ counters.issues += 1;
663
+ }
664
+ }
665
+ for (const unplacedRow of unplaced.values()) {
666
+ insertIssue.run(latestRun, STAGE, unplacedRow.root_id, unplacedRow.rel_path, 'track-performer-unplaced', 'info', `${unplacedRow.tracks} track(s) state a performer and sit in no record, so there is no credit to compare it with and no artist folder to resolve it through`);
667
+ counters.issues += 1;
668
+ }
669
+ // An album no source could name. Declining to guess is this stage's rule and
670
+ // it is right (`albumArtistOf`) — but the refusal was silent, and an empty
671
+ // artist reads the same whether the record is a compilation whose ripper
672
+ // wrote no ALBUMARTIST or an artist that got lost. Those want opposite
673
+ // responses from whoever reads the dump, and telling them apart is the whole
674
+ // of what is reported below: the number of distinct ARTIST tags is the
675
+ // signal, and it stays a report rather than becoming a `Various Artists` the
676
+ // stage would be inventing (task:2701).
677
+ const unclaimed = db
678
+ .prepare(`SELECT a.id AS id, a.root_id AS root_id, a.rel_path AS rel_path,
679
+ (SELECT COUNT(*) FROM track t WHERE t.album_id = a.id) AS tracks
680
+ FROM album a WHERE a.artist_id IS NULL
681
+ ORDER BY a.root_id, a.rel_path`)
682
+ .all();
683
+ for (const album of unclaimed) {
684
+ // The distinct values the tags stage read, counted once per value above:
685
+ // one file shared by every track of an image-cue album is one voice.
686
+ const stated = byAlbum.get(album.id)?.artists.length ?? 0;
687
+ const carried = stated === 0
688
+ ? 'no ARTIST tag'
689
+ : stated === 1
690
+ ? 'one ARTIST tag'
691
+ : `${stated} different ARTIST tags`;
692
+ insertIssue.run(latestRun, STAGE, album.root_id, album.rel_path, 'album-without-artist', 'info', `no source named this record; its ${album.tracks} track${album.tracks === 1 ? '' : 's'} carry ${carried}`);
693
+ counters.issues += 1;
694
+ }
695
+ // An artist holding nothing is one whose performer went away. The stage
696
+ // derives from the present; it does not keep ghosts of the past.
697
+ pruneArtists.run();
698
+ // Two surviving rows whose keys are one name in two alphabets: the Kino
699
+ // catalogue writes `Кино` in 48 cues and `Kino` in the French edition of
700
+ // one, and a reader sees one band where the database holds two. The pairing
701
+ // is *said* and not acted on — see `translit.ts` for why a merge here would
702
+ // be asserting an identity nothing on disk states. Reported after the prune
703
+ // so a pair is only ever named when both rows are actually in the layer.
704
+ const artistRows = db
705
+ .prepare('SELECT id, name, name_key FROM artist')
706
+ .all();
707
+ const unqualified = artistRows.filter((row) => !row.name_key.includes('#'));
708
+ const nonCyrillicByKey = new Map(unqualified.filter((row) => !hasCyrillic(row.name_key)).map((row) => [row.name_key, row]));
709
+ // The path is for tracing and scoping — the finding is about a pair of
710
+ // rows, not about one album — but a report with no location at all is
711
+ // unfindable in the dump, which is why the sibling stage refuses a null
712
+ // root too. A collaborator named only by a credit has no album of its own
713
+ // (`Aube + Кино`), so the fallback reaches it through the credit.
714
+ const albumOfArtist = db.prepare(`SELECT a.root_id, a.rel_path FROM album a WHERE a.artist_id = ?
715
+ UNION ALL
716
+ SELECT a.root_id, a.rel_path FROM artist_credit c JOIN album a ON a.id = c.album_id
717
+ WHERE c.artist_id = ?
718
+ ORDER BY root_id, rel_path LIMIT 1`);
719
+ // Every pair, not the first one found: a single key can be written several
720
+ // ways (`Ксения` as `Kseniya` and as `Ksenia`), and each is a different row
721
+ // a reader would want named. Stopping at the first would drop the rest
722
+ // without saying so — the failure this report exists to prevent.
723
+ //
724
+ // Qualified keys (`кино#2`) are left out of the pairing. The index is a
725
+ // position within that key's own group, so pairing `кино#2` with `kino#2`
726
+ // would claim the second folder of one group is the second folder of the
727
+ // other, and nothing supports that. The base rows already make the
728
+ // band-level finding, so declining costs nothing.
729
+ for (const row of unqualified.filter((candidate) => hasCyrillic(candidate.name_key))) {
730
+ const where = albumOfArtist.get(row.id, row.id);
731
+ for (const latin of latinSpellingsOf(row.name_key)) {
732
+ const other = nonCyrillicByKey.get(latin);
733
+ if (other === undefined)
734
+ continue;
735
+ insertIssue.run(latestRun, STAGE, where?.root_id ?? null, where?.rel_path ?? null, 'artist-transliteration', 'warn', `${[row.name, other.name].sort().join(' / ')} — may be one name in two alphabets`);
736
+ counters.issues += 1;
737
+ }
738
+ }
739
+ counters.artists = db.prepare('SELECT COUNT(*) AS n FROM artist').get().n;
740
+ db.exec('COMMIT');
741
+ }
742
+ catch (err) {
743
+ try {
744
+ db.exec('ROLLBACK');
745
+ }
746
+ catch {
747
+ // Already unwound; the original error is what matters.
748
+ }
749
+ throw err;
750
+ }
751
+ return counters;
752
+ }