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,754 @@
1
+ import { inflateSync } from 'node:zlib';
2
+ import { decodeId3Text, looksLikeText } from "../text/encoding.js";
3
+ import { resolveGenre } from "./genres.js";
4
+ import { betterPicture, noTags, weakestEncoding } from "./types.js";
5
+ /**
6
+ * Read an ID3v2 tag.
7
+ *
8
+ * Written against the specification: `id3v2.3.0` and `id3v2-00` for the two
9
+ * earlier versions, `id3v2.4.0-structure` and `id3v2.4.0-frames` for v2.4 —
10
+ * which renumbered its sections and grouped its frames differently, so a
11
+ * citation below names its version where the numbers could be confused. Every
12
+ * rule here has a section beside it, because this reader was first written from
13
+ * memory of the format, and the v2.2 ids record what that cost.
14
+ *
15
+ * What is kept is what the standard calls a text frame — §4.2: "All text frame
16
+ * identifiers begin with 'T'. Only text frame identifiers begin with 'T', with
17
+ * the exception of the TXXX frame" — together with the comment and the frames a
18
+ * date is split across. Everything else (pictures, lyrics, replay gain) is
19
+ * stepped over by its own size, which is why a tag full of artwork still yields
20
+ * its titles. The set is the standard's rather than this collection's on
21
+ * purpose: the table used to hold nine frames and a FLAC beside it kept every
22
+ * field its comment block named, so the container decided which tags existed
23
+ * (task:2737).
24
+ *
25
+ * Three things make this parser more than a loop. Frame sizes changed meaning
26
+ * between v2.3 and v2.4 — plain big-endian in one, seven bits per byte in the
27
+ * other (§3.3 against §4) — so a reader that assumes either one misreads the
28
+ * other's long frames. The text decoders are handed the frame's declared
29
+ * encoding byte as a hint, never as the answer, because the declaration is
30
+ * routinely a lie. And v2.4 frames may be unsynchronised, which is not a detail
31
+ * the text decoders can absorb: the escaping has to come off before anything
32
+ * reads the bytes.
33
+ */
34
+ /**
35
+ * Frame id -> the name its text is stored under.
36
+ *
37
+ * Both spellings of an id are here, because v2.2 names a frame with **three**
38
+ * letters — `TP1` for the artist, `TAL` for the album — where v2.3 and v2.4 use
39
+ * four. Read as a four-letter id, `TP1` followed by the first byte of its size
40
+ * is not a frame id at all, so the walk stopped at the first frame and a file
41
+ * with a complete tag was read as having none. Two thirds of the operator's
42
+ * Kroogi rips are written this way by iTunes 10, whose tags ffprobe reads
43
+ * without complaint.
44
+ *
45
+ * The nine names this table used to hold were the whole of what the reader kept,
46
+ * and the collection says what that cost: 116 files carry a copyright, 62 a
47
+ * comment, 16 a language, and a FLAC in the same library keeps *every* field its
48
+ * comment block holds — so the container decided which tags existed, and the
49
+ * meta layer could not be read across the two (task:2737).
50
+ *
51
+ * What replaces the list is the standard's own rule, §4.2: "All text frame
52
+ * identifiers begin with 'T'. Only text frame identifiers begin with 'T', with
53
+ * the exception of the TXXX frame." The set is therefore the standard's rather
54
+ * than this collection's, and a text frame the table has no word for is kept
55
+ * under its own identifier, lowercased, rather than dropped — see `nameOf`.
56
+ */
57
+ const TEXT_FRAME_NAMES = {
58
+ // The names the meta layer already answers by and a client already reads.
59
+ // They are product names rather than the standard's words — §4.2.1 calls TPE1
60
+ // "Lead performer(s)/Soloist(s)" and TCON "Content type" — and they stay as
61
+ // they are, because queries are written against them.
62
+ TIT2: 'title', TT2: 'title',
63
+ TPE1: 'artist', TP1: 'artist',
64
+ TPE2: 'albumartist', TP2: 'albumartist',
65
+ TALB: 'album', TAL: 'album',
66
+ TRCK: 'tracknumber', TRK: 'tracknumber',
67
+ TPOS: 'discnumber', TPA: 'discnumber',
68
+ TCON: 'genre', TCO: 'genre',
69
+ // Everything else §4.2.1 names, under its own word, with v2.2's spelling of
70
+ // the same frame beside it. The three-letter ids are the set §4.2.1 of v2.2
71
+ // defines and no wider: v2.2 has no frame for a file's owner or for an
72
+ // internet radio station at all, so there is no v2.2 spelling for `TOWN`,
73
+ // `TRSN` or `TRSO` below.
74
+ TBPM: 'bpm', TBP: 'bpm',
75
+ TCOM: 'composer', TCM: 'composer',
76
+ TCOP: 'copyright', TCR: 'copyright',
77
+ TDLY: 'playlistdelay', TDY: 'playlistdelay',
78
+ TENC: 'encodedby', TEN: 'encodedby',
79
+ TEXT: 'lyricist', TXT: 'lyricist',
80
+ TFLT: 'filetype', TFT: 'filetype',
81
+ TIT1: 'contentgroup', TT1: 'contentgroup',
82
+ TIT3: 'subtitle', TT3: 'subtitle',
83
+ TKEY: 'initialkey', TKE: 'initialkey',
84
+ TLAN: 'language', TLA: 'language',
85
+ TLEN: 'length', TLE: 'length',
86
+ TMED: 'mediatype', TMT: 'mediatype',
87
+ TOAL: 'originalalbum', TOT: 'originalalbum',
88
+ TOFN: 'originalfilename', TOF: 'originalfilename',
89
+ TOLY: 'originallyricist', TOL: 'originallyricist',
90
+ TOPE: 'originalartist', TOA: 'originalartist',
91
+ TORY: 'originalreleaseyear', TOR: 'originalreleaseyear',
92
+ TOWN: 'fileowner',
93
+ TPE3: 'conductor', TP3: 'conductor',
94
+ TPE4: 'remixedby', TP4: 'remixedby',
95
+ TPUB: 'publisher', TPB: 'publisher',
96
+ TRDA: 'recordingdates', TRD: 'recordingdates',
97
+ TRSN: 'radiostation',
98
+ TRSO: 'radiostationowner',
99
+ TSIZ: 'size', TSI: 'size',
100
+ TSRC: 'isrc', TRC: 'isrc',
101
+ TSSE: 'encodersettings', TSS: 'encodersettings',
102
+ // `TDRC` is v2.4's replacement for `TYER`, and it carries a whole timestamp
103
+ // rather than a year. The older date frames are deliberately not here: §4.2.1
104
+ // splits the date across `TYER` and `TDAT` (v2.2's `TYE`, `TDA` and `TIM`),
105
+ // and one name cannot hold both halves without making which is which a coin
106
+ // toss. They are put together into one `date` after the walk — see `dated`.
107
+ TDRC: 'date',
108
+ };
109
+ /**
110
+ * What this reader calls a frame, or undefined when the frame is not its to keep.
111
+ *
112
+ * `TXXX` is excluded because its own description names it — and so is `TXX`,
113
+ * which is the same frame in v2.2's three-letter spelling (§4.2.2 of both
114
+ * documents). Missing that one cost exactly what this table exists to prevent: a
115
+ * v2.2 tag holding `ALBUMARTIST` read back as two rows named `txx`, one of them
116
+ * the *description* standing where a value belongs, and the artist the frame
117
+ * named never reaching the stage that looks for it (task:2851, both axes).
118
+ *
119
+ * The frames that carry no text at all — `PRIV`, `GEOB`, `POPM`, `UFID` — are
120
+ * not here either. None of them was ever a name, and a value read out of an
121
+ * opaque body would be an invention in a table of names, which is the failure
122
+ * this reader exists to avoid. The picture frames are `pictureIn`'s business.
123
+ */
124
+ function nameOf(id) {
125
+ if (id === 'TXXX' || id === 'TXX')
126
+ return undefined;
127
+ return TEXT_FRAME_NAMES[id] ?? (id.startsWith('T') ? id.toLowerCase() : undefined);
128
+ }
129
+ function readUInt24BE(bytes, at) {
130
+ return (((bytes[at] ?? 0) << 16) | ((bytes[at + 1] ?? 0) << 8) | (bytes[at + 2] ?? 0)) >>> 0;
131
+ }
132
+ function readUInt32BE(bytes, at) {
133
+ return ((((bytes[at] ?? 0) << 24) |
134
+ ((bytes[at + 1] ?? 0) << 16) |
135
+ ((bytes[at + 2] ?? 0) << 8) |
136
+ (bytes[at + 3] ?? 0)) >>>
137
+ 0);
138
+ }
139
+ /** Seven bits per byte, which is how every size inside an ID3v2 tag is written. */
140
+ function synchsafeAt(bytes, at) {
141
+ return ((((bytes[at] ?? 0) & 0x7f) << 21) |
142
+ (((bytes[at + 1] ?? 0) & 0x7f) << 14) |
143
+ (((bytes[at + 2] ?? 0) & 0x7f) << 7) |
144
+ ((bytes[at + 3] ?? 0) & 0x7f));
145
+ }
146
+ /** A frame id is upper-case letters or digits — three of them in v2.2, four
147
+ * after it. Zeros mean padding, and anything else is not a frame. */
148
+ function asciiFrameId(bytes, at, width) {
149
+ let out = '';
150
+ for (let i = 0; i < width; i += 1) {
151
+ const byte = bytes[at + i] ?? 0;
152
+ if (!((byte >= 0x41 && byte <= 0x5a) || (byte >= 0x30 && byte <= 0x39)))
153
+ return '';
154
+ out += String.fromCharCode(byte);
155
+ }
156
+ return out;
157
+ }
158
+ /**
159
+ * Undo the unsynchronisation a writer applied.
160
+ *
161
+ * A writer escapes any `FF` that a reader could take for the start of a frame
162
+ * sync, so what is stored is never the original: `FF 00` was written `FF 00 00`
163
+ * and `FF Ex` was written `FF 00 Ex`. Three bytes therefore settle every case —
164
+ * `FF 00 00` is a real `FF 00`, `FF 00 x` is a real `FF x` — and that middle
165
+ * rule is what keeps a UTF-16 letter like `ÿ` (whose bytes *are* `FF 00`) from
166
+ * being eaten down to a lone `FF`.
167
+ *
168
+ * The same three bytes undo every version, but not in the same place, and that
169
+ * is the whole of what a caller has to get right. v2.4 escapes frame by frame
170
+ * and its frame sizes count what is on disk, so the decoding happens per frame,
171
+ * in `frameData` — and decoding the whole tag instead is what
172
+ * `id3v2.4.0-changes` §3 warns against: "Resynchronisation of the complete tag
173
+ * when the unsynchronisation flag in the tag header is set might result in a
174
+ * corrupt tag". v2.2 and v2.3 escape the tag as it stands (§5 applies the scheme
175
+ * after compression; §3.2 of v2.3 has the CRC computed on the frames *before*
176
+ * it), so their frame sizes count the clean bytes and the whole body is decoded
177
+ * once, in `readId3v2`, before the walk begins.
178
+ */
179
+ function deunsynchronise(bytes) {
180
+ // The common case is a frame with nothing to escape, and it is worth not
181
+ // copying for: the frame is handed back as it is.
182
+ let escaped = false;
183
+ for (let i = 0; i + 1 < bytes.length; i += 1) {
184
+ if (bytes[i] === 0xff && bytes[i + 1] === 0x00) {
185
+ escaped = true;
186
+ break;
187
+ }
188
+ }
189
+ if (!escaped)
190
+ return bytes;
191
+ const out = new Uint8Array(bytes.length);
192
+ let at = 0;
193
+ for (let i = 0; i < bytes.length; i += 1) {
194
+ const byte = bytes[i] ?? 0;
195
+ out[at] = byte;
196
+ at += 1;
197
+ if (byte !== 0xff || bytes[i + 1] !== 0x00)
198
+ continue;
199
+ if (bytes[i + 2] === 0x00) {
200
+ out[at] = 0x00; // a real `FF 00`, which the writer had to double
201
+ at += 1;
202
+ i += 2;
203
+ }
204
+ else {
205
+ i += 1; // a real `FF`, which the writer had to fence off
206
+ }
207
+ }
208
+ return out.subarray(0, at);
209
+ }
210
+ /** Where a description ends and its value begins, per the encoding's terminator width. */
211
+ function splitTerminated(body, encoding) {
212
+ const width = encoding === 1 || encoding === 2 ? 2 : 1;
213
+ for (let i = 0; i + width <= body.length; i += width) {
214
+ if (body[i] !== 0)
215
+ continue;
216
+ if (width === 1 || body[i + 1] === 0) {
217
+ return [body.subarray(0, i), body.subarray(i + width)];
218
+ }
219
+ }
220
+ return [body, body.subarray(body.length)];
221
+ }
222
+ /**
223
+ * Push a decoded value, split on any embedded NUL.
224
+ *
225
+ * §4.2 of v2.4: "All text information frames supports multiple strings, stored
226
+ * as a null separated list, where null is represented by the termination code
227
+ * for the character encoding" — which after decoding is a plain NUL at every
228
+ * width. So a reader that kept the frame whole would hand back a single string
229
+ * holding control characters, and two artists as one name.
230
+ *
231
+ * Worth knowing before trusting this: the split never runs on a value that is
232
+ * mostly control characters, because `looksLikeText` refuses those first, and a
233
+ * short multi-value frame — v2.4's own example is "21" $00 "Eurodisco" — is
234
+ * mostly separator by that measure. Not one of the 700 mp3 files in the
235
+ * collection carries such a frame once its unsynchronisation is removed, so the
236
+ * two rules have yet to meet outside a fixture.
237
+ *
238
+ * `resolve` is applied to each value after the split, because the values of a
239
+ * frame are separate statements: a genre frame holding a reference and a
240
+ * refinement holds two of them, and resolving the pair together would answer
241
+ * for one with the other.
242
+ */
243
+ function pushValues(into, name, decoded, resolve = (value) => value) {
244
+ // The verdict is kept even when the value is not kept. A malformed frame is
245
+ // a finding, and a reader that swallowed it whole would leave "why is this
246
+ // album unnamed?" with no answer anywhere in the meta layer.
247
+ into.encoding = weakestEncoding(into.encoding, decoded);
248
+ // ...but a value that is not text is not stored. Splitting such a body on its
249
+ // NULs yields one "tag" per character, and those fragments then outrank a
250
+ // folder name that was readable all along.
251
+ // The judgement is made of the parts, and not of the body they were split
252
+ // from — which is the same judgement, because the harm named above *is* the
253
+ // fragments. Section 4.2 says a text frame's NULs are separators, so a body
254
+ // judged whole counts a legitimate separator against itself: `21` and
255
+ // `Eurodisco` with a NUL between them is one control in thirteen bytes, and
256
+ // the guard threw the value away before ever splitting it (task:2738). What
257
+ // it is really looking for is what a misread UTF-16 body produces — every
258
+ // part one character long — and that is asked of the parts directly.
259
+ const parts = decoded.text
260
+ .split('\u0000')
261
+ .map((part) => part.trim())
262
+ .filter((part) => part !== '');
263
+ const readable = parts.filter((part) => looksLikeText(part));
264
+ if (readable.length === 0)
265
+ return;
266
+ // Every part a single character is not a list of values: it is a body read in
267
+ // the wrong width. `A`, `B`, `C` with NULs between them is `ABC` in UTF-16LE,
268
+ // and one tag per letter would outrank the folder name as the note above says.
269
+ // A majority rather than every one, because a misread body does not end cleanly:
270
+ // its last fragment is whatever is left over. The guard above is a ratio for the
271
+ // same reason.
272
+ const oneChar = readable.filter((part) => [...part].length === 1).length;
273
+ if (readable.length > 1 && oneChar * 2 > readable.length)
274
+ return;
275
+ for (const part of readable)
276
+ into.tags.push({ name, value: resolve(part) });
277
+ }
278
+ /**
279
+ * What a frame's value means, beyond the bytes it is written in.
280
+ *
281
+ * Only the genre has one: section 4.2.1 lets it be a reference to the ID3v1
282
+ * list rather than a word, which is a second encoding of the same value beside
283
+ * the text encoding every frame already carries. Nothing else here reads as
284
+ * anything but itself, and inventing an interpretation for the rest would be
285
+ * deciding what a title is in a stage that only reads.
286
+ */
287
+ const RESOLVERS = {
288
+ genre: resolveGenre,
289
+ };
290
+ /**
291
+ * The picture in an `APIC` frame, or in the `PIC` frame v2.2 wrote instead.
292
+ *
293
+ * §4.15 of v2.3 and §4.14 of v2.4 lay it out the same way: the text encoding,
294
+ * the MIME type as a null-terminated Latin-1 string, the picture type, a
295
+ * description in the frame's encoding and terminated the way that encoding
296
+ * terminates, and then the image. v2.2 named the frame `PIC`, used three
297
+ * characters — `JPG`, `PNG` — instead of a MIME type, and is otherwise the same.
298
+ *
299
+ * Returns where the image starts, or null when the frame is not laid out that
300
+ * way — and a frame that cannot be walked yields no picture rather than a guess
301
+ * at one, which is the same rule the rest of this reader follows.
302
+ */
303
+ function pictureIn(body, major) {
304
+ if (body.length < 4)
305
+ return null;
306
+ let at = 1; // past the encoding byte, which the description below needs
307
+ const encoding = body[0] ?? 0;
308
+ let mime;
309
+ if (major === 2) {
310
+ // Three characters, which are not a MIME type and are not treated as one:
311
+ // the two that were ever defined are read, and anything else is a format
312
+ // this cannot name, which is a reason to say nothing.
313
+ const format = Buffer.from(body.subarray(at, at + 3)).toString('latin1').toUpperCase();
314
+ at += 3;
315
+ if (format === 'JPG')
316
+ mime = 'image/jpeg';
317
+ else if (format === 'PNG')
318
+ mime = 'image/png';
319
+ else
320
+ return null;
321
+ }
322
+ else {
323
+ // The MIME type runs to a null. A frame that never ends it has no picture
324
+ // behind it to find, so the end of the body is the end of the search.
325
+ let end = at;
326
+ while (end < body.length && body[end] !== 0)
327
+ end += 1;
328
+ if (end >= body.length)
329
+ return null;
330
+ mime = Buffer.from(body.subarray(at, end)).toString('latin1');
331
+ at = end + 1;
332
+ }
333
+ if (at >= body.length)
334
+ return null;
335
+ const kind = body[at] ?? 0;
336
+ at += 1;
337
+ // The description, then the image. Its terminator is one byte or two
338
+ // depending on the encoding, which is the same rule a TXXX frame's
339
+ // description follows.
340
+ const [, rest] = splitTerminated(body.subarray(at), encoding);
341
+ const start = body.length - rest.length;
342
+ return start >= body.length ? null : { mime, kind, start };
343
+ }
344
+ function readFrame(into, id, body,
345
+ /** Collects the frames that are half of a date rather than a value. */
346
+ dates,
347
+ /** Where `body` starts in the file, when it is the file's own bytes. */
348
+ at) {
349
+ if (body.length === 0)
350
+ return;
351
+ const encoding = body[0] ?? 0;
352
+ if (id === 'APIC' || id === 'PIC') {
353
+ const picture = pictureIn(body, id === 'PIC' ? 2 : 3);
354
+ // A frame the reader had to rebuild — de-unsynchronised, decompressed — is
355
+ // a copy, and a copy has no offset in the file to be served from. The
356
+ // picture is then simply not recorded: an absent cover is a smaller lie
357
+ // than one served from the middle of something else.
358
+ if (picture !== null && at !== null) {
359
+ into.picture = betterPicture(into.picture, {
360
+ mime: picture.mime,
361
+ kind: picture.kind,
362
+ offset: at + picture.start,
363
+ length: body.length - picture.start,
364
+ });
365
+ }
366
+ return;
367
+ }
368
+ // `TXX` is the same frame in v2.2's spelling (§4.2.2 of `id3v2-00`).
369
+ if (id === 'TXXX' || id === 'TXX') {
370
+ // A user-defined pair: its description names it (§4.2.2 of v2.3, §4.2.6 of
371
+ // v2.4, which moved it). This is where a ripper puts ALBUMARTIST when it
372
+ // does not use TPE2, and where the collection keeps ENSEMBLE and the
373
+ // ripping tool's own notes.
374
+ //
375
+ // No resolver, and that is the rule rather than an omission: the genre
376
+ // resolver answers for the frame the standard defines as the content type,
377
+ // and it is asked for by *frame*, not by name. A file free to name its own
378
+ // field `genre` is free to mean anything by it, and reading `17` in it as
379
+ // Rock is deciding what the file meant — measured on a built tag, the whole
380
+ // of that difference (task:2851).
381
+ const [description, value] = splitTerminated(body.subarray(1), encoding);
382
+ // The description is text and is read as text, so how well it decoded counts
383
+ // towards the file's verdict exactly as the value's does. The Vorbis reader
384
+ // folds its field *name* the same way — one figure, and it was being made
385
+ // two ways until this matched it (task:2756).
386
+ const described = decodeId3Text(encoding, description);
387
+ into.encoding = weakestEncoding(into.encoding, described);
388
+ const name = described.text.trim().toLowerCase();
389
+ if (name === '')
390
+ return;
391
+ pushValues(into, name, decodeId3Text(encoding, value));
392
+ return;
393
+ }
394
+ // A comment is a text frame under an id that does not begin with `T`: §4.11 of
395
+ // v2.3 and §4.10 of v2.4 give it the encoding, then a three-byte language,
396
+ // then the short description that `TXXX` also carries, then the text.
397
+ //
398
+ // The description does *not* name it, though `TXXX`'s does. A TXXX description
399
+ // is the field's name — that is what "user defined" means — while a comment's
400
+ // is a content descriptor, and §4.11 says so: it exists to tell several
401
+ // comments apart, not to introduce a name. Taking it as one let a file name a
402
+ // comment `title` and have it read as a second title ahead of the real one,
403
+ // and the genre resolver then answered for the name it invented (task:2851).
404
+ if (id === 'COMM' || id === 'COM') {
405
+ // Three bytes of language, exactly, whatever they are: they are ISO-639-2
406
+ // codes and not text in the frame's encoding, so the terminator rule does
407
+ // not apply to them.
408
+ const [, text] = splitTerminated(body.subarray(4), encoding);
409
+ pushValues(into, 'comment', decodeId3Text(encoding, text), RESOLVERS.comment);
410
+ return;
411
+ }
412
+ // The frames a date is split across, collected rather than stored — see
413
+ // `DateParts`. The encoding verdict is still folded in: a frame whose value is
414
+ // put to another use has still been read, and how well is a fact about the
415
+ // file.
416
+ if (id === 'TYER' || id === 'TYE' || id === 'TDAT' || id === 'TDA' || id === 'TIME' || id === 'TIM') {
417
+ const decoded = decodeId3Text(encoding, body.subarray(1));
418
+ into.encoding = weakestEncoding(into.encoding, decoded);
419
+ const value = decoded.text.trim();
420
+ if (id === 'TDAT' || id === 'TDA')
421
+ dates.dayMonth = value;
422
+ else if (id === 'TIME' || id === 'TIM')
423
+ dates.hourMinute = value;
424
+ else
425
+ dates.year = value;
426
+ return;
427
+ }
428
+ const known = nameOf(id);
429
+ if (known === undefined)
430
+ return;
431
+ pushValues(into, known, decodeId3Text(encoding, body.subarray(1)), RESOLVERS[known]);
432
+ }
433
+ export function readId3v2(bytes) {
434
+ // The tag block says nothing about the audio behind it: the codec is the
435
+ // frame walk's to name, in `read.ts`, and the bytes here are ID3 and nothing more.
436
+ const result = noTags('id3v2');
437
+ if (bytes.length < 10)
438
+ return result;
439
+ // What this reading declined to use, one sentence each, carried out through
440
+ // `TagRead.refusals` so the stage can write it down — see that field on why a
441
+ // value read and discarded is the silence the contract forbids.
442
+ const refusals = [];
443
+ const major = bytes[3] ?? 0;
444
+ const flags = bytes[5] ?? 0;
445
+ const stored = Math.min(10 + synchsafeAt(bytes, 6), bytes.length);
446
+ let at = 10;
447
+ let end = stored;
448
+ // v2.2's bit 6 is *compression*, not an extended header, and the spec is
449
+ // explicit about what to do with it: "the ID3 decoder (for now) should just
450
+ // ignore the entire tag if the compression bit is set" (id3v2-00 §3.1). No
451
+ // scheme was ever defined, so there is nothing to decompress — parsing on
452
+ // would read compressed bytes as frames and invent names out of them.
453
+ //
454
+ // Ignoring the tag is what the spec asks for; ignoring it *silently* is not,
455
+ // and was: the file then reads exactly like a rip that never had tags, which is
456
+ // a different thing from one whose tags this reader could not use. The sentence
457
+ // goes up through `refusals` and the stage writes it down (task:2754).
458
+ if (major === 2 && (flags & 0x40) !== 0) {
459
+ result.refusals = [
460
+ 'the tag is marked compressed, which v2.2 defines no scheme for — nothing in it was read',
461
+ ];
462
+ return result;
463
+ }
464
+ // v2.2 and v2.3 escape the tag as a whole and leave no per-frame place to say
465
+ // so — §3.3.1 of v2.3's frame flags are compression, encryption and grouping —
466
+ // so the escaping comes off here, once, and everything below reads clean bytes.
467
+ // The extended header is included: §3.2 of v2.3 calls it "subject to
468
+ // unsynchronisation" as well, so decoding from the first frame on would leave
469
+ // it escaped and its size field would then be read out of escaped bytes.
470
+ //
471
+ // What the frame sizes describe follows from where the escaping sat, and v2.3
472
+ // says where: §3.2 has the extended header's CRC "calculated before
473
+ // unsynchronisation on the data between the extended header and the padding,
474
+ // i.e. the frames and only the frames", and §5 applies the scheme after
475
+ // compression — to the tag as it stands. So the escaping was applied to
476
+ // assembled frames, whose size fields were written before it, and those sizes
477
+ // therefore count the clean bytes. v2.2 §3.1 and §5 say the same in the same
478
+ // words. Walking such a tag without decoding it first reads the second frame
479
+ // from the wrong offset and the first frame's escaped bytes as text
480
+ // (task:2753); v2.4 is the other arrangement and is `frameData`'s business.
481
+ if (major < 4 && (flags & 0x80) !== 0) {
482
+ const body = deunsynchronise(bytes.subarray(10, stored));
483
+ // The header is copied rather than decoded — §3.1's size is synchsafe, the
484
+ // version bytes are never $FF, and the flags byte names this very flag — so
485
+ // the bytes below keep meaning what the ones above did.
486
+ const joined = new Uint8Array(10 + body.length);
487
+ joined.set(bytes.subarray(0, 10), 0);
488
+ joined.set(body, 10);
489
+ bytes = joined;
490
+ end = joined.length;
491
+ }
492
+ // v2.2 writes a three-letter id and a plain three-byte size, and has no
493
+ // per-frame flags: its frame header is six bytes where every later version
494
+ // uses ten. Everything below is written against that difference.
495
+ const idWidth = major === 2 ? 3 : 4;
496
+ const frameHeader = major === 2 ? 6 : 10;
497
+ const dates = { year: null, dayMonth: null, hourMinute: null, full: false };
498
+ // An extended header sits between the tag header and the first frame, and its
499
+ // size field means different things in v2.3 and v2.4. §3.2 of v2.3: "the
500
+ // 'Extended header size', currently 6 or 10 bytes, excludes itself", so the
501
+ // header is four bytes longer than the number. §3.2 of v2.4: "the 'Extended
502
+ // header size' is the size of the whole extended header, stored as a 32 bit
503
+ // synchsafe integer", so it is the number itself. Getting this backwards
504
+ // shifts every frame by a few bytes without any error anywhere. v2.2 has no
505
+ // extended header at all: bit 6 of its flag byte means the tag is compressed.
506
+ if (major >= 3 && (flags & 0x40) !== 0 && at + 4 <= end) {
507
+ if (major >= 4)
508
+ at += synchsafeAt(bytes, at);
509
+ else
510
+ at += 4 + readUInt32BE(bytes, at);
511
+ }
512
+ // A header flag saying every frame was escaped. §6.1 of v2.4: "If all frames
513
+ // in the tag are unsynchronised the unsynchronisation flag in the tag header
514
+ // SHOULD be set", with the per-frame flag (§4.1.2, bit n) saying which of
515
+ // them were. The header flag is therefore a summary — but a writer that sets
516
+ // it has escaped every `FF 00` in the tag, so taking it as a blanket
517
+ // instruction costs nothing and missing it would leave escaped bytes in
518
+ // place. v2.2 and v2.3 carry the same flag in their own §3.1 and have no
519
+ // per-frame place to say it instead. A tag of either older version has been
520
+ // decoded above already, so what is left for this flag to mean below is v2.4's
521
+ // summary — and the two places that read it are `frameData` and `sits`.
522
+ const tagEscaped = (flags & 0x80) !== 0;
523
+ // §3.3 of v2.3 and §4 of v2.4 give the frame as an identifier, a size, and two
524
+ // flag bytes; §4.2.1 of v2.2 — three letters where the rest use four, a plain
525
+ // three-byte size, and no flags at all, which is the six-byte header `idWidth`
526
+ // and `frameHeader` below are built around.
527
+ while (at + frameHeader <= end) {
528
+ const id = asciiFrameId(bytes, at, idWidth);
529
+ if (id === '')
530
+ break; // padding, or something that is not a frame
531
+ const size = major >= 4
532
+ ? synchsafeAt(bytes, at + 4)
533
+ : major === 2
534
+ ? readUInt24BE(bytes, at + 3)
535
+ : readUInt32BE(bytes, at + 4);
536
+ const body = at + frameHeader;
537
+ if (size <= 0 || body + size > end)
538
+ break;
539
+ // A v2.4 frame's size counts what is on disk — escaping and indicator
540
+ // included — so the walk stays on the raw bytes and only what is handed to
541
+ // the frame reader is adjusted. Doing it the other way round there —
542
+ // de-escaping the frames and then walking them — reads every boundary from
543
+ // the wrong offset. v2.2 and v2.3 are the opposite arrangement and are
544
+ // already decoded above, sizes and all, which is why only a v2.4 frame ever
545
+ // reaches here escaped.
546
+ const format = major >= 3 ? (bytes[at + 9] ?? 0) : 0;
547
+ const raw = bytes.subarray(body, body + size);
548
+ // A frame this reader would have read had it been readable. One it ignores
549
+ // anyway — a `PRIV`, a `GEOB` — is not a loss, and a line about it would be
550
+ // noise on every file that carries one.
551
+ const wanted = nameOf(id) !== undefined ||
552
+ id === 'TXXX' ||
553
+ id === 'TXX' ||
554
+ id === 'COMM' ||
555
+ id === 'COM' ||
556
+ id === 'APIC' ||
557
+ id === 'PIC';
558
+ const data = frameData(raw, format, major, tagEscaped, (why) => {
559
+ if (wanted)
560
+ refusals.push(`the frame ${id} ${why}`);
561
+ });
562
+ // Where this frame's data sits in the file, or null when it is not certainly
563
+ // the file's own bytes.
564
+ //
565
+ // Two ways that can fail, and the second is the one worth spelling out.
566
+ // `frameData` rebuilds a frame whose format flags say so — de-escaped,
567
+ // decompressed — and a rebuilt frame is a copy with no place in the file;
568
+ // the buffer identity catches that. A tag of v2.2 or v2.3 that carries the
569
+ // tag flag is the same answer for the other reason: its frames describe
570
+ // bytes that are not the file's — escaped, in v2.2's case, and a decoded
571
+ // copy in v2.3's — so an offset into them would serve the escaping, or the
572
+ // wrong bytes entirely, as part of the picture. The guard is deliberately
573
+ // wider than the rebuild: it needs no measurement of which of the two
574
+ // happened, because neither answer is a file offset.
575
+ const escaped = major < 4 && tagEscaped;
576
+ const sits = !escaped && data.buffer === bytes.buffer ? data.byteOffset - bytes.byteOffset : null;
577
+ if (id === 'TDRC')
578
+ dates.full = true;
579
+ readFrame(result, id, data, dates, sits);
580
+ at = body + size;
581
+ }
582
+ dated(result, dates);
583
+ if (refusals.length > 0)
584
+ result.refusals = refusals;
585
+ return result;
586
+ }
587
+ /**
588
+ * Write the date the tag states, out of the frames it states it in.
589
+ *
590
+ * The composite is not the specification's — §4.2.1 defines the three frames and
591
+ * says nothing about putting them together — so it is a choice. It follows
592
+ * ffmpeg, which is the second reader this project measures itself against, on
593
+ * the well-formed case: `2005-09-12` where a year alone loses the day, and it
594
+ * agrees with ffmpeg there on every file of the collection that states a `TDAT`
595
+ * (task:2737).
596
+ *
597
+ * Where they part is the malformed case, and the parting is deliberate: ffmpeg
598
+ * composes `2005-00-00` from a `TDAT` of `0000`, and this reader will not — a
599
+ * date with a date's shape and no date in it is the invention the rest of this
600
+ * file exists to avoid. ffmpeg also drops a `TYER` that holds a whole
601
+ * `2025-03-28`, where this reader keeps what the file wrote; that is the seven
602
+ * files of the collection that carry one (task:2851, measured). Neither
603
+ * difference is a disagreement about the format, which is silent on both.
604
+ *
605
+ * `TDRC`, which v2.4 writes instead, is already a whole timestamp and is left
606
+ * exactly as the file wrote it.
607
+ *
608
+ * A part that is not a date is not one: `TDAT` of `0000` is what a writer puts
609
+ * there when it has no date, and composing `2005-00-00` from it would be an
610
+ * invention with a date's shape. Such a tag keeps the year it does state. A tag
611
+ * that states no year at all yields no `date` rather than a bare `DDMM` — four
612
+ * digits under a name that other code reads a year out of is worse than silence.
613
+ */
614
+ function dated(into, dates) {
615
+ if (dates.year === null)
616
+ return;
617
+ // `TDRC` had the last word, and it is asked for by name *and* by having been
618
+ // seen. The weaker test — is any tag called `date` already here — let a
619
+ // user-defined `TXXX` named `date` suppress the year, the day and the month
620
+ // the tag states in the frames that exist for them (task:2851).
621
+ if (dates.full)
622
+ return;
623
+ // A year is judged by the same rule the stage that reads one out uses — four
624
+ // digits beginning `1` or `2` — and not by being exactly four characters:
625
+ // writers do put a whole `1997-05-06` in `TYER`, and a file that already states
626
+ // a date is not improved by this function recomposing one. `not a year` is
627
+ // what that rule is for, and it stays unanswered.
628
+ const year = /^[12][0-9]{3}$/.test(dates.year.slice(0, 4)) ? dates.year.slice(0, 4) : null;
629
+ if (year === null)
630
+ return;
631
+ if (dates.year.length > 4) {
632
+ into.tags.push({ name: 'date', value: dates.year });
633
+ return;
634
+ }
635
+ const dayMonth = /^[0-9]{4}$/.test(dates.dayMonth ?? '') ? dates.dayMonth : null;
636
+ const month = dayMonth === null ? 0 : Number(dayMonth.slice(2, 4));
637
+ const day = dayMonth === null ? 0 : Number(dayMonth.slice(0, 2));
638
+ const usable = month >= 1 && month <= 12 && day >= 1 && day <= 31;
639
+ if (!usable) {
640
+ into.tags.push({ name: 'date', value: year });
641
+ return;
642
+ }
643
+ const hourMinute = /^[0-9]{4}$/.test(dates.hourMinute ?? '') ? dates.hourMinute : null;
644
+ const hour = hourMinute === null ? 0 : Number(hourMinute.slice(0, 2));
645
+ const minute = hourMinute === null ? 0 : Number(hourMinute.slice(2, 4));
646
+ const clock = hourMinute !== null && hour <= 23 && minute <= 59
647
+ ? `T${hourMinute.slice(0, 2)}:${hourMinute.slice(2, 4)}`
648
+ : '';
649
+ const dd = String(day).padStart(2, '0');
650
+ const mm = String(month).padStart(2, '0');
651
+ into.tags.push({ name: 'date', value: `${year}-${mm}-${dd}${clock}` });
652
+ }
653
+ /** Drop up to `count` bytes from the front, or all of them if there are fewer. */
654
+ function skip(bytes, count) {
655
+ return bytes.subarray(Math.min(count, bytes.length));
656
+ }
657
+ /**
658
+ * Undo whatever the frame's format flags say was done to its data.
659
+ *
660
+ * Both versions put extra fields ahead of the data and say the order is the
661
+ * order of the flags, which is the whole of the difficulty: the layout differs
662
+ * by version even where the flags look alike.
663
+ *
664
+ * v2.3, §3.3.1, flags %ijk00000 — i compression, j encryption, k grouping —
665
+ * puts four bytes of decompressed size first, then the encryption method
666
+ * byte, then the group byte.
667
+ *
668
+ * v2.4, §4.1.2, flags %0h00kmnp — h grouping, k compression, m encryption,
669
+ * n unsynchronisation, p data length indicator — puts the group byte first,
670
+ * then the method byte, then the four-byte indicator.
671
+ *
672
+ * The data length indicator is the length the frame would have had with its
673
+ * format flags zeroed — that is, the length *before* compression. It is not
674
+ * needed to read the frame, whose size is right there, so it is stepped over
675
+ * rather than trusted.
676
+ *
677
+ * What §4.1.2 asks for differs by flag, and the two sentences are worth keeping
678
+ * apart, because they say opposite things: for a **compressed** frame the
679
+ * indicator is required — "If set, this requires the 'Data Length Indicator' bit
680
+ * to be set as well", and "A 'Data Length Indicator' byte MUST be included in
681
+ * the frame" — while for an **unsynchronised** one it is only wanted, "Although
682
+ * desirable, the presence of a 'Data Length Indicator' is not made mandatory by
683
+ * unsynchronisation". This reader takes the indicator when the flag says it is
684
+ * there and does not require it in either case, which is not laxity: the frame's
685
+ * own size settles where it ends, so a writer that omitted a required indicator
686
+ * costs nothing here.
687
+ *
688
+ * An encrypted frame yields nothing. The method byte names a scheme this reader
689
+ * does not implement, and there is no plaintext behind it to read — decoding
690
+ * ciphertext as text is how a title gets invented.
691
+ */
692
+ function frameData(bytes, format, major, tagEscaped, refuse) {
693
+ // v2.2 has no frame flags at all: its header is six bytes and that is the
694
+ // whole of it. Compression there is a tag-level flag, handled by the caller.
695
+ if (major === 2)
696
+ return bytes;
697
+ const grouped = (format & (major >= 4 ? 0x40 : 0x20)) !== 0;
698
+ const encrypted = (format & (major >= 4 ? 0x04 : 0x40)) !== 0;
699
+ const indicator = major >= 4 && (format & 0x01) !== 0;
700
+ const compressed = (format & (major >= 4 ? 0x08 : 0x80)) !== 0;
701
+ if (major === 3 && compressed)
702
+ bytes = skip(bytes, 4);
703
+ // Encryption is answered before the group byte is stepped over, and that is
704
+ // the order the header states: the decompressed size, the method byte, then
705
+ // the group byte. Nothing here reads the method byte — the frame is refused —
706
+ // so the only thing the order decides is which of the two a reader would be
707
+ // wrong about, and this way the comment above and the code agree.
708
+ if (encrypted) {
709
+ refuse('is encrypted, and no scheme here reads it');
710
+ return new Uint8Array(0);
711
+ }
712
+ if (grouped)
713
+ bytes = skip(bytes, 1);
714
+ if (indicator)
715
+ bytes = skip(bytes, 4);
716
+ // De-escaping comes before decompressing, and it has to: a writer compresses
717
+ // first and unsynchronises the result, so the reader takes the escaping off
718
+ // the compressed bytes. v2.2 and v2.3 say the same of their tag-level flag,
719
+ // and their case never reaches here: `readId3v2` decoded the whole body before
720
+ // it began to walk, so a compressed v2.3 frame is handed over already clean.
721
+ if (major >= 4 && (tagEscaped || (format & 0x02) !== 0))
722
+ bytes = deunsynchronise(bytes);
723
+ if (!compressed)
724
+ return bytes;
725
+ const inflated = inflateFrame(bytes);
726
+ // Empty is the answer for a body that came back with nothing, and it is the
727
+ // same answer a frame that failed to inflate gets — which is why the failure
728
+ // has to be said here or nowhere: downstream sees an empty body either way.
729
+ if (inflated.length === 0)
730
+ refuse('says it is compressed and did not decompress');
731
+ return inflated;
732
+ }
733
+ /**
734
+ * A zlib frame body, or nothing at all.
735
+ *
736
+ * §3.3.1 compresses with zlib and §4.1.2 says "zlib deflate method", so Node's
737
+ * own inflate is the same scheme. ffmpeg reads these frames — that was checked
738
+ * by building one to the specification and asking it — while v2.3's variant it
739
+ * does not implement, so that half of this path has the specification behind it
740
+ * and no second instrument.
741
+ *
742
+ * A frame that says it is compressed and does not decompress is left with no
743
+ * value rather than with its compressed bytes as text: the second would put a
744
+ * plausible-looking title made of zlib header bytes into a column that a title
745
+ * belongs in.
746
+ */
747
+ function inflateFrame(bytes) {
748
+ try {
749
+ return inflateSync(bytes);
750
+ }
751
+ catch {
752
+ return new Uint8Array(0);
753
+ }
754
+ }