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.
Files changed (162) hide show
  1. package/.env.example +90 -0
  2. package/CHANGELOG.md +104 -0
  3. package/DEPLOY.md +375 -0
  4. package/LICENSE +21 -0
  5. package/README.md +138 -0
  6. package/deploy/answers.schema.json +151 -0
  7. package/deploy/build.mjs +88 -0
  8. package/deploy/check-publishable.mjs +246 -0
  9. package/deploy/smoke.mjs +681 -0
  10. package/deploy/systemd/funoteka.env.example +41 -0
  11. package/deploy/systemd/funoteka.service +77 -0
  12. package/deploy/windows/funoteka-service.xml +58 -0
  13. package/deploy/windows/install-service.ps1 +158 -0
  14. package/dist/annotation/store.js +67 -0
  15. package/dist/api/admin-export.js +227 -0
  16. package/dist/api/admin-guard.js +181 -0
  17. package/dist/api/admin-library.js +113 -0
  18. package/dist/api/admin.js +1162 -0
  19. package/dist/api/annotation.js +193 -0
  20. package/dist/api/artistinfo.js +140 -0
  21. package/dist/api/audit.js +32 -0
  22. package/dist/api/auth.js +145 -0
  23. package/dist/api/bookmark.js +108 -0
  24. package/dist/api/browse.js +1241 -0
  25. package/dist/api/config-file.js +202 -0
  26. package/dist/api/config.js +285 -0
  27. package/dist/api/cover.js +304 -0
  28. package/dist/api/download.js +112 -0
  29. package/dist/api/envelope.js +162 -0
  30. package/dist/api/extensions.js +43 -0
  31. package/dist/api/genre.js +81 -0
  32. package/dist/api/health.js +35 -0
  33. package/dist/api/history.js +374 -0
  34. package/dist/api/idempotency.js +66 -0
  35. package/dist/api/keys.js +90 -0
  36. package/dist/api/log-file.js +65 -0
  37. package/dist/api/meta.js +1410 -0
  38. package/dist/api/playlist.js +311 -0
  39. package/dist/api/rescan.js +196 -0
  40. package/dist/api/roots.js +161 -0
  41. package/dist/api/router.js +133 -0
  42. package/dist/api/scanner.js +163 -0
  43. package/dist/api/search.js +82 -0
  44. package/dist/api/server.js +643 -0
  45. package/dist/api/settings.js +67 -0
  46. package/dist/api/stream.js +442 -0
  47. package/dist/api/stubs.js +183 -0
  48. package/dist/api/transcode.js +678 -0
  49. package/dist/api/user.js +73 -0
  50. package/dist/api/virtual.js +362 -0
  51. package/dist/api/visibility.js +21 -0
  52. package/dist/artist/apply.js +752 -0
  53. package/dist/artist/credit.js +95 -0
  54. package/dist/artist/folder.js +65 -0
  55. package/dist/artist/name.js +205 -0
  56. package/dist/artist/nfo.js +101 -0
  57. package/dist/artist/translit.js +79 -0
  58. package/dist/bookmark/store.js +54 -0
  59. package/dist/classify/classify.js +405 -0
  60. package/dist/classify/collision-name.js +109 -0
  61. package/dist/classify/folder-name.js +466 -0
  62. package/dist/classify/roles.js +277 -0
  63. package/dist/classify/shelf-name.js +178 -0
  64. package/dist/classify/tree.js +43 -0
  65. package/dist/cli/args.js +169 -0
  66. package/dist/cli/daemon.js +192 -0
  67. package/dist/cli/entry.js +25 -0
  68. package/dist/cli/keys.js +52 -0
  69. package/dist/cli.js +638 -0
  70. package/dist/cover/pick.js +58 -0
  71. package/dist/cover/picture.js +111 -0
  72. package/dist/cue/engine.js +759 -0
  73. package/dist/cue/match.js +142 -0
  74. package/dist/cue/parse.js +182 -0
  75. package/dist/cue/plan.js +286 -0
  76. package/dist/cue/track-name.js +120 -0
  77. package/dist/db/index.js +163 -0
  78. package/dist/db/issue.js +73 -0
  79. package/dist/db/ledger.js +84 -0
  80. package/dist/db/migrations/001_init.sql +196 -0
  81. package/dist/db/migrations/002_cue_track_flexible.sql +35 -0
  82. package/dist/db/migrations/003_cue_metadata.sql +14 -0
  83. package/dist/db/migrations/004_cue_performer.sql +12 -0
  84. package/dist/db/migrations/005_incremental.sql +23 -0
  85. package/dist/db/migrations/006_tags.sql +57 -0
  86. package/dist/db/migrations/007_artist_credit.sql +35 -0
  87. package/dist/db/migrations/008_issue_stage.sql +39 -0
  88. package/dist/db/migrations/009_cue_title.sql +20 -0
  89. package/dist/db/migrations/010_duration_source.sql +31 -0
  90. package/dist/db/migrations/011_track_title_source.sql +28 -0
  91. package/dist/db/migrations/012_file_container.sql +25 -0
  92. package/dist/db/migrations/013_cover_art.sql +28 -0
  93. package/dist/db/migrations/014_album_year.sql +17 -0
  94. package/dist/db/migrations/015_probe_method.sql +14 -0
  95. package/dist/db/migrations/016_tags_method.sql +20 -0
  96. package/dist/db/migrations/017_cover_indirect.sql +23 -0
  97. package/dist/db/migrations/018_release_year.sql +14 -0
  98. package/dist/db/migrations/019_file_tag_name_file_value.sql +20 -0
  99. package/dist/db/migrations/020_analyze_after_index.sql +16 -0
  100. package/dist/db/migrations/021_cue_index_files.sql +15 -0
  101. package/dist/db/migrations/022_sidecar_text.sql +19 -0
  102. package/dist/db/migrations/023_track_credit.sql +23 -0
  103. package/dist/db/migrations/024_playlists.sql +63 -0
  104. package/dist/db/migrations/025_playlist_sequence.sql +27 -0
  105. package/dist/db/migrations/026_playlist_source_file.sql +19 -0
  106. package/dist/db/migrations/027_lookup_indexes.sql +30 -0
  107. package/dist/db/migrations/028_annotations.sql +47 -0
  108. package/dist/db/migrations/029_scan_state_root_run.sql +28 -0
  109. package/dist/db/migrations/030_history.sql +108 -0
  110. package/dist/db/migrations/031_queue_position_and_plays.sql +46 -0
  111. package/dist/db/migrations/032_bookmarks.sql +27 -0
  112. package/dist/db/migrations/033_bookmark_order.sql +14 -0
  113. package/dist/db/migrations/034_junk.sql +33 -0
  114. package/dist/db/migrations/035_api_key.sql +38 -0
  115. package/dist/db/migrations/036_file_tag_first.sql +88 -0
  116. package/dist/db/migrations/037_admin_idempotency.sql +31 -0
  117. package/dist/db/migrations/038_cue_audio_index.sql +24 -0
  118. package/dist/db/sweep.js +109 -0
  119. package/dist/history/store.js +151 -0
  120. package/dist/inventory/inventory.js +294 -0
  121. package/dist/junk/marks.js +143 -0
  122. package/dist/junk/rule.js +93 -0
  123. package/dist/mcp/client.js +48 -0
  124. package/dist/mcp/server.js +175 -0
  125. package/dist/mcp/tools.js +348 -0
  126. package/dist/playlist/files.js +116 -0
  127. package/dist/playlist/import.js +260 -0
  128. package/dist/playlist/store.js +240 -0
  129. package/dist/probe/ffprobe.js +179 -0
  130. package/dist/run.js +120 -0
  131. package/dist/scan/kinds.js +49 -0
  132. package/dist/scan/scan.js +503 -0
  133. package/dist/scan/schedule.js +132 -0
  134. package/dist/scan/settle.js +172 -0
  135. package/dist/scan/walk.js +132 -0
  136. package/dist/search/index.js +41 -0
  137. package/dist/search/query.js +28 -0
  138. package/dist/stream/flac.js +541 -0
  139. package/dist/stream/mpeg.js +179 -0
  140. package/dist/stream/recode.js +393 -0
  141. package/dist/stream/rewrite.js +163 -0
  142. package/dist/stream/segment.js +624 -0
  143. package/dist/tags/apply.js +703 -0
  144. package/dist/tags/encode.js +164 -0
  145. package/dist/tags/first.js +130 -0
  146. package/dist/tags/flac.js +196 -0
  147. package/dist/tags/genres.js +123 -0
  148. package/dist/tags/id3v1.js +209 -0
  149. package/dist/tags/id3v2.js +754 -0
  150. package/dist/tags/mp4.js +534 -0
  151. package/dist/tags/mpeg.js +355 -0
  152. package/dist/tags/ogg.js +463 -0
  153. package/dist/tags/picture.js +71 -0
  154. package/dist/tags/read.js +269 -0
  155. package/dist/tags/types.js +51 -0
  156. package/dist/tags/vorbis-comment.js +155 -0
  157. package/dist/text/encoding.js +384 -0
  158. package/dist/text/site-name.js +37 -0
  159. package/dist/util/child.js +21 -0
  160. package/dist/util/names.js +97 -0
  161. package/funoteka.json.example +27 -0
  162. 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
+ }