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,463 @@
1
+ import { betterPicture, noTags } from "./types.js";
2
+ import { pictureInComment, readCommentList } from "./vorbis-comment.js";
3
+ /**
4
+ * Read an Ogg stream's tags, codec and length.
5
+ *
6
+ * Written against **RFC 3533** — the format's own encapsulation specification —
7
+ * for the page layer, **Vorbis I** for the Vorbis half, and **RFC 7845** for the
8
+ * Opus half. Every rule below is a citation rather than a recollection, which is
9
+ * the standard this directory was swept to after a reader written from memory
10
+ * turned out to have been misreading real files since the day it was written
11
+ * (see `flac.ts`).
12
+ *
13
+ * Ogg is a container of containers, and that shapes the whole reader. A file is
14
+ * a sequence of *pages*; each page carries pieces of *packets*; a packet belongs
15
+ * to one of possibly several *logical bitstreams* multiplexed into the same
16
+ * physical one. So the walk below is three nested loops that answer three
17
+ * different questions — where the next page starts, whether a packet has
18
+ * finished, and whether this page is even ours — and getting any one of them
19
+ * wrong produces a reader that silently reads the wrong bytes.
20
+ *
21
+ * Two facts about the format make this cheap. The name and value pairs live in
22
+ * the second packet, which is a few hundred bytes into the file, so the walk
23
+ * stops collecting as soon as they are read. And the length is a number the last
24
+ * page *states* — no decoding, no bitrate arithmetic.
25
+ *
26
+ * The walk is defensive by design, like every reader here: a truncated download,
27
+ * a page claiming more bytes than the file holds, a comment list full of
28
+ * nonsense — each ends the walk with whatever was already gathered, because the
29
+ * caller is a scan over a whole collection and one damaged file must not be able
30
+ * to stop it.
31
+ */
32
+ /**
33
+ * RFC 3533 §6, field 1: every page opens with these four bytes.
34
+ *
35
+ * They are what `read.ts` dispatches on, and they are also the only thing that
36
+ * says where a page begins — so a file whose stream is damaged in the middle is
37
+ * picked up again here rather than abandoned.
38
+ */
39
+ const CAPTURE = 'OggS';
40
+ /**
41
+ * RFC 3533 §6: the header is `number_page_segments + 27` bytes.
42
+ *
43
+ * The count is *inside* the fixed part and the segment table it describes is
44
+ * not, which is the arithmetic every wrong implementation gets wrong by one.
45
+ */
46
+ const PAGE_HEADER_BYTES = 27;
47
+ /**
48
+ * The granule position of a page no packet finishes on.
49
+ *
50
+ * RFC 3533 §6, field 4: "a special value of -1 (in two's complement) indicates
51
+ * that no packets finish on this page". It is not a position, and reading it as
52
+ * one gives 18446744073709551615 samples — about thirteen million years.
53
+ */
54
+ const NO_GRANULE = -1n;
55
+ /**
56
+ * The rate an Opus granule counts in, whatever the encoder was fed.
57
+ *
58
+ * RFC 7845 §4: "The granule position of an audio data page is in units of PCM
59
+ * audio samples at a fixed rate of 48 kHz (per channel)". §5.1 is emphatic that
60
+ * the `Input Sample Rate` field in the identification header is *not* this — it
61
+ * records what the encoder was handed before resampling, and no decoder plays at
62
+ * it. Both facts are needed to get a length out of an Opus file, and taking the
63
+ * input rate as the playback rate is the mistake this constant exists to stop.
64
+ */
65
+ const OPUS_RATE = 48_000;
66
+ /**
67
+ * The last Opus version this reader will look at.
68
+ *
69
+ * RFC 7845 §5.1, item 2: the version's upper four bits name the major version,
70
+ * and "an implementation of this specification SHOULD accept any stream with a
71
+ * version number of '15' or less, and SHOULD assume any stream with a version
72
+ * number '16' or greater is incompatible". So a later major version is declined
73
+ * rather than guessed at — the layout could be anything.
74
+ */
75
+ const OPUS_MAX_VERSION = 15;
76
+ /**
77
+ * The largest packet this reader will assemble, in bytes.
78
+ *
79
+ * A header packet is a few hundred bytes, and a comment packet carrying a cover
80
+ * is a few megabytes at the very outside. The bound is here because a file can
81
+ * be built whose second packet never finishes — page after page of 255-value
82
+ * lacing, to the end of the file — and the walk below would then hold a view of
83
+ * every segment and hand the lot to `concat`, which copies the whole file a
84
+ * second time. Measured on a 60 MB file of exactly that shape: 246,725 pieces
85
+ * held, a second 60 MB allocated, for a file with no tags in it at all.
86
+ *
87
+ * Past the bound the walk stops collecting and drops what it held. It does not
88
+ * stop walking, because the pages still have to be read for the granule — so a
89
+ * file this happens to still gets its length.
90
+ */
91
+ const MAX_HEADER_PACKET_BYTES = 16 * 1024 * 1024;
92
+ function readUInt32LE(bytes, at) {
93
+ return (((bytes[at] ?? 0) |
94
+ ((bytes[at + 1] ?? 0) << 8) |
95
+ ((bytes[at + 2] ?? 0) << 16) |
96
+ ((bytes[at + 3] ?? 0) << 24)) >>>
97
+ 0);
98
+ }
99
+ /**
100
+ * RFC 3533 §6, field 4: eight bytes, and — like every multi-byte field in this
101
+ * format — least significant byte first.
102
+ *
103
+ * Read as a *signed* number because the format uses -1 as a sentinel, and built
104
+ * as a BigInt because the field is 64 bits wide: a long recording's sample count
105
+ * passes 2^32 within a day of audio, and a reader that kept it in a double would
106
+ * start losing the low bits of a number that is exact everywhere upstream.
107
+ */
108
+ function readGranule(bytes, at) {
109
+ let value = 0n;
110
+ for (let i = 7; i >= 0; i -= 1)
111
+ value = (value << 8n) | BigInt(bytes[at + i] ?? 0);
112
+ // Two's complement: §6 says -1 is the sentinel, which only means anything if
113
+ // the value is signed.
114
+ return value >= 0x8000000000000000n ? value - 0x10000000000000000n : value;
115
+ }
116
+ function magicAt(bytes, at, magic) {
117
+ if (at + magic.length > bytes.length)
118
+ return false;
119
+ for (let i = 0; i < magic.length; i += 1) {
120
+ if (bytes[at + i] !== magic.charCodeAt(i))
121
+ return false;
122
+ }
123
+ return true;
124
+ }
125
+ /**
126
+ * The page at `at`, or null when there is not one there.
127
+ *
128
+ * Null is the answer to every way a page can fail to be a page, and they are
129
+ * deliberately not distinguished: the caller has nothing different to do about a
130
+ * page that is missing, one that is cut off by the end of the file, one whose
131
+ * segment table promises more body than the file holds, and one written by a
132
+ * version of this format that is not the one specified here. Each of them ends
133
+ * the walk, and each of them ends it with what was already read.
134
+ *
135
+ * The checksum is not verified, and that is a decision rather than an omission.
136
+ * RFC 3533 §6 defines one and a decoder "verifies page sync and integrity by
137
+ * computing and comparing" it — but the only thing this reader could do about a
138
+ * page that failed is discard it, and discarding a page of a file that is
139
+ * otherwise readable costs that file its tags over one flipped bit. The pages
140
+ * this reader *needs* are the first two, and a file whose first two pages are
141
+ * corrupt has nothing to lose by being read anyway.
142
+ */
143
+ function pageAt(bytes, at) {
144
+ if (at + PAGE_HEADER_BYTES > bytes.length)
145
+ return null;
146
+ if (!magicAt(bytes, at, CAPTURE))
147
+ return null;
148
+ // §6, field 2: "this document specifies version 0". A different value is a
149
+ // different layout under the same four magic bytes, and guessing at it is
150
+ // worse than declining.
151
+ if (bytes[at + 4] !== 0)
152
+ return null;
153
+ const segmentCount = bytes[at + 26] ?? 0;
154
+ const segmentsAt = at + PAGE_HEADER_BYTES;
155
+ const bodyAt = segmentsAt + segmentCount;
156
+ if (bodyAt > bytes.length)
157
+ return null;
158
+ // §6: the page's own size is the sum of its lacing values. A table that adds
159
+ // up past the end of the file is a truncated download, and the last page of
160
+ // one is exactly what reading this is supposed to survive.
161
+ let body = 0;
162
+ for (let i = 0; i < segmentCount; i += 1)
163
+ body += bytes[segmentsAt + i] ?? 0;
164
+ const end = bodyAt + body;
165
+ if (end > bytes.length)
166
+ return null;
167
+ return {
168
+ headerType: bytes[at + 5] ?? 0,
169
+ granule: readGranule(bytes, at + 6),
170
+ serial: readUInt32LE(bytes, at + 14),
171
+ segmentCount,
172
+ segmentsAt,
173
+ bodyAt,
174
+ end,
175
+ };
176
+ }
177
+ /**
178
+ * Which header packet this is, or null when it is neither.
179
+ *
180
+ * Both codecs identify their headers in the packet's first bytes, and both put
181
+ * the identification header first and the comments second (Vorbis I §4.2.1,
182
+ * RFC 7845 §3). The identification header is checked for three of the conditions
183
+ * its specification makes decodability depend on — Vorbis I §4.2.2 requires
184
+ * `vorbis_version` to read 0, and requires a non-zero channel count and sample
185
+ * rate; RFC 7845 §5.1 requires a non-zero channel count too — because a file
186
+ * that fails them is one whose other fields mean something this reader has no
187
+ * way to know. It is declined here rather than reported as a stream with a
188
+ * length.
189
+ *
190
+ * §4.2.2 asks for two more — a valid block size pair and a non-zero framing bit
191
+ * — and neither is enforced. Both are the same kind of rule as the comment
192
+ * name's character range in `vorbis-comment.ts`: keeping it would mean dropping
193
+ * a file that a decoder might well play, and a stream whose channel count and
194
+ * rate are readable is one worth reading the comments of.
195
+ *
196
+ * The Vorbis *setup* header is not recognised and does not need to be: nothing
197
+ * in this reader goes past the comments, and a packet it cannot name ends the
198
+ * header sequence where it stands.
199
+ */
200
+ function headerOf(packet) {
201
+ // Vorbis I §4.2.1: header packets open with a type octet — 1 identification,
202
+ // 3 comment, 5 setup — followed by the **six** octets `vorbis` (§4.2.1 counts
203
+ // them, and a comment that says five is the kind of small wrongness this file
204
+ // exists to not have).
205
+ if (magicAt(packet, 1, 'vorbis')) {
206
+ const type = packet[0];
207
+ if (type === 0x01 && packet.length >= 30) {
208
+ const version = readUInt32LE(packet, 7);
209
+ const channels = packet[11] ?? 0;
210
+ const sampleRate = readUInt32LE(packet, 12);
211
+ if (version !== 0 || channels === 0 || sampleRate === 0)
212
+ return null;
213
+ return { role: 'identification', codec: 'vorbis', channels, sampleRate };
214
+ }
215
+ if (type === 0x03)
216
+ return { role: 'comments', codec: 'vorbis', at: 7 };
217
+ return null;
218
+ }
219
+ // RFC 7845 §5: the two Opus headers carry an eight-byte signature instead.
220
+ if (magicAt(packet, 0, 'OpusHead') && packet.length >= 19) {
221
+ const version = packet[8] ?? 0;
222
+ const channels = packet[9] ?? 0;
223
+ if (version > OPUS_MAX_VERSION || channels === 0)
224
+ return null;
225
+ // §5.1, item 4: pre-skip is 16 bits, little endian.
226
+ const preSkip = (packet[10] ?? 0) | ((packet[11] ?? 0) << 8);
227
+ return { role: 'identification', codec: 'opus', channels, preSkip };
228
+ }
229
+ if (magicAt(packet, 0, 'OpusTags'))
230
+ return { role: 'comments', codec: 'opus', at: 8 };
231
+ return null;
232
+ }
233
+ /**
234
+ * Walk the pages of one logical stream, reporting what it finishes.
235
+ *
236
+ * The format's three nested loops in one place: pages in file order, their
237
+ * segments in table order, and the packets those segments add up to. Two callers
238
+ * want different things out of the same walk — the reader wants the two header
239
+ * packets and the last page's granule, `oggPicture` wants the picture the
240
+ * comment packet holds — and a second copy of the lacing rule is a second place
241
+ * for it to be wrong.
242
+ *
243
+ * `onPacket` sees each packet the stream *finishes*, in order, and returning
244
+ * false stops it seeing any more. `onPage` sees every page of the stream whether
245
+ * a packet finished on it or not, which is where the granule comes from.
246
+ */
247
+ function walkStream(bytes, serial, onPacket, onPage) {
248
+ let pending = [];
249
+ let held = 0;
250
+ let index = 0;
251
+ let collecting = true;
252
+ let at = 0;
253
+ while (at < bytes.length) {
254
+ const page = pageAt(bytes, at);
255
+ if (page === null)
256
+ break;
257
+ at = page.end;
258
+ if (page.serial !== serial)
259
+ continue;
260
+ onPage?.(page);
261
+ // Once there is nothing left to collect the pages are still walked, and that
262
+ // is not waste: the granule of the last one is the length of the file, and
263
+ // it is stated nowhere else.
264
+ if (!collecting)
265
+ continue;
266
+ let cursor = page.bodyAt;
267
+ for (let i = 0; i < page.segmentCount; i += 1) {
268
+ const lace = bytes[page.segmentsAt + i] ?? 0;
269
+ pending.push(bytes.subarray(cursor, cursor + lace));
270
+ cursor += lace;
271
+ held += lace;
272
+ // A packet that has not finished and has grown past any size a header can
273
+ // be is not a header becoming readable — it is a file built to make this
274
+ // hold everything it is given. Dropping what was collected costs such a
275
+ // file its tags, which it does not have.
276
+ if (held > MAX_HEADER_PACKET_BYTES) {
277
+ collecting = false;
278
+ pending = [];
279
+ break;
280
+ }
281
+ // RFC 3533 §5: a lacing value of 255 means "this packet is not finished",
282
+ // and any value below it ends the packet. That single rule is the whole of
283
+ // the framing — and it is why a packet may be spread across a page
284
+ // boundary, and why the pieces have to be collected rather than read where
285
+ // they lie.
286
+ if (lace === 255)
287
+ continue;
288
+ const packet = concat(pending);
289
+ pending = [];
290
+ held = 0;
291
+ index += 1;
292
+ if (!onPacket(packet, index, page)) {
293
+ collecting = false;
294
+ break;
295
+ }
296
+ }
297
+ }
298
+ }
299
+ /**
300
+ * Read an Ogg file's tags, codec, length and cover.
301
+ *
302
+ * Never throws: a file that opens with `OggS` and then turns into something else
303
+ * ends the walk where it stands, exactly as a damaged FLAC ends its block walk.
304
+ */
305
+ export function readOgg(bytes) {
306
+ const opening = pageAt(bytes, 0);
307
+ // `OggS` and then no page: the magic without the format. Answered as "nothing
308
+ // recognised" rather than as an Ogg with no tags, so that the scan still
309
+ // reports it as a format this project cannot read.
310
+ if (opening === null)
311
+ return noTags();
312
+ const into = noTags('ogg');
313
+ // RFC 3533 §6, field 5: the serial number is "the unique serial number by
314
+ // which the logical bitstream is identified". A physical stream may carry
315
+ // several — a video track beside the audio — interleaved page by page, and a
316
+ // reader that followed all of them would read another stream's packets as
317
+ // this one's. The first page is the one that says which stream this file is
318
+ // about, and every later page of a different serial is stepped over.
319
+ const serial = opening.serial;
320
+ let codec = null;
321
+ let sampleRate = null;
322
+ let preSkip = 0;
323
+ let lastGranule = null;
324
+ walkStream(bytes, serial, (packet, index, page) => {
325
+ const header = headerOf(packet);
326
+ // Something that is neither codec's header, or one of theirs this reader
327
+ // does not read. Either way the file's headers are over, and collecting
328
+ // any more would be collecting audio.
329
+ if (header === null)
330
+ return false;
331
+ if (header.role === 'identification' && index === 1) {
332
+ codec = header.codec;
333
+ if (header.codec === 'vorbis') {
334
+ sampleRate = header.sampleRate;
335
+ into.channels = header.channels;
336
+ }
337
+ else {
338
+ // §4: an Opus granule counts at 48 kHz whatever §5.1's input sample
339
+ // rate says, so that is the rate this stream is measured in.
340
+ sampleRate = OPUS_RATE;
341
+ preSkip = header.preSkip;
342
+ into.channels = header.channels;
343
+ }
344
+ return true;
345
+ }
346
+ // The comment header is the second packet of either codec, and the only
347
+ // one whose contents this reader keeps.
348
+ if (header.role === 'comments' && index === 2 && header.codec === codec) {
349
+ const picture = readCommentList(packet, header.at, packet.length, into);
350
+ if (picture !== null) {
351
+ // Where to find it again. Not the picture's bytes, because there is no
352
+ // range of this file that holds them — the block is base64 inside a
353
+ // packet the lacing rule has scattered across pages. So what is
354
+ // recorded is the head of the file through the page the comment ends
355
+ // on, which is a range worth reading and holds everything needed to
356
+ // derive them again. See `src/cover/picture.ts`.
357
+ into.picture = betterPicture(into.picture, {
358
+ mime: picture.mime,
359
+ kind: picture.kind,
360
+ indirect: true,
361
+ offset: 0,
362
+ length: page.end,
363
+ });
364
+ }
365
+ }
366
+ return false;
367
+ }, (page) => {
368
+ // §6, field 4: the granule is the running total up to the last packet the
369
+ // page *finishes*. Kept from every page of ours, so what stands at the end
370
+ // is the last one the stream states — and the -1 sentinel is skipped
371
+ // rather than kept, because a page that finished nothing has stated
372
+ // nothing.
373
+ if (page.granule !== NO_GRANULE)
374
+ lastGranule = page.granule;
375
+ });
376
+ into.codec = codec;
377
+ into.sampleRate = sampleRate;
378
+ // The length, stated by the stream rather than arrived at. Vorbis I §A.2
379
+ // counts a Vorbis granule in PCM samples; RFC 7845 §4.3 gives the Opus one as
380
+ // the granule *less the pre-skip*, because the samples the pre-skip names are
381
+ // decoder padding that no player ever emits. A granule that is zero or
382
+ // negative states no audio, which is no length rather than a zero-length one.
383
+ if (lastGranule !== null && lastGranule > 0n) {
384
+ if (codec === 'vorbis' && sampleRate !== null) {
385
+ into.durationMs = Math.round((Number(lastGranule) / sampleRate) * 1000);
386
+ }
387
+ else if (codec === 'opus') {
388
+ const samples = lastGranule - BigInt(preSkip);
389
+ if (samples > 0n)
390
+ into.durationMs = Math.round((Number(samples) / OPUS_RATE) * 1000);
391
+ }
392
+ }
393
+ // The bytes were understood and their length was not established: a stream cut
394
+ // off before any page states one. That is a question worth handing on, and a
395
+ // different answer from a file whose format nothing here recognised — see
396
+ // `TagRead.durationRefused`.
397
+ //
398
+ // `codec === null` reaches here as well, and it belongs here. An Ogg holding a
399
+ // codec this project does not read — Theora, Speex, FLAC-in-Ogg — has a length
400
+ // in it that this reader cannot interpret, so the container was understood and
401
+ // the length was not. Left out, such a file is the one thing the contract
402
+ // forbids: read, found unreadable, and reported to nobody — not as an unknown
403
+ // format, since the container *is* known, and not to ffprobe either.
404
+ if (into.durationMs === null)
405
+ into.durationRefused = true;
406
+ return into;
407
+ }
408
+ /**
409
+ * The collected pieces of a packet as one array.
410
+ *
411
+ * `Buffer.concat` where it is available, because this runs once per packet and
412
+ * a scan opens fifty thousand files; the fallback keeps the reader usable
413
+ * anywhere the bytes are a plain `Uint8Array`, which is what its signature
414
+ * promises.
415
+ */
416
+ function concat(pieces) {
417
+ if (pieces.length === 1)
418
+ return pieces[0];
419
+ let total = 0;
420
+ for (const piece of pieces)
421
+ total += piece.length;
422
+ const out = new Uint8Array(total);
423
+ let at = 0;
424
+ for (const piece of pieces) {
425
+ out.set(piece, at);
426
+ at += piece.length;
427
+ }
428
+ return out;
429
+ }
430
+ /**
431
+ * The picture an Ogg file carries, derived again from its bytes.
432
+ *
433
+ * The serving side of this reader, and the reason `TagPicture.indirect` exists.
434
+ * A cover is asked for long after the scan that noticed it, from a file nothing
435
+ * is holding in memory — and because the picture is base64 inside a packet
436
+ * scattered across pages, there is no range of the file that could have been
437
+ * written down instead. So the bytes are derived on demand, from the region the
438
+ * scan recorded, by the same walk and the same parser that found them the first
439
+ * time.
440
+ *
441
+ * `bytes` is that region: the head of the file through the page the comment
442
+ * ends on, which for a real file is a couple of hundred kilobytes — the whole
443
+ * point of recording an end rather than reading the file.
444
+ */
445
+ export function oggPicture(bytes) {
446
+ const opening = pageAt(bytes, 0);
447
+ if (opening === null)
448
+ return null;
449
+ let found = null;
450
+ walkStream(bytes, opening.serial, (packet) => {
451
+ const header = headerOf(packet);
452
+ // The headers are over, so this packet is audio and there is nothing further
453
+ // to look at. The identification header is stepped past; the comment is the
454
+ // one that is read.
455
+ if (header === null)
456
+ return false;
457
+ if (header.role !== 'comments')
458
+ return true;
459
+ found = pictureInComment(packet, header.at, packet.length);
460
+ return false;
461
+ });
462
+ return found;
463
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The picture block, as RFC 9639 section 8.8 defines it.
3
+ *
4
+ * Extracted from `flac.ts` and shared, because the same block turns up in two
5
+ * places that have nothing else to do with each other. A FLAC file carries it as
6
+ * metadata block type 6; a Vorbis comment carries it base64-encoded under the
7
+ * name `METADATA_BLOCK_PICTURE`, which is how an Ogg file holds a cover at all —
8
+ * Ogg has no metadata block of its own to put one in. The bytes of the block are
9
+ * identical in both, down to the four 32-bit dimensions in the middle, and one
10
+ * parser for them is one place for the layout to be wrong.
11
+ *
12
+ * In order: a 32-bit picture type, a 32-bit length and the MIME string, another
13
+ * length and the description, then four 32-bit numbers (width, height, colour
14
+ * depth, number of colours), then the length of the image and the image. The
15
+ * four dimensions are stepped over rather than read — a cover's size is the
16
+ * client's business, and none of them is a number this project wants.
17
+ *
18
+ * Everything here is null when the block is not laid out the way the section
19
+ * says. A description in the middle is what makes that likely enough to check:
20
+ * it is arbitrary text of arbitrary length, and a reader that trusted the two
21
+ * lengths it read first would point at the middle of a caption and serve it as
22
+ * a picture.
23
+ */
24
+ function readUInt32BE(bytes, at) {
25
+ return ((((bytes[at] ?? 0) << 24) |
26
+ ((bytes[at + 1] ?? 0) << 16) |
27
+ ((bytes[at + 2] ?? 0) << 8) |
28
+ (bytes[at + 3] ?? 0)) >>>
29
+ 0);
30
+ }
31
+ /**
32
+ * Read the picture block occupying `[at, end)`, or null when there is not one.
33
+ *
34
+ * `dataAt` is an offset into the array this was handed, whatever that array is —
35
+ * the file, for a FLAC metadata block, or a decoded comment value, for one that
36
+ * came base64-encoded out of a Vorbis comment. The caller is the only one that
37
+ * knows which, and the two mean different things by it, so it is stated rather
38
+ * than assumed: see `TagPicture.pages` on why an Ogg picture is not a range of
39
+ * its file at all.
40
+ */
41
+ export function pictureBlockAt(bytes, at, end) {
42
+ if (end > bytes.length)
43
+ return null;
44
+ const take = (cursor) => {
45
+ if (cursor + 4 > end)
46
+ return null;
47
+ return { value: readUInt32BE(bytes, cursor), next: cursor + 4 };
48
+ };
49
+ const type = take(at);
50
+ if (type === null)
51
+ return null;
52
+ const mimeLength = take(type.next);
53
+ if (mimeLength === null)
54
+ return null;
55
+ const mimeStart = mimeLength.next;
56
+ if (mimeStart + mimeLength.value > end)
57
+ return null;
58
+ const mime = Buffer.from(bytes.subarray(mimeStart, mimeStart + mimeLength.value)).toString('latin1');
59
+ const descriptionLength = take(mimeStart + mimeLength.value);
60
+ if (descriptionLength === null)
61
+ return null;
62
+ // The description, then the four dimensions, then the length of the image.
63
+ const imageLengthAt = descriptionLength.next + descriptionLength.value + 16;
64
+ const imageLength = take(imageLengthAt);
65
+ if (imageLength === null)
66
+ return null;
67
+ const dataAt = imageLength.next;
68
+ if (imageLength.value <= 0 || dataAt + imageLength.value > end)
69
+ return null;
70
+ return { mime, kind: type.value, dataAt, dataLength: imageLength.value };
71
+ }