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,67 @@
1
+ /**
2
+ * The vocabulary: what a setting is called and what shape its value has.
3
+ *
4
+ * One table, because two things need it and neither owns it — the config file,
5
+ * which has to refuse a key it does not know, and the config itself, which has
6
+ * to know which keys exist to say where each one came from. A second copy of
7
+ * this list is how a setting becomes writable by one half and invisible to the
8
+ * other.
9
+ *
10
+ * These are the field names of `ServerConfig` and `AdminConfig`, which are also
11
+ * the names the admin API's `config` route speaks. The environment spells them
12
+ * differently (`FUNOTEKA_DB` for `dbPath`, `FUNOTEKA_CACHE` for `cacheDir`), and
13
+ * that mapping lives where the reading happens.
14
+ */
15
+ export const SETTINGS = {
16
+ dbPath: 'string',
17
+ host: 'string',
18
+ port: 'number',
19
+ user: 'string',
20
+ password: 'string',
21
+ apiKey: 'string',
22
+ ffmpeg: 'string',
23
+ cacheDir: 'string',
24
+ logFile: 'string',
25
+ logRequests: 'boolean',
26
+ cors: 'boolean',
27
+ showJunk: 'boolean',
28
+ adminPort: 'number',
29
+ adminHost: 'string',
30
+ adminToken: 'string',
31
+ adminAllow: 'string',
32
+ adminTrustProxy: 'boolean',
33
+ adminTlsCert: 'string',
34
+ adminTlsKey: 'string',
35
+ supervised: 'boolean',
36
+ scanInterval: 'number',
37
+ scanQuietFrom: 'number',
38
+ scanQuietTo: 'number',
39
+ scanWatch: 'boolean',
40
+ };
41
+ /**
42
+ * The numbers that are ports, and are therefore held to a port's range.
43
+ *
44
+ * Which does not go without saying: this vocabulary now carries numbers that are
45
+ * *not* ports — a scan interval in minutes, an hour of the day — and a reader
46
+ * that held every number to 65535 and called a violation "not a port number"
47
+ * would refuse `scanInterval: 100000` with a sentence about sockets.
48
+ */
49
+ export const PORT_SETTINGS = new Set(['port', 'adminPort']);
50
+ /**
51
+ * The settings whose value is never handed back, only ever accepted.
52
+ *
53
+ * `config get` reports that they are set and where from, and not what they are:
54
+ * a token read back over HTTP is a token in a shell history, a log line and
55
+ * whatever proxy is in front, and the operator who needs to see it already has
56
+ * the file it is in.
57
+ */
58
+ export const SECRET_SETTINGS = new Set(['password', 'adminToken', 'apiKey']);
59
+ /**
60
+ * Whether a number can be a port, which is the range a socket accepts.
61
+ *
62
+ * 0 is inside it on purpose: it asks the kernel to pick a free port, which is
63
+ * what a test does and what an operator whose port is taken does.
64
+ */
65
+ export function isPort(value) {
66
+ return Number.isInteger(value) && value >= 0 && value <= 65535;
67
+ }
@@ -0,0 +1,442 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createReadStream, statSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { HIDDEN } from "../util/child.js";
5
+ import { kept, keyOf } from "../stream/recode.js";
6
+ import { restating } from "../stream/rewrite.js";
7
+ import { alreadyIs, codecOf, DEFAULT_LOSSY, ffmpegWorks, formatsOffered, planSegment, planTarget, planWholeFile, playable, segmentSize, stretchOf, windowOf, } from "../stream/segment.js";
8
+ import { contentType, trackId } from "./browse.js";
9
+ import { ApiError, ERROR } from "./envelope.js";
10
+ import { song } from "./meta.js";
11
+ /**
12
+ * The bytes a client plays.
13
+ *
14
+ * The one method that does not answer in the protocol's envelope: `stream`
15
+ * answers with audio, and everything else about the exchange — the status, the
16
+ * headers, the range — is HTTP's business. So it is a route of a different kind
17
+ * (`binaryRoute` in `router.ts`), and the server hands it the response rather
18
+ * than asking it for a payload.
19
+ *
20
+ * Two kinds of song arrive here and they are not alike. A whole file is a file:
21
+ * it is sent, and a range of it is sent, which is how seeking works. A song cut
22
+ * from a cue image is not a file at all — it is a stretch of another one — and
23
+ * `stream/segment.ts` decides how to produce it: by hand for FLAC and mp3, by
24
+ * ffmpeg for everything else.
25
+ *
26
+ * Range is offered for both, and it means the same thing to a client either way:
27
+ * the bytes it asked for. For a whole file that is a range of the file; for a cue
28
+ * track it is a range of the *answer*, which is built rather than copied, so the
29
+ * range is answered by building the same answer and passing over what comes
30
+ * before it. That is what makes the bar move on a track that is not a file.
31
+ *
32
+ * What cannot be ranged is what ffmpeg has yet to produce: a re-encoded song or
33
+ * a cue track of an ape or MP4 image states no length until it has one, so a
34
+ * client that seeks there re-asks for the song from its beginning.
35
+ */
36
+ export async function stream(context, request, response) {
37
+ await serveSong(context, request, response, {
38
+ id: trackId(context.query),
39
+ asked: () => askedFor(context.query),
40
+ offsetMs: () => offsetOf(context.query),
41
+ });
42
+ }
43
+ /**
44
+ * One song, in the bytes the caller asked for — the whole of `stream` below the
45
+ * query string.
46
+ *
47
+ * A function of its own because `stream` is no longer the only method that
48
+ * answers with a song's bytes. `getTranscodeStream` — the second half of the
49
+ * `transcoding` extension — answers with the very same thing, having been told
50
+ * *which* transcode to make by a decision it was handed earlier rather than by
51
+ * `format` and `maxBitRate` in a query.
52
+ *
53
+ * **One path and not two.** What a format parameter means, what a cue track is
54
+ * answered with, and which stretch of an image a track is *are* this server's
55
+ * delivery policy, and a second copy of that policy would drift from the first
56
+ * on the first change to either. What that costs is written all over this file:
57
+ * the seam between "which stretch" and "in which format" produced four of the
58
+ * findings of task:2865, and every one of them was two answers to one question.
59
+ */
60
+ export async function serveSong(context, request, response, wanted) {
61
+ const { id } = wanted;
62
+ const row = song(context.db, id);
63
+ if (row === undefined)
64
+ throw new ApiError(ERROR.notFound, `No such song: ${id}`);
65
+ const asked = wanted.asked();
66
+ const offsetMs = wanted.offsetMs();
67
+ // The path is the meta layer's own, never the client's: the request chose a
68
+ // row, and the row was written by the walk. Nothing here can be talked into
69
+ // reading a file the scan never saw.
70
+ const path = join(row.root_path, row.rel_path);
71
+ // Which stretch of its file this request is about — worked out once, and from
72
+ // the row. A cue track is a stretch of an image whether or not the client
73
+ // knows it is one, and the client's offset moves its near end. Every branch
74
+ // below is about *that stretch* and never about the file it lies in, which is
75
+ // the seam three of the review's findings lived on: the format branch built
76
+ // its plan from the image's path and the client's offset alone, so a cue track
77
+ // asked for in another format was answered with the whole image (task:2865).
78
+ const stretch = stretchOf(row, offsetMs);
79
+ if (stretch.kind === 'refused')
80
+ throw new ApiError(ERROR.generic, stretch.reason);
81
+ const times = stretch.kind === 'span' ? { startMs: stretch.startMs, endMs: stretch.endMs } : null;
82
+ // `format=raw` is the protocol's own way of saying "do not transcode": nothing
83
+ // below is reached for, and not a byte of this answer is decoded. On a cue
84
+ // track of an image only ffmpeg can read that is a refusal a client can act
85
+ // on, and not the re-encode it said not to make. On a whole file the offset is
86
+ // vacuous — there is no transcode to start late — and a client that means to
87
+ // begin part-way into a byte copy says so with a Range header.
88
+ if (asked === 'raw') {
89
+ if (row.segment_start_ms !== null) {
90
+ await serveSegment(context, row, path, request, response, { times, as: 'raw' });
91
+ return;
92
+ }
93
+ await serveBytes(path, contentType(row.ext), request, response);
94
+ return;
95
+ }
96
+ const target = asked;
97
+ // A cue track is a stretch of an image, and it is answered as one. A client
98
+ // that named no format, or the one the image already holds, gets that stretch
99
+ // cut out of the image — by hand where its frames can be restated, by ffmpeg
100
+ // where they cannot. One that named another format gets that format made from
101
+ // the stretch, and never from the whole image.
102
+ if (row.segment_start_ms !== null) {
103
+ if (target === null || alreadyIs(target, row)) {
104
+ await serveSegment(context, row, path, request, response, { times, as: 'stream' });
105
+ return;
106
+ }
107
+ await serveReencoded(context, path, row.ext, times, carriedOut(planTarget({ path, target, times, tags: tagsOf(row) })), request, response);
108
+ return;
109
+ }
110
+ // A whole file is a file: sent as it is when a client can play it and asked
111
+ // for nothing else, or when it already is what was asked for. An offset is
112
+ // neither — a byte copy has no time axis to start later on — so it is built.
113
+ if (offsetMs === null) {
114
+ const asIs = target === null
115
+ ? playable(await codecOf(path, row.ext, row.codec), row.ext)
116
+ : alreadyIs(target, row);
117
+ if (asIs) {
118
+ await serveBytes(path, contentType(row.ext), request, response);
119
+ return;
120
+ }
121
+ }
122
+ await serveReencoded(context, path, row.ext, times, carriedOut(planWholeFile({ path, ext: row.ext, target, times, tags: tagsOf(row) })), request, response);
123
+ }
124
+ /**
125
+ * The song, as the tags a built file carries.
126
+ *
127
+ * **The song's artist is the file's own where the file names one, and the
128
+ * record's otherwise** — the same rule `songChild` writes into a payload, made
129
+ * once more here because this is the other place the answer leaves the server.
130
+ * A compilation is what it is for: the record is credited `Various Artists`,
131
+ * which is true of the record and false of every track on it, and a cut file
132
+ * that says `ARTIST=Various Artists` has been told something the client was
133
+ * not.
134
+ *
135
+ * Everything else is the record's, because a song has none of its own: the year
136
+ * is the album's, the number is its place on the record, and the genre is the
137
+ * one this file states.
138
+ */
139
+ function tagsOf(row) {
140
+ return {
141
+ title: row.title,
142
+ artist: row.track_artist ?? row.artist_name,
143
+ albumArtist: row.artist_name,
144
+ album: row.album_title,
145
+ trackNumber: row.ordinal,
146
+ discNumber: row.disc_number,
147
+ date: row.album_year === null ? null : String(row.album_year),
148
+ genre: row.genre,
149
+ };
150
+ }
151
+ /** A plan that has to be carried out, or the refusal it turned out to be. */
152
+ function carriedOut(plan) {
153
+ if (plan.kind !== 'transcode')
154
+ throw new ApiError(ERROR.generic, plan.reason);
155
+ return plan;
156
+ }
157
+ /**
158
+ * What the client asked to be sent: a target, `raw`, or nothing.
159
+ *
160
+ * `format` is a *container*, and the protocol lets a client name any string. A
161
+ * name this server cannot make is refused here rather than reaching ffmpeg,
162
+ * where the failure would be a message no client can act on — and the refusal
163
+ * says what *is* on offer, which is the one thing it can do about it.
164
+ */
165
+ function askedFor(query) {
166
+ const format = query.get('format');
167
+ const ceiling = ceilingOf(query);
168
+ if (format === 'raw')
169
+ return 'raw';
170
+ if (format !== null && format !== '') {
171
+ if (!formatsOffered().includes(format)) {
172
+ throw new ApiError(ERROR.generic, `This server cannot make ${format}; it makes ${formatsOffered().join(', ')}`);
173
+ }
174
+ return { format, maxBitRate: ceiling };
175
+ }
176
+ if (ceiling === null)
177
+ return null;
178
+ // A ceiling and no format: a lossless file cannot be limited without leaving
179
+ // lossless, so this is a request for the lossy default — but only when the
180
+ // file is above it, which is `alreadyIs`'s to decide.
181
+ return { format: DEFAULT_LOSSY, maxBitRate: ceiling };
182
+ }
183
+ /** The `maxBitRate` parameter: kilobits per second, and nought means no ceiling. */
184
+ function ceilingOf(query) {
185
+ const raw = query.get('maxBitRate');
186
+ if (raw === null || raw === '')
187
+ return null;
188
+ const rate = Number(raw);
189
+ if (!Number.isFinite(rate) || rate < 0) {
190
+ throw new ApiError(ERROR.generic, `maxBitRate is not a bitrate: ${raw}`);
191
+ }
192
+ return rate === 0 ? null : rate;
193
+ }
194
+ /** The `timeOffset` parameter, in milliseconds — the protocol counts in seconds. */
195
+ function offsetOf(query) {
196
+ const raw = query.get('timeOffset');
197
+ if (raw === null || raw === '')
198
+ return null;
199
+ const seconds = Number(raw);
200
+ if (!Number.isFinite(seconds) || seconds < 0) {
201
+ throw new ApiError(ERROR.generic, `timeOffset is not an offset: ${raw}`);
202
+ }
203
+ return seconds * 1000;
204
+ }
205
+ export async function serveBytes(path, type, request, response) {
206
+ const size = statSync(path).size;
207
+ const wanted = parseRange(request.headers.range, size);
208
+ if (wanted === 'unsatisfiable') {
209
+ response.writeHead(416, { 'content-range': `bytes */${size}` });
210
+ response.end();
211
+ return;
212
+ }
213
+ const start = wanted === null ? 0 : wanted.start;
214
+ const end = wanted === null ? size - 1 : wanted.end;
215
+ const length = size === 0 ? 0 : end - start + 1;
216
+ response.writeHead(wanted === null ? 200 : 206, {
217
+ 'content-type': type,
218
+ 'content-length': length,
219
+ 'accept-ranges': 'bytes',
220
+ ...(wanted === null ? {} : { 'content-range': `bytes ${start}-${end}/${size}` }),
221
+ });
222
+ // A HEAD asks what a GET would bring, and is owed the headers and nothing
223
+ // else. Reading the file to throw the bytes away would be the one thing the
224
+ // method exists to avoid.
225
+ if (request.method === 'HEAD' || length === 0) {
226
+ response.end();
227
+ return;
228
+ }
229
+ await pipeFile(path, start, end, response);
230
+ }
231
+ /**
232
+ * A song that has to be built rather than copied, built — and then kept.
233
+ *
234
+ * Two things arrive here: a whole file no client plays, and a stretch of an image
235
+ * whose frames cannot be cut by hand. What either is re-encoded *into* is what
236
+ * was asked for — the format a client named, or the lossless default when it
237
+ * named none. ffmpeg produces a stream, and a stream's length is not known until
238
+ * it ends, so the answer is written to a file rather than piped: kept whole, it
239
+ * is seekable like any other and the second listen costs nothing. Where it is
240
+ * kept, and for how long, is `stream/recode.ts`'s to say.
241
+ *
242
+ * **The stretch is part of which answer this is**, and is handed to the cache
243
+ * with it. It is not decoration: two cue tracks of one image asked for in the
244
+ * same format are two different answers, and a key that named only the image
245
+ * made them one (task:2865).
246
+ *
247
+ * ffmpeg is asked for only when there is nothing to answer with, so a re-encoded
248
+ * song already in the cache plays on a machine that has no ffmpeg at all.
249
+ *
250
+ * A range cannot be honoured on the *first* play — the length is not known until
251
+ * the file is written, and the file is written before the first byte goes out.
252
+ * That wait is the price of the bar moving at all; it is paid once per song.
253
+ */
254
+ async function serveReencoded(context, source, ext, times, plan, request, response) {
255
+ const binary = context.config.ffmpeg;
256
+ let produced;
257
+ try {
258
+ produced = await kept({
259
+ dir: context.config.cacheDir,
260
+ // The stretch is part of the key and has to be: two cue tracks of one
261
+ // image asked for in the same format are two different answers, and a key
262
+ // that named only the image called them one — so the first track
263
+ // requested became the answer for every other track of that disc, and
264
+ // stayed it (measured, task:2865).
265
+ key: keyOf({ source, ...(times ?? {}), made: plan.made }),
266
+ // What is kept is named by what it holds. It used to be `.flac` whatever
267
+ // was inside, which was true while FLAC was the only thing ever produced
268
+ // and is a file lying about itself now that a client can ask for mp3.
269
+ extension: `.${plan.container}`,
270
+ produce: async (target) => {
271
+ if (!ffmpegWorks(binary)) {
272
+ throw new Error(`This song is a .${ext} file no browser plays, and ffmpeg — which re-encodes one — is not installed`);
273
+ }
274
+ await encode(binary, plan.args(target));
275
+ },
276
+ });
277
+ }
278
+ catch (error) {
279
+ // A refusal a client can read beats a stream that stops: ffmpeg says why it
280
+ // would not produce the answer, and that sentence is the whole of what
281
+ // anyone downstream could know.
282
+ throw new ApiError(ERROR.generic, error instanceof Error ? error.message : String(error));
283
+ }
284
+ await serveBytes(produced, plan.contentType, request, response);
285
+ }
286
+ /** ffmpeg, writing the file it was told to write, or the reason it did not. */
287
+ function encode(binary, args) {
288
+ return new Promise((resolve, reject) => {
289
+ const child = spawn(binary, args, { stdio: ['ignore', 'ignore', 'pipe'], ...HIDDEN });
290
+ let complaint = '';
291
+ child.stderr?.on('data', (chunk) => {
292
+ if (complaint.length < 4096)
293
+ complaint += chunk.toString();
294
+ });
295
+ child.on('error', reject);
296
+ child.on('close', (code) => {
297
+ if (code === 0)
298
+ resolve();
299
+ else
300
+ reject(new Error(`ffmpeg exited ${code}: ${complaint.trim()}`));
301
+ });
302
+ });
303
+ }
304
+ /**
305
+ * A cue track: a stretch of an image, served as a track.
306
+ *
307
+ * Everything that could refuse happens before the first header is written,
308
+ * because a refusal after that is not a refusal — the client has already been
309
+ * told the answer is audio.
310
+ */
311
+ export async function serveSegment(context, row, path, request, response, { times, as = 'stream', }) {
312
+ // A stretch a caller could not bound is a refusal and not an answer, and the
313
+ // caller is the one that knows why — `stretchOf` says it in its own words.
314
+ if (times === null)
315
+ throw new ApiError(ERROR.generic, 'This segment has no length to serve');
316
+ const { startMs, endMs } = times;
317
+ const plan = await planSegment({
318
+ path,
319
+ ext: row.ext,
320
+ startMs,
321
+ endMs,
322
+ tags: tagsOf(row),
323
+ });
324
+ if (plan.kind === 'unavailable')
325
+ throw new ApiError(ERROR.generic, plan.reason);
326
+ if (plan.kind === 'transcode') {
327
+ if (as !== 'stream')
328
+ throw new ApiError(ERROR.generic, onlyByReencoding(row, as));
329
+ await serveReencoded(context, path, row.ext, times, plan, request, response);
330
+ return;
331
+ }
332
+ const { from, to, frames } = plan.segment;
333
+ const size = segmentSize(plan.segment);
334
+ // A range on a cue track is a range of the *answer*, which is a thing built
335
+ // here rather than a stretch of a file — so it is answered by building the
336
+ // same answer and passing over what comes before. That is what makes seeking
337
+ // work on a track that is not a file, and it is cheap: the frames are in
338
+ // memory and the work is the CRC of each, not a walk from the beginning of a
339
+ // three-hundred-megabyte image.
340
+ const wanted = parseRange(request.headers.range, size);
341
+ if (wanted === 'unsatisfiable') {
342
+ response.writeHead(416, { 'content-range': `bytes */${size}` });
343
+ response.end();
344
+ return;
345
+ }
346
+ const start = wanted === null ? 0 : wanted.start;
347
+ const end = wanted === null ? size - 1 : wanted.end;
348
+ const window = windowOf(plan.segment, start, end);
349
+ response.writeHead(wanted === null ? 200 : 206, {
350
+ 'content-type': contentType(row.ext),
351
+ 'content-length': size === 0 ? 0 : end - start + 1,
352
+ 'accept-ranges': 'bytes',
353
+ ...(wanted === null ? {} : { 'content-range': `bytes ${start}-${end}/${size}` }),
354
+ });
355
+ if (request.method === 'HEAD') {
356
+ response.end();
357
+ return;
358
+ }
359
+ if (window.prefix.length > 0)
360
+ response.write(window.prefix);
361
+ await pipeFile(path, from, to - 1, response, restating(frames, from, { skip: window.skip, limit: window.limit }));
362
+ }
363
+ /**
364
+ * Why a stretch of an image only ffmpeg can read is refused to this caller.
365
+ *
366
+ * The sentence has to be the one *this* caller actually made. `download`
367
+ * promises the original media data; `format=raw` is the protocol's "disable
368
+ * transcoding". A refusal that named the other one's promise is a lie to a
369
+ * client — which is the class of thing the whole of this file exists to avoid,
370
+ * and one that arrived here the moment `format=raw` was routed through the
371
+ * branch that already had a message written for download (found live, on a cue
372
+ * track of an `.m4a` image: task:2865).
373
+ */
374
+ function onlyByReencoding(row, as) {
375
+ const promise = as === 'raw'
376
+ ? 'which is the one thing format=raw asked not to do. Ask for a format, or download the image.'
377
+ : 'which is the one thing download promises not to do. Ask stream for it, or download the image.';
378
+ return (`"${row.title ?? row.rel_path}" is a stretch of ${row.rel_path}, and the only way to ` +
379
+ `produce it is to re-encode it — ${promise}`);
380
+ }
381
+ /**
382
+ * One range of one file, as a stream that ends with the response.
383
+ *
384
+ * `through` is where a range that is not sent as the file holds it is restated —
385
+ * a FLAC segment's frame headers, whose numbers become the track's own. The
386
+ * substitution needs no more than one pass and no more memory than a chunk, so
387
+ * a segment of a three-hundred-megabyte image is served as it always was.
388
+ */
389
+ function pipeFile(path, start, end, response, through) {
390
+ return new Promise((resolve, reject) => {
391
+ const file = createReadStream(path, { start, end });
392
+ file.on('error', reject);
393
+ // The response's own end is the other way this finishes: a client that hung
394
+ // up mid-track is not an error worth reporting, but it must not leave the
395
+ // file open.
396
+ response.on('close', () => file.destroy());
397
+ const out = through === undefined ? file : file.pipe(through);
398
+ out.on('error', reject);
399
+ out.pipe(response).on('finish', resolve).on('error', reject);
400
+ });
401
+ }
402
+ /**
403
+ * The bytes a client asked for, or that it asked for none, or that it asked for
404
+ * something that is not there.
405
+ *
406
+ * A header this does not understand is treated as no request at all, which the
407
+ * protocol allows: a server may always answer with the whole file, and a range
408
+ * it cannot parse is better served whole than refused. The distinction matters
409
+ * — `unsatisfiable` is a 416 the client can act on, and everything else here is
410
+ * a 200 it can play.
411
+ */
412
+ function parseRange(header, size) {
413
+ if (header === undefined)
414
+ return null;
415
+ const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
416
+ if (match === null)
417
+ return null;
418
+ const from = match[1] ?? '';
419
+ const to = match[2] ?? '';
420
+ if (from === '' && to === '')
421
+ return null;
422
+ // `bytes=-500` is the last five hundred bytes, not the first: a suffix range
423
+ // measures back from the end, so both of its ends come from the file's size
424
+ // and neither is the number that was written. Reading that number as the end
425
+ // — the obvious thing, and the thing this did first — produced a range whose
426
+ // end was before its start, a negative length, and a connection closed under
427
+ // a client that had done nothing wrong.
428
+ if (from === '') {
429
+ const suffix = Number(to);
430
+ if (suffix === 0)
431
+ return 'unsatisfiable';
432
+ return { start: Math.max(size - suffix, 0), end: size - 1 };
433
+ }
434
+ const start = Number(from);
435
+ if (start >= size)
436
+ return 'unsatisfiable';
437
+ // An end before the start is not a range at all. Serving the whole file is
438
+ // the answer the protocol prescribes for one, and is what a client that wrote
439
+ // it by accident can still play.
440
+ const end = to === '' ? size - 1 : Math.min(Number(to), size - 1);
441
+ return end < start ? null : { start, end };
442
+ }