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