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,384 @@
1
+ /**
2
+ * Bytes to text, with the reasoning written down.
3
+ *
4
+ * The collection is ~50k files ripped by whoever, whenever, and the text side
5
+ * of it is a mess: cue sheets in CP1251, tags that declare Latin-1 over Russian
6
+ * bytes, `.nfo` files from the DOS era. Getting this wrong does not throw — it
7
+ * produces plausible-looking mojibake (`CafЙ`, `Ïðèâåò`) that flows silently
8
+ * into album titles.
9
+ *
10
+ * So this module never returns a bare string. It returns the text *and* the
11
+ * encoding it decided on, how sure it is, and the reason — because the contract
12
+ * forbids losing information silently (requirements:39), and "I guessed CP1251"
13
+ * is information.
14
+ *
15
+ * Files are never rewritten. Everything here is an overlay: the bytes on disk
16
+ * stay exactly as they were.
17
+ *
18
+ * All three entry points have callers. `decodeText` serves the scanner's cue
19
+ * sheets; `decodeVorbisText` and `decodeId3Text` serve the tag readers
20
+ * (`tags/flac.ts`, `tags/id3v2.ts`), which write what they decide into
21
+ * `file.encoding` and `file.encoding_confidence` and report an inference that
22
+ * was not certain. Nothing reads `.nfo`/`.log` sidecars yet.
23
+ */
24
+ /**
25
+ * The confidence reported when the detector had nothing to infer: a byte-order
26
+ * mark, or content every candidate encoding agrees on. Anything below this is
27
+ * an inference the caller should be willing to log.
28
+ */
29
+ export const CERTAIN = 1;
30
+ /**
31
+ * At or above this, an inference is well-founded enough to record as
32
+ * information rather than as a warning.
33
+ */
34
+ export const CONFIDENT = 0.75;
35
+ /**
36
+ * A run of consecutive bytes in `0xC0..0xFF` at least this long is Cyrillic.
37
+ *
38
+ * Both code pages fill that range — CP1251 with А-я, CP1252 with À-ÿ — so the
39
+ * bytes really are the same bytes. What differs is the *shape* of the text that
40
+ * produced them: Cyrillic is written in words, and a Russian word arrives as a
41
+ * run of letters from the range (`Последний` is nine bytes in a row), whereas
42
+ * accented Latin is written one letter at a time (`café`, `déjà`, `Ça` — never
43
+ * two accented letters adjacent).
44
+ *
45
+ * Density was tried first, and it is not enough. A real EAC log from the
46
+ * collection is 700-odd ASCII letters of English boilerplate with one Russian
47
+ * line near the top: Cyrillic is 4% of its letters, so every share-based
48
+ * threshold calls it Western and hands back `Êèíî / Ïîñëåäíèé ãåðîé`. The run
49
+ * rule is blind to that ratio — the words are just as long either way.
50
+ *
51
+ * Three, not two: Spanish opens with `¡Él`, which puts two high bytes in a row
52
+ * with no Cyrillic anywhere near it.
53
+ */
54
+ const MIN_CYRILLIC_RUN = 3;
55
+ /** Shorter than this, a text is too small to call "mostly" anything. */
56
+ const CONTROL_SAMPLE = 8;
57
+ /** Above this share of control characters, the text is not really text. */
58
+ const CONTROL_SHARE = 0.05;
59
+ /**
60
+ * Does the text contain C1 controls, U+0080..U+009F?
61
+ *
62
+ * Written as a loop rather than a character-class regex on purpose: the range
63
+ * is invisible in source, and a regex literal holding raw control characters is
64
+ * one editor round-trip away from silently matching the wrong thing.
65
+ *
66
+ * A real document never contains them — only a broken re-encode does.
67
+ */
68
+ function hasC1Controls(text) {
69
+ for (const ch of text) {
70
+ const point = ch.codePointAt(0);
71
+ if (point >= 0x80 && point <= 0x9f)
72
+ return true;
73
+ }
74
+ return false;
75
+ }
76
+ /**
77
+ * Drop trailing U+0000 characters.
78
+ *
79
+ * ID3 text is NUL-terminated, and doing this on the byte array is wrong the
80
+ * moment the encoding is 16-bit: `'AB\0'` in UTF-16LE is `41 00 42 00 00 00`,
81
+ * a byte-wise strip eats three bytes and leaves `41 00 42`, and the decoder
82
+ * turns that odd length into `A�` — a silent character loss on any value
83
+ * ending in an ASCII letter. Character-domain stripping cannot split a code
84
+ * unit, so it is correct at every width without special-casing any.
85
+ */
86
+ function stripTrailingNulChars(text) {
87
+ let end = text.length;
88
+ while (end > 0 && text.charCodeAt(end - 1) === 0)
89
+ end -= 1;
90
+ return text.slice(0, end);
91
+ }
92
+ /**
93
+ * Is the text mostly control characters?
94
+ *
95
+ * This is the signature of UTF-16 read as UTF-8, which validates perfectly and
96
+ * is pure garbage. ASCII in UTF-16LE is letters separated by NULs; Cyrillic in
97
+ * UTF-16LE is letters interleaved with the high byte (U+041F arrives as
98
+ * `1f 04`), so either way half the "text" is controls.
99
+ *
100
+ * A ratio rather than "any control at all": a stray byte in an otherwise fine
101
+ * cue is noise, not evidence, and flagging it would cry wolf on every scan.
102
+ */
103
+ function isMostlyControls(text) {
104
+ // Judged without the terminator: a NUL-terminated `Аквариум` is nine
105
+ // characters of which one is padding, and counting that padding as evidence
106
+ // of "mostly controls" would flag every well-formed ID3 frame.
107
+ const body = stripTrailingNulChars(text);
108
+ if (body.length < CONTROL_SAMPLE)
109
+ return false;
110
+ let controls = 0;
111
+ for (const ch of body) {
112
+ const point = ch.codePointAt(0);
113
+ const benign = point === 0x09 || point === 0x0a || point === 0x0d;
114
+ if (!benign && (point < 0x20 || (point >= 0x7f && point <= 0x9f)))
115
+ controls += 1;
116
+ }
117
+ return controls / body.length > CONTROL_SHARE;
118
+ }
119
+ /**
120
+ * Could this be text a person wrote?
121
+ *
122
+ * Exported because the parsers have to make the same judgement the decoder
123
+ * makes, and there should be one definition of "this is not text" rather than
124
+ * two that can drift. A tag value whose bytes are mostly control characters is
125
+ * not a value: storing it puts a control character where a title belongs, and
126
+ * splitting it on the NULs it is full of turns one broken frame into a dozen
127
+ * one-character tags that then outrank a readable folder name.
128
+ */
129
+ export function looksLikeText(text) {
130
+ return !isMostlyControls(text);
131
+ }
132
+ const BOM_UTF8 = [0xef, 0xbb, 0xbf];
133
+ const BOM_UTF16LE = [0xff, 0xfe];
134
+ const BOM_UTF16BE = [0xfe, 0xff];
135
+ /** A fresh decoder per call: `fatal` throws, and state must not leak between calls. */
136
+ function decodeAs(encoding, bytes) {
137
+ return new TextDecoder(encoding).decode(bytes);
138
+ }
139
+ /** Strict UTF-8, or null. `fatal` is what turns "looks close enough" into a real answer. */
140
+ function tryUtf8(bytes) {
141
+ try {
142
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
143
+ }
144
+ catch {
145
+ return null;
146
+ }
147
+ }
148
+ function startsWith(bytes, prefix) {
149
+ if (bytes.length < prefix.length)
150
+ return false;
151
+ return prefix.every((byte, i) => bytes[i] === byte);
152
+ }
153
+ function hasHighByte(bytes) {
154
+ for (const byte of bytes)
155
+ if (byte >= 0x80)
156
+ return true;
157
+ return false;
158
+ }
159
+ /**
160
+ * The UTF-8 reading of a body that claims to be UTF-8, or null if it is not.
161
+ *
162
+ * Shared by all three entry points because the check has to be the same in each
163
+ * of them — a body that validates but decodes to control characters is exactly
164
+ * the case that must not be reported as certain, and having that rule in one
165
+ * place is what stops it from being applied in one place out of three.
166
+ *
167
+ * `basis` names the claim being tested, so the reason reads correctly whether
168
+ * it came from a Vorbis comment, an ID3v2 frame, or no declaration at all.
169
+ */
170
+ function fromUtf8(bytes, basis) {
171
+ const text = tryUtf8(bytes);
172
+ if (text === null)
173
+ return null;
174
+ // Bytes 0x80..0x9F are not valid UTF-8 on their own, so their arriving as
175
+ // U+0080..U+009F means someone encoded a Windows code page *as* Latin-1 and
176
+ // then as UTF-8. The text decodes, and it is still wrong.
177
+ if (hasC1Controls(text)) {
178
+ return {
179
+ text,
180
+ encoding: 'utf-8',
181
+ confidence: 0.5,
182
+ basis: `${basis}, but decodes to c1 controls (mojibake?)`,
183
+ };
184
+ }
185
+ if (isMostlyControls(text)) {
186
+ return {
187
+ text,
188
+ encoding: 'utf-8',
189
+ confidence: 0.5,
190
+ basis: `${basis}, but the text is mostly control characters (utf-16 without a bom?)`,
191
+ };
192
+ }
193
+ return { text, encoding: 'utf-8', confidence: CERTAIN, basis };
194
+ }
195
+ /**
196
+ * Longest unbroken stretch of bytes in `0xC0..0xFF`.
197
+ *
198
+ * `0x80..0xBF` is left out of the range on purpose: it is punctuation in *both*
199
+ * code pages — `«` is 0xAB in each, `…` is 0x85 in each — so counting it would
200
+ * let `«…»` pass itself off as a three-byte word.
201
+ */
202
+ function longestCyrillicRun(bytes) {
203
+ let longest = 0;
204
+ let current = 0;
205
+ for (const byte of bytes) {
206
+ if (byte >= 0xc0) {
207
+ current += 1;
208
+ if (current > longest)
209
+ longest = current;
210
+ }
211
+ else {
212
+ current = 0;
213
+ }
214
+ }
215
+ return longest;
216
+ }
217
+ /** Which code page an eight-bit body is in, and how strongly it says so. */
218
+ function singleByteVerdict(bytes) {
219
+ const run = longestCyrillicRun(bytes);
220
+ if (run >= MIN_CYRILLIC_RUN) {
221
+ // Six bytes is already a whole short word; there is no reading of it that
222
+ // is not Cyrillic. Three is a word too, just a shorter one.
223
+ return { encoding: 'windows-1251', confidence: run >= 6 ? 0.9 : 0.75, run };
224
+ }
225
+ // No high byte at all means the family barely mattered. A scattering of them
226
+ // with nothing adjacent is precisely what Western accented text looks like.
227
+ return { encoding: 'windows-1252', confidence: run === 0 ? 0.85 : 0.7, run };
228
+ }
229
+ function fromSingleByteVerdict(bytes, why) {
230
+ const verdict = singleByteVerdict(bytes);
231
+ return {
232
+ text: decodeAs(verdict.encoding, bytes),
233
+ encoding: verdict.encoding,
234
+ confidence: verdict.confidence,
235
+ basis: `${why}; ${verdict.encoding} at longest cyrillic run ${verdict.run}`,
236
+ };
237
+ }
238
+ function withoutTrailingNuls(decoded) {
239
+ return { ...decoded, text: stripTrailingNulChars(decoded.text) };
240
+ }
241
+ /**
242
+ * Decode a document whose encoding nothing declares: a cue sheet, an `.nfo`.
243
+ *
244
+ * Order matters. A byte-order mark is a statement of fact and settles the
245
+ * question. Failing that, strict UTF-8 validation is nearly as strong — a run
246
+ * of CP1251 Cyrillic cannot survive it, because those bytes are lead bytes
247
+ * demanding continuations that Cyrillic text never supplies. Only when both
248
+ * fail is there anything to infer, and then it is the run rule above.
249
+ */
250
+ export function decodeText(bytes) {
251
+ if (startsWith(bytes, BOM_UTF8)) {
252
+ return {
253
+ text: decodeAs('utf-8', bytes.subarray(3)),
254
+ encoding: 'utf-8',
255
+ confidence: CERTAIN,
256
+ basis: 'utf-8 bom',
257
+ };
258
+ }
259
+ if (startsWith(bytes, BOM_UTF16LE)) {
260
+ return {
261
+ text: decodeAs('utf-16le', bytes.subarray(2)),
262
+ encoding: 'utf-16le',
263
+ confidence: CERTAIN,
264
+ basis: 'utf-16le bom',
265
+ };
266
+ }
267
+ if (startsWith(bytes, BOM_UTF16BE)) {
268
+ return {
269
+ text: decodeAs('utf-16be', bytes.subarray(2)),
270
+ encoding: 'utf-16be',
271
+ confidence: CERTAIN,
272
+ basis: 'utf-16be bom',
273
+ };
274
+ }
275
+ const basis = hasHighByte(bytes) ? 'valid utf-8' : 'ascii; every candidate encoding agrees';
276
+ return fromUtf8(bytes, basis) ?? fromSingleByteVerdict(bytes, 'not valid utf-8');
277
+ }
278
+ /**
279
+ * Decode one ID3v2 text frame from its declared encoding byte.
280
+ *
281
+ * ID3v2 frames name their own encoding, so usually there is nothing to detect:
282
+ *
283
+ * 0 = ISO-8859-1, 1 = UTF-16 with a BOM, 2 = UTF-16BE, 3 = UTF-8
284
+ *
285
+ * The catch is that the declaration is often a lie. A ripper writes `0` over
286
+ * bytes it actually took from a CP1251 source, or "upgrades" to `3` without
287
+ * converting anything underneath. Taking the byte at its word is how Russian
288
+ * tags become `Àêâàðèóì`. So the declared value is treated as a hint to verify,
289
+ * not as the answer — and when it is contradicted, the basis says so.
290
+ */
291
+ export function decodeId3Text(encodingByte, bytes) {
292
+ return withoutTrailingNuls(decodeId3Body(encodingByte, bytes));
293
+ }
294
+ function decodeId3Body(encodingByte, body) {
295
+ switch (encodingByte) {
296
+ case 0: {
297
+ const verdict = singleByteVerdict(body);
298
+ if (verdict.encoding === 'windows-1251') {
299
+ return {
300
+ text: decodeAs('windows-1251', body),
301
+ encoding: 'windows-1251',
302
+ confidence: verdict.confidence,
303
+ basis: `id3 declared latin-1, mislabeled windows-1251 at longest cyrillic run ${verdict.run}`,
304
+ };
305
+ }
306
+ const text = decodeAs('windows-1252', body);
307
+ // The declaration is the authority — unless the bytes contradict it. A
308
+ // body that decodes to mostly control characters is not latin-1 text at
309
+ // all: it is what a mangled frame looks like read one byte at a time,
310
+ // and calling that certain is exactly how a row of control characters
311
+ // ends up stored as a title, looking for all the world like one.
312
+ //
313
+ // The same guard `fromUtf8` applies, in the same place in the reasoning.
314
+ // Whether the caller then stores the value is the caller's business;
315
+ // reporting this read as certain is not.
316
+ if (isMostlyControls(text)) {
317
+ return {
318
+ text,
319
+ encoding: 'windows-1252',
320
+ confidence: 0.5,
321
+ basis: 'id3 declared latin-1, but the text is mostly control characters',
322
+ };
323
+ }
324
+ // Declared and actual agree; the declaration is the authority here.
325
+ return {
326
+ text,
327
+ encoding: 'windows-1252',
328
+ confidence: CERTAIN,
329
+ basis: 'id3 declared latin-1',
330
+ };
331
+ }
332
+ case 1: {
333
+ if (startsWith(body, BOM_UTF16LE)) {
334
+ return {
335
+ text: decodeAs('utf-16le', body.subarray(2)),
336
+ encoding: 'utf-16le',
337
+ confidence: CERTAIN,
338
+ basis: 'id3 utf-16, little-endian bom',
339
+ };
340
+ }
341
+ if (startsWith(body, BOM_UTF16BE)) {
342
+ return {
343
+ text: decodeAs('utf-16be', body.subarray(2)),
344
+ encoding: 'utf-16be',
345
+ confidence: CERTAIN,
346
+ basis: 'id3 utf-16, big-endian bom',
347
+ };
348
+ }
349
+ // The spec requires a BOM. Plenty of writers omit it and mean LE.
350
+ return {
351
+ text: decodeAs('utf-16le', body),
352
+ encoding: 'utf-16le',
353
+ confidence: 0.6,
354
+ basis: 'id3 utf-16 without a bom; assumed little-endian',
355
+ };
356
+ }
357
+ case 2:
358
+ return {
359
+ text: decodeAs('utf-16be', body),
360
+ encoding: 'utf-16be',
361
+ confidence: CERTAIN,
362
+ basis: 'id3 utf-16be',
363
+ };
364
+ case 3:
365
+ return (fromUtf8(body, 'id3 utf-8') ??
366
+ fromSingleByteVerdict(body, 'id3 declared utf-8, but the bytes are mislabeled'));
367
+ default:
368
+ // Not a byte ID3v2 defines. Latin-1 can decode anything without throwing,
369
+ // which is what makes it the safe floor — but it is not a claim.
370
+ return fromSingleByteVerdict(body, `id3 unknown encoding byte ${encodingByte}`);
371
+ }
372
+ }
373
+ /**
374
+ * Decode a FLAC Vorbis comment.
375
+ *
376
+ * Vorbis comments are UTF-8 by specification, so validation is the entire job:
377
+ * when it holds there was nothing to infer and the answer is certain. It does
378
+ * not always hold — the same rippers that mangle cue sheets wrote these too.
379
+ */
380
+ export function decodeVorbisText(bytes) {
381
+ const decoded = fromUtf8(bytes, 'vorbis comment is valid utf-8') ??
382
+ fromSingleByteVerdict(bytes, 'vorbis comment is not valid utf-8');
383
+ return withoutTrailingNuls(decoded);
384
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * A string a rip carries that names where it came from, not what it is.
3
+ *
4
+ * Rippers fill tags in with the site they published on, and `ALBUM` is one of
5
+ * the tags they fill in with it: four of the Kino boxes carry
6
+ * `lossless-galaxy.ru`, and every disc of each box agrees on it, so no
7
+ * agreement check can tell it apart from a name. It is the same finding as
8
+ * `-Kroogi.com` glued to a folder name (`classify/folder-name.ts`) and the
9
+ * glued tail `cue/track-name.ts` drops, and it wants the same answer: it
10
+ * describes the download.
11
+ *
12
+ * ## Why the whole value has to be the host
13
+ *
14
+ * A host is written the way a host is written, and that is the whole of the
15
+ * evidence used here. The pattern is lower case throughout, so a title with a
16
+ * capital in it never matches; it has no room for a space, so `Vol. 2` never
17
+ * matches and `Cock E.S.P.` never does either — twice over, since its last
18
+ * label is a single letter and no top-level domain is one. `greatest.hits` is
19
+ * the shape that would match and is not a host, and it is worth saying plainly
20
+ * that the rule is a guess about a string, and a guess allowed to be wrong.
21
+ *
22
+ * The asymmetry is the one `classify/folder-name.ts` already argues for a
23
+ * format note: declining a real name leaves the record its folder name and
24
+ * files an issue, which is visible and costs a name that was never applied;
25
+ * accepting a site name writes a wrong word into the meta layer as knowledge,
26
+ * which is neither.
27
+ */
28
+ const SITE_NAME = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*\.[a-z]{2,6}$/;
29
+ /**
30
+ * Is this value nothing but a hostname?
31
+ *
32
+ * `lossless-galaxy.ru` and `kroogi.com` are; `Cock E.S.P.`, `Lost.And.Found`
33
+ * and `Vol. 2` are not.
34
+ */
35
+ export function isSiteName(value) {
36
+ return SITE_NAME.test(value.trim());
37
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * What every child process this project starts is told.
3
+ *
4
+ * `windowsHide` is the one that matters, and it is the one that is easy to
5
+ * forget. The server runs as a daemon with no console of its own, and Windows
6
+ * gives every console program started from a console-less process a *new*
7
+ * window — so a song that has to be re-encoded flashes a black rectangle on the
8
+ * operator's screen, one per transcode, one per probe of a file whose codec
9
+ * nobody has established, and one per library scan. It is not an error and not
10
+ * a symptom: it is what the operating system does by default, and the default
11
+ * is wrong here.
12
+ *
13
+ * There is no test that can assert a window did not appear. This file is the
14
+ * next best thing: one place that says why, so that the next `spawn` has
15
+ * somewhere to look and something to copy.
16
+ *
17
+ * The rest of the options stay at each call site, because how a child's streams
18
+ * are wired is that call's own business — a probe wants its output read, a
19
+ * transcode wants it written to a file, and neither is a rule.
20
+ */
21
+ export const HIDDEN = { windowsHide: true };
@@ -0,0 +1,97 @@
1
+ /**
2
+ * One answer to "what is this file called".
3
+ *
4
+ * Paths reaching these helpers are in the meta layer's own form: forward
5
+ * slashes, as `walkRoot` produces them. Nothing here second-guesses that with
6
+ * Windows separators.
7
+ */
8
+ /** `a/b/track.flac` -> `track.flac`. */
9
+ export function basenameOf(path) {
10
+ const cut = path.lastIndexOf('/');
11
+ return cut === -1 ? path : path.slice(cut + 1);
12
+ }
13
+ /**
14
+ * `C:\Users\demo\Downloads\Кино` -> `Кино`; `/home/demo/music/Кино/` -> `Кино`.
15
+ *
16
+ * The root path is the one path in the meta layer that the walk did not produce:
17
+ * it arrives from `realpathSync.native`, and on Windows that answers with
18
+ * backslashes. `basenameOf` above splits on `/` alone — deliberately, because
19
+ * everything else it is handed is forward-slashed by the walk — so it would
20
+ * return the whole path here, and a caller asking "what is this root called"
21
+ * would silently get no answer on Windows and the right one on Linux. Both
22
+ * separators are accepted, for roots only.
23
+ */
24
+ export function rootBasenameOf(rootPath) {
25
+ const trimmed = rootPath.replace(/[\\/]+$/, '');
26
+ const cut = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'));
27
+ return cut === -1 ? trimmed : trimmed.slice(cut + 1);
28
+ }
29
+ /** `track.flac` -> `track`. A leading dot is a hidden file, not an extension. */
30
+ export function stemOf(name) {
31
+ const dot = name.lastIndexOf('.');
32
+ return dot <= 0 ? name : name.slice(0, dot);
33
+ }
34
+ /** `a/b/c.flac` -> `a/b`; `c.flac` -> `''` (the root). */
35
+ export function folderOf(relPath) {
36
+ const cut = relPath.lastIndexOf('/');
37
+ return cut === -1 ? '' : relPath.slice(0, cut);
38
+ }
39
+ /**
40
+ * Fold for ordering: case and combining marks, and nothing else.
41
+ *
42
+ * The rule this replaces asked `localeCompare` for `sensitivity: 'base'`, which
43
+ * ignores case and accents. Both are ignored here too, but decided rather than
44
+ * delegated — see `compareNatural`.
45
+ */
46
+ function orderFold(text) {
47
+ return text
48
+ .normalize('NFD')
49
+ .replace(/\p{M}+/gu, '')
50
+ .toLowerCase();
51
+ }
52
+ /** Runs of digits and runs of everything else, in the order they appear. */
53
+ function chunks(text) {
54
+ return text.match(/\d+|\D+/g) ?? [];
55
+ }
56
+ /**
57
+ * Digit-aware string order: `2 - x` before `10 - y`.
58
+ *
59
+ * Plain lexicographic order puts `10` first, which silently pairs cue tracks
60
+ * with the wrong audio files in any album whose names are not zero-padded. So
61
+ * runs of digits compare as numbers and everything else compares as text.
62
+ *
63
+ * No `localeCompare`, and for the reason `artist/apply.ts` gives for its own
64
+ * display keys: its ordering depends on the host's ICU data and its default
65
+ * locale, so the same unchanged collection would number an album's tracks
66
+ * differently on two machines. Code units are the same everywhere, and the
67
+ * folding `localeCompare` was doing — case, and accents through `NFD` — is done
68
+ * here, visibly, instead of being asked for and hoped for.
69
+ *
70
+ * Deterministic, not "correct" in any linguistic sense: it is emulating one
71
+ * option of one API, and it says exactly which.
72
+ */
73
+ export function compareNatural(a, b) {
74
+ const left = chunks(orderFold(a));
75
+ const right = chunks(orderFold(b));
76
+ const shared = Math.min(left.length, right.length);
77
+ for (let index = 0; index < shared; index += 1) {
78
+ const one = left[index];
79
+ const other = right[index];
80
+ if (one === other)
81
+ continue;
82
+ // Both numeric, or both not: a digit run beside a text run is compared as
83
+ // text, which is what code units do and what a reader expects of a name
84
+ // that happens to start with a number.
85
+ const bothNumeric = /^\d/.test(one) && /^\d/.test(other);
86
+ if (bothNumeric) {
87
+ const difference = Number(one) - Number(other);
88
+ // Equal values with different spellings — `01` against `1` — fall to the
89
+ // remaining chunks rather than stopping here.
90
+ if (difference !== 0)
91
+ return difference < 0 ? -1 : 1;
92
+ continue;
93
+ }
94
+ return one < other ? -1 : 1;
95
+ }
96
+ return left.length - right.length;
97
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "//": "supervised says something will start this server again after it exits, which is what makes POST /restart a restart rather than a stop. The compose file and the systemd unit each say it for themselves; a bare `serve --daemon` has nobody, and setting it there would be a lie the server acts on.",
3
+ "dbPath": "/data/funoteka.db",
4
+ "host": "0.0.0.0",
5
+ "port": 4533,
6
+ "logFile": "/data/funoteka.log",
7
+ "logRequests": false,
8
+ "scanInterval": 360,
9
+ "scanQuietFrom": 3,
10
+ "scanQuietTo": 6,
11
+ "scanWatch": false,
12
+ "user": "demo",
13
+ "password": "change-me",
14
+ "apiKey": "",
15
+ "ffmpeg": "ffmpeg",
16
+ "cors": true,
17
+ "showJunk": false,
18
+ "adminPort": 4534,
19
+ "adminHost": "0.0.0.0",
20
+ "adminToken": "",
21
+ "//supervised": "true where something starts this server again after it exits — the compose file and the systemd unit each say it for themselves, and a bare `serve --daemon` has nobody to start it.",
22
+ "supervised": false,
23
+ "adminAllow": "",
24
+ "adminTrustProxy": false,
25
+ "adminTlsCert": "",
26
+ "adminTlsKey": ""
27
+ }
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "funoteka",
3
+ "version": "0.1.0",
4
+ "description": "Subsonic-compatible server: smart music library — scanner-classifier, cue splitting, meta layer, virtual tree",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "engines": {
8
+ "node": ">=24.0.0"
9
+ },
10
+ "bin": {
11
+ "funoteka": "./dist/cli.js"
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "deploy",
16
+ "README.md",
17
+ "DEPLOY.md",
18
+ "CHANGELOG.md",
19
+ "LICENSE",
20
+ ".env.example",
21
+ "funoteka.json.example"
22
+ ],
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/kzntsv-dev/funoteka.git"
26
+ },
27
+ "homepage": "https://github.com/kzntsv-dev/funoteka",
28
+ "bugs": {
29
+ "url": "https://github.com/kzntsv-dev/funoteka/issues"
30
+ },
31
+ "keywords": [
32
+ "subsonic",
33
+ "opensubsonic",
34
+ "music",
35
+ "music-server",
36
+ "flac",
37
+ "cue",
38
+ "mcp",
39
+ "self-hosted"
40
+ ],
41
+ "scripts": {
42
+ "build": "node deploy/build.mjs",
43
+ "prepublishOnly": "npm run build",
44
+ "scan": "node src/cli.ts scan",
45
+ "serve": "node src/cli.ts serve",
46
+ "smoke": "node deploy/smoke.mjs",
47
+ "test": "node --test \"test/**/*.test.ts\"",
48
+ "typecheck": "tsc --noEmit",
49
+ "check:publishable": "node deploy/check-publishable.mjs"
50
+ },
51
+ "devDependencies": {
52
+ "@types/node": "^24.0.0",
53
+ "typescript": "^5.7.0"
54
+ }
55
+ }