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,466 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a folder name carries.
|
|
3
|
+
*
|
|
4
|
+
* A collector's folder is a record sleeve written in one line:
|
|
5
|
+
*
|
|
6
|
+
* 1994 - Cock E.S.P. + Thirdorgan - Split (Cass, C60)
|
|
7
|
+
*
|
|
8
|
+
* Year, artist credit, title, format. Three separate tasks want pieces of it —
|
|
9
|
+
* the credit ([[task:2684]]), the title ([[task:2682]]), artist-folder
|
|
10
|
+
* detection ([[task:2674]]) — and three half-parsers each reading the same
|
|
11
|
+
* string their own way is the Shotgun Surgery the plan already has a task for.
|
|
12
|
+
* So it is parsed once, here, and each task takes the part it needs.
|
|
13
|
+
*
|
|
14
|
+
* The name is parsed, never the path: an album's identity is its path
|
|
15
|
+
* (requirements:39 §2), and rewriting a path to tidy a name would split one
|
|
16
|
+
* album into two.
|
|
17
|
+
*
|
|
18
|
+
* Conservatism is the whole difficulty. Two rules carry it, and both were
|
|
19
|
+
* narrowed by measurement against the real samples rather than by taste:
|
|
20
|
+
*
|
|
21
|
+
* - **A credit is claimed only behind a year slot, or behind a joiner.**
|
|
22
|
+
* Reading any `A - B` as `credit - title` produced a credit for all four of
|
|
23
|
+
* `Pink Floyd - The Wall (JPN Remastered)`, `The Gerogerigegege - 2016 - …`,
|
|
24
|
+
* `CD 2 - Disk Union Bonus` and `This Is a Title - With a Dash`. Every one
|
|
25
|
+
* is wrong.
|
|
26
|
+
* - **A trailing bracket is a format only if it looks like one.** `(Cass,
|
|
27
|
+
* C60)` and `(JPN Remastered)` do; `(Moenai Hai)` and `(UK)` do not, and
|
|
28
|
+
* stripping them would damage titles that are already correct.
|
|
29
|
+
*
|
|
30
|
+
* The format vocabulary is deliberately a list of media and edition words
|
|
31
|
+
* rather than "anything in brackets": the failure mode of a missing word is a
|
|
32
|
+
* format left in the title, which is visible and harmless, while the failure
|
|
33
|
+
* mode of a false positive is a truncated name, which is neither.
|
|
34
|
+
*/
|
|
35
|
+
/** Disc and edition words. A bracket naming one of these is a format note. */
|
|
36
|
+
const MEDIUM = /\b(cd|cdr|cass|cassette|vinyl|lp|ep|dvd|sacd|blu-?ray|minidisc|md|box|digipak|album|comp|single|promo|reissue|remaster|remastered|edition|anniversary|deluxe|expanded|ltd|limited|jpn|jp|disc|enh|c60|c20|c30|c5|bc)\b/i;
|
|
37
|
+
/**
|
|
38
|
+
* `12CD FLAC`, `2CD` — a disc count, which names the medium as plainly as `CD`
|
|
39
|
+
* does and which `MEDIUM` above cannot see.
|
|
40
|
+
*
|
|
41
|
+
* Its `\b` needs a boundary in front of the `c`, and a digit is a word
|
|
42
|
+
* character, so `12CD` has none. The count is how this collection writes a box
|
|
43
|
+
* most often — `The Cure - Assemblage - 1991 (12CD FLAC)`, `2001 - Greatest
|
|
44
|
+
* Hits (2CD ltd …)` — so the note went unread on every one of them and the
|
|
45
|
+
* bracket stayed in the title along with the count.
|
|
46
|
+
*/
|
|
47
|
+
const DISC_COUNT = /\d+\s*(?:cd|dvd|sacd|lp)\b/i;
|
|
48
|
+
/** `1994` is a year; `xxxx` is a year slot with no year in it. */
|
|
49
|
+
const YEAR_SLOT = /^(\d{4}|x{4})$/i;
|
|
50
|
+
/**
|
|
51
|
+
* A whole segment that names a release rather than a record.
|
|
52
|
+
*
|
|
53
|
+
* Deliberately narrow. The words that are *missing* here are the ones that
|
|
54
|
+
* matter: `Split`, `Live`, `Remixes`, `Album`, `Compilation` and `Soundtrack`
|
|
55
|
+
* are all real titles in these collections, and a false positive costs the
|
|
56
|
+
* credit on a collaboration while a false negative costs nothing but a word
|
|
57
|
+
* left in a title.
|
|
58
|
+
*/
|
|
59
|
+
const RELEASE_TYPE = /^(ep|lp|single|cdm|cds|maxi|promo|sampler)$/i;
|
|
60
|
+
/**
|
|
61
|
+
* `CD 1`, `Disc 2` — a disc of a multi-disc release, optionally followed by a
|
|
62
|
+
* catalog bracket. Same slot in the name as a release type, and the same
|
|
63
|
+
* misreading: `2014 - .5 The Gray Chapter - CD 1 [JP - WPCR-16130]` taken as
|
|
64
|
+
* `YEAR - CREDIT - TITLE` names an artist after the album itself.
|
|
65
|
+
*
|
|
66
|
+
* The disc number is not read out here — which disc this is belongs to
|
|
67
|
+
* [[task:2680]], which assigns it. This only stops the marker from becoming a
|
|
68
|
+
* title and a credit.
|
|
69
|
+
*/
|
|
70
|
+
const DISC_SUFFIX = /^(cd|disc|disk)\s*\d+\b/i;
|
|
71
|
+
/**
|
|
72
|
+
* `Артист_-_Заголовок-ГГГГ-Сайт` — the scene layout, as Kroogi hands a record out.
|
|
73
|
+
*
|
|
74
|
+
* Aquarium_-_Archangelsk-2011-Kroogi.com
|
|
75
|
+
*
|
|
76
|
+
* The separator is the whole of the evidence, and it is a *written* one: `_-_`
|
|
77
|
+
* is typed deliberately, where a bare dash merely falls between two words, so
|
|
78
|
+
* it cannot be a hyphen inside a title. That is what lets this shape claim a
|
|
79
|
+
* credit with neither of the two proofs every other branch needs — a year slot
|
|
80
|
+
* or a joiner — and it is why nothing was read here at all before: with no
|
|
81
|
+
* branch for the shape the whole string stayed one title, and the artist and
|
|
82
|
+
* the album were both lost without a word.
|
|
83
|
+
*
|
|
84
|
+
* The tail is the scene's own: `-ГГГГ` the release year, `-Сайт` where it was
|
|
85
|
+
* published. Both optional and both fenced by dashes, so a title carrying
|
|
86
|
+
* dashes of its own keeps them — the year is what ends the title. The release
|
|
87
|
+
* group is dropped the way `[ARDI4701]` is: it describes the download, not the
|
|
88
|
+
* record.
|
|
89
|
+
*
|
|
90
|
+
* Underscores elsewhere are left alone. In a file name the scene writes a space
|
|
91
|
+
* as `_`, but a folder name is not a file name, the one real sample spells its
|
|
92
|
+
* words out, and folding `_` to a space would eat a title that legitimately
|
|
93
|
+
* carries one.
|
|
94
|
+
*/
|
|
95
|
+
const SCENE = /^(.+?)_-_(.+?)(?:-(\d{4})(?:-(.+))?)?$/;
|
|
96
|
+
/** A joiner inside a segment, by the same classes `credit.ts` splits on. */
|
|
97
|
+
const JOINER = /[&+/;×]|\s(?:featuring|feat\.|ft\.|vs\.?|and|with|meets|x)\s/i;
|
|
98
|
+
/**
|
|
99
|
+
* Is this bracketed run a format note, or part of the name?
|
|
100
|
+
*
|
|
101
|
+
* A comma is the giveaway — Discogs writes `(CD, Comp)`, `(Vinyl, 7, Ltd)` —
|
|
102
|
+
* and failing that, a word that names a medium or an edition.
|
|
103
|
+
*/
|
|
104
|
+
function looksLikeFormat(inner) {
|
|
105
|
+
const text = inner.trim();
|
|
106
|
+
if (text === '')
|
|
107
|
+
return false;
|
|
108
|
+
return text.includes(',') || MEDIUM.test(text) || DISC_COUNT.test(text);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* The bracketed run a name ends with, as the folder wrote it.
|
|
112
|
+
*
|
|
113
|
+
* `body` is the note without its brackets, which is what `format` has always
|
|
114
|
+
* been, and `text` is the run itself, brackets and all. Both answers are wanted
|
|
115
|
+
* and they come from one read: a record is named in two places, and each states
|
|
116
|
+
* the note its own way — round brackets outside the tree, the folder's own
|
|
117
|
+
* inside it.
|
|
118
|
+
*/
|
|
119
|
+
function trailingNote(text) {
|
|
120
|
+
const paren = /\(([^()]*)\)\s*$/.exec(text);
|
|
121
|
+
const square = /\[([^[\]]*)\]\s*$/.exec(text);
|
|
122
|
+
const matched = paren !== null && paren[1] !== undefined && looksLikeFormat(paren[1]) ? paren : square;
|
|
123
|
+
if (matched === null || matched[1] === undefined || matched[1].trim() === '')
|
|
124
|
+
return null;
|
|
125
|
+
return { body: matched[1].trim(), text: (matched[0] ?? '').trim(), at: matched.index };
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* The note a folder states, in the brackets the folder wrote it in.
|
|
129
|
+
*
|
|
130
|
+
* `recordVersion` answers what the note *says* with the pressing's own year
|
|
131
|
+
* taken off, and that is what a record's name outside the tree wants: `(2019,
|
|
132
|
+
* Maschina Records, MKK881CD, 3CD)` dates the pressing, not the record. This
|
|
133
|
+
* answers how the folder wrote it, which is the other thing a name wants — the
|
|
134
|
+
* tree is the folder view, and what a leaf there shows is what the folder says,
|
|
135
|
+
* `[UK promo Fiction FIXCD 17]` and `(Cass, C60)` both. See `named` in
|
|
136
|
+
* `api/browse.ts` for the reader, and task:2783 for why the two are not one.
|
|
137
|
+
*/
|
|
138
|
+
export function folderNote(name) {
|
|
139
|
+
return trailingNote(name.trim())?.text ?? null;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* The note a record's name still has to say, or null when it says it already.
|
|
143
|
+
*
|
|
144
|
+
* Every reader that names a record has to ask this the same way, or one of them
|
|
145
|
+
* says what the other has already said. And it has to be asked of the note's
|
|
146
|
+
* **words**, not of the brackets the folder wrote them in.
|
|
147
|
+
*
|
|
148
|
+
* The reason is `recordTitle`, which is what puts the note into a title in the
|
|
149
|
+
* first place. It writes round brackets and takes the pressing's own year off —
|
|
150
|
+
* so a folder stating `[EU Polydor 981 463-0]` leaves a title saying
|
|
151
|
+
* `(EU Polydor 981 463-0)`, and every test for the folder's spelling misses it.
|
|
152
|
+
* `parseFolderName` cannot be asked either: it reads a round bracket only when
|
|
153
|
+
* it looks like a format, and a catalogue number does not, so the note it just
|
|
154
|
+
* wrote is one it will not read back.
|
|
155
|
+
*
|
|
156
|
+
* Measured over the live library: comparing the folder's brackets, 34 records
|
|
157
|
+
* say their pressing twice in the tree and 18 in the album list; comparing the
|
|
158
|
+
* note's words, none do either way (task:2845).
|
|
159
|
+
*
|
|
160
|
+
* What comes back is `recordVersion`'s answer — what the note says, without the
|
|
161
|
+
* pressing's year. The caller wraps it: the tree in the brackets the folder
|
|
162
|
+
* wrote, the album in round ones.
|
|
163
|
+
*/
|
|
164
|
+
export function unsaidNote(title, folderName) {
|
|
165
|
+
const note = recordVersion(folderName);
|
|
166
|
+
if (note === null)
|
|
167
|
+
return null;
|
|
168
|
+
return title.includes(note) ? null : note;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Split a folder name on ` - `, but never inside brackets.
|
|
172
|
+
*
|
|
173
|
+
* The separator is only a separator at depth zero. A catalog number is written
|
|
174
|
+
* with dashes of its own — `1999 - Slipknot [JP - RRCY-1104]` — and splitting
|
|
175
|
+
* inside the bracket produced an artist called `Slipknot [JP` and a title of
|
|
176
|
+
* `RRCY-1104]`, from a bracket that is neither.
|
|
177
|
+
*
|
|
178
|
+
* Both bracket kinds count, and nesting is tracked rather than assumed away: a
|
|
179
|
+
* name may hold `(Live - Remastered)` as easily as `[JP - RRCY-1104]`.
|
|
180
|
+
*/
|
|
181
|
+
export function splitFields(text) {
|
|
182
|
+
const parts = [];
|
|
183
|
+
let depth = 0;
|
|
184
|
+
let start = 0;
|
|
185
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
186
|
+
const ch = text[i];
|
|
187
|
+
if (ch === '(' || ch === '[')
|
|
188
|
+
depth += 1;
|
|
189
|
+
else if (ch === ')' || ch === ']')
|
|
190
|
+
depth = Math.max(0, depth - 1);
|
|
191
|
+
else if (depth === 0 &&
|
|
192
|
+
ch === '-' &&
|
|
193
|
+
/\s/.test(text[i - 1] ?? '') &&
|
|
194
|
+
/\s/.test(text[i + 1] ?? '')) {
|
|
195
|
+
parts.push(text.slice(start, i));
|
|
196
|
+
start = i + 1;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
parts.push(text.slice(start));
|
|
200
|
+
return parts.map((part) => part.trim()).filter((part) => part !== '');
|
|
201
|
+
}
|
|
202
|
+
export function parseFolderName(name) {
|
|
203
|
+
let rest = name.trim();
|
|
204
|
+
let format = null;
|
|
205
|
+
// A trailing bracket is a note, and the two kinds are read by different rules.
|
|
206
|
+
//
|
|
207
|
+
// `(...)` only when it reads like a format — `(Cass, C60)` does, `(Moenai Hai)`
|
|
208
|
+
// does not — because this collection writes both a medium and a plain word in
|
|
209
|
+
// round brackets, and stripping the second truncates a title that was right.
|
|
210
|
+
//
|
|
211
|
+
// `[...]` **always**. The square kind is where the collection writes a pressing
|
|
212
|
+
// — `[UK promo Fiction FIXCD 17]`, `[JP - RRCY-1104]`, `[DE Polydor 873 909-2]`
|
|
213
|
+
// — and deciding that by a word list made `JP` a note and `DE` not: the list's
|
|
214
|
+
// edge rather than the collection's. The risk a word list was guarding against
|
|
215
|
+
// is gone, because the note reaches the name now — a bracket that turns out to
|
|
216
|
+
// be part of a title still reads as one.
|
|
217
|
+
//
|
|
218
|
+
// This was tried before and reverted, and the argument then was sound: the note
|
|
219
|
+
// was sent as `version`, the client this library is read in does not render
|
|
220
|
+
// that field, so `Slipknot [JP - RRCY-1104]` became `Slipknot` with the
|
|
221
|
+
// pressing nowhere a person would see it. What changed is the serving side.
|
|
222
|
+
const note = trailingNote(rest);
|
|
223
|
+
if (note !== null) {
|
|
224
|
+
format = note.body;
|
|
225
|
+
rest = rest.slice(0, note.at).trim();
|
|
226
|
+
}
|
|
227
|
+
// The scene layout, read before the Discogs grammar because it answers a
|
|
228
|
+
// different question: where that one splits on a dash between spaces, this one
|
|
229
|
+
// trusts the written separator and then the dashes after it. A name with no
|
|
230
|
+
// `_-_` cannot match, so nothing below changes for the collections already
|
|
231
|
+
// parsing.
|
|
232
|
+
const scene = SCENE.exec(rest);
|
|
233
|
+
if (scene !== null) {
|
|
234
|
+
const credit = (scene[1] ?? '').trim();
|
|
235
|
+
const title = (scene[2] ?? '').trim();
|
|
236
|
+
if (credit !== '' && title !== '') {
|
|
237
|
+
return {
|
|
238
|
+
year: scene[3] === undefined ? null : Number(scene[3]),
|
|
239
|
+
credit,
|
|
240
|
+
title,
|
|
241
|
+
format,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// A year in front of a title, joined by a dot or by the bullet this collection
|
|
246
|
+
// uses as its separator.
|
|
247
|
+
//
|
|
248
|
+
// `1992. Opiate [61422-31027-2]` says the same thing as `1992 - Opiate [...]`
|
|
249
|
+
// in a different punctuation: the collector writes the year as a prefix rather
|
|
250
|
+
// than as a field. It has to be read before the dash grammar, because a name
|
|
251
|
+
// with no dash in it reaches that grammar as a single segment and finds no
|
|
252
|
+
// year slot at all — which is what Tool's entire catalogue did, and why none
|
|
253
|
+
// of its records could say when it came out.
|
|
254
|
+
//
|
|
255
|
+
// The bullet is the same idea in the punctuation this collection actually
|
|
256
|
+
// writes: `1988 ● Группа крови (2019, Maschina Records, MKK881CD, 3CD)`. It
|
|
257
|
+
// went unread for as long as the dot form did, and the cost is the one the
|
|
258
|
+
// question above describes — the year stayed in the *title* and never reached
|
|
259
|
+
// the field, so every box in the collection was shown as `1988 ● Группа крови`
|
|
260
|
+
// with no year at all.
|
|
261
|
+
let dotYear = null;
|
|
262
|
+
const dotted = /^(\d{4})(?:\.\s+|\s*●\s*)/.exec(rest);
|
|
263
|
+
if (dotted !== null) {
|
|
264
|
+
dotYear = Number(dotted[1]);
|
|
265
|
+
rest = rest.slice(dotted[0].length);
|
|
266
|
+
}
|
|
267
|
+
const parts = splitFields(rest);
|
|
268
|
+
// The artist-first grammar: `The Gerogerigegege - 2016 - 燃えない灰 (Moenai
|
|
269
|
+
// Hai)`. Same three parts as the Discogs form with the first two swapped, and
|
|
270
|
+
// the swap is provable — a year is written *before* a title, never second in
|
|
271
|
+
// a name that already opened with one. So a year slot in the middle is the
|
|
272
|
+
// whole of the evidence needed, and nothing outside the string is consulted.
|
|
273
|
+
//
|
|
274
|
+
// No credit is claimed here. This branch's artist already arrives from a tag
|
|
275
|
+
// on every sample that has one, and filling `credit` would move a result
|
|
276
|
+
// [[task:2684]] verified byte for byte. [[task:2674]] is where an artist read
|
|
277
|
+
// off a folder becomes a thing this project consumes.
|
|
278
|
+
if (parts.length >= 3 && YEAR_SLOT.test(parts[1] ?? '')) {
|
|
279
|
+
const middle = parts[1] ?? '';
|
|
280
|
+
return {
|
|
281
|
+
year: /^\d{4}$/.test(middle) ? Number(middle) : null,
|
|
282
|
+
credit: null,
|
|
283
|
+
title: parts.slice(2).join(' - '),
|
|
284
|
+
format,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
// The same three fields as the Discogs form, written in the opposite order:
|
|
288
|
+
// `The Cure - Assemblage - 1991 (12CD FLAC)`. The proof is positional, as it
|
|
289
|
+
// is above and for the same reason — a year written *after* two other
|
|
290
|
+
// segments is not where a year goes in `год - кредит - заголовок`, so what
|
|
291
|
+
// stands in front of it is the credit and then the title.
|
|
292
|
+
//
|
|
293
|
+
// Three segments is the floor, and it is what keeps `Prince - 1999` out: two
|
|
294
|
+
// segments say nothing about which of them is a credit, and a title ending in
|
|
295
|
+
// a number is a real title. The name this was written for states its count in
|
|
296
|
+
// the same breath — `(12CD FLAC)` — which is the `DISC_COUNT` note above.
|
|
297
|
+
const tailSlot = parts[parts.length - 1];
|
|
298
|
+
if (parts.length >= 3 && tailSlot !== undefined && YEAR_SLOT.test(tailSlot)) {
|
|
299
|
+
return {
|
|
300
|
+
year: /^\d{4}$/.test(tailSlot) ? Number(tailSlot) : null,
|
|
301
|
+
credit: parts[0] ?? null,
|
|
302
|
+
title: parts.slice(1, -1).join(' - '),
|
|
303
|
+
format,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
// A year at the very end of a name is deliberately **not** read.
|
|
307
|
+
//
|
|
308
|
+
// `Led Zeppelin - II USA 8-track 1969` states one, and it is the only name in
|
|
309
|
+
// the collection that does — measured over all 395 of them, with nothing else
|
|
310
|
+
// moved. It looks like free money and it is not: this parser is also what
|
|
311
|
+
// `recordTitle` runs over, and `recordTitle` is handed the TITLE of a cue and
|
|
312
|
+
// of a tag as readily as a folder name. `A State Of Trance: Ibiza 2026` is
|
|
313
|
+
// one of those, and the trailing `2026` is the record's name rather than its
|
|
314
|
+
// year — the rule took a word out of it and `multidisc.test.ts` caught it.
|
|
315
|
+
//
|
|
316
|
+
// Nothing distinguishes the two strings. One record keeping an unknown year
|
|
317
|
+
// is the cheaper mistake by a distance, so the year stays unread.
|
|
318
|
+
const yearSlot = parts.length > 1 && YEAR_SLOT.test(parts[0] ?? '');
|
|
319
|
+
let year = dotYear;
|
|
320
|
+
let carriedSuffix = null;
|
|
321
|
+
if (yearSlot) {
|
|
322
|
+
year = /^\d{4}$/.test(parts[0] ?? '') ? Number(parts[0]) : null;
|
|
323
|
+
parts.shift();
|
|
324
|
+
// `2009 - This Must Be It - EP` has the same number of segments as
|
|
325
|
+
// `1994 - Cock E.S.P. + Thirdorgan - Split`, and only the last one tells
|
|
326
|
+
// them apart. A bare release-type word in that slot is a suffix: the
|
|
327
|
+
// segment before it is the title, and there is no credit at all. Read as a
|
|
328
|
+
// credit it named the album `EP` and invented an artist.
|
|
329
|
+
//
|
|
330
|
+
// The list is short on purpose. `Split`, `Live`, `Remixes`, `Compilation`
|
|
331
|
+
// and `Album` are all real record titles in these collections, and taking
|
|
332
|
+
// any of them as a suffix would cost the credit on the records that carry
|
|
333
|
+
// it — sixteen of them on the Cock E.S.P sample alone.
|
|
334
|
+
const last = parts[parts.length - 1];
|
|
335
|
+
if (parts.length >= 2 &&
|
|
336
|
+
last !== undefined &&
|
|
337
|
+
(RELEASE_TYPE.test(last) || DISC_SUFFIX.test(last))) {
|
|
338
|
+
carriedSuffix = last;
|
|
339
|
+
parts.pop();
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
const title = parts.length === 0 ? null : parts.join(' - ');
|
|
343
|
+
// Behind a year slot, the first remaining segment is the credit when there is
|
|
344
|
+
// anything left for a title to be.
|
|
345
|
+
if (yearSlot && parts.length >= 2) {
|
|
346
|
+
return { year, credit: parts[0] ?? null, title: parts.slice(1).join(' - '), format };
|
|
347
|
+
}
|
|
348
|
+
// The suffix was the only thing after the title, so the title is the segment
|
|
349
|
+
// before it and nothing was a credit.
|
|
350
|
+
if (carriedSuffix !== null && parts.length === 1) {
|
|
351
|
+
return { year, credit: null, title: parts[0] ?? null, format };
|
|
352
|
+
}
|
|
353
|
+
// Without a year slot a credit is claimed only when a joiner proves one, and
|
|
354
|
+
// then only for the segment that carries it.
|
|
355
|
+
//
|
|
356
|
+
// Where a title survives, that is the whole of the evidence, and it is good
|
|
357
|
+
// evidence: `2009 - Twodeadsluts Onegoodfuck + Cock E.S.P. (Cass, Ltd, C5)` is
|
|
358
|
+
// the Discogs form with its title slot missing, and `+` between two names is
|
|
359
|
+
// what this collection writes a split with. Where *nothing* survives, the
|
|
360
|
+
// joiner stands alone — and alone it does not prove a credit, because `&`
|
|
361
|
+
// sits inside titles as readily as between two names. Measured over the live
|
|
362
|
+
// collection: the branch fires on six names, and the five that carry a digit
|
|
363
|
+
// are a year range in a title, a volume number, a disc marker and a catalogue
|
|
364
|
+
// number. The one garbage artist the meta layer holds is one of those five.
|
|
365
|
+
//
|
|
366
|
+
// What tells them apart is that a credit is a list of *names*, and a name
|
|
367
|
+
// carries no number. So the guard is asked only when no title is left, and it
|
|
368
|
+
// asks only that.
|
|
369
|
+
const first = parts[0];
|
|
370
|
+
if (first !== undefined && JOINER.test(first)) {
|
|
371
|
+
const remainder = parts.slice(1).join(' - ');
|
|
372
|
+
if (remainder !== '' || !/\d/.test(first)) {
|
|
373
|
+
return { year, credit: first, title: remainder === '' ? null : remainder, format };
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return { year, credit: null, title, format };
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* The name a *record* is shown by: the title, the edition note kept, no year.
|
|
380
|
+
*
|
|
381
|
+
* `parseFolderName` answers what a folder's name is made of; this answers what a
|
|
382
|
+
* client should be shown, and the live collection settled both differences.
|
|
383
|
+
*
|
|
384
|
+
* **The note stays in the name.** It was moved out to `version` on the argument
|
|
385
|
+
* that the protocol has a field for it — and it does, but a client that does not
|
|
386
|
+
* render `version` (Feishin, the one the library is actually read in) then shows
|
|
387
|
+
* `Группа крови` five times with nothing to choose between them. The operator
|
|
388
|
+
* asked where `Maschina Records` had gone. A record's edition is information
|
|
389
|
+
* about which record it is, and it belongs where every client will show it.
|
|
390
|
+
* `recordVersion` still offers it, and the API suppresses that copy when the
|
|
391
|
+
* name already carries it — so a client that reads the field is not told twice.
|
|
392
|
+
*
|
|
393
|
+
* **The year goes.** A year written in front of a title, in any of the
|
|
394
|
+
* punctuations this collection uses (`1988 ● …`, `1992. …`, `1989 …`), is the
|
|
395
|
+
* record's year and belongs in the field that holds it — not in the name. The
|
|
396
|
+
* live collection showed every box as `1988 ● Группа крови` with an empty `year`
|
|
397
|
+
* because the bullet went unread.
|
|
398
|
+
*
|
|
399
|
+
* Both a folder and an `ALBUM` tag name a record, and they must agree: this
|
|
400
|
+
* collection showed a name from a tag beside a name from a folder, under one
|
|
401
|
+
* artist, and only one of them looked like the rest.
|
|
402
|
+
*/
|
|
403
|
+
export function recordTitle(name) {
|
|
404
|
+
const parsed = parseFolderName(name);
|
|
405
|
+
const note = (parsed.format ?? '').replace(/^\d{4}\s*,\s*/, '').trim();
|
|
406
|
+
// Nothing was separated out, so the name is the whole of what there is: it
|
|
407
|
+
// already carries its bracket, and appending the note would write it twice.
|
|
408
|
+
// `Кинохроники 2021/1982 (Maschina Records, MASHCD-099)` is the case — the
|
|
409
|
+
// slash in the title reads as a joiner, so the parser finds a credit and no
|
|
410
|
+
// title, and the verbatim name is the only honest answer.
|
|
411
|
+
if (parsed.title === null)
|
|
412
|
+
return name;
|
|
413
|
+
// A separator has to follow, so a record genuinely called `1999` keeps it.
|
|
414
|
+
const title = parsed.title.replace(/^(?:19|20)\d{2}(?:[\s.●•]+|-[-\s]+)/, '').trim();
|
|
415
|
+
return note === '' ? title : `${title} (${note})`;
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Which edition of a record this is, or null when the name says nothing.
|
|
419
|
+
*
|
|
420
|
+
* `AlbumID3.version` is the field the protocol keeps for exactly this —
|
|
421
|
+
* "Remastered, Anniversary Box Set". It is **not** where the note belongs by
|
|
422
|
+
* right: `recordTitle` above argues the other way and won, because the client
|
|
423
|
+
* this library is read in does not render the field, and a note nobody is shown
|
|
424
|
+
* is not worth taking out of a name. What is left for this is the record whose
|
|
425
|
+
* *name* does not carry the note — one a tag or a cue named, which wrote its own
|
|
426
|
+
* title and dropped the bracket on the way. The API suppresses this copy when
|
|
427
|
+
* the name already says it, so a client that does render the field is never told
|
|
428
|
+
* twice; see `albumId3`.
|
|
429
|
+
*
|
|
430
|
+
* The note's own year goes the way the folder's did: `(2019, Maschina Records,
|
|
431
|
+
* MKK881CD, 3CD)` says 2019 about the *pressing*, and the record is 1988.
|
|
432
|
+
*/
|
|
433
|
+
export function recordVersion(name) {
|
|
434
|
+
const note = (parseFolderName(name).format ?? '').replace(/^\d{4}\s*,\s*/, '').trim();
|
|
435
|
+
return note === '' ? null : note;
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* The name a disc carries beyond its number, or null when it carries none.
|
|
439
|
+
*
|
|
440
|
+
* `CD2 ● Ранний вариант` names a disc that is more than its number: a bonus
|
|
441
|
+
* disc, an early version, a live set. The protocol has a place for it
|
|
442
|
+
* (`discTitles`), and the number in front is not part of it.
|
|
443
|
+
*
|
|
444
|
+
* A disc is numbered in one of two ways and both put the number first. It may
|
|
445
|
+
* be *marked* as a disc — `CD2 ● …`, `Disc 1 [JP]` — or merely numbered, which
|
|
446
|
+
* is how a box whose discs are named after the albums on them is laid out:
|
|
447
|
+
* `01 - Three Imaginary Boys (1979)`, `12 - Disintegration (1989)`. The second
|
|
448
|
+
* shape went unread until the box it belongs to was recognised at all, and the
|
|
449
|
+
* cost was that all twelve discs of it were labelled with the record's name and
|
|
450
|
+
* nothing else — the album each one holds appeared nowhere.
|
|
451
|
+
*
|
|
452
|
+
* So the only thing this refuses is a name that is *nothing but* its number:
|
|
453
|
+
* `CD1` is its number and has no name to offer. A name with no marker at all is
|
|
454
|
+
* not refused — `Show` is the name it is, and whether it says anything the
|
|
455
|
+
* record does not is not a question about this string. The caller compares it
|
|
456
|
+
* with the record's title and drops it when they agree, which is where the
|
|
457
|
+
* record is known.
|
|
458
|
+
*/
|
|
459
|
+
export function discSubtitle(title) {
|
|
460
|
+
const withoutMarker = title.replace(/^(?:cd|disc|disk|d)\s*[._-]?\s*\d{1,2}\b/i, '');
|
|
461
|
+
// An ordinal needs its separator: `1999` is a title, and a number with
|
|
462
|
+
// nothing after it is not an ordinal.
|
|
463
|
+
const withoutOrdinal = withoutMarker.replace(/^\d{1,3}\s*[.\-–]\s+/, '');
|
|
464
|
+
const rest = withoutOrdinal.replace(/^[\s._●•\-–]+/, '').trim();
|
|
465
|
+
return rest === '' ? null : rest;
|
|
466
|
+
}
|