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,95 @@
1
+ /**
2
+ * An artist credit, read as a list.
3
+ *
4
+ * A collaboration is not one artist with punctuation in the name, and the
5
+ * collection says so constantly: `Cock E.S.P. + Thirdorgan`, `Merzbow & Cock
6
+ * E.S.P.`, `Aube / Cock E.S.P.` spread across 20 of the 24 Cock E.S.P folders.
7
+ * Reading them as a single name loses the collaboration; reading them as a list
8
+ * and throwing the string away loses the album when the reading is wrong.
9
+ *
10
+ * So neither is lost. Each entry carries the exact phrase that joined it to the
11
+ * entry before, which means the list rebuilds into the original string
12
+ * character for character. A split is a *reading* of a credit rather than a
13
+ * replacement for it, and an operator who disagrees with one can see exactly
14
+ * what was done and undo it.
15
+ *
16
+ * That is what makes "catch maximally" safe. And it needs to be, because two of
17
+ * these rules are provably wrong and will stay wrong:
18
+ *
19
+ * - `Smell & Quim` is one artist. The rules split it. Nothing available to a
20
+ * deterministic core can tell it apart from `Merzbow & Cock E.S.P.`, and
21
+ * §2 of the design spec puts machine inference outside the core — so the
22
+ * honest answer is to split it and be seen doing so.
23
+ * - A **word** token matched anywhere rather than as a whole word is not a
24
+ * near-miss, it is a disaster: `Extreme Noise Terror` becomes `E` +
25
+ * `treme Noise Terror` the moment `x` is a bare token, and `The Nihilist
26
+ * Spasm Band` becomes `The Nihilist Spasm B` + `and`. Both were measured on
27
+ * the real sample before this rule was written.
28
+ */
29
+ /**
30
+ * Symbols join wherever they appear, with whatever whitespace surrounds them.
31
+ *
32
+ * Unambiguous as characters: nobody spells an artist with a `;`. `&` is the
33
+ * risky one (`Smell & Quim`) and there is no way around that except to keep the
34
+ * original string on the row.
35
+ */
36
+ const SYMBOLS = '&+/;×';
37
+ /**
38
+ * Words join only when they stand alone — whitespace on both sides.
39
+ *
40
+ * The rule the measurement bought. `x` and `and` are far too common *inside*
41
+ * names to match as bare substrings, and `x` counts only in lower case:
42
+ * `Malcolm X` is a name, `Malcolm X and Xzibit` is a collaboration.
43
+ */
44
+ const WORDS = ['featuring', 'feat\\.', 'ft\\.', 'vs\\.?', 'and', 'with', 'meets', 'x'];
45
+ /**
46
+ * One pattern, two alternatives: a symbol with the whitespace around it, or a
47
+ * whole word with the whitespace that isolates it.
48
+ *
49
+ * The whitespace is captured *into* the match deliberately — it belongs to the
50
+ * phrase that joined the two artists, and dropping it is how a credit stops
51
+ * rebuilding into itself. Both branches therefore take the *run*, `\s+` and
52
+ * `\s*`, and not a single character: `A and B` has two spaces either side of
53
+ * the word, and a pattern that took one of each rebuilt it as `A and B` — which
54
+ * is what the module's promise of a character-for-character round trip turned
55
+ * out not to cover (task:2756).
56
+ */
57
+ const JOINER = new RegExp(`\\s*([${SYMBOLS}])\\s*|\\s+(?:${WORDS.join('|')})\\s+`, 'g');
58
+ /**
59
+ * Read a credit string as a list of artists and the phrases between them.
60
+ *
61
+ * Never throws and never loses its input: for every string with content on both
62
+ * sides of a joiner, `splitCredit(raw).map((e) => e.joinPhrase + e.name)
63
+ * .join('')` returns `raw`. Outer whitespace is the one exception, and
64
+ * deliberately so — a name with a leading space sorts wrongly forever.
65
+ */
66
+ export function splitCredit(raw) {
67
+ const text = raw.trim();
68
+ const entries = [];
69
+ let at = 0;
70
+ // The phrase leading to the entry about to be pushed; empty before the first.
71
+ let phrase = '';
72
+ JOINER.lastIndex = 0;
73
+ for (let match = JOINER.exec(text); match !== null; match = JOINER.exec(text)) {
74
+ const start = match.index;
75
+ const after = start + match[0].length;
76
+ const name = text.slice(at, start).trim();
77
+ // A joiner with nothing before it (`& x`) or nothing after it (`a +`) is
78
+ // not joining two artists — it is part of one name. Splitting there would
79
+ // produce an empty name and break the round-trip on precisely the
80
+ // malformed input that most needs to survive intact.
81
+ if (name === '' || text.slice(after).trim() === '')
82
+ continue;
83
+ entries.push({ name, joinPhrase: phrase });
84
+ phrase = match[0];
85
+ at = after;
86
+ }
87
+ if (entries.length === 0) {
88
+ // Either the whole thing is one name, or it is empty.
89
+ return text === '' ? [] : [{ name: text, joinPhrase: '' }];
90
+ }
91
+ const tail = text.slice(at).trim();
92
+ if (tail !== '')
93
+ entries.push({ name: tail, joinPhrase: phrase });
94
+ return entries;
95
+ }
@@ -0,0 +1,65 @@
1
+ import { artistName } from "./name.js";
2
+ import { rootBasenameOf } from "../util/names.js";
3
+ /**
4
+ * The artist folder above `albumRelPath`, or null when there is none.
5
+ *
6
+ * Null is the ordinary answer, not a failure: most albums in a loose collection
7
+ * have no artist folder at all, and a null folder is simply not evidence. The
8
+ * caller must not read it as "a different artist" — see `applyArtists`, where a
9
+ * key splits only when two or more *real* folders disagree.
10
+ */
11
+ export function artistFolderOf(input) {
12
+ const { albumRelPath, rootPath, key } = input;
13
+ // An empty key means "not a name" (see `artistName`), and it would match the
14
+ // first folder whose own name is also not a name — `The`, say — inventing an
15
+ // artist folder out of nothing. The caller already refuses empty keys; this is
16
+ // the guard that keeps the refusal from having to be repeated.
17
+ if (key === '')
18
+ return null;
19
+ // An album that *is* the root has no folder above it. The root is its own
20
+ // folder here, and a record's folder is never its artist's.
21
+ if (albumRelPath === '')
22
+ return null;
23
+ // The root first: it is the outermost folder there is, and `Downloads\Кино`
24
+ // is a real artist folder (wiki:3519). Its name has to be read
25
+ // separator-agnostically — on Windows the stored path is backslashed.
26
+ const rootName = rootBasenameOf(rootPath);
27
+ if (artistName(rootName).key === key) {
28
+ return { id: `${rootPath}:`, rootPath, relPath: '' };
29
+ }
30
+ const parts = albumRelPath.split('/');
31
+ // Shallowest first. Bounding at `i < parts.length` is what keeps the album's
32
+ // own folder — the last segment — out of the running: the deepest candidate
33
+ // is its parent.
34
+ for (let i = 1; i < parts.length; i += 1) {
35
+ const name = parts[i - 1] ?? '';
36
+ if (artistName(name).key === key) {
37
+ const relPath = parts.slice(0, i).join('/');
38
+ return { id: `${rootPath}:${relPath}`, rootPath, relPath };
39
+ }
40
+ }
41
+ return null;
42
+ }
43
+ /**
44
+ * The merge key for the `index`-th artist folder of one name.
45
+ *
46
+ * The first folder keeps the bare key — for the overwhelming majority of
47
+ * artists there is only one folder anyway, and `nirvana` is a better key to read
48
+ * in a dump than `nirvana#1`. The rest are numbered in the order the caller
49
+ * sorted them, so a folder added ahead of an existing one takes the bare key and
50
+ * the one behind it moves to the next number. Nothing corrupts when that
51
+ * happens — albums and credits are re-resolved through the new keys every run —
52
+ * but a reader who knew a row as `nirvana#2` last time may find it elsewhere.
53
+ *
54
+ * A suffixed key can never collide with a bare one, though not for the obvious
55
+ * reason. `#` *does* survive into a key: `artistName` falls back to the raw
56
+ * lowercased name for a name with no letter or digit in it, so an artist called
57
+ * `#` keys to `#`. The invariant is narrower and holds regardless — a key
58
+ * containing `#` has no letter or digit anywhere in it (that is the only route
59
+ * to the fallback), while a suffixed key always ends in one. A name reading
60
+ * `nirvana#2` folds to `nirvana 2`, and `##2` folds to `2`, so neither can
61
+ * produce a suffixed key. The two sets are disjoint by construction.
62
+ */
63
+ export function qualifiedKey(key, index) {
64
+ return index === 0 ? key : `${key}#${index + 1}`;
65
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * One artist, however many ways the collection spells them.
3
+ *
4
+ * `The Cure`, `Cure, The` and `cure` are one artist, and a library that lists
5
+ * them three times has failed at the only thing it promised. So names are
6
+ * reduced to a key, and the key is what merges.
7
+ *
8
+ * But merging is a claim, and some claims are guesses. The module therefore
9
+ * answers at *two* levels, and the gap between them is the point:
10
+ *
11
+ * - `folded` normalises only what is obviously the same record — case,
12
+ * punctuation, whitespace, the definite article, and the accents a tag
13
+ * loses on its way through a ripper.
14
+ * - `key` goes further and also drops a disambiguator, because `Nirvana (UK)`
15
+ * and `Nirvana` would otherwise never merge.
16
+ *
17
+ * Two names that fold to the same thing are the same artist, plainly. Two that
18
+ * fold *differently* and still share a key were merged by discarding something
19
+ * a human wrote on purpose — and that is exactly the merge worth flagging
20
+ * rather than performing in silence (requirements:39 §6).
21
+ */
22
+ /**
23
+ * `’` for `'` and the typographic dashes for `-`.
24
+ *
25
+ * Rippers write all of these, and a key that treats `N’Ko` and `N'Ko` as two
26
+ * artists is not doing its job. Whitespace is collapsed here rather than at
27
+ * each use, since every path below assumes single spaces.
28
+ */
29
+ function tidy(raw) {
30
+ return raw
31
+ .replace(/[‘’ʼ]/g, "'")
32
+ .replace(/[“”]/g, '"')
33
+ .replace(/[–—]/g, '-')
34
+ .replace(/\s+/g, ' ')
35
+ .trim();
36
+ }
37
+ const LEADING_ARTICLE = /^the\s+/i;
38
+ const TRAILING_ARTICLE = /,\s*the$/i;
39
+ /** A name that is nothing but the article — not an artist, and not a key. */
40
+ const ONLY_ARTICLE = /^the$/i;
41
+ /**
42
+ * Remove the definite article, from whichever end a ripper put it.
43
+ *
44
+ * Only `the`: it is the one article that gets inverted in practice, and
45
+ * stripping `a`/`an` would merge `A Perfect Circle` with a hypothetical
46
+ * `Perfect Circle` on no evidence at all.
47
+ */
48
+ function dropArticle(name) {
49
+ if (ONLY_ARTICLE.test(name))
50
+ return '';
51
+ if (LEADING_ARTICLE.test(name))
52
+ return name.replace(LEADING_ARTICLE, '');
53
+ if (TRAILING_ARTICLE.test(name))
54
+ return name.replace(TRAILING_ARTICLE, '');
55
+ return name;
56
+ }
57
+ /**
58
+ * Letters, digits and combining marks survive; everything else becomes a
59
+ * separator.
60
+ *
61
+ * Unicode-aware on purpose — `Кино` has to come out the other side intact, and
62
+ * `\w` would not manage it. `\p{M}` survives *here* so that a mark stays part
63
+ * of its letter instead of becoming a separator: `foldName` below is where it
64
+ * is dropped, deliberately, and a mark turned into a space first would leave
65
+ * `bj rk` behind and merge nothing.
66
+ */
67
+ function stripPunctuation(name) {
68
+ return name.replace(/[^\p{L}\p{N}\p{M}]+/gu, ' ').trim();
69
+ }
70
+ /**
71
+ * A dot inside a word belongs to the word, not between two of them.
72
+ *
73
+ * `Cock E.S.P.` and `Cock Esp` are one act, and this collection holds both
74
+ * spellings — twelve albums under the first, two under the second, and no flag
75
+ * between them, because the fold turned the acronym's dots into spaces and left
76
+ * `cock e s p` standing beside `cock esp`. An initialism is written with its
77
+ * dots in some places and without them in others; dropping them is the same
78
+ * class of normalisation as case, and it is what makes the two one key.
79
+ *
80
+ * Only a dot with something other than a space after it. `Dr. Dre` and
81
+ * `St. Vincent` keep the space their dot was standing in, and a dot at the end
82
+ * of a name is turned into a separator by the fold below either way.
83
+ */
84
+ function closeAcronymDots(name) {
85
+ return name.replace(/\.(?=\S)/g, '');
86
+ }
87
+ /**
88
+ * The key a name folds to, with its accents gone.
89
+ *
90
+ * `Röyksopp` and `Royksopp` are one artist, and so are `Sigur Rós` and `Sigur
91
+ * Ros` — measured over the live collection, where those two pairs are the only
92
+ * names this merges and nothing else in it moves.
93
+ *
94
+ * The claim is about the collection rather than about Unicode: an unaccented
95
+ * spelling is nearly always a tag that lost its accent, not a name written
96
+ * differently on purpose. Nobody types `Royksopp` deliberately, and the
97
+ * opposite case — a collector who meant the plain spelling and got the accented
98
+ * one — costs a letter rather than a record.
99
+ *
100
+ * Decomposing before the marks come off is what keeps the two writings of one
101
+ * letter together: `ö` is a single code point in a tag and `o` plus U+0308 in a
102
+ * tree ripped on macOS, and both have to fold the same way. It is also what
103
+ * lets `Bjork`, carrying no mark at all, join them.
104
+ *
105
+ * What it costs: a mark that carries meaning goes with the rest. Cyrillic loses
106
+ * `й` to `и` and `ё` to `е`, so two names differing only there — and they are
107
+ * two names — would fold together. Nothing in this collection does, and the
108
+ * alternative is a library that lists `Röyksopp` twice.
109
+ */
110
+ function foldName(name) {
111
+ const folded = stripPunctuation(closeAcronymDots(dropArticle(name).toLowerCase()));
112
+ return folded.normalize('NFD').replace(/\p{M}/gu, '');
113
+ }
114
+ /** `(UK)`, `[US]` — a human marking which of several same-named artists this is. */
115
+ const BRACKETED = /[([][^)\]]*[)\]]/g;
116
+ function stripDisambiguators(name) {
117
+ const found = [];
118
+ // Whitespace is collapsed, not merely left behind. Removing `(UK)` from
119
+ // `Cure, The (UK)` otherwise leaves a trailing space, and the article
120
+ // pattern — anchored at the end — stops matching, so the name keys as
121
+ // `cure the` while `Cure, The` keys as `cure`. One artist, two rows, which is
122
+ // the whole failure this module exists to prevent.
123
+ const cleaned = name
124
+ .replace(BRACKETED, (match) => {
125
+ const inner = match.slice(1, -1).trim();
126
+ if (inner !== '')
127
+ found.push(inner);
128
+ return ' ';
129
+ })
130
+ .replace(/\s+/g, ' ')
131
+ .trim();
132
+ // A name written *entirely* in brackets is the name, and the bracket has
133
+ // nothing left to qualify. `[LINKIN PARK]` is how a file states the artist —
134
+ // not a qualifier of one — and stripping it anyway left an empty name, which
135
+ // the key reads as "not a name". So one act the collection writes two ways
136
+ // became two identities, one of them nameless: thirteen records of
137
+ // `Linkin Park Japan CD` state `[LINKIN PARK]` on some tracks and
138
+ // `Linkin Park` on the rest, the stage found no name every track states, and
139
+ // they carried no album artist at all (task:2837).
140
+ if (cleaned === '' && found.length > 0)
141
+ return { name: found[0], stripped: null };
142
+ return { name: cleaned, stripped: found.length === 0 ? null : found.join('; ') };
143
+ }
144
+ /**
145
+ * Sort key: `The Cure` files under C.
146
+ *
147
+ * A name already written inverted (`Cure, The`) is left as it is — inverting
148
+ * it again would produce `The, Cure, The`.
149
+ */
150
+ function sortKeyOf(name) {
151
+ if (!LEADING_ARTICLE.test(name))
152
+ return name;
153
+ return `${name.replace(LEADING_ARTICLE, '')}, The`;
154
+ }
155
+ /** Anything that could be a word. `The` has it; `!!!` does not. */
156
+ function hasWordChar(name) {
157
+ return /[\p{L}\p{N}]/u.test(name);
158
+ }
159
+ export function artistName(raw) {
160
+ const name = tidy(raw);
161
+ const disambiguated = stripDisambiguators(name);
162
+ // An empty key means "not a name", and folding alone cannot tell the two
163
+ // cases apart: `The` folds to nothing because it is not a name, while `!!!`
164
+ // and `∆` fold to nothing because they are names written in symbols. Both
165
+ // are real artists, and giving them one shared empty key would merge every
166
+ // symbol-only act into a single nonexistent row.
167
+ const foldedDisambiguated = foldName(disambiguated.name);
168
+ const key = foldedDisambiguated !== '' || hasWordChar(name) ? foldedDisambiguated : name.toLowerCase();
169
+ return {
170
+ name,
171
+ folded: foldName(name),
172
+ key,
173
+ sortKey: sortKeyOf(name),
174
+ stripped: disambiguated.stripped,
175
+ };
176
+ }
177
+ /**
178
+ * The artist a folder's name belongs to, or null.
179
+ *
180
+ * The rule `shelf-name.ts` spells out, read the other way round. There a shelf's
181
+ * name is the artist's name *plus* something and the something is subtracted to
182
+ * name the record; here the same fact gathers the artist — a folder whose name
183
+ * opens with an artist's key is that artist's.
184
+ *
185
+ * The longest key wins, not the first: `Röyksopp Discography` opens with
186
+ * `Röyksopp`, and a shorter artist called `Röy` would otherwise take it.
187
+ *
188
+ * A separator has to follow, or `S` claims `Slipknot`. The same guard
189
+ * `shelf-name.ts` puts on the subtraction, for the same reason and from the
190
+ * same measurement.
191
+ */
192
+ export function artistOwning(folderName, keys) {
193
+ const folderKey = artistName(folderName).key;
194
+ if (folderKey === '')
195
+ return null;
196
+ let best = null;
197
+ for (const key of keys) {
198
+ if (key === '')
199
+ continue;
200
+ const fits = folderKey === key || folderKey.startsWith(`${key} `);
201
+ if (fits && (best === null || key.length > best.length))
202
+ best = key;
203
+ }
204
+ return best;
205
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * What an artist's `.nfo` says about them.
3
+ *
4
+ * The collection keeps a note about an artist in the folder named for them —
5
+ * `The Cure/artist.nfo`, written by Jellyfin — and it is the only offline
6
+ * source of a biography this project has. The scan records `.nfo` files as
7
+ * files and never parses them: their text is not in the meta layer, so it is
8
+ * read out of the document at the point a client asks, which is what makes this
9
+ * a reader rather than a column.
10
+ *
11
+ * It is not an XML parser and does not pretend to be. The documents this
12
+ * collection holds are the ones Jellyfin writes, and this reads the two things
13
+ * about them that matter: a tag's text, and the five entities XML defines. A
14
+ * document that needed more than that would need a parser, and adding one
15
+ * before there is a document to justify it would be guessing at the shape of
16
+ * the problem.
17
+ */
18
+ /** The text a document holds between one tag's opening and its closing. */
19
+ function elementText(xml, tag) {
20
+ const open = `<${tag}>`;
21
+ const close = `</${tag}>`;
22
+ const from = xml.indexOf(open);
23
+ if (from === -1)
24
+ return null;
25
+ const to = xml.indexOf(close, from + open.length);
26
+ if (to === -1)
27
+ return null;
28
+ return xml.slice(from + open.length, to);
29
+ }
30
+ /** The last code point Unicode has, which is also the last XML allows. */
31
+ const LAST_CODE_POINT = 0x10ffff;
32
+ /** The surrogate range: a pair's halves, never a character on its own. */
33
+ const SURROGATES = { from: 0xd800, to: 0xdfff };
34
+ /**
35
+ * Whether a number names a character XML has — which is not every number
36
+ * Unicode does.
37
+ *
38
+ * `Char` is what the format permits in a document, and it excludes the
39
+ * surrogates (each is half of a pair) and everything past the last plane.
40
+ * `String.fromCodePoint` refuses only the second of those, so the first is
41
+ * checked here rather than left to it: a lone surrogate that reached the XML
42
+ * envelope would be a document no reader could parse.
43
+ */
44
+ function isXmlChar(point) {
45
+ if (!Number.isInteger(point) || point < 0 || point > LAST_CODE_POINT)
46
+ return false;
47
+ return point < SURROGATES.from || point > SURROGATES.to;
48
+ }
49
+ /**
50
+ * The text with its entities resolved — `Siouxsie &amp; the Banshees` read as
51
+ * a client should show it.
52
+ *
53
+ * Named and numeric alike, because a document may spell an ampersand either
54
+ * way and both mean the same thing to a reader. Anything that is not an entity
55
+ * this reader can resolve is left exactly as it was written, and that includes
56
+ * a numeric one naming no XML character: `&#1114112;` is not a character, so
57
+ * there is nothing to resolve it to, and a bare `&` is not this reader's to
58
+ * fix. Leaving the text is also what keeps this total — a note read off disk
59
+ * is somebody else's file, and a malformed one must not be able to throw out
60
+ * of a pure reader that answers with text.
61
+ */
62
+ function unescapeXml(text) {
63
+ return text.replace(/&(?:#(\d+)|#x([0-9a-fA-F]+)|(amp|lt|gt|quot|apos));/g, (whole, dec, hex, named) => {
64
+ if (dec !== undefined || hex !== undefined) {
65
+ const point = dec !== undefined ? Number(dec) : parseInt(hex ?? '', 16);
66
+ return isXmlChar(point) ? String.fromCodePoint(point) : whole;
67
+ }
68
+ switch (named) {
69
+ case 'amp':
70
+ return '&';
71
+ case 'lt':
72
+ return '<';
73
+ case 'gt':
74
+ return '>';
75
+ case 'quot':
76
+ return '"';
77
+ case 'apos':
78
+ return "'";
79
+ default:
80
+ return whole;
81
+ }
82
+ });
83
+ }
84
+ /**
85
+ * The artist's biography, or nothing when the document does not hold one.
86
+ *
87
+ * A document with no `<biography>` has no biography — an `<outline>` beside it
88
+ * is a summary and is not the same claim, so it is not offered in its place.
89
+ * Whitespace around the text is the document's layout rather than its content
90
+ * and goes; the lines inside it are the author's and stay.
91
+ *
92
+ * Empty is nothing. A caller that has to check for an empty string before
93
+ * showing it is a caller doing this reader's job.
94
+ */
95
+ export function biographyOf(xml) {
96
+ const raw = elementText(xml, 'biography');
97
+ if (raw === null)
98
+ return null;
99
+ const text = unescapeXml(raw).trim();
100
+ return text === '' ? null : text;
101
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The Latin spellings a Cyrillic artist key may be written as.
3
+ *
4
+ * This table never merges anything. It exists so that two rows which are one
5
+ * name in two alphabets can be *named* to a reader (task:2675); the decision
6
+ * that they are the same artist is left to a human or to v1.5's MBID, because
7
+ * nothing on disk states it.
8
+ *
9
+ * The reason it cannot be a merge rule is the reason it is a list of schemes
10
+ * rather than one table. A `name_key` folds spellings that differ only in
11
+ * decoration — case, articles, punctuation — and folding is idempotent and
12
+ * reversible enough to trust. Cyrillic-to-Latin is not a fold: it is a mapping
13
+ * between scripts, it is many-to-many in both directions (`Кино` → `Kino`,
14
+ * `Kino` → `Кино` or `Кіно`), and the schemes disagree with one another
15
+ * (ГОСТ / BGN / ISO render `х` as `kh` or `h`, `я` as `ya` or `a`). A rule that
16
+ * asserted the identity would be choosing a scheme, and a wrong choice is
17
+ * silent: two bands that merely sound alike become one row, and no consumer
18
+ * downstream can see that it happened. Reporting a wrong pair costs a line.
19
+ */
20
+ /**
21
+ * What rippers, Discogs and MusicBrainz actually write — the BGN/PCGN side of
22
+ * each disagreement. `х` is `kh`, `я` is `ya`.
23
+ */
24
+ const PRACTICAL = {
25
+ а: 'a', б: 'b', в: 'v', г: 'g', д: 'd', е: 'e', ё: 'e', ж: 'zh', з: 'z',
26
+ и: 'i', й: 'y', к: 'k', л: 'l', м: 'm', н: 'n', о: 'o', п: 'p', р: 'r',
27
+ с: 's', т: 't', у: 'u', ф: 'f', х: 'kh', ц: 'ts', ч: 'ch', ш: 'sh',
28
+ щ: 'shch', ъ: '', ы: 'y', ь: '', э: 'e', ю: 'yu', я: 'ya',
29
+ і: 'i', ї: 'i', є: 'e', ґ: 'g',
30
+ };
31
+ /**
32
+ * The simplified side: one letter per letter, as an ASCII keyboard or a
33
+ * country-agnostic database tends to write it. It differs from `PRACTICAL` on
34
+ * `й` (`i`), `х` (`h`), `ц` (`c`), `щ` (`sh`), `ю` (`u`) and `я` (`a`).
35
+ */
36
+ const SIMPLIFIED = {
37
+ ...PRACTICAL,
38
+ й: 'i', х: 'h', ц: 'c', щ: 'sh', ю: 'u', я: 'a',
39
+ };
40
+ const SCHEMES = [PRACTICAL, SIMPLIFIED];
41
+ /**
42
+ * The property escape rather than a hand-written range: `Ѐ-ӿ` is the main
43
+ * block only, and would read the Cyrillic Supplement (U+0500–U+052F) as a name
44
+ * that is already Latin. `name.ts` asks the same question the same way.
45
+ */
46
+ const CYRILLIC = /\p{Script=Cyrillic}/u;
47
+ /** Whether a key holds a Cyrillic letter at all. */
48
+ export function hasCyrillic(key) {
49
+ return CYRILLIC.test(key);
50
+ }
51
+ /**
52
+ * Every Latin key the schemes would write this one as, deduplicated. Empty when
53
+ * the key holds no Cyrillic letter — a Latin key is not a candidate for being
54
+ * the Latin side of a pair.
55
+ *
56
+ * The key is lowercased on entry: the schemes are written for lowercase
57
+ * Cyrillic, and a caller holding a display name rather than a key would
58
+ * otherwise get an answer that is still half Cyrillic (`Ха` → `Хa`).
59
+ *
60
+ * Characters no scheme maps — spaces, digits, the `#2` a split homonym carries
61
+ * — pass through unchanged, so `кино` and `кино#2` stay distinguishable. What a
62
+ * caller does with a qualified key is its own decision: the index is the row's
63
+ * position within *its own* key group, so `кино#2` and `kino#2` are second
64
+ * folders of two independently sorted groups and nothing makes them the same
65
+ * folder. Pairing them would assert a correspondence that does not exist.
66
+ */
67
+ export function latinSpellingsOf(key) {
68
+ const lowered = key.toLowerCase();
69
+ if (!hasCyrillic(lowered))
70
+ return [];
71
+ const spellings = new Set();
72
+ for (const scheme of SCHEMES) {
73
+ let latin = '';
74
+ for (const letter of lowered)
75
+ latin += scheme[letter] ?? letter;
76
+ spellings.add(latin);
77
+ }
78
+ return [...spellings];
79
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Every bookmark, most recently moved first.
3
+ *
4
+ * Ordered by when it was last touched rather than by the song: a list of where
5
+ * somebody stopped is a list of the things they are in the middle of, and the
6
+ * one they are furthest into is the one they last listened to.
7
+ */
8
+ export function bookmarks(db) {
9
+ return db
10
+ .prepare(`SELECT track_id AS trackId,
11
+ position_ms AS positionMs,
12
+ comment AS comment,
13
+ created_at AS createdAt,
14
+ changed_at AS changedAt
15
+ FROM bookmark
16
+ ORDER BY changed_at DESC, track_id`)
17
+ .all();
18
+ }
19
+ /**
20
+ * Put the mark on a song, or move the one that is there.
21
+ *
22
+ * `created_at` survives a second write and `changed_at` does not: moving a
23
+ * bookmark is not making one, and a client that shows "added" beside a list of
24
+ * bookmarks would be told something untrue about every mark its user has
25
+ * adjusted.
26
+ *
27
+ * **The comment is written as given, and a write that names none clears the
28
+ * one that was there** — which is the protocol's rule for this method and not
29
+ * the one playlists follow. `updatePlaylist` treats a missing song list as
30
+ * silence because a client renaming a playlist means to keep its songs; here
31
+ * the protocol says the bookmark "will be overwritten", and a client that
32
+ * updates the position and drops the comment has said what the mark now is.
33
+ * The difference is worth the sentence because the other rule is the one this
34
+ * project would reach for by habit.
35
+ */
36
+ export function bookmark(db, trackId, positionMs, comment, at) {
37
+ db.prepare(`INSERT INTO bookmark (track_id, position_ms, comment, created_at, changed_at)
38
+ VALUES (?, ?, ?, ?, ?)
39
+ ON CONFLICT (track_id) DO UPDATE SET
40
+ position_ms = excluded.position_ms,
41
+ comment = excluded.comment,
42
+ changed_at = excluded.changed_at`).run(trackId, positionMs, comment, at, at);
43
+ }
44
+ /**
45
+ * Take the mark off a song. Nothing to take off is not an error.
46
+ *
47
+ * One statement and no transaction around it, which is `playlist/store.ts`'s
48
+ * `remove` as well: a lone `DELETE` is its own transaction, and a `withTransaction`
49
+ * here would be a wrapper that says something is being kept together when
50
+ * nothing is.
51
+ */
52
+ export function unbookmark(db, trackId) {
53
+ db.prepare('DELETE FROM bookmark WHERE track_id = ?').run(trackId);
54
+ }