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,269 @@
1
+ import { readFlac } from "./flac.js";
2
+ import { readId3v1 } from "./id3v1.js";
3
+ import { readId3v2 } from "./id3v2.js";
4
+ import { mpegLength } from "./mpeg.js";
5
+ import { readMp4 } from "./mp4.js";
6
+ import { readOgg } from "./ogg.js";
7
+ import { noTags } from "./types.js";
8
+ /**
9
+ * Which reading of a file this code takes.
10
+ *
11
+ * The reader set below is not a constant — readers get fixed and new formats get
12
+ * added, and on 2026-09-12 a FLAC reader was found to have been misreading real
13
+ * files since it was written. A stored verdict describes a file *as read by a
14
+ * particular reading*, so when that reading changes, every verdict below it is
15
+ * no longer a verdict about the file.
16
+ *
17
+ * This number is what says so. It is written on every file the stage reads and
18
+ * is a reason to read a file again, exactly as `PROBE_METHOD` is for a probe row
19
+ * (`db/migrations/015_probe_method.sql`, and the same idea in `apply.ts`).
20
+ * Raising it re-reads the library once; without it a reader could be corrected
21
+ * and never reach a file that had not moved, which is precisely what happened
22
+ * when the Ogg reader landed and eighty `.ogg` files stayed empty
23
+ * (`db/migrations/016_tags_method.sql`).
24
+ *
25
+ * 2 — a picture carried as a comment stopped being written down as a tag. It
26
+ * arrives looking like any other name and value, so the eighty `.ogg` files put
27
+ * nineteen and a half megabytes of base64 into `file_tag`, one of them in a
28
+ * single value of 562 KB. Correcting that is a change of reading like any other,
29
+ * and a file it does not reach keeps its base64 for good.
30
+ *
31
+ * 3 — ID3v1 is read. Its block sits at the *other* end of an mp3 from the v2
32
+ * block and had been read by nobody, so a file whose names are only there was
33
+ * read as a bare stream with no tags and no complaint. 273 files of this
34
+ * collection are exactly that, and 489 more carry both blocks — the older one
35
+ * filling names the newer left unstated.
36
+ *
37
+ * 4 — and the verdict stored for such a file is now about the text that
38
+ * survives. 3 weighed the whole older block, so an mp3 whose v2 block is clean
39
+ * UTF-8 and whose v1 block is Latin-1 was filed as a guess on the strength of
40
+ * titles nothing keeps: 328 findings on this collection about a name the newer
41
+ * block had already answered for. The reading did not change; what is recorded
42
+ * about it did, which is the same kind of change and reaches a stored row the
43
+ * same way.
44
+ *
45
+ * 5 — an ID3 tag yields every frame the standard calls text rather than the nine
46
+ * the reader used to hold, a comment is read, and the date is composed from the
47
+ * frames §4.2.1 splits it across (task:2737). Measured by running the reader
48
+ * over the collection before and after: 447 of its 703 tagged mp3 gain at least
49
+ * one name, and no name or value a file already had is lost. Without this number
50
+ * none of that reaches a file that does not move — which was measured too: a
51
+ * scan after the change but before this bump read *no* files at all.
52
+ *
53
+ * 6 — an MP4's `rtng` atom is read, which is the content rating the
54
+ * specification calls `explicitStatus` (task:2866). 1425 of this collection's
55
+ * files are `.m4a`, and the atom was previously dropped without a word: through
56
+ * the pair branch (`trkn`/`disk`) a one-byte atom fails the length check, and
57
+ * the pass-through branch does not exist for MP4 at all. Measured after the
58
+ * reader landed: fifteen of fifty-two sampled `.m4a` files carry the atom, and
59
+ * the field was empty for every one of them.
60
+ *
61
+ * **This entry exists because the bump was missed.** The reader was corrected in
62
+ * `ca3af89` and this number was left at 5, so a rescan re-read nothing — 3350
63
+ * files sat at `tags_method = 5` and stayed there, the one `rtng` row in the
64
+ * whole meta layer belonging to a file that had moved for other reasons. The
65
+ * defect was found by the cluster's acceptance review and not by a test, which
66
+ * is what synthetic-only coverage of this field buys.
67
+ *
68
+ * 7 — the MPEG frame walk reports the sample rate and the channel count, which
69
+ * it had always read and never carried out (task:2910). Nothing about the
70
+ * reading changed; what is *recorded* about it did, which is exactly the case
71
+ * entry 4 above is about and reaches a stored row the same way.
72
+ *
73
+ * Measured on the live base before the change: of the collection's mp3, **728**
74
+ * had a codec and no channel count, because the reader answered the length and
75
+ * nothing else — and `alreadyIs` cannot check a client's `maxAudioChannels`
76
+ * against a number that is not there, so every one of those files was
77
+ * transcoded whole rather than served: **2423.7 ms** cold against 10.3 ms for
78
+ * the byte copy, on a request that needed no work at all. This bump is what
79
+ * takes them back, and without it a rescan would re-read none of them.
80
+ *
81
+ * 8 — the MP4 reader names the codec its sound track's sample description
82
+ * states (task:2910, the other half). Same shape of change as 5 and 7 and the
83
+ * same reason: what is *recorded* about the file changed, and the 1425 `.m4a`
84
+ * of this collection are the files it is about. Its own probe-method bump was
85
+ * needed beside this one and for the reason recorded there.
86
+ *
87
+ * 9 — that same reader states the channel count and the sample rate beside the
88
+ * codec, out of the same 28-byte header it was already standing on. Three
89
+ * entries and three bumps in one afternoon is what working the same hole from
90
+ * one end looks like; each is a real change to what a stored row says, and the
91
+ * number is the only thing that can say so to a file that has not moved.
92
+ *
93
+ * 10 — the stage writes a second thing beside `file_tag`: `file_tag_first`, the
94
+ * eight tags a listing shows, one row a file (task:2925). **This is the entry
95
+ * that makes that row self-healing, and it was nearly not written.**
96
+ *
97
+ * The row is derived from `file_tag` and written by the stage, and the migration
98
+ * that first filled it runs once. So a build that predates the row — a `git
99
+ * revert`, which this runbook calls safe, or a scan still running the old code
100
+ * while the daemon has been restarted onto the new — writes `file_tag` and no
101
+ * row. Nothing brings that file back: this build sees `tags_method` already at
102
+ * its own number and does not re-read it, and the migration will not run twice.
103
+ * The file answers empty in all eight fields for good, which is the same defect
104
+ * `task:2921` was: a client that normalises by ReplayGain, and a genre nobody is
105
+ * shown.
106
+ *
107
+ * Measured rather than reasoned — a fixture read by the build at `0fa535a` and
108
+ * then by this one: `tags 0 (0 files read)`, and the same bytes answering
109
+ * `{"artist":"Slipknot","replayGain":{}}` under one build and
110
+ * `{"genre":"Trance","artist":"VA","replayGain":{"trackGain":-6.62}}` under the
111
+ * other.
112
+ *
113
+ * Raising this number is what closes it, and it closes it *for every future
114
+ * mixed build* rather than for this one: a writer that does not know about the
115
+ * row writes an older number, so the next scan of this build re-reads the file
116
+ * and derives the row. The number is what the stage *writes*, not only what the
117
+ * reader reads — entries 4, 7, 8 and 9 say the same thing, and this is the first
118
+ * time the thing written was not a column of a row the stage already wrote.
119
+ */
120
+ export const TAGS_METHOD = 10;
121
+ /** Does the file say this, at this offset? */
122
+ function magicAt(bytes, at, magic) {
123
+ if (at + magic.length > bytes.length)
124
+ return false;
125
+ for (let i = 0; i < magic.length; i += 1) {
126
+ if (bytes[at + i] !== magic.charCodeAt(i))
127
+ return false;
128
+ }
129
+ return true;
130
+ }
131
+ /**
132
+ * Read whatever a file's own bytes say about it.
133
+ *
134
+ * Dispatched on the bytes rather than the extension: a `.flac` holding
135
+ * something else is a normal thing for a collection to contain, and believing
136
+ * the name over the file is how a reader ends up parsing nonsense.
137
+ *
138
+ * Never throws, by design. A format nothing here understands, a file with no
139
+ * metadata blocks, and a half-copied download all answer the same way — with
140
+ * nothing — because the caller is a scan over fifty thousand files and one
141
+ * damaged one must not be able to stop it.
142
+ */
143
+ export function readTags(bytes) {
144
+ if (magicAt(bytes, 0, 'fLaC'))
145
+ return readFlac(bytes);
146
+ // Ogg is dispatched on the same four bytes a reader synchronises on, and both
147
+ // of its codecs — Vorbis and Opus — keep their tags in the same comment
148
+ // packet, so one reader covers the two.
149
+ if (magicAt(bytes, 0, 'OggS'))
150
+ return readOgg(bytes);
151
+ // At four, not zero: an MP4 opens with the length of the box that names it,
152
+ // and `ftyp` is that box's type. Checking offset zero finds `ftyp` on nothing
153
+ // and misses every m4a there is.
154
+ if (magicAt(bytes, 4, 'ftyp'))
155
+ return readMp4(bytes);
156
+ if (magicAt(bytes, 0, 'ID3')) {
157
+ const tag = readId3v2(bytes);
158
+ // The tag states the names and the audio behind it states the length, and
159
+ // both come out of the one read. An ID3 block is followed by MPEG frames in
160
+ // every file this project meets, but nothing is assumed: if no frame can be
161
+ // found the duration is simply unknown, exactly as before.
162
+ const length = mpegLength(bytes, id3End(bytes));
163
+ const withOlder = olderTag(tag, bytes);
164
+ if (length === null)
165
+ return withOlder;
166
+ // Refused and unknown are different answers and the difference is carried
167
+ // up: this one has something behind it to measure, and a reader that failed
168
+ // to is worth telling someone who might not. What the frames turned out to
169
+ // be is carried up with it, because the tag block above never knew — and
170
+ // that is the whole of the audio format, not just its name: the rate and
171
+ // the channel count are in the same header the walk read (task:2910).
172
+ const format = {
173
+ codec: length.codec,
174
+ sampleRate: length.sampleRate,
175
+ channels: length.channels,
176
+ };
177
+ return length.refused
178
+ ? { ...withOlder, ...format, durationRefused: true }
179
+ : { ...withOlder, ...format, durationMs: length.durationMs };
180
+ }
181
+ // The ID3v1 block, which is the *other* end of the file and has nothing to do
182
+ // with whether a v2 block is at this one — a file may carry both, one, or
183
+ // neither.
184
+ const older = readId3v1(bytes);
185
+ // A bare MPEG stream — no v2 tag block at all, which for a collection rip is
186
+ // entirely ordinary and is not "a format nothing understands". An ID3v1 block
187
+ // is what a rip of that age usually has instead.
188
+ const bare = mpegLength(bytes, 0);
189
+ if (bare === null) {
190
+ // No frames found. With a v1 block the file is still understood — the
191
+ // container is the tag block, and its names are read even though nothing
192
+ // here can measure the audio. Without one there is nothing to say.
193
+ return older === null
194
+ ? noTags()
195
+ : { ...noTags('id3v1'), tags: older.tags, refusals: recover(older.refusals), encoding: older.encoding };
196
+ }
197
+ // The same header the length came from, so all three fields arrive together
198
+ // or not at all — see the note on the tag-block path above.
199
+ const format = { sampleRate: bare.sampleRate, channels: bare.channels };
200
+ const measured = bare.refused
201
+ ? { ...noTags('mpeg', bare.codec), ...format, durationRefused: true }
202
+ : { ...noTags('mpeg', bare.codec), ...format, durationMs: bare.durationMs };
203
+ return older === null
204
+ ? measured
205
+ : {
206
+ ...measured,
207
+ // No v2 block here, so the v1 names are the only statement of them —
208
+ // this is the container's own tag, not a fallback behind another.
209
+ container: 'id3v1',
210
+ tags: older.tags,
211
+ refusals: recover(older.refusals),
212
+ encoding: older.encoding,
213
+ };
214
+ }
215
+ /** A field list or nothing at all, so an empty array never reaches a caller. */
216
+ function recover(refusals) {
217
+ return refusals.length === 0 ? undefined : refusals;
218
+ }
219
+ /**
220
+ * The v2 reading, with the v1 block beside the same file attached as a fallback.
221
+ *
222
+ * `fallbackTags` rather than appended to `tags`, and the stage is what acts on
223
+ * the difference — see `TagRead.fallbackTags` on why two blocks merged is a
224
+ * collaboration this project would then invent. What the older block *refused*
225
+ * is reported either way, because a field read and discarded is worth saying
226
+ * whatever became of the tag around it.
227
+ */
228
+ function olderTag(tag, bytes) {
229
+ const older = readId3v1(bytes);
230
+ if (older === null)
231
+ return tag;
232
+ // Only the names the newer block does not speak are weighted, because only
233
+ // those are kept — the same rule the stage applies, and it has to be the same
234
+ // one: the reader says how sure it is of the text that survives, and text
235
+ // nobody uses is not the file's certainty. Weighting the whole older block
236
+ // instead put 328 encoding findings on this collection, every one of them
237
+ // about a title the v2 block had already answered for.
238
+ const newerNames = new Set(tag.tags.map((one) => one.name));
239
+ let encoding = tag.encoding;
240
+ for (const item of older.tags) {
241
+ if (newerNames.has(item.name))
242
+ continue;
243
+ const verdict = older.verdicts.get(item.name);
244
+ if (verdict !== undefined && (encoding === null || verdict.confidence < encoding.confidence)) {
245
+ encoding = verdict;
246
+ }
247
+ }
248
+ // Two blocks can each have refused something, and both are worth saying: the
249
+ // v2 reader now reports frames it declined, so replacing rather than joining
250
+ // would drop them the moment the file also carries a v1 block — which is most
251
+ // of the files this applies to.
252
+ return {
253
+ ...tag,
254
+ fallbackTags: older.tags,
255
+ refusals: recover([...(tag.refusals ?? []), ...older.refusals]),
256
+ encoding,
257
+ };
258
+ }
259
+ /** Where the ID3v2 tag ends and the audio begins. */
260
+ function id3End(bytes) {
261
+ const size = (((bytes[6] ?? 0) & 0x7f) << 21) |
262
+ (((bytes[7] ?? 0) & 0x7f) << 14) |
263
+ (((bytes[8] ?? 0) & 0x7f) << 7) |
264
+ ((bytes[9] ?? 0) & 0x7f);
265
+ // A footer, when present, is another ten bytes after the tag — and it sits
266
+ // *before* the audio, so not counting it starts the search ten bytes early.
267
+ const footer = (((bytes[5] ?? 0) & 0x10) !== 0 ? 10 : 0);
268
+ return Math.min(10 + size + footer, bytes.length);
269
+ }
@@ -0,0 +1,51 @@
1
+ /** The picture type that means "this is the cover", in every format here. */
2
+ export const FRONT_COVER = 3;
3
+ /**
4
+ * The better of two pictures the same file holds.
5
+ *
6
+ * A file may carry several — front, back, artist, a photograph of the disc — and
7
+ * the cover is the one to keep. A front cover wins over anything; between two of
8
+ * the same standing the first one seen wins, so that the answer is a function of
9
+ * the file's bytes rather than of the order a walk happened to visit boxes in.
10
+ */
11
+ export function betterPicture(current, candidate) {
12
+ if (current === undefined)
13
+ return candidate;
14
+ if (current.kind === FRONT_COVER)
15
+ return current;
16
+ return candidate.kind === FRONT_COVER ? candidate : current;
17
+ }
18
+ /**
19
+ * A fresh empty answer, as a function and not a shared constant.
20
+ *
21
+ * A constant would hand every caller the same `tags` array, and the first
22
+ * reader to push into it would be filling in the answer for every later file.
23
+ */
24
+ export function noTags(container = null, codec = null) {
25
+ return {
26
+ tags: [],
27
+ container,
28
+ codec,
29
+ durationMs: null,
30
+ durationRefused: false,
31
+ sampleRate: null,
32
+ channels: null,
33
+ bitsPerSample: null,
34
+ encoding: null,
35
+ };
36
+ }
37
+ /**
38
+ * Keep the less certain of two decode verdicts.
39
+ *
40
+ * Ties keep the one already held, which makes the answer a function of the
41
+ * file's bytes and their order rather than of anything ambient — the same
42
+ * unchanged file must not report a different encoding on two machines.
43
+ */
44
+ export function weakestEncoding(current, candidate) {
45
+ const verdict = {
46
+ encoding: candidate.encoding,
47
+ confidence: candidate.confidence,
48
+ basis: candidate.basis,
49
+ };
50
+ return current === null || verdict.confidence < current.confidence ? verdict : current;
51
+ }
@@ -0,0 +1,155 @@
1
+ import { decodeVorbisText, looksLikeText } from "../text/encoding.js";
2
+ import { pictureBlockAt } from "./picture.js";
3
+ import { FRONT_COVER, betterPicture, noTags, weakestEncoding } from "./types.js";
4
+ /**
5
+ * The comment name that carries a picture, and the reason Ogg files have covers
6
+ * at all: the container has no metadata block to put one in, so the Vorbis
7
+ * convention is to base64 a FLAC picture block (RFC 9639 §8.8) into a comment.
8
+ *
9
+ * Read as a comment and it looks like any other tag, which is the trap: taken at
10
+ * face value it becomes a tag row of a hundred and eighty kilobytes of base64,
11
+ * out of a meta layer that exists to describe a collection rather than to carry
12
+ * one. Measured before this was written: eighty files put nineteen and a half
13
+ * megabytes into `file_tag`, one of them in a single value of 562 KB.
14
+ *
15
+ * The older `COVERART`/`COVERARTMIME` pair does the same job, in two comments
16
+ * with the image base64'd raw and no block around it. No file in this collection
17
+ * uses it, and it is not read here — but it is the reason the name below is
18
+ * spelled out in full rather than matched loosely.
19
+ */
20
+ const PICTURE_COMMENT = 'metadata_block_picture';
21
+ /**
22
+ * The better of two pictures a comment list carries, by the rule `betterPicture`
23
+ * applies to the ones a file carries as ranges.
24
+ *
25
+ * A front cover wins over anything; between two of the same standing the first
26
+ * one seen wins, so that the answer is a function of the file's bytes rather
27
+ * than of the order a walk happened to visit them in.
28
+ */
29
+ function betterCommentPicture(current, candidate) {
30
+ if (current === null)
31
+ return candidate;
32
+ if (current.kind === FRONT_COVER)
33
+ return current;
34
+ return candidate.kind === FRONT_COVER ? candidate : current;
35
+ }
36
+ /**
37
+ * The picture one `METADATA_BLOCK_PICTURE` value holds, or null.
38
+ *
39
+ * Base64 is decoded leniently by Node — it skips characters it does not know
40
+ * rather than refusing — so a value that is not base64 at all decodes to
41
+ * something rather than throwing, and the block parser above is what decides
42
+ * whether the result is a picture. A value that does not parse is dropped
43
+ * silently, which is right for this one name and wrong for every other: an
44
+ * unreadable *picture* is a file with no cover, while an unreadable tag is a
45
+ * finding about the file that the caller counts and reports.
46
+ */
47
+ function pictureFromComment(value) {
48
+ const decoded = Buffer.from(Buffer.from(value).toString('latin1'), 'base64');
49
+ const block = pictureBlockAt(decoded, 0, decoded.length);
50
+ if (block === null)
51
+ return null;
52
+ return {
53
+ mime: block.mime,
54
+ kind: block.kind,
55
+ data: decoded.subarray(block.dataAt, block.dataAt + block.dataLength),
56
+ };
57
+ }
58
+ /**
59
+ * Read a comment list occupying `[at, end)`, and report the picture it carried.
60
+ *
61
+ * Two things about this are worth knowing before the code below reads as
62
+ * arbitrary. The lengths are 32-bit little-endian, not the big-endian a reader
63
+ * arriving from FLAC's *blocks* would expect — §5.2.1 says the fields are packed
64
+ * "lsb first" and, being octet-aligned, "can simply be read as unaligned 32 bit
65
+ * little endian unsigned integers". And the field *contents* are UTF-8 while the
66
+ * field *names* are ASCII, so a name in any other script is something a file can
67
+ * carry and the specification forbids at once.
68
+ *
69
+ * The walk stops at the first entry it cannot believe — a length that runs past
70
+ * the end, a count that promises more than is there — and keeps what it already
71
+ * read, because the caller is a scan over a whole collection and one damaged
72
+ * file must not be able to stop it.
73
+ */
74
+ export function readCommentList(bytes, at, end, into) {
75
+ let cursor = at;
76
+ let picture = null;
77
+ const takeLength = () => {
78
+ if (cursor + 4 > end)
79
+ return null;
80
+ const value = ((bytes[cursor] ?? 0) |
81
+ ((bytes[cursor + 1] ?? 0) << 8) |
82
+ ((bytes[cursor + 2] ?? 0) << 16) |
83
+ ((bytes[cursor + 3] ?? 0) << 24)) >>>
84
+ 0;
85
+ cursor += 4;
86
+ return value;
87
+ };
88
+ const vendorLength = takeLength();
89
+ if (vendorLength === null)
90
+ return null;
91
+ // The vendor string names the encoder. Nothing here reads it, but its length
92
+ // is exactly what stands between this cursor and the first comment.
93
+ cursor += vendorLength;
94
+ if (cursor > end)
95
+ return null;
96
+ const count = takeLength();
97
+ if (count === null)
98
+ return null;
99
+ for (let i = 0; i < count; i += 1) {
100
+ const size = takeLength();
101
+ if (size === null || cursor + size > end)
102
+ return picture;
103
+ const entry = bytes.subarray(cursor, cursor + size);
104
+ cursor += size;
105
+ const equals = entry.indexOf(0x3d); // '='
106
+ // §5.2.2 defines a field as a name, an `=`, and the contents. An entry with
107
+ // no separator, or with one in first place, is not a field with a missing
108
+ // half — it never named anything, so there is no value being lost. This is
109
+ // the only shape dropped outright, and the only shape that can be.
110
+ if (equals <= 0)
111
+ continue;
112
+ // §5.2.2: names are case-insensitive, so they are folded on the way in and
113
+ // every later stage compares one way. Enforcing the section's other rule —
114
+ // a name holds only U+0020..U+007D, which RFC 9639 §8.6 widens to
115
+ // U+0020..U+007E without `=` for the FLAC comments this reader also serves —
116
+ // would mean dropping a field or renaming it, both of which lose what the file
117
+ // said. A name nothing downstream matches is inert; a value thrown away is not.
118
+ const named = decodeVorbisText(entry.subarray(0, equals));
119
+ into.encoding = weakestEncoding(into.encoding, named);
120
+ const name = named.text.toLowerCase();
121
+ // The picture is taken out here, before anything is done to the value: it is
122
+ // not a tag, and the decoding below would turn it into one — a hundred and
123
+ // eighty kilobytes of base64 in a table of names.
124
+ if (name === PICTURE_COMMENT) {
125
+ const found = pictureFromComment(entry.subarray(equals + 1));
126
+ if (found !== null)
127
+ picture = betterCommentPicture(picture, found);
128
+ continue;
129
+ }
130
+ const decoded = decodeVorbisText(entry.subarray(equals + 1));
131
+ // A value that is not valid UTF-8 still comes out as something readable —
132
+ // it just may be readable and wrong. Recording how it was decided is what
133
+ // lets a later stage say so instead of printing mojibake with confidence.
134
+ // The verdict is kept whether or not the value itself survives; a comment
135
+ // that turned out not to be text is still a finding about the file.
136
+ into.encoding = weakestEncoding(into.encoding, decoded);
137
+ if (!looksLikeText(decoded.text))
138
+ continue;
139
+ into.tags.push({ name, value: decoded.text });
140
+ }
141
+ return picture;
142
+ }
143
+ /**
144
+ * The picture a comment *packet* carries, and nothing else.
145
+ *
146
+ * The serving side of `readCommentList`, and deliberately the same code: a cover
147
+ * is fetched long after the scan that noticed it, from a file nothing is holding
148
+ * in memory, so the bytes have to be derived again — and the derivation has to
149
+ * be the one the scan used, or the two can disagree about which comment is the
150
+ * picture. The names are read into a throwaway answer rather than a second
151
+ * parser.
152
+ */
153
+ export function pictureInComment(bytes, at, end) {
154
+ return readCommentList(bytes, at, end, noTags());
155
+ }