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,541 @@
1
+ import { statSync } from 'node:fs';
2
+ import { open } from 'node:fs/promises';
3
+ import { vorbisComment } from "../tags/encode.js";
4
+ import { flacAudioStart, flacStreamInfo } from "../tags/flac.js";
5
+ /**
6
+ * A slice of a FLAC image, as a FLAC file.
7
+ *
8
+ * Cutting audio out of a FLAC image is not a byte range, for three reasons that
9
+ * have nothing to do with each other. A frame is not self-delimiting — how long
10
+ * it is depends on what was coded inside it — so the byte where a moment starts
11
+ * cannot be computed and has to be found. The stream states its total length
12
+ * once, in a header at the top of the file, so bytes cut out of the middle still
13
+ * announce the length of the whole record; a client would show a five-minute
14
+ * track as a seventy-minute one and run past its end. And every frame header
15
+ * says where its frame sits in the stream — so frames cut out of the middle go
16
+ * on saying they are the record's, and a player that reads them counts the
17
+ * track from where it sits in the disc rather than from its first second.
18
+ *
19
+ * All three are dealt with here, and the result is a stream a decoder reads as
20
+ * the track it is.
21
+ *
22
+ * Written against **RFC 9639** — the format's standard — so that every section
23
+ * number below is a citation and not a recollection. The reader it builds on
24
+ * (`tags/flac.ts`) was written the same way, after one written from memory
25
+ * turned out to have been misreading real files for as long as it existed; the
26
+ * wiki page `3551` is the record of that.
27
+ *
28
+ * The walk searches rather than counts, and that is the decision worth stating.
29
+ * Stepping from one frame to the next would mean decoding subframes to learn
30
+ * where each ends — a bit-level parser for LPC and Rice-coded residuals, which
31
+ * is a great deal of code to get wrong. Searching for the next frame header
32
+ * costs one pass over the bytes and no decoding, and it is *safer*, not merely
33
+ * cheaper: a candidate is accepted only when its header CRC agrees with its own
34
+ * bytes **and** the number it states is exactly the one that must come next.
35
+ *
36
+ * This page said a coincidence in coded audio would have to satisfy both at
37
+ * once, and then one did. `Игры - Крик в жизни (MASHCD-058-1).flac` carries a run
38
+ * of bytes inside the body of its twenty-second frame that agrees with its own
39
+ * footer, states the number the walk was waiting for, and is wrong only in the
40
+ * blocking strategy it contradicts — which cost the walk 31806 samples of drift
41
+ * and put fifteen tracks of that disc 743 ms early (task:2849). So the walk now
42
+ * holds three things against a candidate: the CRC, the number, and the
43
+ * *strategy* — a stream states that once and keeps it (§9.1.1). And when the
44
+ * walk is done, what it counted is held against the length the stream states for
45
+ * itself, because a walk that has gone wrong is a walk whose total disagrees,
46
+ * and the caller is owed a refusal rather than boundaries where the music is
47
+ * not.
48
+ */
49
+ /** How much of the file is read at a time. */
50
+ const WINDOW = 1 << 23;
51
+ /**
52
+ * Room kept for a frame header that straddles two windows.
53
+ *
54
+ * A header is four bytes, the coded number (at most seven), an uncommon block
55
+ * size (at most two), an uncommon sample rate (at most two) and a CRC: sixteen
56
+ * bytes at the very most, and this is four times that.
57
+ */
58
+ const HEADER_ROOM = 64;
59
+ /** How many files' frame indexes are held. A twelve-track cue album is one file. */
60
+ const CACHE_LIMIT = 8;
61
+ /**
62
+ * CRC-8 as section 9.1.8 defines it: initialised with zero, polynomial
63
+ * `x^8 + x^2 + x^1 + x^0`, covering the whole header before the CRC itself.
64
+ */
65
+ function crc8(bytes, from, to) {
66
+ let crc = 0;
67
+ for (let at = from; at < to; at += 1) {
68
+ crc ^= bytes[at] ?? 0;
69
+ for (let bit = 0; bit < 8; bit += 1) {
70
+ crc = (crc & 0x80) !== 0 ? ((crc << 1) ^ 0x07) & 0xff : (crc << 1) & 0xff;
71
+ }
72
+ }
73
+ return crc;
74
+ }
75
+ /**
76
+ * The coded number at `at`: section 9.1.5, a code like UTF-8's, extended to
77
+ * seven bytes.
78
+ *
79
+ * The leading one-bits of the first byte say how many bytes it takes, exactly as
80
+ * UTF-8's do — with the one difference that a lone leading byte (`10xxxxxx`,
81
+ * which UTF-8 reserves for continuations) is refused rather than read as one
82
+ * byte, since a number that begins with a continuation byte is not a number.
83
+ */
84
+ function readCodedNumber(bytes, at) {
85
+ const lead = bytes[at] ?? 0;
86
+ let ones = 0;
87
+ while (ones < 8 && ((lead >> (7 - ones)) & 1) === 1)
88
+ ones += 1;
89
+ const length = ones === 0 ? 1 : ones;
90
+ if (ones === 1 || length > 7)
91
+ return null;
92
+ if (at + length > bytes.length)
93
+ return null;
94
+ // The first byte carries whatever is left of it below the marker bits; with
95
+ // all seven taken by the marker there is nothing left, which is correct.
96
+ let value = BigInt(ones === 0 ? lead : lead & ((1 << (7 - ones)) - 1));
97
+ for (let i = 1; i < length; i += 1) {
98
+ const next = bytes[at + i] ?? 0;
99
+ if ((next & 0xc0) !== 0x80)
100
+ return null;
101
+ value = (value << 6n) | BigInt(next & 0x3f);
102
+ }
103
+ // 36 bits unencoded is the format's limit and far more than any file needs.
104
+ // The walk indexes with these, so anything past a JavaScript integer is
105
+ // refused rather than silently rounded.
106
+ return value > 0xffffffffn ? null : { value: Number(value), length };
107
+ }
108
+ /** How many samples a frame carries, from section 9.1.1's Table 14. */
109
+ function blockSizeOf(code, bytes, at) {
110
+ if (code === 1)
111
+ return 192;
112
+ // 0b0010-0b0101 is 144 * 2^v; 0b1000-0b1111 is 2^v.
113
+ if (code >= 2 && code <= 5)
114
+ return 144 * (1 << code);
115
+ if (code >= 8)
116
+ return 1 << code;
117
+ // 0b0110 and 0b0111 carry the block size minus one, after the coded number.
118
+ if (code === 6)
119
+ return (bytes[at] ?? 0) + 1;
120
+ return (((bytes[at] ?? 0) << 8) | (bytes[at + 1] ?? 0)) + 1;
121
+ }
122
+ /**
123
+ * The frame header at `at`, or null when the bytes there are not one.
124
+ *
125
+ * Section 9.1: the sync code, the blocking strategy, four bits of block size,
126
+ * four of sample rate, the coded number, whichever of the uncommon block size
127
+ * and uncommon sample rate are stored, and a CRC-8 over everything before it.
128
+ * The channel assignment and the bit depth sit between the sample rate and the
129
+ * coded number and are not read: nothing here decodes audio, and a header's
130
+ * length does not depend on them.
131
+ *
132
+ * The CRC is what makes searching for a header possible at all. The two sync
133
+ * bytes occur in coded audio constantly; a header whose CRC agrees with its own
134
+ * bytes occurs by chance once in 256 of those.
135
+ */
136
+ function readHeader(bytes, at) {
137
+ if (at + 5 > bytes.length)
138
+ return null;
139
+ // 0xFF, then 0b111110 and the blocking strategy bit — section 9.1 says the
140
+ // first two bytes are 0xFFF8 for a fixed-block stream and 0xFFF9 for a
141
+ // variable-block one.
142
+ if (bytes[at] !== 0xff || ((bytes[at + 1] ?? 0) & 0xfe) !== 0xf8)
143
+ return null;
144
+ const blockCode = ((bytes[at + 2] ?? 0) >> 4) & 0x0f;
145
+ const rateCode = (bytes[at + 2] ?? 0) & 0x0f;
146
+ if (blockCode === 0 || rateCode === 0x0f)
147
+ return null;
148
+ const coded = readCodedNumber(bytes, at + 4);
149
+ if (coded === null)
150
+ return null;
151
+ const uncommonBlock = blockCode === 6 ? 1 : blockCode === 7 ? 2 : 0;
152
+ const uncommonRate = rateCode === 12 ? 1 : rateCode === 13 || rateCode === 14 ? 2 : 0;
153
+ const length = 4 + coded.length + uncommonBlock + uncommonRate + 1;
154
+ if (at + length > bytes.length)
155
+ return null;
156
+ if (crc8(bytes, at, at + length - 1) !== (bytes[at + length - 1] ?? 0))
157
+ return null;
158
+ // The uncommon block size follows the coded number, so it is read from where
159
+ // the cursor has arrived at rather than from the start of the header.
160
+ const blockAt = at + 4 + coded.length;
161
+ return { at, blockSize: blockSizeOf(blockCode, bytes, blockAt), number: coded.value, length };
162
+ }
163
+ /**
164
+ * Walk the whole file and record where every frame is.
165
+ *
166
+ * The walk keeps the tail of each window and refills from the file behind it, so
167
+ * a frame header lying across the seam between two windows is still readable.
168
+ *
169
+ * **Every read is awaited, and that is the point of the signature.** Of a
170
+ * five-hundred-megabyte image this walks sixty-eight eight-megabyte windows, and
171
+ * done synchronously it was one unbroken turn of the event loop — 385 ms in
172
+ * which this single-threaded server answered nobody. Awaited, the loop gets a
173
+ * turn between windows instead, and another client waits for a window rather
174
+ * than for the file (measured, task:2897).
175
+ */
176
+ async function buildIndex(file, size, mtimeMs, audioStart, head) {
177
+ // Section 8.2: the stream information MUST be the first metadata block, so its
178
+ // body sits past the four-byte signature and its own four-byte block header.
179
+ const info = flacStreamInfo(head, 8);
180
+ if (info.sampleRate <= 0)
181
+ return null;
182
+ const buffer = Buffer.allocUnsafe(WINDOW);
183
+ let base = audioStart;
184
+ const first = await file.read(buffer, 0, Math.min(WINDOW, size - audioStart), audioStart);
185
+ let filled = first.bytesRead;
186
+ let search = 0;
187
+ const frames = [];
188
+ let sample = 0;
189
+ // What the next frame must state: a frame number rising by one in a
190
+ // fixed-block stream, or a sample number rising by the block size of the
191
+ // frame before it in a variable-block one. Nothing is expected of the first,
192
+ // which is the frame the audio starts with.
193
+ let expected = null;
194
+ let variable = null;
195
+ for (;;) {
196
+ let found = null;
197
+ // The tail of the window is held back so that a header straddling the seam
198
+ // between two windows is read whole once the next one is in behind it. At
199
+ // the end of the file there is no seam and no next window — the bytes in
200
+ // hand are every byte there will ever be — so holding them back means never
201
+ // looking at them at all, and a frame that starts inside that room is a
202
+ // frame the walk does not have.
203
+ //
204
+ // Which is not a corner: an encoder codes its last frame with as many
205
+ // samples as are left, so the closing frames of an image are its shortest —
206
+ // `Кино - Это не любовь (MKK851CD1).flac` ends with frames of sixteen,
207
+ // sixteen and eighteen bytes, and all three of them sat inside the last
208
+ // sixty-four bytes of a two-hundred-and-fifty-megabyte file (task:2783).
209
+ // Six is the shortest a header can be — the sync word, the two bytes of its
210
+ // fields, the coded number and the CRC-8 — so a candidate with fewer bytes
211
+ // left than that is not scanned: there is nothing there to read.
212
+ const ended = base + filled >= size;
213
+ const last = ended ? filled - 6 : Math.max(0, filled - HEADER_ROOM);
214
+ const window = buffer.subarray(0, filled);
215
+ for (let at = search; at <= last;) {
216
+ const next = buffer.indexOf(0xff, at);
217
+ if (next === -1 || next > last)
218
+ break;
219
+ const header = readHeader(window, next);
220
+ at = next + 1;
221
+ if (header === null)
222
+ continue;
223
+ // A stream states its blocking strategy once and keeps it (section 9.1.1),
224
+ // so a candidate stating the other one is coded audio, not a header — and
225
+ // this is the one thing the CRC cannot catch. The run of bytes that cost
226
+ // `Игры - Крик в жизни (MASHCD-058-1).flac` 31806 samples of drift agrees
227
+ // with its own footer, carries the number the walk was waiting for, and is
228
+ // wrong only in the strategy it contradicts (task:2849).
229
+ const strategy = ((buffer[next + 1] ?? 0) & 0x01) === 1;
230
+ if (variable === null)
231
+ variable = strategy;
232
+ if (strategy !== variable)
233
+ continue;
234
+ if (expected === null || header.number === expected) {
235
+ found = header;
236
+ break;
237
+ }
238
+ }
239
+ if (found !== null) {
240
+ frames.push({
241
+ offset: base + found.at,
242
+ sample,
243
+ blockSize: found.blockSize,
244
+ // Copied, because the buffer this was read from is refilled as the walk
245
+ // moves on and the header has to outlive the window it came in.
246
+ header: Buffer.from(window.subarray(found.at, found.at + found.length)),
247
+ });
248
+ expected = variable === true ? sample + found.blockSize : found.number + 1;
249
+ sample += found.blockSize;
250
+ search = found.at + 1;
251
+ continue;
252
+ }
253
+ if (base + filled >= size)
254
+ break;
255
+ // Keep the tail — where a header straddling the seam would start — and read
256
+ // the next window in behind it.
257
+ const keep = Math.min(HEADER_ROOM, filled);
258
+ buffer.copy(buffer, 0, filled - keep, filled);
259
+ base += filled - keep;
260
+ search = Math.max(0, search - (filled - keep));
261
+ const more = await file.read(buffer, keep, Math.min(WINDOW - keep, size - base - keep), base + keep);
262
+ if (more.bytesRead <= 0)
263
+ break;
264
+ filled = keep + more.bytesRead;
265
+ }
266
+ if (frames.length === 0)
267
+ return null;
268
+ // The stream says how long it is, and this walk now has an answer to hold
269
+ // against it. A walk that took a run of coded audio for a frame is a walk
270
+ // whose total disagrees — and a caller served from that index gets boundaries
271
+ // where the music is not, which is worse than being refused. So a file the two
272
+ // cannot agree on is one this does not answer for, which is what the caller is
273
+ // promised above.
274
+ //
275
+ // The check earns its keep only because the walk cannot prove itself: the
276
+ // coincidence that cost `Игры - Крик в жизни (MASHCD-058-1).flac` 31806 samples
277
+ // satisfied every test the walk had. Zero is "not known" (section 8.2), and a
278
+ // stream that does not state its length has nothing to compare.
279
+ const total = frames.reduce((sum, frame) => sum + frame.blockSize, 0);
280
+ if (info.totalSamples > 0 && total !== info.totalSamples)
281
+ return null;
282
+ return { frames, info, size, mtimeMs, variable: variable === true };
283
+ }
284
+ const indexes = new Map();
285
+ /** The walks in flight, so two tracks asked for at once walk the image once. */
286
+ const walking = new Map();
287
+ /**
288
+ * The frame index of one file, remembered while it stays useful.
289
+ *
290
+ * Without this a client playing a twelve-track cue album would walk the whole
291
+ * image twelve times, once per track, to find where each begins. The guard is
292
+ * the file's size and modification time, so a re-rip is noticed rather than
293
+ * answered from the index of the file it replaced.
294
+ *
295
+ * A walk already under way is shared rather than started again. That could not
296
+ * happen while the walk held the event loop; now that it yields, two tracks of
297
+ * one disc can be inside it together, and without this they would walk a
298
+ * five-hundred-megabyte image twice over.
299
+ */
300
+ async function indexOf(path, size, mtimeMs) {
301
+ const known = indexes.get(path);
302
+ if (known !== undefined && known.size === size && known.mtimeMs === mtimeMs)
303
+ return known;
304
+ const already = walking.get(path);
305
+ if (already !== undefined)
306
+ return already;
307
+ const work = walk(path, size, mtimeMs).finally(() => walking.delete(path));
308
+ walking.set(path, work);
309
+ return work;
310
+ }
311
+ /** One walk of one file, from the head it is asked for to the last frame. */
312
+ async function walk(path, size, mtimeMs) {
313
+ const file = await open(path, 'r');
314
+ try {
315
+ // The metadata chain stands at the top of the file, and a megabyte is far
316
+ // more than any chain of blocks needs — even one carrying embedded covers.
317
+ const head = Buffer.alloc(Math.min(size, 1 << 20));
318
+ const read = await file.read(head, 0, head.length, 0);
319
+ if (read.bytesRead < head.length)
320
+ return null;
321
+ const audioStart = flacAudioStart(head);
322
+ if (audioStart === null)
323
+ return null;
324
+ const index = await buildIndex(file, size, mtimeMs, audioStart, head);
325
+ if (index === null)
326
+ return null;
327
+ indexes.set(path, index);
328
+ if (indexes.size > CACHE_LIMIT) {
329
+ const oldest = indexes.keys().next().value;
330
+ if (oldest !== undefined && oldest !== path)
331
+ indexes.delete(oldest);
332
+ }
333
+ return index;
334
+ }
335
+ finally {
336
+ await file.close();
337
+ }
338
+ }
339
+ /**
340
+ * The index of the first frame whose first sample is at or after `sample`.
341
+ *
342
+ * Frames are contiguous and in order, so this is the frame a moment falls
343
+ * inside — and `frames.length` when the moment is past the end of the stream.
344
+ */
345
+ function frameFrom(index, sample) {
346
+ let low = 0;
347
+ let high = index.frames.length;
348
+ while (low < high) {
349
+ const middle = (low + high) >> 1;
350
+ const frame = index.frames[middle];
351
+ if (frame.sample < sample)
352
+ low = middle + 1;
353
+ else
354
+ high = middle;
355
+ }
356
+ return low;
357
+ }
358
+ /**
359
+ * The FLAC header a segment is served behind.
360
+ *
361
+ * Section 8.2 again, and it is rebuilt rather than copied because three of its
362
+ * fields would describe a stream this is not. The total sample count becomes the
363
+ * segment's, since that is the length a decoder reports and a client believes.
364
+ * The minimum and maximum frame size become zero, which the same section defines
365
+ * as "not known" — the truth about a slice, and better than the whole file's
366
+ * figures presented as this one's. And the MD5 of the unencoded audio becomes
367
+ * sixteen zero bytes, which the section also defines as "not known": the
368
+ * checksum cannot be recomputed without decoding the audio, and one that does
369
+ * not match is worse than one that says it was never taken.
370
+ *
371
+ * The block sizes *are* filled in, because the walk knows them exactly.
372
+ *
373
+ * **A comment block follows the stream information, and that is the second half
374
+ * of task:2895.** The header used to be the stream information and nothing else,
375
+ * so a cut segment carried no tags at all — measured on a real one, one
376
+ * `STREAMINFO` and no other block. What the tags say and why they are the
377
+ * song's rather than the image's is `tags/encode.ts`; what belongs here is only
378
+ * that they are a block, that section 8.2 puts the stream information first, and
379
+ * that section 8.1's last-block flag is a flag on the *last* one — so the stream
380
+ * information no longer claims to be the end of the list, and the comment does.
381
+ */
382
+ function headerFor(info, samples, blocks, tags) {
383
+ const comment = vorbisComment(tags);
384
+ const prefix = Buffer.alloc(4 + 4 + 34 + 4 + comment.length);
385
+ prefix.write('fLaC', 0, 'latin1');
386
+ // Section 8.1: the last-block flag, the seven-bit type — 0 is STREAMINFO —
387
+ // and the body's length in three big-endian bytes.
388
+ prefix[4] = 0x00;
389
+ prefix.writeUIntBE(34, 5, 3);
390
+ const at = 8;
391
+ const minBlock = blocks.length === 0 ? 16 : Math.min(...blocks);
392
+ const maxBlock = blocks.length === 0 ? 16 : Math.max(...blocks);
393
+ prefix.writeUInt16BE(minBlock, at);
394
+ prefix.writeUInt16BE(maxBlock, at + 2);
395
+ // Minimum and maximum frame size: zero, "not known" (section 8.2).
396
+ prefix.writeUIntBE(0, at + 4, 3);
397
+ prefix.writeUIntBE(0, at + 7, 3);
398
+ // rate(20) | channels−1(3) | bits−1(5) | total samples(36), as one 64-bit word.
399
+ const packed = (BigInt(info.sampleRate) << 44n) |
400
+ (BigInt(info.channels - 1) << 41n) |
401
+ (BigInt(info.bitsPerSample - 1) << 36n) |
402
+ BigInt(samples);
403
+ prefix.writeBigUInt64BE(packed, at + 10);
404
+ // The MD5 field is at `at + 18`, and `alloc` has already zeroed it.
405
+ // Section 8.1 again, for the comment: type 4 (Vorbis comment), the last-block
406
+ // flag set, and the body's length in three big-endian bytes.
407
+ const commentAt = at + 34;
408
+ prefix[commentAt] = 0x80 | 0x04;
409
+ prefix.writeUIntBE(comment.length, commentAt + 1, 3);
410
+ comment.copy(prefix, commentAt + 4);
411
+ return prefix;
412
+ }
413
+ /**
414
+ * The bytes of one cue track, and the header that makes them a file.
415
+ *
416
+ * Null when the image cannot be walked — which is not a failure of this function
417
+ * but an answer: a caller that gets null knows these bytes cannot be served as
418
+ * the track they claim to be, and must not serve them as anything else.
419
+ */
420
+ export async function flacSegment(path, startMs, endMs, tags) {
421
+ const stat = statSync(path);
422
+ const index = await indexOf(path, stat.size, stat.mtimeMs);
423
+ if (index === null)
424
+ return null;
425
+ const rate = index.info.sampleRate;
426
+ // The frame a moment falls *inside*, not the one after it: a client asking
427
+ // for 0:02 of a track wants the music that is playing at 0:02, and that music
428
+ // is in the frame that started at or before it. `frameFrom` answers the other
429
+ // question — the first frame starting at or after a sample — which is what
430
+ // the end of the segment needs and the start must not have. Using it for both
431
+ // cost every segment its opening frame, and that frame is where the first
432
+ // second of the track lives.
433
+ const first = frameFrom(index, Math.floor((startMs * rate) / 1000) + 1) - 1;
434
+ if (first < 0 || first >= index.frames.length)
435
+ return null;
436
+ // Everything before the first frame of the next segment belongs to this one.
437
+ // Past the last frame there is nothing to stop before and the answer is the
438
+ // end of the file: the closing track of a disc runs to the end of the image
439
+ // by definition.
440
+ const after = frameFrom(index, Math.floor((endMs * rate) / 1000));
441
+ const frames = index.frames.slice(first, after);
442
+ const opening = frames[0];
443
+ const from = opening.offset;
444
+ const to = after >= index.frames.length ? index.size : index.frames[after].offset;
445
+ const blocks = frames.map((frame) => frame.blockSize);
446
+ const samples = blocks.reduce((total, size) => total + size, 0);
447
+ return {
448
+ prefix: headerFor(index.info, samples, blocks, tags),
449
+ from,
450
+ to,
451
+ frames: numbered(frames, opening, index.variable, to),
452
+ };
453
+ }
454
+ /**
455
+ * The frames of a segment, with the numbers they state made the segment's own.
456
+ *
457
+ * This is what makes a cut-out track a track. A frame header says where the
458
+ * frame sits in the stream it belongs to — a frame number, or the number of its
459
+ * first sample — and the frames of an image say where they sit in the *record*:
460
+ * the first frame of "The Pot", thirty-two minutes into the disc, states 20677.
461
+ * Served as they are, they contradict the header in front of them, which says
462
+ * the stream is six minutes long and starts here — and a player that reads the
463
+ * numbers counts the track from thirty-two minutes instead of from zero.
464
+ *
465
+ * So each frame is numbered again from the segment's start: the frames of a
466
+ * fixed-block stream from zero upwards, and those of a variable-block one by
467
+ * the samples before them. A frame already stating the right number is left out
468
+ * entirely — footer included — which is the common case for the first track of
469
+ * a disc and for an image served whole.
470
+ *
471
+ * Each one is named by the whole frame and not by its header, because the header
472
+ * is not all of what changes: the frame's CRC-16 covers it (section 9.3), so a
473
+ * frame restated here is a frame whose footer is computed again on the way out.
474
+ */
475
+ function numbered(frames, opening, variable, end) {
476
+ const reframes = [];
477
+ frames.forEach((frame, ordinal) => {
478
+ const number = variable ? frame.sample - opening.sample : ordinal;
479
+ const restated = renumbered(frame.header, number);
480
+ if (restated.equals(frame.header))
481
+ return;
482
+ const to = ordinal + 1 < frames.length ? frames[ordinal + 1].offset : end;
483
+ reframes.push({
484
+ header: { at: frame.offset, length: frame.header.length, bytes: restated },
485
+ from: frame.offset,
486
+ to,
487
+ });
488
+ });
489
+ return reframes;
490
+ }
491
+ /**
492
+ * A frame header stating `number` instead of the one it was read with.
493
+ *
494
+ * Section 9.1.5 puts the coded number after the header's first four bytes, and
495
+ * whatever uncommon block size and sample rate it carries after the number;
496
+ * section 9.1.8's CRC-8 covers all of it but itself. So the number is replaced,
497
+ * the bytes on either side of it are kept, and the CRC is computed again over
498
+ * the result — a frame whose header CRC no longer agrees with its bytes is one a
499
+ * decoder is right to reject.
500
+ *
501
+ * The replacement is as long as the number needs and no longer, which is how a
502
+ * segment comes out shorter than the bytes it was cut from: frame numbers of a
503
+ * whole record run into the thousands and take two bytes where a segment's
504
+ * first frames take one.
505
+ */
506
+ function renumbered(header, number) {
507
+ const coded = readCodedNumber(header, 4);
508
+ // The walk parsed this header to find the frame, so there is a number here.
509
+ if (coded === null)
510
+ return header;
511
+ const body = Buffer.concat([
512
+ header.subarray(0, 4),
513
+ codedNumber(number),
514
+ header.subarray(4 + coded.length, header.length - 1),
515
+ ]);
516
+ return Buffer.concat([body, Buffer.from([crc8(body, 0, body.length)])]);
517
+ }
518
+ /**
519
+ * A number written the way section 9.1.5 states it — the inverse of the reader
520
+ * above, and the same scheme: the leading byte's one-bits say how many follow,
521
+ * each of them carries six bits of the number, and the leading byte keeps
522
+ * whatever is left below its marker bits.
523
+ */
524
+ function codedNumber(value) {
525
+ if (value < 0x80)
526
+ return Buffer.from([value]);
527
+ // One byte holds seven bits, and every byte added holds six more. The sizes
528
+ // are checked against the same table the reader uses, so a number is never
529
+ // written in a form the reader would take for a longer one.
530
+ let length = 2;
531
+ while (length < 7 && value >= 2 ** (5 * length + 1))
532
+ length += 1;
533
+ const bytes = Buffer.alloc(length);
534
+ let rest = value;
535
+ for (let at = length - 1; at > 0; at -= 1) {
536
+ bytes[at] = 0x80 | (rest % 64);
537
+ rest = Math.floor(rest / 64);
538
+ }
539
+ bytes[0] = ((0xff << (8 - length)) & 0xff) | rest;
540
+ return bytes;
541
+ }