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,179 @@
1
+ import { statSync } from 'node:fs';
2
+ import { open } from 'node:fs/promises';
3
+ import { id3v2 } from "../tags/encode.js";
4
+ import { firstFrame, isEncoderFrame, readFrame } from "../tags/mpeg.js";
5
+ /**
6
+ * A slice of an mp3 image, as an mp3 stream.
7
+ *
8
+ * mp3 is the easy one, and it is worth saying why, because it looks like the
9
+ * hard one. Its frames *are* self-delimiting: a frame's header states a bitrate
10
+ * and a sample rate, and those give the frame's length in bytes without any
11
+ * decoding at all. So a walk can simply step from frame to frame, and both ends
12
+ * of a segment are found exactly, with no search and no guessing. And nothing in
13
+ * an mp3 states the stream's total length — a player counts frames — so a slice
14
+ * of the stream needs no header rebuilt in front of it, only the frames.
15
+ *
16
+ * What *does* have to be built is the tag block, and this is the other half of
17
+ * task:2895. An image's tags sit in front of its first frame, and a segment cut
18
+ * out of the middle begins at a frame — so the cut left every tag behind and a
19
+ * client that saved it had a file nothing could name. The tags written here are
20
+ * the song's own, from the meta layer, for the reason `tags/encode.ts` states:
21
+ * the image's describe the disc, and a track wearing them is worse off than one
22
+ * wearing nothing.
23
+ *
24
+ * The one thing that has to go is the encoder's own header frame. LAME writes
25
+ * `Xing`/`Info` into the side info of the first frame and Fraunhofer `VBRI`, and
26
+ * that frame carries no audio; it states the frame count of the *whole* file,
27
+ * which served at the head of a five-minute segment would tell a client it is
28
+ * holding an hour. Leaving it out is also what keeps the served bytes in step
29
+ * with the times they were chosen by, since the walk does not count it either.
30
+ *
31
+ * The frame reader itself is `tags/mpeg.ts`, which is checked field by field
32
+ * against MP3'Tech and holds its three unsourced tables up by measurement
33
+ * against ffprobe's own frame count. Nothing here re-derives any of that.
34
+ */
35
+ /**
36
+ * What a frame header occupies: the sync word, and the three bytes that state
37
+ * the version, the layer, the bitrate and the sample rate.
38
+ *
39
+ * That is all the walk needs, because the frame's length comes out of those
40
+ * four bytes and nothing else is read. It used to ask for forty-four — enough
41
+ * for the side info and the encoder magic a *different* reader looks for — and
42
+ * a window that insists on forty-four bytes cannot answer for a frame that
43
+ * starts closer than that to the end of the file. A low bitrate makes exactly
44
+ * that shape: MPEG-2 at 8 kbps is 26 bytes a frame, so the last one of a file
45
+ * sat inside the room the window was holding back (task:2846).
46
+ */
47
+ const HEADER = 4;
48
+ /**
49
+ * A window onto one file, refilled as the walk moves along it.
50
+ *
51
+ * The walk never needs more than a frame's header at a time — the next frame's
52
+ * position comes out of this one's — so an mp3 of any size is read a few bytes
53
+ * at a time, and nothing is held afterwards.
54
+ */
55
+ class Window {
56
+ buffer = Buffer.alloc(0);
57
+ base = 0;
58
+ file;
59
+ size;
60
+ constructor(file, size) {
61
+ this.file = file;
62
+ this.size = size;
63
+ }
64
+ /**
65
+ * The `need` bytes at `at`, or null when the file does not hold that many.
66
+ *
67
+ * Awaited, so that the walk of an image yields between windows instead of
68
+ * holding the event loop for the whole file — see `buildIndex` in `flac.ts`,
69
+ * where the same walk cost this single-threaded server 385 ms of answering
70
+ * nobody (measured, task:2897).
71
+ */
72
+ async at(at, need) {
73
+ if (at + need > this.size)
74
+ return null;
75
+ if (at < this.base || at + need > this.base + this.buffer.length) {
76
+ const span = Math.min(1 << 16, this.size - at);
77
+ const buffer = Buffer.allocUnsafe(span);
78
+ const read = await this.file.read(buffer, 0, span, at);
79
+ if (read.bytesRead < need)
80
+ return null;
81
+ this.buffer = buffer.subarray(0, read.bytesRead);
82
+ this.base = at;
83
+ }
84
+ return this.buffer.subarray(at - this.base, at - this.base + need);
85
+ }
86
+ }
87
+ /**
88
+ * The frames of one segment, and where they are.
89
+ *
90
+ * Null when the segment cannot be walked to its end — a stream that breaks
91
+ * before the end of the track is a file this cannot answer for, and a caller
92
+ * that got a range anyway would be serving silence or somebody else's audio
93
+ * under this track's name.
94
+ *
95
+ * Walking *to the end* is not the same as covering the request to the sample.
96
+ * The end comes from a duration the scan rounded to the millisecond, so it can
97
+ * ask for a fraction of a frame the file does not hold; the frames running out
98
+ * there is the end of the audio. The line between that and a broken stream is a
99
+ * frame, and it is drawn at the walk — see the note there.
100
+ */
101
+ export async function mpegSegment(path, startMs, endMs, tags) {
102
+ const size = statSync(path).size;
103
+ const file = await open(path, 'r');
104
+ try {
105
+ // The first frame of the stream, found by resynchronising past whatever
106
+ // tags stand in front of it. `firstFrame` wants the bytes in hand, and the
107
+ // tag it may be standing behind is the reason this reads a megabyte rather
108
+ // than a header's worth.
109
+ const head = Buffer.alloc(Math.min(size, 1 << 20));
110
+ await file.read(head, 0, head.length, 0);
111
+ const first = firstFrame(head, 0);
112
+ if (first === null)
113
+ return null;
114
+ // Past the encoder's frame, if the stream opens with one.
115
+ let at = isEncoderFrame(head, first) ? first.at + first.size : first.at;
116
+ const window = new Window(file, size);
117
+ const rate = first.sampleRate;
118
+ const startSample = Math.floor((startMs * rate) / 1000);
119
+ const endSample = Math.floor((endMs * rate) / 1000);
120
+ let from = null;
121
+ let consumed = 0;
122
+ /**
123
+ * What the frame before carried, which is the yardstick below for "the
124
+ * frames have run out" — nothing of the file, only of the last frame read.
125
+ */
126
+ let lastFrame = 0;
127
+ for (;;) {
128
+ if (consumed >= endSample)
129
+ break;
130
+ const bytes = await window.at(at, HEADER);
131
+ const frame = bytes === null ? null : readFrame(bytes, 0);
132
+ if (frame === null) {
133
+ // The frames have run out. That is the end of the audio, and it is not
134
+ // the same thing as a stream that broke: the request's end comes from a
135
+ // duration the scan rounded to the millisecond, so it can ask for a
136
+ // fraction of a frame more than the file holds — eight and eighteen
137
+ // samples of a 1152-sample frame, over three tracks of the live
138
+ // collection that were refused whole for it (task:2846). Nothing is
139
+ // missing there by a frame, and what is left after the last frame is a
140
+ // tag, not audio.
141
+ //
142
+ // One frame is where the line is drawn, and it is wide: the rounding of
143
+ // a millisecond is at most a fiftieth of a frame at any rate and bitrate
144
+ // this format has. A file that really is missing audio is missing a
145
+ // whole frame or more of it, and that is still refused.
146
+ if (endSample - consumed <= lastFrame)
147
+ break;
148
+ return null;
149
+ }
150
+ // The frame a moment falls inside, not the one after it: a client that
151
+ // seeks to 1:30 wants the music that is playing at 1:30, and the frame
152
+ // holding it is the one that starts at or before it.
153
+ if (from === null && consumed + frame.samplesPerFrame > startSample)
154
+ from = at;
155
+ consumed += frame.samplesPerFrame;
156
+ lastFrame = frame.samplesPerFrame;
157
+ at += frame.size;
158
+ }
159
+ if (from === null)
160
+ return null;
161
+ // No header to restate: mp3 states no length anywhere a player reads and
162
+ // numbers its frames from nowhere, so the frames go out as the file holds
163
+ // them. What is put in front of them is the song's tags and nothing else —
164
+ // the frame *reader* has already stepped past the file's own tag block, so
165
+ // this cannot double up with one.
166
+ //
167
+ // The end is held to the file. A frame's length comes from its header, so a
168
+ // file cut off inside its last frame walks to the end of that frame — a byte
169
+ // offset past the end of the file — and a segment that ended there would
170
+ // promise more bytes than the file holds: `content-length` one number, the
171
+ // stream a shorter body, and a client reading that as a download that
172
+ // stopped. The frames ran out at the end of the file, and that is where the
173
+ // audio ends (task:2848).
174
+ return { prefix: id3v2(tags), from, to: Math.min(at, size), frames: [] };
175
+ }
176
+ finally {
177
+ await file.close();
178
+ }
179
+ }
@@ -0,0 +1,393 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync, statSync, utimesSync } from 'node:fs';
3
+ import { mkdir, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ /**
6
+ * A re-encoded answer, kept where a part of it can be asked for.
7
+ *
8
+ * A song no browser plays is re-encoded to FLAC, and ffmpeg produces that answer
9
+ * as a stream — which means its length is not known until it has finished, so a
10
+ * range cannot be answered and a client that seeks hears the song from its
11
+ * beginning again. Keeping the answer in a file of its own settles both at once:
12
+ * the length is known the moment the file is, and the file can be ranged like
13
+ * any other.
14
+ *
15
+ * Three things make it a cache rather than a heap of files. The name is derived
16
+ * from *what was asked for* — the source, its size and modification time, and
17
+ * the stretch of it — so a re-rip or an edit is a different answer rather than
18
+ * the old one served again under a new name. The bytes are written to a
19
+ * temporary name and renamed when they are complete, so a reader never sees half
20
+ * a song — and a half-written file from a killed server is swept rather than
21
+ * served. And the oldest answers are dropped when the directory outgrows its
22
+ * cap, because a cache that only grows is a disk that fills.
23
+ *
24
+ * What is *not* here is how the answer is produced: the caller brings the
25
+ * arguments, since what to re-encode is the delivery layer's question.
26
+ */
27
+ /**
28
+ * How much of the disk the re-encoded answers may hold.
29
+ *
30
+ * Written as arithmetic and not as `4 << 30`: JavaScript's bitwise operators are
31
+ * 32-bit, so the shift is zero — a cap of nothing, which drops every answer but
32
+ * the one just written. It was, for an afternoon.
33
+ */
34
+ const CACHE_BYTES = 4 * 1024 ** 3;
35
+ /** The suffix a half-written answer carries, so it is never mistaken for one. */
36
+ const PARTIAL = '.part';
37
+ /** Where a swept directory records the shape of the names it now holds. */
38
+ const VERSION_MARKER = '.keys';
39
+ /**
40
+ * The shape of the names this build writes.
41
+ *
42
+ * **Bumped whenever `keyOf` changes what a name means.** The cache is a
43
+ * directory of names derived from questions, and a build that changes the
44
+ * derivation cannot recognise what an older one left: those answers are not
45
+ * wrong, they are *unreachable* — and they go on being counted in the cap and
46
+ * `statSync`ed after every transcode while never being served again. Measured
47
+ * the first time it happened without one: 803 MiB, across 24 files, half of it
48
+ * turned over by a single commit (task:2900).
49
+ */
50
+ const KEY_VERSION = 4;
51
+ /** The answers being produced right now, so two clients wait on one transcode. */
52
+ const running = new Map();
53
+ /**
54
+ * How many of a directory's entries are asked about in one go.
55
+ *
56
+ * **Bounded, and the measurement is why.** Handing the whole directory to one
57
+ * `Promise.all` looks like the same thing and is not: four thousand `stat` calls
58
+ * are queued at once, and their completions drain in a handful of very long
59
+ * turns of the loop — which is the stall the asynchronous walk was supposed to
60
+ * remove, arrived at from the other side. Measured over 4000 answers: the
61
+ * unbounded form still held the loop for **97 ms** in its worst single turn,
62
+ * against 806 ms for the synchronous walk it replaced. A batch yields between
63
+ * every group, so the longest turn is bounded by this number rather than by how
64
+ * much the cache holds.
65
+ */
66
+ const BATCH = 64;
67
+ /** `work` over every item, a batch at a time, so the loop runs in between. */
68
+ async function inBatches(items, work) {
69
+ const answers = [];
70
+ for (let at = 0; at < items.length; at += BATCH) {
71
+ answers.push(...(await Promise.all(items.slice(at, at + BATCH).map(work))));
72
+ }
73
+ return answers;
74
+ }
75
+ /**
76
+ * Directories already swept by this process, so the marker is read once.
77
+ *
78
+ * Holds the *promise* of the sweep rather than a flag, because the sweep is
79
+ * asynchronous now (see below): two requests that arrive together must wait on
80
+ * one sweep rather than start a second, which is the same reason `running` holds
81
+ * promises for transcodes in flight.
82
+ */
83
+ const swept = new Map();
84
+ /**
85
+ * The identity of one re-encoded answer.
86
+ *
87
+ * The source is named by its path and by the two things that say *which* file
88
+ * that path holds — its size and its modification time. What is asked of it is
89
+ * named by the stretch, when a stretch is what is wanted: a whole song and a
90
+ * cue track cut from an image are different answers from the same bytes.
91
+ */
92
+ export function keyOf(input) {
93
+ const stat = statSync(input.source);
94
+ // The stretch as one string, with the shape riding after it: two clients
95
+ // asking for different things from the same bytes are not asking the same
96
+ // question, and a cache that answered one with the other's answer handed back
97
+ // an mp3 under an `audio/ogg` header (found live, task:2865).
98
+ //
99
+ // The separator is a real NUL, written as the same escape the digest below
100
+ // uses. What stood here was that escape doubled, which is the six characters
101
+ // `\u0000` and not a NUL at all — a sequence a source path could contain,
102
+ // and so a key two different questions can collide on.
103
+ const stretch = `${input.startMs === undefined ? 'whole' : `${input.startMs}-${input.endMs ?? 0}`}` +
104
+ `\u0000${input.made}`;
105
+ // The version rides in the digest and not beside it, so that a name stays a
106
+ // function of one string: two builds that mean different things by the same
107
+ // question cannot arrive at the same name.
108
+ return createHash('sha1')
109
+ .update(`v${KEY_VERSION}`)
110
+ .update(`${input.source}\u0000${stat.size}\u0000${Math.round(stat.mtimeMs)}\u0000${stretch}`)
111
+ .digest('hex')
112
+ .slice(0, 32);
113
+ }
114
+ /**
115
+ * The file an answer is kept in, producing it if it is not there yet.
116
+ *
117
+ * `produce` is handed the path to write to, and the rename happens here: a
118
+ * caller cannot forget it, and a caller that throws leaves nothing behind for
119
+ * the next request to serve.
120
+ */
121
+ export async function kept(input) {
122
+ const target = join(input.dir, `${input.key}${input.extension}`);
123
+ const ready = isAnswer(target);
124
+ if (ready) {
125
+ // Touched on every use, which is what makes "oldest" mean least recently
126
+ // wanted rather than first written.
127
+ const now = new Date();
128
+ utimesSync(target, now, now);
129
+ return target;
130
+ }
131
+ const already = running.get(target);
132
+ if (already !== undefined)
133
+ return already;
134
+ const work = produce(input, target, input.cap ?? CACHE_BYTES).finally(() => running.delete(target));
135
+ running.set(target, work);
136
+ return work;
137
+ }
138
+ /**
139
+ * The last steps of building an answer, and why they are not synchronous calls.
140
+ *
141
+ * **Adding a name to this directory costs twenty milliseconds, and removing one
142
+ * costs the same.** Measured 2026-09-16, on this deployment, three megabytes
143
+ * written and then renamed:
144
+ *
145
+ * cache dir `C:\ProgramData\funoteka\cache` rename p50 20.7 rm p50 20.6
146
+ * the directory above it rename p50 19.7 rm p50 19.0
147
+ * a fresh directory under `C:\ProgramData` rename p50 53.3 rm p50 52.7
148
+ * the same work in `%TEMP%` rename p50 0.9 rm p50 0.6
149
+ *
150
+ * Twenty-five times the price, on one volume — and it is the *directory entry*
151
+ * and not the bytes: `writeFile` is 1.8 ms in both, `stat` 0.07, `utimes` 0.3,
152
+ * and `mkdir` over a directory that already exists 0.2. What is expensive is
153
+ * creating or removing a name, which is what a filter driver watching a
154
+ * deployment's own folder would charge for.
155
+ *
156
+ * That price was being paid **in the thread that answers every client**. A
157
+ * transcode already waits a second or two for ffmpeg, so twenty milliseconds is
158
+ * nothing to the client that asked — but it is twenty milliseconds in which no
159
+ * other client is answered at all, and the same twenty are paid again by every
160
+ * answer the trim drops. The review found it as a `ping` column of 43.7–121 ms
161
+ * at the moment ffmpeg exited, and could not attribute it (task:2927).
162
+ *
163
+ * **Twenty renames in this directory, and the longest turn of the loop:**
164
+ *
165
+ * `renameSync` 1437.9 ms — 72 ms each, all of it blocking
166
+ * `await rename` 3.1 ms — the same twenty, on the pool
167
+ *
168
+ * And on the live daemon, six cold transcodes with a `ping` every 5 ms from
169
+ * another process, before and after:
170
+ *
171
+ * worst ping across the six windows 109.8 ms -> 15.9 ms
172
+ * the same measurement with no transcode in flight 18.4 -> 14.9
173
+ *
174
+ * So the column is gone, and what is left in those windows is the background of
175
+ * a daemon with a client attached rather than a cost of re-encoding.
176
+ *
177
+ * The two calls that touch a directory entry are therefore on the pool, where
178
+ * `prune` and the sweep already are. `statSync`, `utimesSync` and the marker
179
+ * read stay where they are: they are a fifth of a millisecond, and a promise
180
+ * costs more than they do.
181
+ */
182
+ async function produce(input, target, cap) {
183
+ const partial = `${target}${PARTIAL}`;
184
+ try {
185
+ // The directory is the module's to make: a deployment configures where the
186
+ // answers go and nothing else has to know that it is not there yet.
187
+ await mkdir(input.dir, { recursive: true });
188
+ await sweepIfStale(input.dir);
189
+ await input.produce(partial);
190
+ await rename(partial, target);
191
+ }
192
+ catch (error) {
193
+ // Nothing half-written is left for a later request to mistake for an answer.
194
+ await rm(partial, { force: true });
195
+ throw error;
196
+ }
197
+ // The trim is not this request's answer, and the client does not wait for it.
198
+ void prune(input.dir, target, cap);
199
+ return target;
200
+ }
201
+ /**
202
+ * Empty a cache whose names were derived by a different build.
203
+ *
204
+ * Once per directory per process, and only when the marker does not already say
205
+ * this build. A cache is allowed to be thrown away — every byte in it is
206
+ * reproducible from the collection — which is what makes this cheaper *and* more
207
+ * honest than keeping answers nothing can name any more: an unreachable entry
208
+ * still counts against the cap and is still `statSync`ed after every transcode.
209
+ *
210
+ * Half-written answers are left where they are. They belong to a request in
211
+ * flight, whose own producer will rename or sweep them, and deleting one under
212
+ * its producer would turn a version bump into a failed request.
213
+ */
214
+ function sweepIfStale(dir) {
215
+ const already = swept.get(dir);
216
+ if (already !== undefined)
217
+ return already;
218
+ const sweep = sweepNow(dir);
219
+ swept.set(dir, sweep);
220
+ return sweep;
221
+ }
222
+ /**
223
+ * **Asynchronous for the same reason `prune` is, and it was the larger of the
224
+ * two.** This walk deletes rather than measures, so it was measured first and
225
+ * worst: on a directory holding 4000 answers and no marker, the synchronous
226
+ * version held the thread that answers clients for **806 ms** — six times the
227
+ * `prune` walk over the same directory, because every entry costs a deletion and
228
+ * not a `stat`.
229
+ *
230
+ * It runs once per directory per process, which is what made it look harmless:
231
+ * "once" is once per day for a long-lived daemon, and a version bump is exactly
232
+ * the moment a full cache is swept. Once is all it takes to be the request that
233
+ * happened to arrive first.
234
+ *
235
+ * `readFile` is on the pool too, but the marker is one small file, and leaving
236
+ * that one call synchronous keeps the "is this directory already ours?" decision
237
+ * free of an await for the overwhelming majority of calls — the ones that return
238
+ * at the line below.
239
+ */
240
+ async function sweepNow(dir) {
241
+ const marker = join(dir, VERSION_MARKER);
242
+ try {
243
+ if (readFileSync(marker, 'utf8').trim() === String(KEY_VERSION))
244
+ return;
245
+ }
246
+ catch {
247
+ // No marker at all: a directory from a build that wrote none, or a new one.
248
+ }
249
+ let names;
250
+ try {
251
+ names = await readdir(dir);
252
+ }
253
+ catch {
254
+ names = [];
255
+ }
256
+ await inBatches(names.filter((name) => name !== VERSION_MARKER && !name.endsWith(PARTIAL)), (name) => rm(join(dir, name), { recursive: true, force: true }).catch(() => undefined));
257
+ try {
258
+ await writeFile(marker, `${KEY_VERSION}\n`);
259
+ }
260
+ catch {
261
+ // A cache that cannot be marked is swept again next process, which is wasted
262
+ // work and never a wrong answer.
263
+ }
264
+ }
265
+ /** Whether a complete answer is there, as opposed to none or half of one. */
266
+ function isAnswer(path) {
267
+ try {
268
+ return statSync(path).isFile();
269
+ }
270
+ catch {
271
+ return false;
272
+ }
273
+ }
274
+ /**
275
+ * Drop the least recently wanted answers until the directory fits.
276
+ *
277
+ * Half-written files are swept first and without counting: they are the remains
278
+ * of a server that was killed mid-transcode, they are never served, and nothing
279
+ * will ever finish them. What is left is measured and trimmed oldest-first, and
280
+ * the answer just written is never the one dropped — a cache that evicted what
281
+ * it had only just produced would transcode the same song forever.
282
+ *
283
+ * **The walk is asynchronous, and the measurement is what settled it.** The
284
+ * version before this one read the directory with `readdirSync` and called
285
+ * `statSync` once per entry, in the thread that answers clients — a cost nobody
286
+ * had written down (task:2869 measured it around ffmpeg's noise, task:2908
287
+ * carried it as unattributable). Measuring the walk on its own, with no
288
+ * transcode running, is what made it attributable, and the answer was larger
289
+ * than expected: **~35 µs a name**, against a `ping` floor of 0.8 ms.
290
+ *
291
+ * cache 25 names (this deployment, 267 MiB) walk p50 1.0 ms
292
+ * 400 names (a 4 GiB cache of albums) walk p50 14.3 ms
293
+ * 1000 names walk p50 33.9 ms
294
+ * 4000 names walk p50 132.2 ms
295
+ *
296
+ * So it was never "the cache holds one file" — it holds one file at the start of
297
+ * an afternoon, and the cap it is allowed to reach is four gigabytes. A
298
+ * thirty-millisecond stall of a single-threaded server, after every transcode,
299
+ * is a real mechanic to trade.
300
+ *
301
+ * The fix is not to do less: it is to do the same work on the pool. `readdir`,
302
+ * `stat` and `rm` from `node:fs/promises` run on libuv's threads, so the event
303
+ * loop stays free to answer while the directory is being walked, and the stall
304
+ * becomes latency that overlaps instead of latency that blocks.
305
+ *
306
+ * **Serialised among themselves, and only among themselves.** Two prunes running
307
+ * at once would each compute a total from a directory the other is changing, so
308
+ * the chain keeps the arithmetic sound — but it is a chain of promises, not a
309
+ * lock on the loop, and a client is answered between every step of it.
310
+ *
311
+ * **And nobody waits on it** (task:2927). It used to be awaited by the request
312
+ * that had just been transcoded, which is the one request that can least afford
313
+ * it: that client has already waited a second for ffmpeg, and the trim it was
314
+ * then made to wait for is bookkeeping about *other* answers — a walk whose
315
+ * evictions cost 20 ms each in this directory. So `kept` returns the answer the
316
+ * moment the answer exists, and the chain runs on behind it.
317
+ *
318
+ * A failure is swallowed rather than reported, and that is the one place in this
319
+ * module where silence is the honest answer: there is no logger here, and a
320
+ * directory that could not be trimmed is trimmed by the next one. What must
321
+ * never happen is an *unobserved* rejection — a promise no one awaits takes the
322
+ * process down with it — so the returned promise is the swallowed copy and not
323
+ * the raw one. `trimmed` is how anything that genuinely needs the directory
324
+ * settled waits for it.
325
+ */
326
+ let pruning = Promise.resolve();
327
+ function prune(dir, keep, cap) {
328
+ // The chain is advanced by a copy that has already swallowed a failure, so
329
+ // that one prune throwing cannot wedge every prune after it. That copy is
330
+ // also what is returned, because the callers of this one do not await it.
331
+ pruning = pruning.then(() => pruneNow(dir, keep, cap)).catch(() => undefined);
332
+ return pruning;
333
+ }
334
+ /**
335
+ * The trimming this module has been asked for, done.
336
+ *
337
+ * `kept` hands back an answer as soon as the answer exists and lets the trim
338
+ * that keeps the directory inside its cap follow on its own, so this is the only
339
+ * way to know the cache has settled — a test that asserts what was dropped, or
340
+ * an operator about to measure the directory. Nothing on a request's path should
341
+ * wait here: that is the whole point of the trim being detached.
342
+ */
343
+ export function trimmed() {
344
+ return pruning;
345
+ }
346
+ async function pruneNow(dir, keep, cap) {
347
+ let names;
348
+ try {
349
+ names = await readdir(dir);
350
+ }
351
+ catch {
352
+ return;
353
+ }
354
+ const measured = await inBatches(names, async (name) => {
355
+ // The marker is not an answer and is never dropped: it is how the next
356
+ // process knows this directory's names are ones it can read.
357
+ if (name === VERSION_MARKER)
358
+ return null;
359
+ const path = join(dir, name);
360
+ try {
361
+ return { name, path, info: await stat(path) };
362
+ }
363
+ catch {
364
+ return null;
365
+ }
366
+ });
367
+ const files = [];
368
+ for (const entry of measured) {
369
+ if (entry === null || !entry.info.isFile())
370
+ continue;
371
+ if (entry.name.endsWith(PARTIAL)) {
372
+ // A partial file older than this run's is a corpse. The one being
373
+ // written now belongs to another request and is left alone.
374
+ if (Date.now() - entry.info.mtimeMs > 60 * 60 * 1000) {
375
+ await rm(entry.path, { force: true });
376
+ }
377
+ continue;
378
+ }
379
+ files.push({ path: entry.path, size: entry.info.size, at: entry.info.mtimeMs });
380
+ }
381
+ let total = files.reduce((sum, file) => sum + file.size, 0);
382
+ if (total <= cap)
383
+ return;
384
+ files.sort((left, right) => left.at - right.at);
385
+ for (const file of files) {
386
+ if (total <= cap)
387
+ break;
388
+ if (file.path === keep)
389
+ continue;
390
+ await rm(file.path, { force: true });
391
+ total -= file.size;
392
+ }
393
+ }