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,355 @@
1
+ /**
2
+ * How long an MPEG audio stream plays.
3
+ *
4
+ * **Sources, and which parts have one.** The frame header — where each field
5
+ * sits, the bitrate tables, the sample rates, the slot a padding bit adds — is
6
+ * checked against the MP3'Tech page on it (`mp3-tech.org/programmer/
7
+ * frame_header.html`, saved as `2026-09-12-mpeg-audio-frame-header---mp3-tech.md`).
8
+ * That page is **secondary**: ISO/IEC 11172-3, the standard itself, is not
9
+ * openly available, so nothing here is verified against the standard, and
10
+ * saying "checked against the standard" would be false.
11
+ *
12
+ * Three things in this file that page does not cover, and no other source in
13
+ * hand does either — the samples a frame carries, the frame-length formula, and
14
+ * the Xing/Info and VBRI header layouts with the side-info sizes that lead to
15
+ * them. Those are held up by measurement instead: on 700 mp3 files here the
16
+ * length this reader reports is identical to the frame count ffprobe arrives at
17
+ * when it reads every frame (`-count_frames`), which is an independent
18
+ * instrument reaching the same number by a different route. 528 of those files
19
+ * take the Xing/Info path, so its offsets are exercised rather than assumed.
20
+ * **VBRI is exercised by nothing**: no file in the collection carries one, and
21
+ * it remains the one path here that rests on recollection.
22
+ *
23
+ * mp3 has no field that states this outright the way FLAC's STREAMINFO does,
24
+ * which is not the same as saying it is unknown — every player knows it exactly,
25
+ * and it knows it from the file alone. There are two ways and both are here:
26
+ *
27
+ * - a **Xing/Info** header (LAME writes one; Fraunhofer writes `VBRI`), which
28
+ * states the frame count and costs a multiplication; or
29
+ * - **walking the frames**, which needs no assumption about the stream and is
30
+ * exact for constant and variable bitrate alike.
31
+ *
32
+ * There is deliberately no "assume CBR and divide the file size by the bitrate"
33
+ * shortcut, though it is the next thing anyone reaches for. It is wrong on any
34
+ * file with a trailing ID3v1 or APEv2 tag, wrong on a free-format stream, and
35
+ * wrong on the variable bitrate rips where it is most tempting — and the frames
36
+ * are already in memory, so walking them costs a jump per frame and no
37
+ * assumption at all.
38
+ *
39
+ * Counting is also what makes it possible to say the length is *not* known,
40
+ * which is the other half of this module. A walk that runs out of frames with
41
+ * most of the file still ahead of it has lost the stream, and the number it
42
+ * would otherwise report is short by however much it skipped — plausible,
43
+ * wrong, and indistinguishable downstream from a correct one. That case is
44
+ * refused: the caller is told to ask something else, and ffprobe answers. The
45
+ * same refusal is what stops a file that merely *looks* like a stream — a
46
+ * couple of headers matching by chance inside an mp4 — from being claimed as
47
+ * one; see `MIN_STREAM_FRAMES` and `REFUSAL_MIN_GAP`.
48
+ *
49
+ * The tables are the part that is easy to get subtly wrong, and every one of
50
+ * them differs between MPEG versions:
51
+ *
52
+ * - samples per frame: Layer III is 1152 at MPEG-1 and **576** at MPEG-2/2.5.
53
+ * Using 1152 everywhere reports a low-bitrate rip at double its length.
54
+ * - the bitrate table: MPEG-2/2.5 Layer III starts at 8 kbps and stops at
55
+ * 160; MPEG-1 runs 32 to 320. Reading one with the other's table gives a
56
+ * plausible, wrong number.
57
+ * - the sample rate: 44.1/48/32 kHz at MPEG-1, half that at MPEG-2, half
58
+ * again at MPEG-2.5.
59
+ */
60
+ /**
61
+ * Bitrate in kbps by table index, keyed by the same layer field as everything
62
+ * else — **1 is Layer III**. Index 0 is "free" and 15 is invalid.
63
+ *
64
+ * Every value here is the MP3'Tech table, transcribed column for column: V1/L1
65
+ * runs 32 to 448, V1/L2 32 to 384, V1/L3 32 to 320, V2/L1 32 to 256, and
66
+ * V2/L2 with L3 8 to 160 — the last being the one that catches people, since
67
+ * low-bitrate MPEG-2 audio is exactly where a wrong table is least obvious.
68
+ *
69
+ * Getting a layer's key wrong here is the quietest failure in the file: read
70
+ * Layer III's index 9 against Layer I's table and a 128 kbps stream reports as
71
+ * 288, which is a real bitrate at a real index and produces a frame length of
72
+ * 864 instead of 384 — no error, no exception, just a track three times too
73
+ * long.
74
+ */
75
+ const BITRATES_V1 = {
76
+ 1: [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0], // Layer III
77
+ 2: [0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384, 0], // Layer II
78
+ 3: [0, 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448, 0], // Layer I
79
+ };
80
+ const BITRATES_V2 = {
81
+ 1: [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0], // Layer III
82
+ 2: [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0], // Layer II
83
+ 3: [0, 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256, 0], // Layer I
84
+ };
85
+ /** Indexed by the two-bit version field: 0 = MPEG-2.5, 2 = MPEG-2, 3 = MPEG-1. */
86
+ const SAMPLE_RATES = {
87
+ 0: [11025, 12000, 8000],
88
+ 2: [22050, 24000, 16000],
89
+ 3: [44100, 48000, 32000],
90
+ };
91
+ /**
92
+ * Samples per frame, indexed by version field and then by the two-bit layer
93
+ * field — where **1 is Layer III**, 2 is Layer II and 3 is Layer I.
94
+ *
95
+ * **No source in hand states these numbers.** The MP3'Tech page describes the
96
+ * 32-bit header and stops; ISO/IEC 11172-3 has them and is paywalled. So this
97
+ * table, and the frame-length formula that is built on it, are the recollection
98
+ * the rest of this file is checked against text — held up by measurement
99
+ * instead: over 700 mp3 files the length derived from them matches the frame
100
+ * count ffprobe gets by reading every frame, exactly, to the millisecond. A
101
+ * wrong figure here cannot hide inside that.
102
+ *
103
+ * Getting this table's keys the wrong way round is worth spelling out, because
104
+ * it is silent: Layer I carries 384 samples and Layer III 1152, so swapping
105
+ * them reports a three-second track for a nine-second one and nothing looks
106
+ * malformed.
107
+ */
108
+ const SAMPLES_PER_FRAME = {
109
+ 3: { 1: 1152, 2: 1152, 3: 384 }, // MPEG-1
110
+ 2: { 1: 576, 2: 1152, 3: 384 }, // MPEG-2
111
+ 0: { 1: 576, 2: 1152, 3: 384 }, // MPEG-2.5
112
+ };
113
+ /** The codec a layer field names, as ffprobe would name it. */
114
+ export function codecOfLayer(layerField) {
115
+ return layerField === 3 ? 'mp1' : layerField === 2 ? 'mp2' : 'mp3';
116
+ }
117
+ /**
118
+ * Read the frame whose header starts at `at`, or null if nothing valid does.
119
+ *
120
+ * The 32-bit header is the MP3'Tech one field for field — `AAAAAAAA AAABBCCD
121
+ * EEEEFFGH IIJJKLMM`, where B is the version, C the layer, E the bitrate index,
122
+ * F the sample rate, G padding, I the channel mode. The shifts below are that
123
+ * layout read as big-endian bits, and the three values each field reserves —
124
+ * version 1, layer 0, bitrate 15, sample rate index 3 — are refused rather than
125
+ * guessed at, because a frame built on one of them is not a frame.
126
+ */
127
+ export function readFrame(bytes, at) {
128
+ if (at + 4 > bytes.length)
129
+ return null;
130
+ if (bytes[at] !== 0xff || ((bytes[at + 1] ?? 0) & 0xe0) !== 0xe0)
131
+ return null;
132
+ const versionField = ((bytes[at + 1] ?? 0) >> 3) & 0x03;
133
+ const layerField = ((bytes[at + 1] ?? 0) >> 1) & 0x03;
134
+ const bitrateIndex = ((bytes[at + 2] ?? 0) >> 4) & 0x0f;
135
+ const rateIndex = ((bytes[at + 2] ?? 0) >> 2) & 0x03;
136
+ const padding = ((bytes[at + 2] ?? 0) >> 1) & 0x01;
137
+ // 1 is reserved as a version, 0 as a layer, and both 0 and 15 as bitrates.
138
+ if (versionField === 1 || layerField === 0)
139
+ return null;
140
+ if (bitrateIndex === 0 || bitrateIndex === 15 || rateIndex === 3)
141
+ return null;
142
+ const table = versionField === 3 ? BITRATES_V1 : BITRATES_V2;
143
+ const bitrateKbps = table[layerField][bitrateIndex] ?? 0;
144
+ const sampleRate = SAMPLE_RATES[versionField][rateIndex] ?? 0;
145
+ const samplesPerFrame = SAMPLES_PER_FRAME[versionField][layerField];
146
+ if (bitrateKbps === 0 || sampleRate === 0)
147
+ return null;
148
+ const mono = (((bytes[at + 3] ?? 0) >> 6) & 0x03) === 3;
149
+ // A Layer I frame counts in four-byte slots; Layers II and III do not.
150
+ const size = layerField === 3
151
+ ? (Math.floor((12 * bitrateKbps * 1000) / sampleRate) + padding) * 4
152
+ : Math.floor((samplesPerFrame / 8) * ((bitrateKbps * 1000) / sampleRate)) + padding;
153
+ // Side info sits between the header and any Xing/Info header.
154
+ const sideInfo = versionField === 3 ? (mono ? 17 : 32) : mono ? 9 : 17;
155
+ return {
156
+ at,
157
+ size,
158
+ bitrateKbps,
159
+ sampleRate,
160
+ samplesPerFrame,
161
+ mono,
162
+ sideInfoAt: at + 4 + sideInfo,
163
+ versionField,
164
+ layerField,
165
+ };
166
+ }
167
+ /**
168
+ * The first frame at or after `from`, resynchronising past leading garbage.
169
+ *
170
+ * Two frames, not one. A plausible-looking header occurs by chance inside
171
+ * padding and artwork constantly, and believing the first one costs the whole
172
+ * length: everything walked from a false start is noise counted as frames. On
173
+ * a real file in the collection that read as 98 seconds against a true 24, and
174
+ * the rate at which a lone coincidence is followed by another at exactly the
175
+ * right offset is low enough to make this the whole of the test.
176
+ */
177
+ export function firstFrame(bytes, from) {
178
+ for (let at = from; at + 4 <= bytes.length; at += 1) {
179
+ const frame = readFrame(bytes, at);
180
+ if (frame === null)
181
+ continue;
182
+ if (readFrame(bytes, at + frame.size) !== null)
183
+ return frame;
184
+ }
185
+ return null;
186
+ }
187
+ function uint32be(bytes, at) {
188
+ return ((((bytes[at] ?? 0) << 24) |
189
+ ((bytes[at + 1] ?? 0) << 16) |
190
+ ((bytes[at + 2] ?? 0) << 8) |
191
+ (bytes[at + 3] ?? 0)) >>>
192
+ 0);
193
+ }
194
+ function magicAt(bytes, at, magic) {
195
+ if (at + magic.length > bytes.length)
196
+ return false;
197
+ for (let i = 0; i < magic.length; i += 1) {
198
+ if (bytes[at + i] !== magic.charCodeAt(i))
199
+ return false;
200
+ }
201
+ return true;
202
+ }
203
+ /**
204
+ * Frames stated by the header, or null when there is no usable one.
205
+ *
206
+ * `Xing` marks a variable-bitrate stream and `Info` a constant one, but both
207
+ * carry the same count and neither is trusted blindly — the count is only used
208
+ * when the flag saying it is present is set.
209
+ *
210
+ * The two layouts are **not from a specification in hand** — no primary text
211
+ * covers either, and MP3'Tech does not describe them. Xing and Info are the
212
+ * same nine bytes of header: the magic, a flags word, then the frame count.
213
+ * VBRI, Fraunhofer's, is longer and puts the count fourteen bytes in. On the
214
+ * collection, 528 files carry Xing or Info and their stated counts agree with
215
+ * the frames that are actually there.
216
+ *
217
+ * **VBRI is carried by no file in the collection**, so the offsets above it were
218
+ * settled by construction instead: a stream was built with a VBRI header and
219
+ * handed to ffmpeg, which read 522 ms from a twenty-frame stream and 26 ms when
220
+ * the count was moved ten bytes earlier — the same two answers this reader
221
+ * gives, on the right layout and on the deliberately wrong one. Two parsers
222
+ * written independently agreeing on both is what stands in for the text neither
223
+ * of them had, and the fixture in `test/helpers/bytes.ts` writes the same bytes
224
+ * so the branch is exercised rather than assumed.
225
+ */
226
+ function framesFromHeader(bytes, frame) {
227
+ const at = frame.sideInfoAt;
228
+ if (magicAt(bytes, at, 'Xing') || magicAt(bytes, at, 'Info')) {
229
+ const flags = uint32be(bytes, at + 4);
230
+ if ((flags & 0x01) === 0)
231
+ return null;
232
+ const frames = uint32be(bytes, at + 8);
233
+ return frames > 0 ? frames : null;
234
+ }
235
+ if (magicAt(bytes, at, 'VBRI')) {
236
+ const frames = uint32be(bytes, at + 14);
237
+ return frames > 0 ? frames : null;
238
+ }
239
+ return null;
240
+ }
241
+ /**
242
+ * Whether this frame is the encoder's own header rather than audio.
243
+ *
244
+ * LAME writes `Xing`/`Info` there and Fraunhofer `VBRI`, and whatever the flags
245
+ * word says afterwards, the frame itself carries no audio a decoder plays. A
246
+ * stream cut out of the middle of a file has to leave it out — not only because
247
+ * it states the *whole* file's frame count and would misreport the segment's
248
+ * length, but because the walk above does not count it either, so keeping it
249
+ * would put the served bytes one frame out of step with the times they were
250
+ * chosen by.
251
+ */
252
+ export function isEncoderFrame(bytes, frame) {
253
+ const at = frame.sideInfoAt;
254
+ return magicAt(bytes, at, 'Xing') || magicAt(bytes, at, 'Info') || magicAt(bytes, at, 'VBRI');
255
+ }
256
+ /**
257
+ * A gap between where the walk stopped and the end of the bytes has to be both
258
+ * absolutely and relatively large before it means a lost stream.
259
+ *
260
+ * Both tests earn their place. The absolute one clears the tags that sit after
261
+ * the audio in an ordinary rip — an ID3v1 block is 128 bytes and an APEv2 one
262
+ * carrying artwork can be a hundred kilobytes, and neither is a lost frame. The
263
+ * relative one keeps a fixed byte threshold from calling a long track damaged
264
+ * because its artwork is large: what makes a gap a loss is a stream continuing
265
+ * past it, and a gap that is a tenth of the file cannot be the tail of it.
266
+ *
267
+ * Erring towards refusing is deliberate and cheap. A needless refusal costs one
268
+ * ffprobe spawn and returns the same number; a missed one stores a short length
269
+ * that nothing downstream can tell from a correct one.
270
+ */
271
+ const REFUSAL_MIN_GAP = 4096;
272
+ const REFUSAL_GAP_SHARE = 8;
273
+ /**
274
+ * How many frames in a row it takes to call these bytes an MPEG stream at all.
275
+ *
276
+ * Without this, a file that is not an MPEG stream can still be claimed as one:
277
+ * `firstFrame` accepts a header that is followed by another at exactly the right
278
+ * offset, and inside unrelated data that happens. Measured over 229 real `.m4a`
279
+ * files — which are MP4, share no structure with MPEG audio, and are simply not
280
+ * understood here — every such coincidence ran to **exactly two frames**, the
281
+ * minimum `firstFrame` will accept, and no further. The genuine damaged stream
282
+ * this reader has to hand over (`02. 218 Tracks.mp3`) ran to **657**.
283
+ *
284
+ * So the populations do not overlap and the line goes in the space between
285
+ * them. Eight frames is a fifth of a second — far below any track a music
286
+ * collection holds, and four times the most a coincidence has ever produced.
287
+ * The margin does not need to be wider than that: two is not where coincidence
288
+ * happens to stop, it is where `firstFrame` stops looking, and a third frame
289
+ * arriving at exactly the computed offset is already the unlikely part.
290
+ *
291
+ * Below the line the honest answer is "no stream here", which leaves the file
292
+ * reported as a format this reader does not understand. That is a worse answer
293
+ * than a duration, but it is a true one — and claiming an m4a is an mpeg
294
+ * container is not. It cost the collection a correct count of what is
295
+ * unreadable, and it did so for 14 files out of 229 while the other 215 were
296
+ * still being described accurately.
297
+ */
298
+ const MIN_STREAM_FRAMES = 8;
299
+ /**
300
+ * How long the MPEG stream starting at `from` plays, and whether that is known.
301
+ *
302
+ * Null when no frame can be found at all, which is the honest answer for
303
+ * something that is not a stream — and this never throws, because the caller is
304
+ * a scan over a whole collection and one damaged file must not stop it.
305
+ */
306
+ export function mpegLength(bytes, from) {
307
+ const first = firstFrame(bytes, from);
308
+ if (first === null)
309
+ return null;
310
+ // A frame count written by the encoder is a statement, not a measurement —
311
+ // nothing was walked, so there is nothing to have lost.
312
+ const stated = framesFromHeader(bytes, first);
313
+ if (stated !== null) {
314
+ return {
315
+ durationMs: Math.round((stated * first.samplesPerFrame * 1000) / first.sampleRate),
316
+ codec: codecOfLayer(first.layerField),
317
+ sampleRate: first.sampleRate,
318
+ channels: first.mono ? 1 : 2,
319
+ refused: false,
320
+ };
321
+ }
322
+ // Walk the stream. Each frame states its own length, so this is a jump per
323
+ // frame rather than a pass over the bytes.
324
+ //
325
+ // Contiguous, and it stops at the first position that is not a frame. There
326
+ // was a resync here that looked for the next plausible header after a break,
327
+ // and it was worse than useless: MPEG frames run back to back, so a break
328
+ // means the stream ended, and every "recovered" position was a coincidence in
329
+ // trailing data counted as audio. What replaced it is not a better resync but
330
+ // the refusal below — a walk that loses the stream no longer has to guess at
331
+ // the rest, because it can decline to answer.
332
+ let at = first.at;
333
+ let samples = 0;
334
+ let frames = 0;
335
+ for (let guard = 0; guard < 1_000_000; guard += 1) {
336
+ const frame = readFrame(bytes, at);
337
+ if (frame === null)
338
+ break;
339
+ samples += frame.samplesPerFrame;
340
+ frames += 1;
341
+ at += frame.size;
342
+ }
343
+ // Too few frames to have found a stream — see `MIN_STREAM_FRAMES`. Not a
344
+ // refusal: there is nothing here to hand over, because there is nothing here.
345
+ if (frames < MIN_STREAM_FRAMES)
346
+ return null;
347
+ const gap = bytes.length - at;
348
+ return {
349
+ durationMs: Math.round((samples * 1000) / first.sampleRate),
350
+ codec: codecOfLayer(first.layerField),
351
+ sampleRate: first.sampleRate,
352
+ channels: first.mono ? 1 : 2,
353
+ refused: gap > REFUSAL_MIN_GAP && gap * REFUSAL_GAP_SHARE > bytes.length - from,
354
+ };
355
+ }