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,703 @@
1
+ import { closeSync, openSync, readFileSync, readSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { withTransaction } from "../db/index.js";
4
+ import { clearIssues } from "../db/issue.js";
5
+ import { ledgerEntry, moved } from "../db/ledger.js";
6
+ import { PROBE_METHOD, probeFile } from "../probe/ffprobe.js";
7
+ import { CERTAIN, CONFIDENT, decodeText } from "../text/encoding.js";
8
+ import { firstTagStatements } from "./first.js";
9
+ import { readTags, TAGS_METHOD } from "./read.js";
10
+ /**
11
+ * How many files one write transaction carries.
12
+ *
13
+ * **The number is the whole of what this stage promises a listener**, and it was
14
+ * chosen the way the scan chose its own (`scan.ts`): the lock is held for a
15
+ * batch and let go between them, so a save that loses the race for one batch
16
+ * gets in on the next. The budget is the 250 ms a save waits
17
+ * (`busy_timeout`, `db/index.ts`).
18
+ *
19
+ * Measured on a copy of the live collection before this existed, with the
20
+ * transaction around the whole pass: 3432 files held the lock for **113.0 s**
21
+ * and refused 284 of 311 saves ([[task:2929]], report `wiki:3647`). What that
22
+ * pass spends its time on is *reading* files, so the reads come out of the
23
+ * transaction altogether and this bounds only the writing that is left.
24
+ *
25
+ * Files and not rows, unlike the scan: what one file writes depends on what its
26
+ * tags say — a cover, two advisories, eight first-values — so there is no row
27
+ * count to name in advance.
28
+ *
29
+ * **The number is smaller than the budget wants, and the reason is measured.**
30
+ * A batch's window is not what this stage writes — instrumenting `withTransaction`
31
+ * through a full pass of 3432 files puts the work at **8–98 ms a batch** and the
32
+ * commit that follows it at **189–285 ms**. The commit is the window, it does not
33
+ * shrink with the batch, and on this deployment's volume it is already at the
34
+ * 250 ms a save waits. So there is no batch size that reaches the budget; what a
35
+ * batch size can do is decide whether the *work* is part of the problem.
36
+ *
37
+ * It was a hundred to begin with, which put the worst batch at **534 ms** across
38
+ * a full pass; thirty-two measures the same worst window (530) and the same
39
+ * refusals, because the commit is most of both. Thirty-two is chosen for the
40
+ * other volume: where a commit costs a millisecond or two, this batch's window
41
+ * is its work and nothing else, and the stage's promise does not depend on how
42
+ * slow the disk under it happens to be.
43
+ */
44
+ const FILES_PER_TRANSACTION = 32;
45
+ /** This stage's name in `issue.stage`. Bound to the insert and to the clear. */
46
+ const STAGE = 'tags';
47
+ /** `fLaC`, the four bytes a FLAC opens with. */
48
+ const FLAC_MARKER = [0x66, 0x4c, 0x61, 0x43];
49
+ /** A window of a file from disk, short reads filled in until the length is met. */
50
+ function readRangeFromDisk(absPath, at, length) {
51
+ const fd = openSync(absPath, 'r');
52
+ try {
53
+ const buffer = Buffer.alloc(length);
54
+ let filled = 0;
55
+ while (filled < length) {
56
+ const got = readSync(fd, buffer, filled, length - filled, at + filled);
57
+ if (got <= 0)
58
+ break;
59
+ filled += got;
60
+ }
61
+ return buffer.subarray(0, filled);
62
+ }
63
+ finally {
64
+ closeSync(fd);
65
+ }
66
+ }
67
+ /**
68
+ * The bytes `readTags` needs from a file, which for a FLAC is not all of them.
69
+ *
70
+ * Measured on this collection: its twenty largest FLACs come to 9.7 GB together,
71
+ * and reading them whole took 16.7 seconds against 7 milliseconds for the
72
+ * metadata chain alone — 1.4 MB read instead of 9706, a factor of about 6900.
73
+ * The scan reads every file it has not read before, so this is the difference
74
+ * between tens of gigabytes of I/O and about one.
75
+ *
76
+ * **Only FLAC is trimmed, and that is a decision rather than an oversight.** An
77
+ * MP3 keeps a second tag block at the *other* end of the file and measures its
78
+ * length against the buffer it was handed; an MP4's `moov` may sit after the
79
+ * audio. Handing either of them a prefix would not save I/O, it would change the
80
+ * answer. A FLAC's chain is a closed list that states where it ends, and its
81
+ * reader stops at that flag — which is what makes this safe for that format, and
82
+ * only for it.
83
+ *
84
+ * A chain that cannot be walked — a truncated file, a header promising more than
85
+ * the file holds — falls back to the whole read rather than to a partial one. The
86
+ * reader could make nothing of either, and the whole read is the answer this
87
+ * stage gave before.
88
+ */
89
+ function bytesForTags(absPath, readBytes, readRange) {
90
+ const head = readRange(absPath, 0, FLAC_MARKER.length);
91
+ if (head.length < FLAC_MARKER.length)
92
+ return readBytes(absPath);
93
+ if (FLAC_MARKER.some((byte, at) => head[at] !== byte))
94
+ return readBytes(absPath);
95
+ let at = FLAC_MARKER.length;
96
+ for (;;) {
97
+ const header = readRange(absPath, at, 4);
98
+ if (header.length < 4)
99
+ return readBytes(absPath);
100
+ const last = (header[0] & 0x80) !== 0;
101
+ const length = (header[1] << 16) | (header[2] << 8) | header[3];
102
+ at += 4 + length;
103
+ if (last)
104
+ return readRange(absPath, 0, at);
105
+ }
106
+ }
107
+ /**
108
+ * Read what each audio file says about itself, and write it down.
109
+ *
110
+ * Sits between `classify` and `applyCues`, for one reason: the classifier names
111
+ * an album from the folder and a cue overrides that name, so the tag has to
112
+ * arrive between the two to be third in line. Reading tags after the cue would
113
+ * leave a stage that can only ever agree with what is already written.
114
+ *
115
+ * This stage reads and stores; it decides nothing about what a title should be.
116
+ * The places that already own those decisions — `planAlbum` for track titles,
117
+ * `applyCues` for album titles, `applyArtists` for who the artist is — consume
118
+ * the rows this writes. Putting the priority here would mean a second copy of
119
+ * each rule, free to drift from the first.
120
+ *
121
+ * What is read is decided by the ledger, and this is the one place where the
122
+ * obvious rule is wrong. "Read a file whose bytes moved" is right and
123
+ * insufficient: a file nothing has ever read has not moved either, so the
124
+ * existing collection would never be read at all. "Or has no tag rows" looks
125
+ * like it fixes that and cannot, because a file with no tags is an ordinary
126
+ * untagged rip — it would be re-read on every scan forever with nothing to show
127
+ * for it. So the read is stamped in `file.tags_read_run_id`, NULL meaning never
128
+ * read, and both cases fall out of one condition.
129
+ *
130
+ * A file that cannot be opened is left unstamped on purpose: a permission error
131
+ * or a locked file is a transient, and the next scan should try again.
132
+ */
133
+ export function applyTags(db, deps = {}) {
134
+ // The trimming lives in the default, and that placement is the contract: a
135
+ // caller that supplies its own `readBytes` keeps deciding what the reader is
136
+ // handed. Every test that has no file on disk does exactly that, and reaching
137
+ // for one anyway is what this arrangement prevents.
138
+ const readBytes = deps.readBytes ??
139
+ ((absPath) => bytesForTags(absPath, () => readFileSync(absPath), deps.readRange ?? readRangeFromDisk));
140
+ // A sidecar is read whole. The audio path is trimmed to what a reader needs —
141
+ // a FLAC's metadata chain, a compressed frame's prefix — and a `.log` is not a
142
+ // container with a beginning worth finding: the document *is* the file.
143
+ const readWhole = deps.readBytes ?? ((absPath) => readFileSync(absPath));
144
+ const probe = deps.probe ?? ((absPath) => probeFile(absPath));
145
+ const counters = {
146
+ files: 0,
147
+ tags: 0,
148
+ encodings: 0,
149
+ durations: 0,
150
+ probed: 0,
151
+ probeFailures: 0,
152
+ years: 0,
153
+ sidecars: 0,
154
+ sidecarFailures: 0,
155
+ issues: 0,
156
+ };
157
+ const latestRun = db.prepare('SELECT MAX(id) AS id FROM scan_run').get().id;
158
+ // The left join is deliberate: a file the walk has not seen twice yet has no
159
+ // ledger row at all, and that is not a reason to skip it. Its `tags_read_run_id`
160
+ // being NULL is what brings it in, so the join only has to supply the verdict
161
+ // when there is one.
162
+ //
163
+ // `last_seen_run_id` is not a ledger matter but the run's own scope, and it is
164
+ // the one that says which files this run may read at all. What it means is
165
+ // that the run *stands behind* the file — the walk saw it, or the sweep's
166
+ // amnesty kept it as a path under a directory the walk could not enter, which
167
+ // `sweep.ts` stamps for exactly that reason. A file under a root this run did
168
+ // not visit is neither, and it is the case that matters: the walk never
169
+ // happened, so `changed` — a flag the walk sets — is whatever an older run
170
+ // left, the file reads as moved, and the stage opens it and reports on it
171
+ // (task:2751). What a run reports on is what it stands behind.
172
+ //
173
+ // The amnesty case is worth being plain about, since it is not "the walk saw
174
+ // it": a file beneath a directory the walk could not enter is still read, and
175
+ // still reported on, whenever it can be opened. That is deliberate — the
176
+ // library holds the file, its row survived the sweep, and the run stands
177
+ // behind the subtree it could not look into. Chosen rather than inherited, and
178
+ // measured rather than argued: the amnesty puts such a file in scope and does
179
+ // not decide the read — that is the ledger's `changed` — and where the read
180
+ // happens it is true in both states of the file, present and gone. Reading
181
+ // nothing there would suppress a true finding, not a false one
182
+ // (`concepts/last-seen-run-id`, task:2759).
183
+ // `tags_method` and `probe_method` are the next two reasons, and they are the
184
+ // ones that repair rather than maintain: a verdict written by a reading that
185
+ // is no longer taken is not an answer about the file, so the file is read
186
+ // again and a verdict is taken again. `tags_method` is this stage's own reader
187
+ // — the column says which reading stamped the file — and `probe_method` is
188
+ // the probe row's, which this stage is also the one to re-take. Either fires
189
+ // once per change of method: after a run everything is at the current number,
190
+ // and a file with no row at all is not due *for that reason*, which is what
191
+ // keeps this from re-reading the library on every scan forever.
192
+ const selectDue = db.prepare(`SELECT f.id AS id, f.rel_path AS rel_path, f.root_id AS root_id, r.path AS root_path,
193
+ f.ext AS ext, ss.changed AS changed
194
+ FROM file f
195
+ JOIN root r ON r.id = f.root_id
196
+ LEFT JOIN scan_state ss ON ${ledgerEntry('f', 'ss')}
197
+ LEFT JOIN audio_probe p ON p.file_id = f.id
198
+ WHERE f.kind = 'audio'
199
+ AND f.last_seen_run_id = ?
200
+ AND (f.tags_read_run_id IS NULL OR ${moved('ss')}
201
+ OR f.tags_method <> ?
202
+ OR (p.probe_method IS NOT NULL AND p.probe_method <> ?))
203
+ ORDER BY f.root_id, f.rel_path`);
204
+ // The same question asked of the record's documentation, and asked the same
205
+ // way on purpose: a sidecar is due when this run stands behind it and the
206
+ // ledger says it moved, which is exactly the rule the songs above follow. There
207
+ // is no probe arm — nothing measures a `.log` — and the kinds are the scan's.
208
+ const selectSidecarDue = db.prepare(`SELECT f.id AS id, f.rel_path AS rel_path, f.root_id AS root_id, r.path AS root_path
209
+ FROM file f
210
+ JOIN root r ON r.id = f.root_id
211
+ LEFT JOIN scan_state ss ON ${ledgerEntry('f', 'ss')}
212
+ WHERE f.kind IN ('log', 'nfo')
213
+ AND f.last_seen_run_id = ?
214
+ AND (f.tags_read_run_id IS NULL OR ${moved('ss')} OR f.tags_method <> ?)
215
+ ORDER BY f.root_id, f.rel_path`);
216
+ const stampSidecar = db.prepare(`UPDATE file
217
+ SET encoding = ?, encoding_confidence = ?, tags_read_run_id = ?, tags_method = ?
218
+ WHERE id = ?`);
219
+ const upsertSidecar = db.prepare(`INSERT INTO sidecar_text (file_id, text) VALUES (?, ?)
220
+ ON CONFLICT (file_id) DO UPDATE SET text = excluded.text`);
221
+ const deleteTags = db.prepare('DELETE FROM file_tag WHERE file_id = ?');
222
+ // Re-derived with the tags and for the same reason: a picture taken out of the
223
+ // file has to leave the meta layer, and only a delete can say that.
224
+ const deleteCover = db.prepare('DELETE FROM cover_art WHERE file_id = ?');
225
+ const insertCover = db.prepare('INSERT INTO cover_art (file_id, mime, picture_type, offset, length, kind) VALUES (?, ?, ?, ?, ?, ?)');
226
+ const insertTag = db.prepare('INSERT INTO file_tag (file_id, name, value, position) VALUES (?, ?, ?, ?)');
227
+ // The same tags again, in the shape a listing reads them — one row a file,
228
+ // written here so that it cannot drift from the rows it is derived from. See
229
+ // `first.ts` for why the listing does not read `file_tag` itself.
230
+ const firstTags = firstTagStatements(db);
231
+ const stampRead = db.prepare(`UPDATE file
232
+ SET encoding = ?, encoding_confidence = ?, tags_container = ?, tags_read_run_id = ?,
233
+ tags_method = ?
234
+ WHERE id = ?`);
235
+ /** What a failed read owes the next run: the file back in the due list. */
236
+ const clearStamp = db.prepare('UPDATE file SET tags_read_run_id = NULL WHERE id = ?');
237
+ // Seeded, not asserted — with one exception, and the exception is the whole
238
+ // reason for the `WHERE`. A duration from a header is exactly as good as one
239
+ // from a decoder for the same file, so a probe that already succeeded is not
240
+ // this stage's to overwrite *while the bytes are the ones it measured* — nor
241
+ // while it is a reading of the kind this code takes. `probe_method` is that
242
+ // second half: a row written by an older method is not an answer about the
243
+ // file, which is how the `codec` column came to hold container names for two
244
+ // thousand files without anything saying so.
245
+ const seedProbe = db.prepare(`INSERT INTO audio_probe (file_id, duration_ms, codec, sample_rate, channels, bitrate, probe_ok, probe_err, duration_source, probe_method)
246
+ VALUES (?, ?, ?, ?, ?, NULL, 1, NULL, 'container', ?)
247
+ ON CONFLICT (file_id) DO UPDATE SET
248
+ duration_ms = excluded.duration_ms,
249
+ codec = excluded.codec,
250
+ sample_rate = excluded.sample_rate,
251
+ channels = excluded.channels,
252
+ probe_ok = 1,
253
+ probe_err = NULL,
254
+ duration_source = excluded.duration_source,
255
+ probe_method = excluded.probe_method
256
+ WHERE audio_probe.probe_ok = 0 OR ? = 1 OR audio_probe.probe_method <> ?`);
257
+ // A row left over from bytes that have been replaced is not an answer about
258
+ // this file, and there is nothing here to put in its place — see the loop.
259
+ const deleteProbe = db.prepare('DELETE FROM audio_probe WHERE file_id = ?');
260
+ // The probe's answer replaces whatever is there, failure included. It was
261
+ // asked because nothing else here could answer, so it is the only word on the
262
+ // file — unlike `seedProbe`, which defers to a probe that already succeeded.
263
+ 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)
264
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
265
+ ON CONFLICT (file_id) DO UPDATE SET
266
+ duration_ms = excluded.duration_ms,
267
+ codec = excluded.codec,
268
+ sample_rate = excluded.sample_rate,
269
+ channels = excluded.channels,
270
+ bitrate = excluded.bitrate,
271
+ probe_ok = excluded.probe_ok,
272
+ probe_err = excluded.probe_err,
273
+ duration_source = excluded.duration_source,
274
+ probe_method = excluded.probe_method`);
275
+ // Cleared per file rather than per stage, in the loop below. This stage reads
276
+ // only the files the ledger reports as changed or never read, so a row it
277
+ // files describes the last attempt on *that* file — and clearing the stage
278
+ // wholesale would drop findings for every file this run did not open, which
279
+ // is the reported silence `issue` exists to prevent. Per run it does grow:
280
+ // a file that cannot be opened is left unstamped on purpose so the next scan
281
+ // retries it, and it is reported again each time. `db/issue.ts` has the rule.
282
+ const insertIssue = db.prepare(`INSERT INTO issue (scan_run_id, stage, root_id, rel_path, kind, severity, detail)
283
+ VALUES (?, ?, ?, ?, ?, ?, ?)`);
284
+ const due = selectDue.all(latestRun, TAGS_METHOD, PROBE_METHOD);
285
+ // ── Reading, with nothing open ────────────────────────────────────────────
286
+ //
287
+ // **The reads are what this stage spends its time on, and they need no lock.**
288
+ // Measured by the review umbrella ([[task:2928]], report `wiki:3647`): with the
289
+ // transaction wrapped around the whole pass, all 3432 due files held the write
290
+ // lock for **113.0 s**, and a listener's `savePlayQueue` arriving in that window
291
+ // was refused **284 times out of 311** — a `ping` p90 of 349.81 ms against a
292
+ // floor of 0.78. Reading a file off this disk is most of that time, and probing
293
+ // a file that refused to be measured is a *process*: neither touches the
294
+ // database, and both were inside the transaction anyway.
295
+ //
296
+ // So they come out, and the writing that follows happens a batch at a time.
297
+ // What is held between the two is every due file's parse — a tag list and a
298
+ // picture's offset, small — bounded by the collection rather than by the run.
299
+ //
300
+ // A file that could not be opened is carried as the error rather than thrown,
301
+ // because the writing pass is where that has to be said: the stamp comes off
302
+ // and the finding is filed, and both are writes.
303
+ const parsed = new Map();
304
+ const answered = new Map();
305
+ for (const file of due) {
306
+ let read;
307
+ try {
308
+ read = readTags(readBytes(join(file.root_path, file.rel_path)));
309
+ }
310
+ catch (err) {
311
+ parsed.set(file.id, err);
312
+ continue;
313
+ }
314
+ parsed.set(file.id, read);
315
+ // The only thing in this stage worth a process, and it is one spawn per file
316
+ // that refused and none for the rest — but a spawn inside the write
317
+ // transaction charges a process's whole lifetime to the lock.
318
+ //
319
+ // **Outside the `catch` above, and that is not tidiness.** A reader that
320
+ // cannot open a file is a fact about *the file*; a probe that throws is a
321
+ // fault in this stage. Wrapped together, the second was filed as the first —
322
+ // the run said `tag-unreadable` and carried on with every other file, where
323
+ // the stage it replaced raised and took the run down with it. Found by
324
+ // `test/run.test.ts` on the first run of this shape.
325
+ if (read.durationRefused)
326
+ answered.set(file.id, probe(join(file.root_path, file.rel_path)));
327
+ }
328
+ // ── Writing, one batch of files to a transaction ──────────────────────────
329
+ //
330
+ // A batch rather than the run, which is the whole of what this shape buys: the
331
+ // lock is held for a batch, so a save waiting on it gets in between them. The
332
+ // budget is the 250 ms `busy_timeout` a listener's save waits (`db/index.ts`),
333
+ // and the sibling stage that solved this first is the scan — measured there, a
334
+ // batch of five hundred rows holds the lock **12 ms at the median and 96 ms at
335
+ // its worst** (`scan.ts`, `task:2871`).
336
+ //
337
+ // A batch that throws unwinds only itself: what came before it stays, the run
338
+ // is marked `failed` with its reason filed, and every file it did not reach is
339
+ // still `due` — the recovery this project's stages already promise (`run.ts`),
340
+ // because a rerun reaches the state a clean run would.
341
+ for (let at = 0; at < due.length; at += FILES_PER_TRANSACTION) {
342
+ const batch = due.slice(at, at + FILES_PER_TRANSACTION);
343
+ withTransaction(db, () => {
344
+ for (const file of batch) {
345
+ // This file is about to be read again, so its previous report describes an
346
+ // attempt that has been superseded — including the aggregate row written
347
+ // for it at the end of the loop, which carries the same path.
348
+ clearIssues(db, STAGE, { rootId: file.root_id, relPath: file.rel_path });
349
+ const read = parsed.get(file.id);
350
+ if (read === undefined || read instanceof Error) {
351
+ // A file that could not be opened is left unstamped, so the next scan
352
+ // tries again — that is the promise `readBytes` makes above, and the
353
+ // stamp is what has to come off for it to hold. An earlier successful
354
+ // read left one, and the walk had already recorded the bytes it found
355
+ // before this read was attempted, so `changed` settled to 0: a file read
356
+ // once and then locked was never read again. Its old tags stayed in the
357
+ // dump as current, and this row stayed forever — by then untrue
358
+ // (task:2751, round 4).
359
+ //
360
+ // The reading pass carried the failure here rather than throwing it,
361
+ // because both halves of what it owes are writes.
362
+ clearStamp.run(file.id);
363
+ insertIssue.run(latestRun, STAGE, file.root_id, file.rel_path, 'tag-unreadable', 'warn', read instanceof Error ? read.message : 'the file was not read');
364
+ counters.issues += 1;
365
+ continue;
366
+ }
367
+ counters.files += 1;
368
+ // The reader dispatches on the bytes, so `container === null` means it
369
+ // recognised nothing at all — not "a format with no tags in it", which
370
+ // is a FLAC with an empty Vorbis block and is perfectly understood. The
371
+ // difference matters: the first is a gap in this project, the second is a
372
+ // fact about the rip, and only the first is worth saying out loud.
373
+ // The scan called this file audio from its extension, and the bytes say
374
+ // otherwise: a picture track is a video whatever it is named. Said out
375
+ // loud rather than passed over, because nothing else in the meta layer
376
+ // records it — a track is made from the file either way, so it costs no
377
+ // counter and leaves no gap in `unaccounted` (task:2727).
378
+ if (read.video === true) {
379
+ insertIssue.run(latestRun, STAGE, file.root_id, file.rel_path, 'tag-video-as-audio', 'warn', 'the file holds a picture track — it is a video the scan called audio from its extension; no tags and no length were read from it');
380
+ counters.issues += 1;
381
+ }
382
+ // Re-derived every time rather than added to: a tag taken out of the file
383
+ // has to leave the meta layer, and only a delete can say that.
384
+ deleteTags.run(file.id);
385
+ deleteCover.run(file.id);
386
+ // The cover the file carries, as the place its bytes are — see
387
+ // `db/migrations/013_cover_art.sql` on why it is a place and not a copy.
388
+ if (read.picture !== undefined) {
389
+ insertCover.run(file.id, read.picture.mime, read.picture.kind, read.picture.offset, read.picture.length,
390
+ // Whether those two numbers are the picture itself or a region that
391
+ // holds it — see migration 017 and `TagPicture.indirect`.
392
+ read.picture.indirect === true ? 'indirect' : 'image');
393
+ }
394
+ // Position restarts per name, because a name is what repeats: two ARTIST
395
+ // values are the first and second artist, not an artist and a title.
396
+ const seen = new Map();
397
+ for (const tag of read.tags) {
398
+ const position = seen.get(tag.name) ?? 0;
399
+ seen.set(tag.name, position + 1);
400
+ insertTag.run(file.id, tag.name, tag.value, position);
401
+ counters.tags += 1;
402
+ }
403
+ // The older tag block, for the names the newer one did not state at all.
404
+ //
405
+ // By *name*, and that is the whole of the priority rule: the v2 block is
406
+ // what its writer maintained, so where both speak the v2 value is the
407
+ // answer — and where only the v1 block speaks, its value is the only
408
+ // statement of that fact in the file. What is deliberately not done is
409
+ // storing both. Position would then read as "two artists", and this
410
+ // project turns two artists into a collaboration — so an mp3 whose blocks
411
+ // disagree would be filed under a credit nobody recorded, or, with no
412
+ // `albumartist` to settle it, under none at all.
413
+ const newerNames = new Set(read.tags.map((tag) => tag.name));
414
+ for (const tag of read.fallbackTags ?? []) {
415
+ if (newerNames.has(tag.name))
416
+ continue;
417
+ const position = seen.get(tag.name) ?? 0;
418
+ seen.set(tag.name, position + 1);
419
+ insertTag.run(file.id, tag.name, tag.value, position);
420
+ counters.tags += 1;
421
+ }
422
+ // And the eight a listing shows, as one row, now that the rows they come
423
+ // from are in place. Here rather than in a stage of its own because it is
424
+ // not a fact about the file that could be read without it: it *is* these
425
+ // rows, and a second pass over them would be a second chance to disagree.
426
+ firstTags.clear.run(file.id);
427
+ firstTags.write.run(file.id);
428
+ const encoding = read.encoding;
429
+ stampRead.run(encoding?.encoding ?? null, encoding?.confidence ?? null,
430
+ // Whether the reader recognised the container at all, kept because the
431
+ // aggregate that reports it is counted over the collection and cannot
432
+ // depend on this run having opened the file. See migration 012.
433
+ read.container, latestRun, TAGS_METHOD, file.id);
434
+ // The reader normalises to text either way; when it had to *infer* the
435
+ // source encoding, that inference is the finding. Stored on the file and
436
+ // reported, like a cue's — a title that decodes to plausible mojibake is
437
+ // exactly what the contract forbids losing silently.
438
+ // A field the tag stated and the reader would not use — an ID3v1 year
439
+ // that is not four digits, a genre byte the list does not name. Only the
440
+ // reader knows this happened, and reading a value in order to throw it
441
+ // away is precisely the silence the contract forbids. `info` rather than
442
+ // `warn`: the file is not damaged and there is nothing to go and do, but
443
+ // the dump should not read as if the field had been understood.
444
+ if (read.refusals !== undefined) {
445
+ insertIssue.run(latestRun, STAGE, file.root_id, file.rel_path, 'tag-field-refused', 'info', read.refusals.join('; '));
446
+ counters.issues += 1;
447
+ }
448
+ if (encoding !== null && encoding.confidence < CERTAIN) {
449
+ counters.encodings += 1;
450
+ insertIssue.run(latestRun, STAGE, file.root_id, file.rel_path, 'tag-encoding-guessed', encoding.confidence >= CONFIDENT ? 'info' : 'warn', encoding.basis);
451
+ counters.issues += 1;
452
+ }
453
+ // Do the bytes just read belong to the row already in `audio_probe`? The
454
+ // ledger is what knows: `changed` is the walk's own comparison of size and
455
+ // mtime against the previous observation, so a file it reports as moved —
456
+ // or one it has never observed twice, which has no verdict at all — is not
457
+ // the file any stored measurement was taken from.
458
+ const stale = file.changed !== 0;
459
+ if (read.durationMs !== null) {
460
+ seedProbe.run(file.id, read.durationMs, read.codec, read.sampleRate, read.channels, PROBE_METHOD, stale ? 1 : 0, PROBE_METHOD);
461
+ counters.durations += 1;
462
+ }
463
+ else if (stale && !read.durationRefused) {
464
+ // The reader understood the bytes and produced no length, and nothing
465
+ // below is going to either — a refused walk asks ffprobe, this does not.
466
+ // So whatever row is stored describes bytes that are gone, and the cue
467
+ // stage reads `probe_ok = 1` as "there is a length here". There is not
468
+ // one any more, and the honest answer is that this stage has none.
469
+ deleteProbe.run(file.id);
470
+ }
471
+ // The reader understood the bytes and still could not measure them: the
472
+ // frame walk lost the stream with most of the file ahead of it. Nothing
473
+ // here can recover that, and the number it did produce is short by
474
+ // however much it skipped — so instead of storing that, this asks a
475
+ // reader that can. It is the only thing in this stage worth a process,
476
+ // and it is one spawn per file that refused and none for the rest.
477
+ //
478
+ // ffprobe is not required to be installed, and a probe that cannot run
479
+ // is not an exception. It is the second failure of two, which is exactly
480
+ // the loss the contract forbids leaving unsaid.
481
+ if (read.durationRefused) {
482
+ // Asked in the reading pass, where a process costs no lock.
483
+ const answer = answered.get(file.id);
484
+ if (answer === undefined) {
485
+ // Unreachable — the pass above probes every file whose walk refused
486
+ // and this is one — and said out loud rather than asserted away,
487
+ // because a silent `undefined` here reads as "ffprobe could not
488
+ // measure it", which is the opposite of what happened.
489
+ throw new Error(`no probe answer was taken for ${file.rel_path}`);
490
+ }
491
+ counters.probed += 1;
492
+ if (!answer.ok)
493
+ counters.probeFailures += 1;
494
+ upsertProbe.run(file.id, answer.durationMs, answer.codec, answer.sampleRate, answer.channels, answer.bitrate, answer.ok ? 1 : 0, answer.err, 'ffprobe', PROBE_METHOD);
495
+ if (answer.ok) {
496
+ counters.durations += 1;
497
+ // Kept, and named. ffprobe answered where this stage could not, which
498
+ // for an mp3 means one stating no frame count — so ffprobe had none
499
+ // to read and derived the length from the size and the bitrate. The
500
+ // number is worth having and is not a measurement, and this is the
501
+ // only place that knows which. `info`, not `warn`: the collection is
502
+ // not damaged by it and nothing needs doing.
503
+ insertIssue.run(latestRun, STAGE, file.root_id, file.rel_path, 'tag-duration-estimated', 'info', 'the frame walk lost the stream; the length kept is the one ffprobe worked out from the file size and bitrate, not one the file states');
504
+ counters.issues += 1;
505
+ }
506
+ else {
507
+ insertIssue.run(latestRun, STAGE, file.root_id, file.rel_path, 'tag-duration-refused', 'warn', `the frame walk lost the stream, and ffprobe could not measure it either: ${answer.err ?? 'no reason given'}`);
508
+ counters.issues += 1;
509
+ }
510
+ }
511
+ }
512
+ });
513
+ }
514
+ // The record's documentation: an `.nfo`, an EAC `.log`.
515
+ //
516
+ // These are read for the same reason a song is — the meta layer should not
517
+ // lose what a file says — and they are read *here* because this is the stage
518
+ // that opens bytes and keeps an account of what it made of them: the
519
+ // encoding, the confidence in it, and a row of its own for a document it
520
+ // could not open. A sidecar whose bytes are never looked at is a hole in that
521
+ // account, and there were 417 of them: `file.encoding` was NULL for every
522
+ // one, while 41 FLAC rips in the same library carry their `.log` and `.cue`
523
+ // as tags *inside* the file. The container was deciding whether the record's
524
+ // documentation existed at all (task:2757).
525
+ //
526
+ // What is *not* done with the text is worth saying, because it was tried and
527
+ // withdrawn: it is not a tracklist (`task:2711` — that was a way around a
528
+ // broken ID3v2.2 reader, and the tags were there all along). Nothing here
529
+ // derives a name, a year or a credit from a log. The text goes to the schema
530
+ // and the refusals reach the dump; a consumer that wants more than that
531
+ // should say so as a task of its own.
532
+ //
533
+ // Read outside the transaction like the songs, and it is the larger of the two
534
+ // reads: a sidecar is a whole document, up to fifteen kilobytes of it, where a
535
+ // song is a metadata chain.
536
+ const sidecars = selectSidecarDue.all(latestRun, TAGS_METHOD);
537
+ const readSidecars = new Map();
538
+ for (const sidecar of sidecars) {
539
+ try {
540
+ readSidecars.set(sidecar.id, decodeText(readWhole(join(sidecar.root_path, sidecar.rel_path))));
541
+ }
542
+ catch (err) {
543
+ readSidecars.set(sidecar.id, err);
544
+ }
545
+ }
546
+ // Written the same way, and for the same reason.
547
+ for (let at = 0; at < sidecars.length; at += FILES_PER_TRANSACTION) {
548
+ const batch = sidecars.slice(at, at + FILES_PER_TRANSACTION);
549
+ withTransaction(db, () => {
550
+ for (const sidecar of batch) {
551
+ clearIssues(db, STAGE, { rootId: sidecar.root_id, relPath: sidecar.rel_path });
552
+ const decoded = readSidecars.get(sidecar.id);
553
+ if (decoded === undefined || decoded instanceof Error) {
554
+ // Unstamped, like a song that could not be opened, and for the same
555
+ // reason: the next run should try again rather than file the failure as
556
+ // the answer.
557
+ clearStamp.run(sidecar.id);
558
+ insertIssue.run(latestRun, STAGE, sidecar.root_id, sidecar.rel_path, 'sidecar-unreadable', 'warn', decoded instanceof Error ? decoded.message : 'the sidecar was not read');
559
+ counters.sidecarFailures += 1;
560
+ continue;
561
+ }
562
+ stampSidecar.run(decoded.encoding, decoded.confidence, latestRun, TAGS_METHOD, sidecar.id);
563
+ upsertSidecar.run(sidecar.id, decoded.text);
564
+ counters.sidecars += 1;
565
+ // A document that declared nothing and had to be guessed at is a finding
566
+ // about the file, exactly as it is for a tag — and the value is kept
567
+ // either way, so what is reported is the uncertainty and not a loss.
568
+ if (decoded.confidence < 1) {
569
+ insertIssue.run(latestRun, STAGE, sidecar.root_id, sidecar.rel_path, 'sidecar-encoding-guessed', 'info', decoded.basis ?? `${decoded.encoding} was inferred`);
570
+ counters.encodings += 1;
571
+ }
572
+ }
573
+ });
574
+ }
575
+ // ── What the stage says about the collection as a whole ──────────────────
576
+ //
577
+ // In a transaction of its own, and after every file: the year below is read
578
+ // off `file_tag`, so it can only be worked out once the tags are written.
579
+ withTransaction(db, () => {
580
+ // The year, for a record whose folder name did not state one.
581
+ //
582
+ // A folder is what a collector writes, so where it names a year that is the
583
+ // year; where it does not, the files' own DATE is the only thing that says
584
+ // anything, and until v1.5 brings an external source it is the best answer
585
+ // there is. Counted over the whole collection rather than over the files
586
+ // this run opened, like the format count below and for the same reason: the
587
+ // rows are read off `file_tag`, which a previous run wrote, and a number
588
+ // that depended on which files this run happened to open would change
589
+ // without anything changing.
590
+ //
591
+ // The four digits are checked rather than taken: a DATE is `1988`, or
592
+ // `1988-05-06`, or a timestamp, and a value that does not open with a year
593
+ // is not one — `substr` of something else would file a record under a year
594
+ // that appears nowhere in it.
595
+ // The relation is the record's own, and it has two arms because a record is
596
+ // keyed on two different things.
597
+ //
598
+ // An ordinary album *is* a folder, and the files whose tags say when it came
599
+ // out are the ones in it. But a disc of a flat multi-disc release is keyed
600
+ // on its **image** — `…/01. On The Beach.mp3` is that row's `rel_path`,
601
+ // because the folder holds several discs and belongs to none of them — so
602
+ // for that row the file *is* the record and its own path names it. With the
603
+ // folder arm alone that row matched nothing: two ASOT records held no year
604
+ // while every file in them stated `2025-03-28`.
605
+ //
606
+ // It cannot go through `track`, which is what the obvious version of this
607
+ // query would do — tracks are made by the cue stage, which runs after this
608
+ // one, so at this point in the chain there is nothing to join through.
609
+ //
610
+ // The second arm costs the first nothing: no file's path equals the path of
611
+ // the folder holding it, so a folder-keyed record can never match it.
612
+ const dated = db
613
+ .prepare(`UPDATE album
614
+ SET year = (SELECT CAST(substr(ft.value, 1, 4) AS INTEGER)
615
+ FROM file f JOIN file_tag ft ON ft.file_id = f.id
616
+ WHERE f.root_id = album.root_id
617
+ AND (f.folder_rel_path = album.rel_path OR f.rel_path = album.rel_path)
618
+ AND ft.name IN ('date', 'year')
619
+ AND substr(ft.value, 1, 4) GLOB '[12][0-9][0-9][0-9]'
620
+ ORDER BY f.rel_path, ft.name
621
+ LIMIT 1),
622
+ year_source = 'tag'
623
+ WHERE year IS NULL
624
+ AND EXISTS (SELECT 1
625
+ FROM file f JOIN file_tag ft ON ft.file_id = f.id
626
+ WHERE f.root_id = album.root_id
627
+ AND (f.folder_rel_path = album.rel_path OR f.rel_path = album.rel_path)
628
+ AND ft.name IN ('date', 'year')
629
+ AND substr(ft.value, 1, 4) GLOB '[12][0-9][0-9][0-9]')`)
630
+ .run();
631
+ counters.years = Number(dated.changes);
632
+ // Formats nothing here can read, counted per root and extension. A `m4a` is
633
+ // a third of some collections and yields no tags and no duration; that is
634
+ // information about the collection rather than a defect in one file, and it
635
+ // is stated once per extension so that 309 files are a finding and not 309
636
+ // findings. Keyed by root too, because an issue with no root cannot be found
637
+ // in the dump.
638
+ //
639
+ // Counted over the whole collection rather than over the files this run
640
+ // opened, so that it is the same number on any run: what is on disk is what
641
+ // makes it true, and reading a file is not. That is why the reader's verdict
642
+ // is kept on the file (`tags_container`, migration 012) — a verdict that
643
+ // lives only in the run dies with it, and the count then became "one file",
644
+ // the one this run happened to open, beside an older row saying something
645
+ // else (task:2706). Replaced rather than added to, like the row it is.
646
+ clearIssues(db, STAGE, { kind: 'tag-format-unknown' });
647
+ const unknownFormats = db
648
+ .prepare(`SELECT root_id AS root_id, ext AS ext, COUNT(*) AS n, MIN(rel_path) AS rel_path
649
+ FROM file
650
+ WHERE kind = 'audio' AND tags_read_run_id IS NOT NULL AND tags_container IS NULL
651
+ GROUP BY root_id, ext
652
+ ORDER BY root_id, ext`)
653
+ .all();
654
+ for (const format of unknownFormats) {
655
+ insertIssue.run(latestRun, STAGE, format.root_id, format.rel_path, 'tag-format-unknown', 'info', `${format.n} .${format.ext} file(s) the reader does not recognise: no tags, no duration`);
656
+ counters.issues += 1;
657
+ }
658
+ // The rows this stage stands behind are not only the ones it wrote this run.
659
+ // A file the ledger did not report changed is never re-read, and its report
660
+ // describes the last read — which is still true, and which the dump has to
661
+ // see. Stamping them as this run's is what says so: `inventory` reads the
662
+ // run it describes, and a row left on an earlier run's id is a row the
663
+ // reader is never shown. Re-reading the file to say nothing new is the other
664
+ // way to close that gap, and it is the one incrementality exists to avoid
665
+ // (task:2726).
666
+ //
667
+ // What may be claimed that way is not a matter of taste: the stamp says the
668
+ // run stands behind the file — `file.last_seen_run_id`, the same stamp the
669
+ // sweep judges rows by, and the meaning of it is spelled out at the query
670
+ // above. So a row moves only for a file this run stands behind. A file that
671
+ // is gone, and a file of a root this run did not visit, are neither, and a
672
+ // diagnosis of one has no business in its report (task:2751). Unguarded, the
673
+ // row rode into every later run: the dump named a path that was in neither
674
+ // the walk nor `file`, while `unaccounted` read 0/0/0 beside it.
675
+ //
676
+ // A row about a file that is gone is left where it is rather than deleted.
677
+ // Nothing reads an old run, the file may come back, and the sweep is not
678
+ // this stage's to extend.
679
+ //
680
+ // Only rows that describe a file, which is the whole of what this stage
681
+ // derives. A row about the stage itself — the `tags-failed` `run.ts` writes
682
+ // — carries no path, is an event rather than a derivation, and is scoped to
683
+ // the attempt that made it on purpose: a later run that succeeds is what
684
+ // supersedes it, and restamping would keep it in the report forever.
685
+ //
686
+ // The count goes with the stamp: the report totals what the stages say they
687
+ // filed, the dump totals the rows of the run, and a number that moves on one
688
+ // side only is the same lie in a smaller place. Rows already carrying this
689
+ // run's id are left out of the statement so they are not counted twice.
690
+ const restamped = db
691
+ .prepare(`UPDATE issue SET scan_run_id = ?
692
+ WHERE stage = ? AND rel_path IS NOT NULL AND scan_run_id <> ?
693
+ AND EXISTS (
694
+ SELECT 1 FROM file f
695
+ WHERE f.root_id = issue.root_id
696
+ AND f.rel_path = issue.rel_path
697
+ AND f.last_seen_run_id = ?
698
+ )`)
699
+ .run(latestRun, STAGE, latestRun, latestRun);
700
+ counters.issues += Number(restamped.changes);
701
+ });
702
+ return counters;
703
+ }