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