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,534 @@
1
+ import { decodeText, looksLikeText } from "../text/encoding.js";
2
+ import { ID3V1_GENRES } from "./genres.js";
3
+ import { betterPicture, FRONT_COVER, noTags, weakestEncoding } from "./types.js";
4
+ /**
5
+ * Read what an MP4 file says about itself.
6
+ *
7
+ * `.m4a` is a third of this collection and was, until this reader existed, the
8
+ * one format that gave neither a name nor a length — every stage after this one
9
+ * saw an empty file. It is MP4 underneath: a tree of length-prefixed boxes, and
10
+ * the two things wanted sit in well-known ones.
11
+ *
12
+ * Both are cheap, and both are worth reading here rather than asking ffprobe,
13
+ * which can do it:
14
+ *
15
+ * - the length is `moov.mvhd`, a `timescale` and a `duration` — the same two
16
+ * numbers `STREAMINFO` gives for FLAC, and dividing one by the other is the
17
+ * whole of it;
18
+ * - the names are `moov.udta.meta.ilst`, and the reason to read them rather
19
+ * than ask is that ffprobe's tag output is a dictionary. Two `©ART` entries
20
+ * are a collaboration to this project — `file_tag` carries a position so
21
+ * they survive — and a dictionary has nowhere to put the second one.
22
+ *
23
+ * Asking would also mean a process per file for a format that is a third of the
24
+ * collection, where ffprobe is spawned elsewhere only for the one file in 267
25
+ * this reader cannot measure.
26
+ *
27
+ * Everything here is stepped over by the length the box declares, never by
28
+ * guessing where the next one starts. That is what lets artwork — most of a
29
+ * tag's bytes and none of its meaning — pass through without being decoded, and
30
+ * what makes it not matter whether `moov` sits before or after the audio.
31
+ */
32
+ /** Atom name -> the name it is stored under, matching the ID3v2 and Vorbis maps. */
33
+ const TEXT_ATOMS = {
34
+ '©nam': 'title',
35
+ '©ART': 'artist',
36
+ aART: 'albumartist',
37
+ '©alb': 'album',
38
+ '©gen': 'genre',
39
+ '©day': 'date',
40
+ };
41
+ /**
42
+ * Atoms carrying numbers rather than text.
43
+ *
44
+ * `trkn` and `disk` are a number and its total as two 16-bit values; read as
45
+ * text they are a NUL and a control character, the kind of value that looks
46
+ * like an empty tag and quietly loses the track number.
47
+ *
48
+ * `gnre` is one 16-bit value — the ID3v1 genre list by **one-based** index, so
49
+ * 53 is Electronic and 1 is Blues. It is how iTunes writes a genre it took from
50
+ * that list, and it writes no `©gen` beside it: **480 of the 1424 m4a files in
51
+ * the collection carry `gnre` and no words at all**, so before this a third of
52
+ * them had no genre while ffprobe named one for each. The rule that the index
53
+ * is one-based was read off those files — the name every one of the 480 numbers
54
+ * yields at `value - 1` is the name ffprobe reports, 480 times out of 480 —
55
+ * rather than taken on faith, because being wrong by one here is silent: every
56
+ * genre would be the next one along the list, and each would still be a genre.
57
+ */
58
+ const NUMBER_ATOMS = {
59
+ trkn: 'tracknumber',
60
+ disk: 'discnumber',
61
+ gnre: 'genre',
62
+ };
63
+ /** The one of those that is a single value rather than a pair. */
64
+ const ID3_GENRE_ATOM = 'gnre';
65
+ /**
66
+ * Atoms carrying one number, read as one.
67
+ *
68
+ * `rtng` is iTunes' content rating — one byte, and the value is the rating
69
+ * itself rather than an index into anything, so it belongs in neither table
70
+ * above: `NUMBER_ATOMS`' entries are all a number *and its total*, and read
71
+ * through that branch a one-byte atom fails the length check and is dropped
72
+ * without a word. Which is what happened to every `rtng` in this collection:
73
+ * nothing read it, and `explicitStatus` had nothing to answer from.
74
+ */
75
+ const INTEGER_ATOMS = {
76
+ rtng: 'rtng',
77
+ };
78
+ function uint16be(bytes, at) {
79
+ return (((bytes[at] ?? 0) << 8) | (bytes[at + 1] ?? 0)) >>> 0;
80
+ }
81
+ function uint32be(bytes, at) {
82
+ return ((((bytes[at] ?? 0) << 24) |
83
+ ((bytes[at + 1] ?? 0) << 16) |
84
+ ((bytes[at + 2] ?? 0) << 8) |
85
+ (bytes[at + 3] ?? 0)) >>>
86
+ 0);
87
+ }
88
+ /** Only for a 64-bit `largesize`; every file this meets is far under 2^53. */
89
+ function uint64be(bytes, at) {
90
+ return uint32be(bytes, at) * 0x1_0000_0000 + uint32be(bytes, at + 4);
91
+ }
92
+ /** Four characters of atom name. Not validated: an unknown name is stepped over. */
93
+ function nameAt(bytes, at) {
94
+ let out = '';
95
+ for (let i = 0; i < 4; i += 1)
96
+ out += String.fromCharCode(bytes[at + i] ?? 0);
97
+ return out;
98
+ }
99
+ /**
100
+ * The box starting at `at`, or null when there is not a whole one there.
101
+ *
102
+ * Three of the size field's values are not sizes. One means the real length is
103
+ * in the eight bytes after the name; zero means the box runs to the end of
104
+ * whatever holds it. Reading either as a plain number walks off into nonsense.
105
+ */
106
+ function boxAt(bytes, at, limit) {
107
+ if (at + 8 > limit)
108
+ return null;
109
+ let size = uint32be(bytes, at);
110
+ let bodyAt = at + 8;
111
+ if (size === 1) {
112
+ if (at + 16 > limit)
113
+ return null;
114
+ size = uint64be(bytes, at + 8);
115
+ bodyAt = at + 16;
116
+ }
117
+ else if (size === 0) {
118
+ size = limit - at;
119
+ }
120
+ // A box shorter than its own header, or one that does not fit, is the end of
121
+ // the road rather than a place to read from.
122
+ if (size < bodyAt - at)
123
+ return null;
124
+ return { type: nameAt(bytes, at + 4), bodyAt, bodyEnd: Math.min(at + size, limit) };
125
+ }
126
+ /** The first box of this type among the children, stepping over the rest. */
127
+ function findBox(bytes, from, to, wanted) {
128
+ let at = from;
129
+ while (at + 8 <= to) {
130
+ const box = boxAt(bytes, at, to);
131
+ if (box === null)
132
+ return null;
133
+ if (box.type === wanted)
134
+ return box;
135
+ if (box.bodyEnd <= at)
136
+ return null;
137
+ at = box.bodyEnd;
138
+ }
139
+ return null;
140
+ }
141
+ /**
142
+ * Playback length in milliseconds from `mvhd`, or null when it states none.
143
+ *
144
+ * A fragmented file carries its timing in the fragments and writes zero here,
145
+ * and zero is not a length. Nor is it an error: the caller is told the length
146
+ * was refused and asks something that can measure it.
147
+ */
148
+ function lengthFromMvhd(bytes, mvhd) {
149
+ const version = bytes[mvhd.bodyAt] ?? 0;
150
+ // Version 1 widens the two timestamps before `timescale` to 64 bits, which
151
+ // moves everything after them by eight bytes.
152
+ const timescaleAt = version === 1 ? mvhd.bodyAt + 20 : mvhd.bodyAt + 12;
153
+ const durationAt = timescaleAt + 4;
154
+ if (durationAt + 4 > mvhd.bodyEnd)
155
+ return null;
156
+ const timescale = uint32be(bytes, timescaleAt);
157
+ const duration = version === 1 ? uint64be(bytes, durationAt) : uint32be(bytes, durationAt);
158
+ if (timescale === 0 || duration === 0)
159
+ return null;
160
+ return Math.round((duration * 1000) / timescale);
161
+ }
162
+ /**
163
+ * The value of one `data` box, as the list of values it holds and the call made
164
+ * reading its text.
165
+ *
166
+ * A list because one entry may carry several: iTunes writes a collaboration
167
+ * into a single `©ART` separated by NULs, the same way an ID3v2 frame does, and
168
+ * splitting it is what keeps two artists from becoming one string.
169
+ *
170
+ * The verdict travels out with the values because this reader is the third one
171
+ * in the project and the only one that used to skip asking. It is kept even
172
+ * when no value survives the read: a call made on bytes that turned out not to
173
+ * be text is still a finding about the file.
174
+ */
175
+ /**
176
+ * The integer at `from`, big-endian, in however many bytes the payload holds.
177
+ *
178
+ * Four at most: no atom this reads states more, and a longer run of bytes read
179
+ * as a number would be a number that means nothing.
180
+ */
181
+ function readInteger(bytes, from, end) {
182
+ const width = Math.min(end - from, 4);
183
+ if (width <= 0)
184
+ return null;
185
+ let value = 0;
186
+ for (let i = 0; i < width; i += 1)
187
+ value = value * 256 + (bytes[from + i] ?? 0);
188
+ return value;
189
+ }
190
+ function valueOf(bytes, data, atomType) {
191
+ if (data.bodyAt + 8 > data.bodyEnd)
192
+ return null;
193
+ // The type indicator says what the payload is; the four bytes after it are a
194
+ // locale, which nothing here reads.
195
+ const kind = uint32be(bytes, data.bodyAt);
196
+ const from = data.bodyAt + 8;
197
+ /** A value that was never text: nothing was decoded, so there is no call. */
198
+ const plain = (values) => ({
199
+ values,
200
+ encoding: null,
201
+ });
202
+ if (atomType === ID3_GENRE_ATOM) {
203
+ // The genre list by number, one-based — see `NUMBER_ATOMS`.
204
+ if (from + 2 > data.bodyEnd)
205
+ return null;
206
+ const name = ID3V1_GENRES[uint16be(bytes, from) - 1];
207
+ return name === undefined ? null : plain([name]);
208
+ }
209
+ if (INTEGER_ATOMS[atomType] !== undefined) {
210
+ // Asked of the name and not of `kind`: these atoms are numbers whatever the
211
+ // payload declares, and the pair branch below would drop a one-byte one.
212
+ const value = readInteger(bytes, from, data.bodyEnd);
213
+ return value === null ? null : plain([String(value)]);
214
+ }
215
+ if (NUMBER_ATOMS[atomType] !== undefined) {
216
+ // Two zero bytes, then the number, then the total.
217
+ if (from + 6 > data.bodyEnd)
218
+ return null;
219
+ const first = uint16be(bytes, from + 2);
220
+ const total = uint16be(bytes, from + 4);
221
+ return plain([total > 0 ? `${first}/${total}` : String(first)]);
222
+ }
223
+ // 21 is a signed integer, which some taggers write a year as.
224
+ if (kind === 21) {
225
+ const value = readInteger(bytes, from, data.bodyEnd);
226
+ return value === null ? null : plain([String(value)]);
227
+ }
228
+ // Everything else — 13 and 14 are JPEG and PNG — is not text and is not read.
229
+ if (kind !== 1)
230
+ return null;
231
+ // The atom declares its payload UTF-8, and a declaration is a hint to verify
232
+ // rather than an answer. It is the same claim, and the same lie, as an ID3v2
233
+ // frame's encoding byte: the rippers who wrote CP1251 under a declaration of
234
+ // UTF-8 wrote it here too. A bare `TextDecoder` turned those bytes into
235
+ // mojibake and stored it as fact, with nothing recording that any call had
236
+ // been made — this reader was the one of the three that never asked.
237
+ const decoded = decodeText(bytes.subarray(from, data.bodyEnd));
238
+ const text = decoded.text;
239
+ const parts = text.split('\u0000').filter((part) => part !== '');
240
+ return { values: parts, encoding: decoded };
241
+ }
242
+ /**
243
+ * The handler type of every track in the movie — `soun`, `vide`, or something
244
+ * else again.
245
+ *
246
+ * This is the only thing in an MP4 that distinguishes a song from a video. The
247
+ * extension does not: an `.m4a` and a phone clip are the same container with
248
+ * the same `ftyp`, and a live clip carries both tracks at once. Rejecting video
249
+ * by extension is what this project's kind list does, and it is exactly the
250
+ * kind of name-based judgement the byte dispatch exists to avoid — so the
251
+ * judgement is made here instead, where the bytes are.
252
+ */
253
+ function trackHandlers(bytes, moov) {
254
+ const handlers = [];
255
+ let at = moov.bodyAt;
256
+ while (at + 8 <= moov.bodyEnd) {
257
+ const box = boxAt(bytes, at, moov.bodyEnd);
258
+ if (box === null)
259
+ break;
260
+ if (box.type === 'trak') {
261
+ const mdia = findBox(bytes, box.bodyAt, box.bodyEnd, 'mdia');
262
+ const hdlr = mdia === null ? null : findBox(bytes, mdia.bodyAt, mdia.bodyEnd, 'hdlr');
263
+ // Past the version and flags, and the four-byte predefined field.
264
+ if (hdlr !== null)
265
+ handlers.push(nameAt(bytes, hdlr.bodyAt + 8));
266
+ }
267
+ if (box.bodyEnd <= at)
268
+ break;
269
+ at = box.bodyEnd;
270
+ }
271
+ return handlers;
272
+ }
273
+ /**
274
+ * Past a descriptor's tag and length, to the first byte of its payload.
275
+ *
276
+ * An MPEG-4 descriptor length is one to four bytes, and every byte but the last
277
+ * has its high bit set — the same continuation form the box sizes do not use.
278
+ */
279
+ function pastDescriptor(bytes, at, limit) {
280
+ let cursor = at + 1;
281
+ while (cursor < limit && ((bytes[cursor] ?? 0) & 0x80) !== 0)
282
+ cursor += 1;
283
+ return cursor + 1;
284
+ }
285
+ /**
286
+ * The object type an `esds` declares, which for `mp4a` is what says AAC.
287
+ *
288
+ * Only the two descriptors on the path to it are walked — `ES_Descriptor`
289
+ * (tag 3) holding a `DecoderConfigDescriptor` (tag 4), whose first payload byte
290
+ * is the type. The tags are what identify them rather than the offsets,
291
+ * because an `ES_Descriptor` may carry a URL, an OCR code or a stream
292
+ * dependence between the two, and each of those shifts everything after it.
293
+ */
294
+ function objectTypeOf(bytes, esds) {
295
+ const limit = esds.bodyEnd;
296
+ // A full box: four bytes of version and flags before the first descriptor.
297
+ let at = esds.bodyAt + 4;
298
+ if (at >= limit || bytes[at] !== 0x03)
299
+ return null;
300
+ at = pastDescriptor(bytes, at, limit);
301
+ at += 2; // the ES_ID, which is not an identifier this project has any use for
302
+ const flags = bytes[at] ?? 0;
303
+ at += 1;
304
+ if ((flags & 0x80) !== 0)
305
+ at += 2; // stream dependence
306
+ if ((flags & 0x40) !== 0)
307
+ at += 1; // URL
308
+ if ((flags & 0x20) !== 0)
309
+ at += 2; // OCR
310
+ if (at >= limit || bytes[at] !== 0x04)
311
+ return null;
312
+ at = pastDescriptor(bytes, at, limit);
313
+ return at < limit ? (bytes[at] ?? null) : null;
314
+ }
315
+ /**
316
+ * What a sound track's sample description states, said the way ffprobe says it.
317
+ *
318
+ * This is the one place in an MP4 that answers the question a `.m4a` raises.
319
+ * The container is the same whether it holds AAC or Apple's ALAC, so the box
320
+ * name answers nothing — and beside the codec sit the channel count and the
321
+ * sample rate, which the decision path needs for the same reason. Not reading
322
+ * any of the three was not a gap in the meta layer but work on the path that
323
+ * answers a client: `codecOf` spawned ffprobe for every `.m4a` of a collection
324
+ * that has 1425 of them, and a client naming `maxAudioChannels` could not be
325
+ * answered from a row with no channel count at all, so those files were
326
+ * transcoded whole (task:2910).
327
+ *
328
+ * **Measured against ffprobe over that whole collection, not reasoned about.**
329
+ * All three fields, over all 1425 `.m4a`, no disagreement:
330
+ *
331
+ * - 1088 carry `mp4a` with an object type of 0x40, and ffprobe names every one
332
+ * of them `aac`; 337 carry `alac`, and ffprobe names every one of them
333
+ * `alac`. That is why the codec map below is written as narrowly as it is.
334
+ * - the channel count and the sample rate agree with ffprobe on 1425 of 1425,
335
+ * which is what makes them worth storing rather than asking about.
336
+ *
337
+ * A code this does not recognise is `null` rather than a guess, and null is not
338
+ * a failure: it is what sends the question on to a process that can answer it.
339
+ * Dolby on an `.m4a` is an ordinary thing to meet, and calling it AAC would be
340
+ * a number from nowhere.
341
+ */
342
+ function formatOfSampleEntry(bytes, moov) {
343
+ const nothing = { codec: null, sampleRate: null, channels: null };
344
+ let at = moov.bodyAt;
345
+ while (at + 8 <= moov.bodyEnd) {
346
+ const trak = boxAt(bytes, at, moov.bodyEnd);
347
+ if (trak === null)
348
+ break;
349
+ if (trak.type === 'trak') {
350
+ const mdia = findBox(bytes, trak.bodyAt, trak.bodyEnd, 'mdia');
351
+ const hdlr = mdia === null ? null : findBox(bytes, mdia.bodyAt, mdia.bodyEnd, 'hdlr');
352
+ // Only a sound track describes audio; a picture track's samples are not
353
+ // what any of this is about, and a live clip carries both at once.
354
+ if (mdia !== null && hdlr !== null && nameAt(bytes, hdlr.bodyAt + 8) === 'soun') {
355
+ const minf = findBox(bytes, mdia.bodyAt, mdia.bodyEnd, 'minf');
356
+ const stbl = minf === null ? null : findBox(bytes, minf.bodyAt, minf.bodyEnd, 'stbl');
357
+ const stsd = stbl === null ? null : findBox(bytes, stbl.bodyAt, stbl.bodyEnd, 'stsd');
358
+ // Full box: version and flags, then the entry count, then the entries.
359
+ const entry = stsd === null ? null : boxAt(bytes, stsd.bodyAt + 8, stsd.bodyEnd);
360
+ if (entry === null)
361
+ return nothing;
362
+ // An *audio* sample entry states both, at fixed offsets: six reserved
363
+ // bytes and a data reference index, then version, revision and vendor,
364
+ // then the channel count, the sample size, two predefined fields, and
365
+ // the sample rate as a 16.16 fixed number — whose integer half is the
366
+ // rate. A zero in either is the format's way of saying it is not there,
367
+ // and null is this project's.
368
+ const channels = uint16be(bytes, entry.bodyAt + 16);
369
+ const rate = uint16be(bytes, entry.bodyAt + 24);
370
+ const measured = {
371
+ sampleRate: rate === 0 ? null : rate,
372
+ channels: channels === 0 ? null : channels,
373
+ };
374
+ if (entry.type === 'alac')
375
+ return { codec: 'alac', ...measured };
376
+ if (entry.type !== 'mp4a')
377
+ return { ...nothing, ...measured };
378
+ // `mp4a` keeps its children 28 bytes in, past the fields above.
379
+ const esds = findBox(bytes, entry.bodyAt + 28, entry.bodyEnd, 'esds');
380
+ const objectType = esds === null ? null : objectTypeOf(bytes, esds);
381
+ // 0x40 is MPEG-4 audio, and 0x66/0x67/0x68 are the AAC profiles that
382
+ // are still AAC. 0x69 and 0x6b are MPEG-1 and MPEG-2 audio wrapped in
383
+ // an MP4, which is a real thing an old encoder produced.
384
+ const named = objectType === 0x40 || objectType === 0x66 || objectType === 0x67 || objectType === 0x68
385
+ ? 'aac'
386
+ : objectType === 0x69 || objectType === 0x6b
387
+ ? 'mp3'
388
+ : null;
389
+ return { codec: named, ...measured };
390
+ }
391
+ }
392
+ if (trak.bodyEnd <= at)
393
+ break;
394
+ at = trak.bodyEnd;
395
+ }
396
+ return nothing;
397
+ }
398
+ /** Every value the `ilst` holds, in the order the file states them. */
399
+ function readIlst(bytes, ilst, into) {
400
+ let at = ilst.bodyAt;
401
+ while (at + 8 <= ilst.bodyEnd) {
402
+ const entry = boxAt(bytes, at, ilst.bodyEnd);
403
+ if (entry === null)
404
+ return;
405
+ // The cover, as an entry of its own. Its `data` box states what the image
406
+ // is — 13 is JPEG and 14 is PNG, the two the format defines — and the bytes
407
+ // follow the type indicator and a locale. `covr` has no picture *type*
408
+ // because it has only one meaning, and it is recorded as a front cover,
409
+ // which is the type that wins when a file also carries something else.
410
+ if (entry.type === 'covr') {
411
+ let payloadAt = entry.bodyAt;
412
+ while (payloadAt + 8 <= entry.bodyEnd) {
413
+ const data = boxAt(bytes, payloadAt, entry.bodyEnd);
414
+ if (data === null)
415
+ break;
416
+ if (data.type === 'data' && data.bodyAt + 8 <= data.bodyEnd) {
417
+ const kind = uint32be(bytes, data.bodyAt);
418
+ const mime = kind === 13 ? 'image/jpeg' : kind === 14 ? 'image/png' : null;
419
+ const from = data.bodyAt + 8;
420
+ if (mime !== null && data.bodyEnd > from) {
421
+ into.picture = betterPicture(into.picture, {
422
+ mime,
423
+ kind: FRONT_COVER,
424
+ offset: from,
425
+ length: data.bodyEnd - from,
426
+ });
427
+ }
428
+ }
429
+ if (data.bodyEnd <= payloadAt)
430
+ break;
431
+ payloadAt = data.bodyEnd;
432
+ }
433
+ }
434
+ const name = TEXT_ATOMS[entry.type] ?? NUMBER_ATOMS[entry.type] ?? INTEGER_ATOMS[entry.type];
435
+ if (name !== undefined) {
436
+ // An entry is a box of `data` boxes, and may hold more than one.
437
+ let payloadAt = entry.bodyAt;
438
+ while (payloadAt + 8 <= entry.bodyEnd) {
439
+ const data = boxAt(bytes, payloadAt, entry.bodyEnd);
440
+ if (data === null)
441
+ break;
442
+ if (data.type === 'data') {
443
+ const read = valueOf(bytes, data, entry.type);
444
+ if (read !== null) {
445
+ // The weakest call across the file, the way FLAC and ID3 report it:
446
+ // a title that decoded cleanly says nothing about the artist beside
447
+ // it that did not.
448
+ if (read.encoding !== null) {
449
+ into.encoding = weakestEncoding(into.encoding, read.encoding);
450
+ }
451
+ // ...and a value that is not text is not stored. Same judgement the
452
+ // other two readers make, and for the same reason: splitting such a
453
+ // payload on its NULs makes a dozen one-character tags that then
454
+ // outrank a folder name that was readable all along.
455
+ for (const value of read.values) {
456
+ if (looksLikeText(value))
457
+ into.tags.push({ name, value });
458
+ }
459
+ }
460
+ }
461
+ if (data.bodyEnd <= payloadAt)
462
+ break;
463
+ payloadAt = data.bodyEnd;
464
+ }
465
+ }
466
+ if (entry.bodyEnd <= at)
467
+ return;
468
+ at = entry.bodyEnd;
469
+ }
470
+ }
471
+ /**
472
+ * Read an MP4's length and names.
473
+ *
474
+ * Never throws: a truncated file, a box that claims a size it does not have and
475
+ * a file with no index at all each end the walk where it stands, because the
476
+ * caller is a scan over a whole collection.
477
+ */
478
+ export function readMp4(bytes) {
479
+ // No codec yet: the sample description is read further down, and only after
480
+ // the track types have said this is a song — see `formatOfSampleEntry`.
481
+ const into = noTags('mp4');
482
+ const moov = findBox(bytes, 0, bytes.length, 'moov');
483
+ if (moov === null) {
484
+ // No index: a download that stopped early, or a fragmented file whose
485
+ // `moov` is still being written. Not a length — but a question worth
486
+ // asking, which is what the refusal says.
487
+ //
488
+ // A video truncated the same way is indistinguishable here, and would be
489
+ // asked about too. That is left alone deliberately: it takes a video that
490
+ // is also classified as audio, which the kind list prevents, and the
491
+ // alternative — declining anything whose index is missing — would throw
492
+ // away the length of every half-downloaded song instead.
493
+ return { ...into, durationRefused: true };
494
+ }
495
+ // A picture anywhere in the movie makes it a video, sound track or not: a
496
+ // live clip has both, and the collection keeps those as clips. A sound track
497
+ // is required, a picture track is disqualifying.
498
+ //
499
+ // `noTags('mp4')` rather than `noTags()`: the file has been understood, and
500
+ // saying otherwise made the stage report `tag-format-unknown` — "a format the
501
+ // reader does not recognise, no tags, no duration" — about an mp4 whose boxes
502
+ // it had just walked. The dump was being told the wrong thing.
503
+ //
504
+ // A video misclassified as audio is a finding, and it is not that one, so it
505
+ // travels out as itself: `video` says the picture track was seen, and the
506
+ // stage reports it under its own name. Reading it here is the whole of what
507
+ // this reader can do about it — the kind came from the extension, and the
508
+ // reader is the only thing that has looked inside the boxes.
509
+ const handlers = trackHandlers(bytes, moov);
510
+ if (handlers.includes('vide') || !handlers.includes('soun')) {
511
+ return handlers.includes('vide') ? { ...noTags('mp4'), video: true } : noTags('mp4');
512
+ }
513
+ // What is inside the container, which the container's own name does not say.
514
+ // Read here rather than beside the boxes above so that a video never gets
515
+ // any of it: the picture track's sample description describes pictures.
516
+ const format = formatOfSampleEntry(bytes, moov);
517
+ into.codec = format.codec;
518
+ into.sampleRate = format.sampleRate;
519
+ into.channels = format.channels;
520
+ const mvhd = findBox(bytes, moov.bodyAt, moov.bodyEnd, 'mvhd');
521
+ if (mvhd !== null)
522
+ into.durationMs = lengthFromMvhd(bytes, mvhd);
523
+ if (into.durationMs === null)
524
+ into.durationRefused = true;
525
+ const udta = findBox(bytes, moov.bodyAt, moov.bodyEnd, 'udta');
526
+ const meta = udta === null ? null : findBox(bytes, udta.bodyAt, udta.bodyEnd, 'meta');
527
+ // `meta` is a full box: four bytes of version and flags sit before its
528
+ // children, and a walk that starts at the payload reads those bytes as a
529
+ // box header and finds nothing after them.
530
+ const ilst = meta === null ? null : findBox(bytes, meta.bodyAt + 4, meta.bodyEnd, 'ilst');
531
+ if (ilst !== null)
532
+ readIlst(bytes, ilst, into);
533
+ return into;
534
+ }