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.
- package/.env.example +90 -0
- package/CHANGELOG.md +104 -0
- package/DEPLOY.md +375 -0
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/deploy/answers.schema.json +151 -0
- package/deploy/build.mjs +88 -0
- package/deploy/check-publishable.mjs +246 -0
- package/deploy/smoke.mjs +681 -0
- package/deploy/systemd/funoteka.env.example +41 -0
- package/deploy/systemd/funoteka.service +77 -0
- package/deploy/windows/funoteka-service.xml +58 -0
- package/deploy/windows/install-service.ps1 +158 -0
- package/dist/annotation/store.js +67 -0
- package/dist/api/admin-export.js +227 -0
- package/dist/api/admin-guard.js +181 -0
- package/dist/api/admin-library.js +113 -0
- package/dist/api/admin.js +1162 -0
- package/dist/api/annotation.js +193 -0
- package/dist/api/artistinfo.js +140 -0
- package/dist/api/audit.js +32 -0
- package/dist/api/auth.js +145 -0
- package/dist/api/bookmark.js +108 -0
- package/dist/api/browse.js +1241 -0
- package/dist/api/config-file.js +202 -0
- package/dist/api/config.js +285 -0
- package/dist/api/cover.js +304 -0
- package/dist/api/download.js +112 -0
- package/dist/api/envelope.js +162 -0
- package/dist/api/extensions.js +43 -0
- package/dist/api/genre.js +81 -0
- package/dist/api/health.js +35 -0
- package/dist/api/history.js +374 -0
- package/dist/api/idempotency.js +66 -0
- package/dist/api/keys.js +90 -0
- package/dist/api/log-file.js +65 -0
- package/dist/api/meta.js +1410 -0
- package/dist/api/playlist.js +311 -0
- package/dist/api/rescan.js +196 -0
- package/dist/api/roots.js +161 -0
- package/dist/api/router.js +133 -0
- package/dist/api/scanner.js +163 -0
- package/dist/api/search.js +82 -0
- package/dist/api/server.js +643 -0
- package/dist/api/settings.js +67 -0
- package/dist/api/stream.js +442 -0
- package/dist/api/stubs.js +183 -0
- package/dist/api/transcode.js +678 -0
- package/dist/api/user.js +73 -0
- package/dist/api/virtual.js +362 -0
- package/dist/api/visibility.js +21 -0
- package/dist/artist/apply.js +752 -0
- package/dist/artist/credit.js +95 -0
- package/dist/artist/folder.js +65 -0
- package/dist/artist/name.js +205 -0
- package/dist/artist/nfo.js +101 -0
- package/dist/artist/translit.js +79 -0
- package/dist/bookmark/store.js +54 -0
- package/dist/classify/classify.js +405 -0
- package/dist/classify/collision-name.js +109 -0
- package/dist/classify/folder-name.js +466 -0
- package/dist/classify/roles.js +277 -0
- package/dist/classify/shelf-name.js +178 -0
- package/dist/classify/tree.js +43 -0
- package/dist/cli/args.js +169 -0
- package/dist/cli/daemon.js +192 -0
- package/dist/cli/entry.js +25 -0
- package/dist/cli/keys.js +52 -0
- package/dist/cli.js +638 -0
- package/dist/cover/pick.js +58 -0
- package/dist/cover/picture.js +111 -0
- package/dist/cue/engine.js +759 -0
- package/dist/cue/match.js +142 -0
- package/dist/cue/parse.js +182 -0
- package/dist/cue/plan.js +286 -0
- package/dist/cue/track-name.js +120 -0
- package/dist/db/index.js +163 -0
- package/dist/db/issue.js +73 -0
- package/dist/db/ledger.js +84 -0
- package/dist/db/migrations/001_init.sql +196 -0
- package/dist/db/migrations/002_cue_track_flexible.sql +35 -0
- package/dist/db/migrations/003_cue_metadata.sql +14 -0
- package/dist/db/migrations/004_cue_performer.sql +12 -0
- package/dist/db/migrations/005_incremental.sql +23 -0
- package/dist/db/migrations/006_tags.sql +57 -0
- package/dist/db/migrations/007_artist_credit.sql +35 -0
- package/dist/db/migrations/008_issue_stage.sql +39 -0
- package/dist/db/migrations/009_cue_title.sql +20 -0
- package/dist/db/migrations/010_duration_source.sql +31 -0
- package/dist/db/migrations/011_track_title_source.sql +28 -0
- package/dist/db/migrations/012_file_container.sql +25 -0
- package/dist/db/migrations/013_cover_art.sql +28 -0
- package/dist/db/migrations/014_album_year.sql +17 -0
- package/dist/db/migrations/015_probe_method.sql +14 -0
- package/dist/db/migrations/016_tags_method.sql +20 -0
- package/dist/db/migrations/017_cover_indirect.sql +23 -0
- package/dist/db/migrations/018_release_year.sql +14 -0
- package/dist/db/migrations/019_file_tag_name_file_value.sql +20 -0
- package/dist/db/migrations/020_analyze_after_index.sql +16 -0
- package/dist/db/migrations/021_cue_index_files.sql +15 -0
- package/dist/db/migrations/022_sidecar_text.sql +19 -0
- package/dist/db/migrations/023_track_credit.sql +23 -0
- package/dist/db/migrations/024_playlists.sql +63 -0
- package/dist/db/migrations/025_playlist_sequence.sql +27 -0
- package/dist/db/migrations/026_playlist_source_file.sql +19 -0
- package/dist/db/migrations/027_lookup_indexes.sql +30 -0
- package/dist/db/migrations/028_annotations.sql +47 -0
- package/dist/db/migrations/029_scan_state_root_run.sql +28 -0
- package/dist/db/migrations/030_history.sql +108 -0
- package/dist/db/migrations/031_queue_position_and_plays.sql +46 -0
- package/dist/db/migrations/032_bookmarks.sql +27 -0
- package/dist/db/migrations/033_bookmark_order.sql +14 -0
- package/dist/db/migrations/034_junk.sql +33 -0
- package/dist/db/migrations/035_api_key.sql +38 -0
- package/dist/db/migrations/036_file_tag_first.sql +88 -0
- package/dist/db/migrations/037_admin_idempotency.sql +31 -0
- package/dist/db/migrations/038_cue_audio_index.sql +24 -0
- package/dist/db/sweep.js +109 -0
- package/dist/history/store.js +151 -0
- package/dist/inventory/inventory.js +294 -0
- package/dist/junk/marks.js +143 -0
- package/dist/junk/rule.js +93 -0
- package/dist/mcp/client.js +48 -0
- package/dist/mcp/server.js +175 -0
- package/dist/mcp/tools.js +348 -0
- package/dist/playlist/files.js +116 -0
- package/dist/playlist/import.js +260 -0
- package/dist/playlist/store.js +240 -0
- package/dist/probe/ffprobe.js +179 -0
- package/dist/run.js +120 -0
- package/dist/scan/kinds.js +49 -0
- package/dist/scan/scan.js +503 -0
- package/dist/scan/schedule.js +132 -0
- package/dist/scan/settle.js +172 -0
- package/dist/scan/walk.js +132 -0
- package/dist/search/index.js +41 -0
- package/dist/search/query.js +28 -0
- package/dist/stream/flac.js +541 -0
- package/dist/stream/mpeg.js +179 -0
- package/dist/stream/recode.js +393 -0
- package/dist/stream/rewrite.js +163 -0
- package/dist/stream/segment.js +624 -0
- package/dist/tags/apply.js +703 -0
- package/dist/tags/encode.js +164 -0
- package/dist/tags/first.js +130 -0
- package/dist/tags/flac.js +196 -0
- package/dist/tags/genres.js +123 -0
- package/dist/tags/id3v1.js +209 -0
- package/dist/tags/id3v2.js +754 -0
- package/dist/tags/mp4.js +534 -0
- package/dist/tags/mpeg.js +355 -0
- package/dist/tags/ogg.js +463 -0
- package/dist/tags/picture.js +71 -0
- package/dist/tags/read.js +269 -0
- package/dist/tags/types.js +51 -0
- package/dist/tags/vorbis-comment.js +155 -0
- package/dist/text/encoding.js +384 -0
- package/dist/text/site-name.js +37 -0
- package/dist/util/child.js +21 -0
- package/dist/util/names.js +97 -0
- package/funoteka.json.example +27 -0
- 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
|
+
}
|