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.
- package/.env.example +90 -0
- package/CHANGELOG.md +104 -0
- package/DEPLOY.md +375 -0
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/deploy/answers.schema.json +151 -0
- package/deploy/build.mjs +88 -0
- package/deploy/check-publishable.mjs +246 -0
- package/deploy/smoke.mjs +681 -0
- package/deploy/systemd/funoteka.env.example +41 -0
- package/deploy/systemd/funoteka.service +77 -0
- package/deploy/windows/funoteka-service.xml +58 -0
- package/deploy/windows/install-service.ps1 +158 -0
- package/dist/annotation/store.js +67 -0
- package/dist/api/admin-export.js +227 -0
- package/dist/api/admin-guard.js +181 -0
- package/dist/api/admin-library.js +113 -0
- package/dist/api/admin.js +1162 -0
- package/dist/api/annotation.js +193 -0
- package/dist/api/artistinfo.js +140 -0
- package/dist/api/audit.js +32 -0
- package/dist/api/auth.js +145 -0
- package/dist/api/bookmark.js +108 -0
- package/dist/api/browse.js +1241 -0
- package/dist/api/config-file.js +202 -0
- package/dist/api/config.js +285 -0
- package/dist/api/cover.js +304 -0
- package/dist/api/download.js +112 -0
- package/dist/api/envelope.js +162 -0
- package/dist/api/extensions.js +43 -0
- package/dist/api/genre.js +81 -0
- package/dist/api/health.js +35 -0
- package/dist/api/history.js +374 -0
- package/dist/api/idempotency.js +66 -0
- package/dist/api/keys.js +90 -0
- package/dist/api/log-file.js +65 -0
- package/dist/api/meta.js +1410 -0
- package/dist/api/playlist.js +311 -0
- package/dist/api/rescan.js +196 -0
- package/dist/api/roots.js +161 -0
- package/dist/api/router.js +133 -0
- package/dist/api/scanner.js +163 -0
- package/dist/api/search.js +82 -0
- package/dist/api/server.js +643 -0
- package/dist/api/settings.js +67 -0
- package/dist/api/stream.js +442 -0
- package/dist/api/stubs.js +183 -0
- package/dist/api/transcode.js +678 -0
- package/dist/api/user.js +73 -0
- package/dist/api/virtual.js +362 -0
- package/dist/api/visibility.js +21 -0
- package/dist/artist/apply.js +752 -0
- package/dist/artist/credit.js +95 -0
- package/dist/artist/folder.js +65 -0
- package/dist/artist/name.js +205 -0
- package/dist/artist/nfo.js +101 -0
- package/dist/artist/translit.js +79 -0
- package/dist/bookmark/store.js +54 -0
- package/dist/classify/classify.js +405 -0
- package/dist/classify/collision-name.js +109 -0
- package/dist/classify/folder-name.js +466 -0
- package/dist/classify/roles.js +277 -0
- package/dist/classify/shelf-name.js +178 -0
- package/dist/classify/tree.js +43 -0
- package/dist/cli/args.js +169 -0
- package/dist/cli/daemon.js +192 -0
- package/dist/cli/entry.js +25 -0
- package/dist/cli/keys.js +52 -0
- package/dist/cli.js +638 -0
- package/dist/cover/pick.js +58 -0
- package/dist/cover/picture.js +111 -0
- package/dist/cue/engine.js +759 -0
- package/dist/cue/match.js +142 -0
- package/dist/cue/parse.js +182 -0
- package/dist/cue/plan.js +286 -0
- package/dist/cue/track-name.js +120 -0
- package/dist/db/index.js +163 -0
- package/dist/db/issue.js +73 -0
- package/dist/db/ledger.js +84 -0
- package/dist/db/migrations/001_init.sql +196 -0
- package/dist/db/migrations/002_cue_track_flexible.sql +35 -0
- package/dist/db/migrations/003_cue_metadata.sql +14 -0
- package/dist/db/migrations/004_cue_performer.sql +12 -0
- package/dist/db/migrations/005_incremental.sql +23 -0
- package/dist/db/migrations/006_tags.sql +57 -0
- package/dist/db/migrations/007_artist_credit.sql +35 -0
- package/dist/db/migrations/008_issue_stage.sql +39 -0
- package/dist/db/migrations/009_cue_title.sql +20 -0
- package/dist/db/migrations/010_duration_source.sql +31 -0
- package/dist/db/migrations/011_track_title_source.sql +28 -0
- package/dist/db/migrations/012_file_container.sql +25 -0
- package/dist/db/migrations/013_cover_art.sql +28 -0
- package/dist/db/migrations/014_album_year.sql +17 -0
- package/dist/db/migrations/015_probe_method.sql +14 -0
- package/dist/db/migrations/016_tags_method.sql +20 -0
- package/dist/db/migrations/017_cover_indirect.sql +23 -0
- package/dist/db/migrations/018_release_year.sql +14 -0
- package/dist/db/migrations/019_file_tag_name_file_value.sql +20 -0
- package/dist/db/migrations/020_analyze_after_index.sql +16 -0
- package/dist/db/migrations/021_cue_index_files.sql +15 -0
- package/dist/db/migrations/022_sidecar_text.sql +19 -0
- package/dist/db/migrations/023_track_credit.sql +23 -0
- package/dist/db/migrations/024_playlists.sql +63 -0
- package/dist/db/migrations/025_playlist_sequence.sql +27 -0
- package/dist/db/migrations/026_playlist_source_file.sql +19 -0
- package/dist/db/migrations/027_lookup_indexes.sql +30 -0
- package/dist/db/migrations/028_annotations.sql +47 -0
- package/dist/db/migrations/029_scan_state_root_run.sql +28 -0
- package/dist/db/migrations/030_history.sql +108 -0
- package/dist/db/migrations/031_queue_position_and_plays.sql +46 -0
- package/dist/db/migrations/032_bookmarks.sql +27 -0
- package/dist/db/migrations/033_bookmark_order.sql +14 -0
- package/dist/db/migrations/034_junk.sql +33 -0
- package/dist/db/migrations/035_api_key.sql +38 -0
- package/dist/db/migrations/036_file_tag_first.sql +88 -0
- package/dist/db/migrations/037_admin_idempotency.sql +31 -0
- package/dist/db/migrations/038_cue_audio_index.sql +24 -0
- package/dist/db/sweep.js +109 -0
- package/dist/history/store.js +151 -0
- package/dist/inventory/inventory.js +294 -0
- package/dist/junk/marks.js +143 -0
- package/dist/junk/rule.js +93 -0
- package/dist/mcp/client.js +48 -0
- package/dist/mcp/server.js +175 -0
- package/dist/mcp/tools.js +348 -0
- package/dist/playlist/files.js +116 -0
- package/dist/playlist/import.js +260 -0
- package/dist/playlist/store.js +240 -0
- package/dist/probe/ffprobe.js +179 -0
- package/dist/run.js +120 -0
- package/dist/scan/kinds.js +49 -0
- package/dist/scan/scan.js +503 -0
- package/dist/scan/schedule.js +132 -0
- package/dist/scan/settle.js +172 -0
- package/dist/scan/walk.js +132 -0
- package/dist/search/index.js +41 -0
- package/dist/search/query.js +28 -0
- package/dist/stream/flac.js +541 -0
- package/dist/stream/mpeg.js +179 -0
- package/dist/stream/recode.js +393 -0
- package/dist/stream/rewrite.js +163 -0
- package/dist/stream/segment.js +624 -0
- package/dist/tags/apply.js +703 -0
- package/dist/tags/encode.js +164 -0
- package/dist/tags/first.js +130 -0
- package/dist/tags/flac.js +196 -0
- package/dist/tags/genres.js +123 -0
- package/dist/tags/id3v1.js +209 -0
- package/dist/tags/id3v2.js +754 -0
- package/dist/tags/mp4.js +534 -0
- package/dist/tags/mpeg.js +355 -0
- package/dist/tags/ogg.js +463 -0
- package/dist/tags/picture.js +71 -0
- package/dist/tags/read.js +269 -0
- package/dist/tags/types.js +51 -0
- package/dist/tags/vorbis-comment.js +155 -0
- package/dist/text/encoding.js +384 -0
- package/dist/text/site-name.js +37 -0
- package/dist/util/child.js +21 -0
- package/dist/util/names.js +97 -0
- package/funoteka.json.example +27 -0
- 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
|
+
}
|