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,405 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
2
|
+
import { withTransaction } from "../db/index.js";
|
|
3
|
+
import { prepareSweep, SWEPT_BY_CLASSIFY } from "../db/sweep.js";
|
|
4
|
+
import { junkReason } from "../junk/rule.js";
|
|
5
|
+
import { compareNatural, stemOf } from "../util/names.js";
|
|
6
|
+
import { parseFolderName, recordTitle, splitFields } from "./folder-name.js";
|
|
7
|
+
import { assignRoles, discMarker, discMarkerIndex, discNumber, flatDiscPairs, isDiscName, } from "./roles.js";
|
|
8
|
+
import { buildTree } from "./tree.js";
|
|
9
|
+
const ALL_ROLES = ['album', 'disc', 'box', 'category', 'empty'];
|
|
10
|
+
function toWalkedFile(row) {
|
|
11
|
+
return {
|
|
12
|
+
relPath: row.rel_path,
|
|
13
|
+
folderRelPath: row.folder_rel_path,
|
|
14
|
+
name: row.name,
|
|
15
|
+
kind: row.kind,
|
|
16
|
+
ext: row.ext,
|
|
17
|
+
size: row.size,
|
|
18
|
+
mtimeMs: row.mtime_ms,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Provisional album name.
|
|
23
|
+
*
|
|
24
|
+
* The contract says metadata comes from the folder, the cue and the .nfo
|
|
25
|
+
* (requirements:39 §4). Only the folder is available at this stage, so the name
|
|
26
|
+
* stands in until the cue and tag readers can override it.
|
|
27
|
+
*
|
|
28
|
+
* A collector's folder carries more than the title, though — the year, the
|
|
29
|
+
* artist, the Discogs format note — and a release called
|
|
30
|
+
* `1996 - Greatest Dicks (CD, Comp)` is called `Greatest Dicks`. `parseFolderName`
|
|
31
|
+
* reads the name once for all three tasks that want a piece of it, and the
|
|
32
|
+
* title is this stage's piece. The verbatim name is the fallback: when there is
|
|
33
|
+
* nothing to strip, or stripping leaves nothing (`2009 - Twodeadsluts
|
|
34
|
+
* Onegoodfuck + Cock E.S.P. (Cass, Ltd, C5)` is a credit and a format with no
|
|
35
|
+
* title between them), a noisy name still beats an empty one.
|
|
36
|
+
*/
|
|
37
|
+
function displayName(node, rootPath) {
|
|
38
|
+
const name = node.relPath === '' ? basename(rootPath) : node.name;
|
|
39
|
+
return parseFolderName(name).title ?? name;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The name a *record* is shown by — see `recordTitle`, which owns the rule.
|
|
43
|
+
*
|
|
44
|
+
* Only a release reads it, and it parts from `displayName` twice, both times
|
|
45
|
+
* over what a person is shown. It takes the year out of the name — a record's
|
|
46
|
+
* year is the one a box states on its own folder, and `folderYear` reads it for
|
|
47
|
+
* an album either way — and it *keeps* the edition note that `displayName`
|
|
48
|
+
* strips. `1988 ● Группа крови (2019, Maschina Records, MKK881CD, 3CD)` and
|
|
49
|
+
* `Группа Крови (Gold Castle Rec.)` are one album by one band, and a client
|
|
50
|
+
* shown `Группа крови` twice has nothing to choose between them.
|
|
51
|
+
*
|
|
52
|
+
* The note is not said again in `AlbumID3.version`: the API offers that field
|
|
53
|
+
* only when the name does not already carry the note, which for a release named
|
|
54
|
+
* here it always does. See `albumId3`.
|
|
55
|
+
*/
|
|
56
|
+
function releaseName(node, rootPath) {
|
|
57
|
+
return recordTitle(node.relPath === '' ? basename(rootPath) : node.name);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The year the folder's name states, or nothing.
|
|
61
|
+
*
|
|
62
|
+
* The same read as the name above, and the same source: the year slot a
|
|
63
|
+
* collector puts in front of a title. It was parsed and thrown away until a
|
|
64
|
+
* client asked for a release date — 236 of the 241 albums in the live
|
|
65
|
+
* collection state one, and all of them were being discarded on the line above.
|
|
66
|
+
*/
|
|
67
|
+
function folderYear(node, rootPath) {
|
|
68
|
+
const name = node.relPath === '' ? basename(rootPath) : node.name;
|
|
69
|
+
return parseFolderName(name).year;
|
|
70
|
+
}
|
|
71
|
+
/** `01. x - Pulse` -> `1`; null when the name carries no disc number. */
|
|
72
|
+
function leadingNumber(name) {
|
|
73
|
+
const match = /^(\d{1,2})\s*[.\-_)\]]/.exec(name.trim());
|
|
74
|
+
const digits = match?.[1];
|
|
75
|
+
return digits === undefined ? null : Number.parseInt(digits, 10);
|
|
76
|
+
}
|
|
77
|
+
/** Whether a folder holds audio of its own — the only thing a disc can be. */
|
|
78
|
+
function hasAudioHere(node) {
|
|
79
|
+
return node.files.some((file) => file.kind === 'audio');
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The children of a folder that are discs by their *name*, whatever their role.
|
|
83
|
+
*
|
|
84
|
+
* `assignRoles` promotes a child to `disc` only where the folder around it came
|
|
85
|
+
* out a box, and a shelf is not one. So the discs of a shelf carry the role of
|
|
86
|
+
* an ordinary album, and the only thing left that says what they are is their
|
|
87
|
+
* name — the same `isDiscName` the role rule reads.
|
|
88
|
+
*/
|
|
89
|
+
function namedDiscs(node) {
|
|
90
|
+
return node.children.filter((child) => isDiscName(child.name) && hasAudioHere(child));
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Those children gathered into the sets they name.
|
|
94
|
+
*
|
|
95
|
+
* A disc's set is everything in front of its marker: `2014 - .5 The Gray
|
|
96
|
+
* Chapter - CD 1 [JP - WPCR-16130]` and `… CD 2 [JP - WPCR-16131]` are one set
|
|
97
|
+
* although their catalogue numbers differ, because the difference comes *after*
|
|
98
|
+
* the marker. Bare `CD 1` and `CD 2` are one set too — nothing in front of the
|
|
99
|
+
* marker, both times.
|
|
100
|
+
*
|
|
101
|
+
* The distinction matters because a shelf may hold two sets, and they are two
|
|
102
|
+
* records. Pooling them is not a naming problem: the second set stops existing,
|
|
103
|
+
* its songs joining the first record's totals ([[task:2811]], finding 1).
|
|
104
|
+
*/
|
|
105
|
+
function discSets(node) {
|
|
106
|
+
const byName = new Map();
|
|
107
|
+
for (const child of namedDiscs(node)) {
|
|
108
|
+
const at = discMarkerIndex(child.name);
|
|
109
|
+
const prefix = at === -1 ? child.name : child.name.slice(0, at);
|
|
110
|
+
const key = prefix.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
|
111
|
+
byName.set(key, [...(byName.get(key) ?? []), child]);
|
|
112
|
+
}
|
|
113
|
+
return [...byName.values()];
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* The name a shelf's release starts out as: what its discs are called.
|
|
117
|
+
*
|
|
118
|
+
* The discs are the record — the shelf's own name is a statement about the rip,
|
|
119
|
+
* not about the music (`Slipknot AAC 320` names a format). Both discs of a pair
|
|
120
|
+
* are named after the record, so either answers; the lowest disc number is
|
|
121
|
+
* taken, which is the same choice `ALBUM_GROUPS` makes about which row stands
|
|
122
|
+
* for a group, made here for the same reason.
|
|
123
|
+
*
|
|
124
|
+
* What tells one shelf from the next is *not* added here. That needs the
|
|
125
|
+
* artist, and this stage writes no artists — `shelf-name.ts` runs last and
|
|
126
|
+
* appends it, to this name or to whatever a tag or cue made of it.
|
|
127
|
+
*/
|
|
128
|
+
function shelfReleaseName(discs, rootPath) {
|
|
129
|
+
const ordered = [...discs].sort((a, b) => (discNumber(a.name) ?? 0) - (discNumber(b.name) ?? 0) ||
|
|
130
|
+
compareNatural(a.relPath, b.relPath));
|
|
131
|
+
const first = ordered[0];
|
|
132
|
+
return first === undefined ? '' : displayName(first, rootPath);
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Cut a shared prefix back to a boundary between two names.
|
|
136
|
+
*
|
|
137
|
+
* Two discs of one record share almost their whole name. `Pink Floyd - The Wall
|
|
138
|
+
* [Disc 1]` and `Pink Floyd - The Wall [1994 Remaster](Disc 2)` agree as far as
|
|
139
|
+
* `Pink Floyd - The Wall [`, and taking that prefix leaves `Disc 1]` and
|
|
140
|
+
* `1994 Remaster](Disc 2)` — titles opening on a closing bracket, which is not a
|
|
141
|
+
* name anybody wrote. A prefix ending inside a bracket is not a boundary between
|
|
142
|
+
* two names, so it walks back to the last one that is.
|
|
143
|
+
*
|
|
144
|
+
* The boundary comes from `splitFields`, the bracket-aware splitter the folder
|
|
145
|
+
* parser already uses, rather than from a second scanner counting depth here.
|
|
146
|
+
* `folder-name.ts` opens by naming that duplication as the Shotgun Surgery this
|
|
147
|
+
* project already has a task for.
|
|
148
|
+
*/
|
|
149
|
+
function snapPrefix(prefix) {
|
|
150
|
+
const opened = (prefix.match(/[([]/g) ?? []).length;
|
|
151
|
+
const closed = (prefix.match(/[)\]]/g) ?? []).length;
|
|
152
|
+
// Balanced, so the cut landed between two names and is a boundary already.
|
|
153
|
+
if (opened <= closed)
|
|
154
|
+
return prefix;
|
|
155
|
+
// Inside a bracket. Keep everything before the last boundary; when there is no
|
|
156
|
+
// boundary to walk back to, keep nothing and let the caller fall back to the
|
|
157
|
+
// file's own name rather than serve a fragment.
|
|
158
|
+
const parts = splitFields(prefix);
|
|
159
|
+
return parts.length > 1 ? parts.slice(0, -1).join(' - ') : '';
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Name each disc by what survives once the folder's shared prefix is removed,
|
|
163
|
+
* so `VA - A State Of Trance_Ibiza 2026 - Pulse/Frequency/Energy` becomes
|
|
164
|
+
* `Pulse`, `Frequency`, `Energy`. Falls back to the file's own stem if
|
|
165
|
+
* stripping leaves nothing — a name is better than an empty string.
|
|
166
|
+
*
|
|
167
|
+
* What survives has to be a **name**, and that is the whole of what a letter
|
|
168
|
+
* tests. `CD1` and `CD2` agree as far as `CD`, so the strip leaves `1` and `2`:
|
|
169
|
+
* honest, and telling a reader nothing — the album list showed the Kino box as
|
|
170
|
+
* two records called `1` and `2`. A folder box has no such hole, because its
|
|
171
|
+
* discs are *folders* and their names are kept whole (`CD1 ● Группа крови`), and
|
|
172
|
+
* the flat pair's equivalent of that name is the file's own stem, which is what
|
|
173
|
+
* the disc is called (task:2756, finding 7).
|
|
174
|
+
*/
|
|
175
|
+
function discTitles(pairs) {
|
|
176
|
+
const stripped = pairs.map((pair) => stemOf(pair.audio.name).replace(/^\d+\s*[.\-_)\]]*\s*/, ''));
|
|
177
|
+
let prefix = stripped[0] ?? '';
|
|
178
|
+
for (const value of stripped) {
|
|
179
|
+
while (prefix !== '' && !value.startsWith(prefix))
|
|
180
|
+
prefix = prefix.slice(0, -1);
|
|
181
|
+
}
|
|
182
|
+
prefix = snapPrefix(prefix);
|
|
183
|
+
return stripped.map((value, index) => {
|
|
184
|
+
const trimmed = value.slice(prefix.length).replace(/^[\s\-_.]+|[\s\-_.]+$/g, '');
|
|
185
|
+
if (/\p{L}/u.test(trimmed))
|
|
186
|
+
return trimmed;
|
|
187
|
+
return stemOf(pairs[index]?.audio.name ?? '');
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Classify every folder and materialise the album tree.
|
|
192
|
+
*
|
|
193
|
+
* Album identity is the path — `(root_id, rel_path)` and nothing else. Two
|
|
194
|
+
* pressings of the same record, or one album present under two roots, produce
|
|
195
|
+
* two rows, because the filesystem says they are two things. Tags may enrich a
|
|
196
|
+
* row later; they never merge two of them.
|
|
197
|
+
*
|
|
198
|
+
* Re-running is safe: rows are matched on the same identity and updated.
|
|
199
|
+
*
|
|
200
|
+
* Albums and releases are created, updated, and — where the folder beneath
|
|
201
|
+
* them is gone — dropped. Letting go belongs here rather than in the scan
|
|
202
|
+
* because the scan sweeps folders first: by the time this runs, a folder row
|
|
203
|
+
* that is missing is missing because a walk did not see it. What keeps that
|
|
204
|
+
* from being a mistake is the stamp — a row the latest classification
|
|
205
|
+
* re-derived from a surviving folder carries the run, and a row it did not
|
|
206
|
+
* carries an older one. So a root the latest run never walked keeps everything
|
|
207
|
+
* it had.
|
|
208
|
+
*/
|
|
209
|
+
export function classify(db) {
|
|
210
|
+
const byRole = Object.fromEntries(ALL_ROLES.map((role) => [role, 0]));
|
|
211
|
+
let folders = 0;
|
|
212
|
+
let albums = 0;
|
|
213
|
+
let releases = 0;
|
|
214
|
+
const roots = db.prepare('SELECT id, path FROM root ORDER BY id').all();
|
|
215
|
+
// The run this classification belongs to. With no run there is nothing to
|
|
216
|
+
// scope a sweep by — and nothing that rows could be older than.
|
|
217
|
+
const runId = db.prepare('SELECT MAX(id) AS id FROM scan_run').get().id;
|
|
218
|
+
const selectFolders = db.prepare('SELECT rel_path FROM folder WHERE root_id = ?');
|
|
219
|
+
// The hand marks for one root, read once: `junk/rule.ts` owns what they mean.
|
|
220
|
+
const selectMarks = db.prepare('SELECT rel_path, verdict FROM junk_mark WHERE root_id = ?');
|
|
221
|
+
const selectFiles = db.prepare(`SELECT rel_path, folder_rel_path, name, kind, ext, size, mtime_ms
|
|
222
|
+
FROM file WHERE root_id = ?`);
|
|
223
|
+
const updateRole = db.prepare('UPDATE folder SET role = ? WHERE root_id = ? AND rel_path = ?');
|
|
224
|
+
// artist_id is deliberately absent: normalising artists is a later stage, and
|
|
225
|
+
// leaving it out here means reclassifying never clobbers what it wrote.
|
|
226
|
+
//
|
|
227
|
+
// `title_source` is set, and set to the plain truth: this name came from the
|
|
228
|
+
// folder. It is overwritten by whichever later stage has a better one, and it
|
|
229
|
+
// is written unconditionally because the folder name is re-derived on every
|
|
230
|
+
// pass — a stage that wants its own name to survive a rescan has to say so
|
|
231
|
+
// again on that rescan anyway.
|
|
232
|
+
// The year follows the same rule the title does and one more: a folder that
|
|
233
|
+
// names no year must not erase the one a file's own DATE stated, which is the
|
|
234
|
+
// only thing the `COALESCE` and the `CASE` below are for. Everything else is
|
|
235
|
+
// re-derived on every pass, because the folder is.
|
|
236
|
+
// `junk_reason` is written unconditionally, like `title` and unlike `year`:
|
|
237
|
+
// what the folder holds is on disk and is re-derived on every pass, so a folder
|
|
238
|
+
// that stops being a dumping ground stops being junk without anyone saying so.
|
|
239
|
+
// The one thing that outranks it is a hand mark, and `junkReason` is where that
|
|
240
|
+
// is decided — this stage only carries the answer.
|
|
241
|
+
const upsertAlbum = db.prepare(`INSERT INTO album (root_id, rel_path, title, title_source, release_id, disc_number, year, year_source, junk_reason, last_seen_run_id)
|
|
242
|
+
VALUES (?, ?, ?, 'folder', ?, ?, ?, ?, ?, ?)
|
|
243
|
+
ON CONFLICT (root_id, rel_path) DO UPDATE SET
|
|
244
|
+
title = excluded.title,
|
|
245
|
+
title_source = excluded.title_source,
|
|
246
|
+
release_id = excluded.release_id,
|
|
247
|
+
disc_number = excluded.disc_number,
|
|
248
|
+
year = COALESCE(excluded.year, album.year),
|
|
249
|
+
year_source = CASE WHEN excluded.year IS NULL THEN album.year_source ELSE excluded.year_source END,
|
|
250
|
+
junk_reason = excluded.junk_reason,
|
|
251
|
+
last_seen_run_id = excluded.last_seen_run_id`);
|
|
252
|
+
// The release carries the year too, and for the opposite reason the album's
|
|
253
|
+
// `COALESCE` above exists: a box states its year on the *box* folder and its
|
|
254
|
+
// discs state none (`1988 ● Группа крови (…)` over `CD1 ● Альбом`), so the
|
|
255
|
+
// year has nowhere else to live. `ALBUM_SELECT` asks the release first for
|
|
256
|
+
// exactly that reason — a record *is* its release where it has one, and a
|
|
257
|
+
// disc's year is the year of the album that disc holds, which for a box is
|
|
258
|
+
// not the record's. What a tag or a cue writes is the *album's*, and it is
|
|
259
|
+
// still what dates a record with no release above it.
|
|
260
|
+
const upsertRelease = db.prepare(`INSERT INTO release (root_id, rel_path, title, title_source, year, last_seen_run_id)
|
|
261
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
262
|
+
ON CONFLICT (root_id, rel_path) DO UPDATE SET
|
|
263
|
+
title = excluded.title,
|
|
264
|
+
title_source = excluded.title_source,
|
|
265
|
+
year = excluded.year,
|
|
266
|
+
last_seen_run_id = excluded.last_seen_run_id`);
|
|
267
|
+
const selectRelease = db.prepare('SELECT id FROM release WHERE root_id = ? AND rel_path = ?');
|
|
268
|
+
// Letting go of albums and releases whose folder is gone. This belongs here
|
|
269
|
+
// because classify is what creates them, and it runs after the scan, so the
|
|
270
|
+
// folder rows for whatever disappeared are already gone by now. How a row is
|
|
271
|
+
// judged, and what has to be released before it goes, is `db/sweep.ts`.
|
|
272
|
+
const sweep = prepareSweep(db, SWEPT_BY_CLASSIFY);
|
|
273
|
+
for (const root of roots) {
|
|
274
|
+
const folderPaths = selectFolders.all(root.id).map((r) => r.rel_path);
|
|
275
|
+
// node:sqlite types a row as Record<string, SQLOutputValue>; the columns are
|
|
276
|
+
// ours, so the bridge through unknown is a naming step, not a conversion.
|
|
277
|
+
const files = selectFiles.all(root.id).map(toWalkedFile);
|
|
278
|
+
const marks = new Map(selectMarks.all(root.id).map((row) => [
|
|
279
|
+
row.rel_path,
|
|
280
|
+
row.verdict,
|
|
281
|
+
]));
|
|
282
|
+
const tree = buildTree(folderPaths, files);
|
|
283
|
+
assignRoles(tree);
|
|
284
|
+
const persist = (node, releaseId, discOrdinal) => {
|
|
285
|
+
if (node.relPath !== '') {
|
|
286
|
+
updateRole.run(node.role, root.id, node.relPath);
|
|
287
|
+
folders += 1;
|
|
288
|
+
if (node.role !== null)
|
|
289
|
+
byRole[node.role] += 1;
|
|
290
|
+
}
|
|
291
|
+
let inheritedRelease = releaseId;
|
|
292
|
+
// When *this* folder's release covers named children rather than all of
|
|
293
|
+
// them: a shelf's covers its disc set and stops there, where a box's
|
|
294
|
+
// covers every child it has, because every child it has is a disc. The
|
|
295
|
+
// set is carried rather than a flag, so a disc-named child that is *not*
|
|
296
|
+
// in the set does not slip in beside it.
|
|
297
|
+
let shelfDiscs = null;
|
|
298
|
+
if (node.role === 'box') {
|
|
299
|
+
upsertRelease.run(root.id, node.relPath, releaseName(node, root.path), 'folder', folderYear(node, root.path), runId);
|
|
300
|
+
inheritedRelease = selectRelease.get(root.id, node.relPath)?.id ?? null;
|
|
301
|
+
releases += 1;
|
|
302
|
+
const pairs = flatDiscPairs(node.files);
|
|
303
|
+
const paired = new Set(pairs.map((pair) => pair.audio.relPath));
|
|
304
|
+
const loose = node.files.filter((file) => file.kind === 'audio' && !paired.has(file.relPath));
|
|
305
|
+
if (pairs.length >= 2) {
|
|
306
|
+
// Flat multi-disc: each pair IS a disc, so it takes its own album row
|
|
307
|
+
// keyed on the image it lives in. Without this every disc past the
|
|
308
|
+
// first loses its tracks entirely.
|
|
309
|
+
const titles = discTitles(pairs);
|
|
310
|
+
pairs.forEach((pair, index) => {
|
|
311
|
+
upsertAlbum.run(root.id, pair.audio.relPath, titles[index] ?? pair.audio.name, inheritedRelease,
|
|
312
|
+
// A stated disc marker outranks a leading number: `01 - The Wall
|
|
313
|
+
// [Disc 2].ape` says which disc it is, and the `01` is the order it
|
|
314
|
+
// was ripped in. Order is the last resort, for names that state
|
|
315
|
+
// nothing.
|
|
316
|
+
discMarker(pair.audio.name) ?? leadingNumber(pair.audio.name) ?? index + 1, folderYear(node, root.path), folderYear(node, root.path) === null ? null : 'folder',
|
|
317
|
+
// The disc's own key is the image it lives in, so the mark is
|
|
318
|
+
// looked up by that and not by the box's folder: a hand edit names
|
|
319
|
+
// the path a client is shown, which is the album's row.
|
|
320
|
+
junkReason(node.files, marks.get(pair.audio.relPath)), runId);
|
|
321
|
+
albums += 1;
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
// The box's own audio: the files the discs did not claim.
|
|
325
|
+
//
|
|
326
|
+
// Two shapes reach here and both want the row. With fewer than two pairs
|
|
327
|
+
// nothing was keyed on an image at all, so the folder is the album and
|
|
328
|
+
// every file under it belongs to it. With two or more, the albums went
|
|
329
|
+
// to the discs and `loose` is what they left behind — a bonus track, a
|
|
330
|
+
// rip whose cue never matched. That second shape is the one a condition
|
|
331
|
+
// reading `pairs.length < 2` alone missed, and its cost is total: the
|
|
332
|
+
// file reaches no track at all, without an issue or a counter ever
|
|
333
|
+
// moving.
|
|
334
|
+
if (loose.length > 0 || (pairs.length < 2 && node.files.some((f) => f.kind === 'audio'))) {
|
|
335
|
+
upsertAlbum.run(root.id, node.relPath, displayName(node, root.path), null, null, folderYear(node, root.path), folderYear(node, root.path) === null ? null : 'folder', junkReason(node.files, marks.get(node.relPath)), runId);
|
|
336
|
+
albums += 1;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
else if (node.role === 'category') {
|
|
340
|
+
// A shelf that holds a disc set. The set is a release and the shelf is
|
|
341
|
+
// not: `a9f06a5` settled that, and what it settled is *how far* the
|
|
342
|
+
// release reaches rather than whether one exists. The albums beside the
|
|
343
|
+
// pair stay records of their own — making the shelf a box is what
|
|
344
|
+
// collapsed sixteen releases into two, and the difference between the
|
|
345
|
+
// two shapes is exactly this list.
|
|
346
|
+
//
|
|
347
|
+
// Nothing here asks what tells one shelf from the next. That question
|
|
348
|
+
// needs the artist, which this stage has not got and deliberately does
|
|
349
|
+
// not write; it is answered after every stage that names anything, by
|
|
350
|
+
// `shelf-name.ts`. Grouping a pair is right whether or not a qualifier
|
|
351
|
+
// can be found — a pair inside one shelf collides with itself.
|
|
352
|
+
//
|
|
353
|
+
// One set, and only one: a release is keyed by its folder, so a second
|
|
354
|
+
// set could only be poured into the first one's record. See `discSets`.
|
|
355
|
+
const sets = discSets(node).filter((set) => set.length >= 2);
|
|
356
|
+
const discs = sets.length === 1 ? (sets[0] ?? []) : [];
|
|
357
|
+
if (discs.length >= 2) {
|
|
358
|
+
upsertRelease.run(root.id, node.relPath, shelfReleaseName(discs, root.path), 'folder',
|
|
359
|
+
// No year: a shelf's name states none, and the discs' own years
|
|
360
|
+
// reach the record through its representative row.
|
|
361
|
+
null, runId);
|
|
362
|
+
inheritedRelease = selectRelease.get(root.id, node.relPath)?.id ?? null;
|
|
363
|
+
shelfDiscs = new Set(discs.map((disc) => disc.relPath));
|
|
364
|
+
releases += 1;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
else if (node.role === 'album' || node.role === 'disc') {
|
|
368
|
+
// A disc named `CD2` states its own number; one named after its album
|
|
369
|
+
// does not, and falls back to its position in the release.
|
|
370
|
+
//
|
|
371
|
+
// A shelf's discs arrive here with the role of an ordinary album — the
|
|
372
|
+
// role rule only promotes children of a *box* — so the release it holds
|
|
373
|
+
// is what says they are discs, and a name that states a number is read
|
|
374
|
+
// for the same reason it is read anywhere else.
|
|
375
|
+
const number = node.role === 'disc' || (releaseId !== null && isDiscName(node.name))
|
|
376
|
+
? (discNumber(node.name) ?? discOrdinal)
|
|
377
|
+
: null;
|
|
378
|
+
upsertAlbum.run(root.id, node.relPath, displayName(node, root.path), inheritedRelease, number, folderYear(node, root.path), folderYear(node, root.path) === null ? null : 'folder', junkReason(node.files, marks.get(node.relPath)), runId);
|
|
379
|
+
albums += 1;
|
|
380
|
+
}
|
|
381
|
+
const discs = node.children.filter((child) => child.role === 'disc');
|
|
382
|
+
for (const child of node.children) {
|
|
383
|
+
const position = discs.indexOf(child);
|
|
384
|
+
// The release a shelf created belongs to its discs and to nothing else.
|
|
385
|
+
// Everything the shelf holds beside them is a record in its own right,
|
|
386
|
+
// which is the whole of what separates a shelf from a box.
|
|
387
|
+
const carriesRelease = shelfDiscs === null ? true : shelfDiscs.has(child.relPath);
|
|
388
|
+
persist(child, carriesRelease ? inheritedRelease : null, position === -1 ? null : position + 1);
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
// One transaction per root. Without it every `updateRole` and `upsertAlbum`
|
|
392
|
+
// below is its own commit, and a commit is a disk flush: measured on the
|
|
393
|
+
// live collection, this stage cost **10 356 ms** committing each and **437
|
|
394
|
+
// ms** with the transaction (task:2883). The tree it builds is the same
|
|
395
|
+
// either way — all of the difference was the commits. The sweep goes in the
|
|
396
|
+
// same transaction as the rows it judges, which is the rule `scan.ts` states
|
|
397
|
+
// for the same reason.
|
|
398
|
+
withTransaction(db, () => {
|
|
399
|
+
persist(tree, null, null);
|
|
400
|
+
if (runId !== null)
|
|
401
|
+
sweep.run(root.id, runId);
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
return { folders, albums, releases, byRole };
|
|
405
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { withTransaction } from "../db/index.js";
|
|
2
|
+
import { basenameOf } from "../util/names.js";
|
|
3
|
+
import { unsaidNote } from "./folder-name.js";
|
|
4
|
+
/**
|
|
5
|
+
* Two records that read the same get a number, so a client shows two.
|
|
6
|
+
*
|
|
7
|
+
* The last resort, and it is deliberately the *last*: every other rule here tries
|
|
8
|
+
* to say what distinguishes a record, and this one gives up on saying it and
|
|
9
|
+
* merely makes the difference visible. Two folders of one Sigur Rós record —
|
|
10
|
+
* eleven tracks against twelve, the Japanese bonus missing from one — carry the
|
|
11
|
+
* same album tag, so every name the stages above could build for them is the same
|
|
12
|
+
* name, and a client lists the record twice with nothing to choose between.
|
|
13
|
+
*
|
|
14
|
+
* `(1)` and `(2)` are not a claim about which is which. They are the honest
|
|
15
|
+
* minimum: these are two, and which one a person keeps is a question about their
|
|
16
|
+
* collection that the server has no business answering. The operator's rule for
|
|
17
|
+
* the whole of this: show what the folder says, and if a person dislikes the
|
|
18
|
+
* result they will go and tidy the folder.
|
|
19
|
+
*
|
|
20
|
+
* **Numbered by record, never by row.** `ALBUM_GROUPS` folds a box's discs into
|
|
21
|
+
* one record, and this stage reads the same two sets that stage does — albums
|
|
22
|
+
* that belong to no release, and releases — because numbering rows would put
|
|
23
|
+
* `(1)` through `(10)` on the discs of one box.
|
|
24
|
+
*
|
|
25
|
+
* Only records an artist owns are numbered. A record with no artist appears on no
|
|
26
|
+
* artist's page, so two of them reading alike is not something a client can be
|
|
27
|
+
* shown; numbering them would invent a difference nobody sees.
|
|
28
|
+
*
|
|
29
|
+
* The order is the collection's own — path, then id — so the same unchanged
|
|
30
|
+
* library numbers the same record `(1)` on every machine.
|
|
31
|
+
*/
|
|
32
|
+
export function numberCollidingRecords(db) {
|
|
33
|
+
const counters = { groups: 0, numbered: 0 };
|
|
34
|
+
const albums = db
|
|
35
|
+
.prepare(`SELECT id, title, artist_id, rel_path FROM album
|
|
36
|
+
WHERE release_id IS NULL AND artist_id IS NOT NULL AND title IS NOT NULL`)
|
|
37
|
+
.all();
|
|
38
|
+
const releases = db
|
|
39
|
+
.prepare(`SELECT id, title, artist_id, rel_path FROM release
|
|
40
|
+
WHERE artist_id IS NOT NULL AND title IS NOT NULL`)
|
|
41
|
+
.all();
|
|
42
|
+
const byKey = new Map();
|
|
43
|
+
const add = (kind, id, title, artistId, relPath) => {
|
|
44
|
+
const base = title.replace(NUMBERED, '').trim();
|
|
45
|
+
if (base === '')
|
|
46
|
+
return;
|
|
47
|
+
// Grouped by what a client is *shown*, not by the title a tag states.
|
|
48
|
+
//
|
|
49
|
+
// The two are not the same field, and this stage read the wrong one. A folder
|
|
50
|
+
// writes the pressing beside the record — `1990 - Entreat [1991 issue AU
|
|
51
|
+
// Warner 903174106-2]`, `1994 - … - Split (Cass, C60)` — and that note is
|
|
52
|
+
// part of every name the record is shown by. A tag says `Entreat` three times
|
|
53
|
+
// for three pressings, so this stage saw one name three times and numbered
|
|
54
|
+
// them, and the note arrived afterwards and told them apart anyway. Sixteen
|
|
55
|
+
// of the eighteen numbers it wrote were that: the collection had already
|
|
56
|
+
// answered the question the number was asked to answer (task:2783).
|
|
57
|
+
//
|
|
58
|
+
// The note goes in as `unsaidNote` answers it — the same answer every name of
|
|
59
|
+
// the record is built from — and not as the folder spells it. The folder's
|
|
60
|
+
// spelling is finer: it keeps the pressing's own year, which each name drops,
|
|
61
|
+
// so two pressings of one catalogue number would be told apart here and read
|
|
62
|
+
// identically in the album list, with no number to separate them (task:2845).
|
|
63
|
+
const key = `${artistId}\u0000${base}\u0000${unsaidNote(base, basenameOf(relPath)) ?? ''}`;
|
|
64
|
+
const at = byKey.get(key) ?? [];
|
|
65
|
+
at.push({ kind, id, base, relPath });
|
|
66
|
+
byKey.set(key, at);
|
|
67
|
+
};
|
|
68
|
+
for (const row of albums)
|
|
69
|
+
add('album', row.id, row.title, row.artist_id, row.rel_path);
|
|
70
|
+
for (const row of releases)
|
|
71
|
+
add('release', row.id, row.title, row.artist_id, row.rel_path);
|
|
72
|
+
const renameAlbum = db.prepare('UPDATE album SET title = ?, title_source = ? WHERE id = ?');
|
|
73
|
+
const renameRelease = db.prepare('UPDATE release SET title = ?, title_source = ? WHERE id = ?');
|
|
74
|
+
// One transaction, and it is not a micro-optimisation. Without it every
|
|
75
|
+
// rename below is its own commit, and a commit is a disk flush: measured on
|
|
76
|
+
// the live collection, this stage cost **43 ms** committing each and **5 ms**
|
|
77
|
+
// with the transaction. It renames a handful of records, so the writes were
|
|
78
|
+
// never the cost — the commits were the whole of it (task:2883). `db/index.ts`
|
|
79
|
+
// has the rule and `scan.ts` the same fix at a larger scale.
|
|
80
|
+
withTransaction(db, () => {
|
|
81
|
+
for (const set of byKey.values()) {
|
|
82
|
+
if (set.length < 2)
|
|
83
|
+
continue;
|
|
84
|
+
counters.groups += 1;
|
|
85
|
+
set.sort((a, b) => cmp(a.relPath, b.relPath) || a.id - b.id);
|
|
86
|
+
for (const [at, record] of set.entries()) {
|
|
87
|
+
const title = `${record.base} (${at + 1})`;
|
|
88
|
+
if (record.kind === 'album')
|
|
89
|
+
renameAlbum.run(title, 'collision', record.id);
|
|
90
|
+
else
|
|
91
|
+
renameRelease.run(title, 'collision', record.id);
|
|
92
|
+
counters.numbered += 1;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
return counters;
|
|
97
|
+
}
|
|
98
|
+
function cmp(a, b) {
|
|
99
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* A number this stage put there on an earlier run.
|
|
103
|
+
*
|
|
104
|
+
* Stripped before numbering, for the reason `qualified` in `shelf-name.ts` gives:
|
|
105
|
+
* a root the walk did not see keeps the rows it had, this stage's among them, and
|
|
106
|
+
* a name that collected a second `(1)` on every run would be a name nobody could
|
|
107
|
+
* read.
|
|
108
|
+
*/
|
|
109
|
+
const NUMBERED = / \((\d+)\)$/;
|