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,624 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { statSync } from 'node:fs';
3
+ import { probeAsync } from "../probe/ffprobe.js";
4
+ import { HIDDEN } from "../util/child.js";
5
+ import { flacSegment } from "./flac.js";
6
+ import { mpegSegment } from "./mpeg.js";
7
+ import { restatedSize } from "./rewrite.js";
8
+ /**
9
+ * How many bytes a segment comes to.
10
+ *
11
+ * The range, the prefix in front of it, and what restating the frames adds or
12
+ * takes away — a number written in fewer bytes than the image's makes the served
13
+ * stream shorter than the bytes it was cut from. The footers are two bytes for
14
+ * two bytes and change nothing.
15
+ */
16
+ export function segmentSize(segment) {
17
+ return segment.prefix.length + (segment.to - segment.from) + restatedSize(segment.frames);
18
+ }
19
+ /**
20
+ * The piece of a segment a client asked for, as what the route sends.
21
+ *
22
+ * A range on a cue track is a range of the *answer*, not of the image: the
23
+ * answer begins with a rebuilt header that exists in no file, and each frame
24
+ * after it is a header written out, a body copied, and a footer computed. So
25
+ * the offsets a client seeks by are answered by building the same stream and
26
+ * passing over what comes before — the prefix is a slice of itself, and the
27
+ * rest is a window onto the frames.
28
+ */
29
+ export function windowOf(segment, start, end) {
30
+ const intoPrefix = Math.min(Math.max(start, 0), segment.prefix.length);
31
+ const prefixTo = Math.min(Math.max(end + 1, intoPrefix), segment.prefix.length);
32
+ const prefix = segment.prefix.subarray(intoPrefix, prefixTo);
33
+ return {
34
+ prefix,
35
+ skip: Math.max(0, start - segment.prefix.length),
36
+ limit: end - start + 1 - prefix.length,
37
+ };
38
+ }
39
+ /** The formats whose segments are cut from the container by hand. */
40
+ const NATIVE = new Set(['flac', 'mp3']);
41
+ /**
42
+ * Formats the layer of delivery handles, and the ones it does so with ffmpeg.
43
+ *
44
+ * Monkey's Audio is here for the same reason the MP4 containers are: the audio
45
+ * cannot be cut out by hand — the frames are not self-delimiting the way mp3's
46
+ * are, and nothing in the stream states a length — and ffmpeg decodes it, so a
47
+ * cue image of it is re-encoded instead of being refused. Two albums of the
48
+ * collection are ape images, and a refusal they can read is still no music.
49
+ */
50
+ const THROUGH_FFMPEG = new Set(['m4a', 'mp4', 'aac', 'alac', 'ape']);
51
+ /**
52
+ * Codecs no browser decodes.
53
+ *
54
+ * The collection holds more than the web does: ALAC is Apple's and no browser
55
+ * decodes it, Monkey's Audio none at all, and both are lossless — which is what
56
+ * makes re-encoding them cheap to justify. The names are ffprobe's own, which is
57
+ * where the meta layer's `codec` comes from.
58
+ */
59
+ const UNPLAYABLE_CODECS = new Set([
60
+ 'alac',
61
+ 'ape',
62
+ 'wavpack',
63
+ 'wma',
64
+ 'wmav1',
65
+ 'wmav2',
66
+ 'wmavoice',
67
+ 'tta',
68
+ 'shorten',
69
+ 'dsd_lsbf',
70
+ 'dsd_msbf',
71
+ 'dsd_lsbf_planar',
72
+ 'dsd_msbf_planar',
73
+ ]);
74
+ /** Codecs a browser plays, named the way ffprobe names them. */
75
+ const PLAYABLE_CODECS = new Set([
76
+ 'flac',
77
+ 'mp3',
78
+ 'mp3float',
79
+ 'aac',
80
+ 'aac_latm',
81
+ 'opus',
82
+ 'vorbis',
83
+ 'pcm_s16le',
84
+ 'pcm_s24le',
85
+ 'pcm_s32le',
86
+ 'pcm_u8',
87
+ 'pcm_f32le',
88
+ 'pcm_s16be',
89
+ 'pcm_s24be',
90
+ 'pcm_mulaw',
91
+ 'pcm_alaw',
92
+ ]);
93
+ /** Containers a browser opens. Anything else is re-encoded, whatever is inside. */
94
+ const PLAYABLE_CONTAINERS = new Set(['flac', 'mp3', 'm4a', 'm4b', 'mp4', 'aac', 'ogg', 'oga', 'opus', 'wav', 'weba']);
95
+ /**
96
+ * Containers that can hold a codec no browser plays, whatever they are named.
97
+ *
98
+ * `.m4a` is one container whether it holds AAC or Apple's ALAC, so the name is
99
+ * the one thing that cannot answer for it — the file has to be read.
100
+ */
101
+ const AMBIGUOUS_CONTAINERS = new Set(['m4a', 'm4b', 'mp4', 'mov']);
102
+ /**
103
+ * Whether a client can play these bytes as they are.
104
+ *
105
+ * A browser needs both things at once: a container it opens and a codec inside
106
+ * it that it decodes. Neither answer stands in for the other — Monkey's Audio
107
+ * is a box nothing opens whatever is inside it, and ALAC sits in the same `.m4a`
108
+ * that AAC does.
109
+ *
110
+ * A codec this has never heard of is not a codec: the probe is optional, and an
111
+ * older scan of this collection wrote the *container's* name where the codec
112
+ * belongs (`mp4` for AAC, `id3v2` for mp3). Those files play, so an unknown name
113
+ * leaves the container to answer — see `codecOf`, which reads the file when the
114
+ * container is one that could be hiding something.
115
+ */
116
+ export function playable(codec, ext) {
117
+ if (!PLAYABLE_CONTAINERS.has(ext))
118
+ return false;
119
+ return !(codec !== null && UNPLAYABLE_CODECS.has(codec));
120
+ }
121
+ /** What has been read of which file, kept until the file itself moves. */
122
+ const readings = new Map();
123
+ /**
124
+ * The readings being taken right now, so two callers wait on one ffprobe.
125
+ *
126
+ * `readings` answers the *second* question about a file and not the first: two
127
+ * requests that arrive before either probe has returned both find nothing kept
128
+ * and both spawn ffprobe. It was two, and it is a number that multiplies —
129
+ * `getTranscodeDecision` asks this of every song a client asks a decision about,
130
+ * and a client asking about a library asks about a library. One process per file
131
+ * in flight is the bound this keeps; there is no bound across files, which is
132
+ * named and measured in task:2910 rather than guessed at here.
133
+ */
134
+ const probing = new Map();
135
+ /**
136
+ * The codec of a file that is about to be sent.
137
+ *
138
+ * The meta layer answers for it when it named a codec this knows — either one a
139
+ * browser plays or one it does not — because that reading is the scan's own and
140
+ * was taken from the same bytes. When it named something else, and the container
141
+ * is one that could be hiding an unplayable codec, the file is read here: a
142
+ * `.m4a` whose codec nobody has established is exactly the case this exists for,
143
+ * and sending ALAC to a browser is a download it sits silent through.
144
+ *
145
+ * The reading is kept per file until its size or modification time moves — the
146
+ * same guard the frame index uses — so a re-encode is noticed rather than
147
+ * answered from the reading of the file it replaced.
148
+ *
149
+ * Awaited, because this stands on the hot path of every `stream` of such a file
150
+ * and the reading is a whole ffprobe process: asked synchronously it held this
151
+ * single-threaded server for 90 ms apiece (measured, task:2898).
152
+ */
153
+ export async function codecOf(path, ext, known) {
154
+ if (known !== null && (UNPLAYABLE_CODECS.has(known) || PLAYABLE_CODECS.has(known)))
155
+ return known;
156
+ if (!AMBIGUOUS_CONTAINERS.has(ext))
157
+ return known;
158
+ const stat = statSync(path);
159
+ const kept = readings.get(path);
160
+ if (kept !== undefined && kept.size === stat.size && kept.mtimeMs === stat.mtimeMs) {
161
+ return kept.codec ?? known;
162
+ }
163
+ const already = probing.get(path);
164
+ if (already !== undefined)
165
+ return (await already) ?? known;
166
+ const work = probeAsync(path)
167
+ .then((probe) => {
168
+ readings.set(path, { size: stat.size, mtimeMs: stat.mtimeMs, codec: probe.codec });
169
+ return probe.codec;
170
+ })
171
+ .finally(() => probing.delete(path));
172
+ probing.set(path, work);
173
+ return (await work) ?? known;
174
+ }
175
+ /**
176
+ * Whether the binary is there, asked once per process.
177
+ *
178
+ * Asked before any byte is written, never after: a stream that discovers
179
+ * half-way through that it cannot be made has already told the client it could.
180
+ * The probe costs one short-lived process and the answer cannot change under a
181
+ * running server, which is why it is kept.
182
+ */
183
+ const ready = new Map();
184
+ export function ffmpegWorks(binary) {
185
+ const known = ready.get(binary);
186
+ if (known !== undefined)
187
+ return known;
188
+ let works = false;
189
+ try {
190
+ execFileSync(binary, ['-version'], {
191
+ stdio: ['ignore', 'ignore', 'ignore'],
192
+ timeout: 10_000,
193
+ ...HIDDEN,
194
+ });
195
+ works = true;
196
+ }
197
+ catch {
198
+ works = false;
199
+ }
200
+ ready.set(binary, works);
201
+ return works;
202
+ }
203
+ /** See `Stretch`. */
204
+ export function stretchOf(song, offsetMs) {
205
+ if (song.segment_start_ms === null) {
206
+ if (offsetMs === null)
207
+ return { kind: 'file' };
208
+ if (song.duration_ms === null) {
209
+ return {
210
+ kind: 'refused',
211
+ reason: 'This song has no measured length, so there is nothing to start it later from',
212
+ };
213
+ }
214
+ if (offsetMs >= song.duration_ms) {
215
+ return {
216
+ kind: 'refused',
217
+ reason: `timeOffset ${offsetMs / 1000} is at or past the end of a song of ${song.duration_ms / 1000} seconds`,
218
+ };
219
+ }
220
+ return { kind: 'span', startMs: offsetMs, endMs: song.duration_ms };
221
+ }
222
+ // The closing track of a disc has no following index to end it, and its
223
+ // length comes from the measurement the probe took rather than from the cue.
224
+ const endMs = song.segment_end_ms ?? song.segment_start_ms + (song.duration_ms ?? 0);
225
+ const startMs = song.segment_start_ms + (offsetMs ?? 0);
226
+ if (endMs <= startMs) {
227
+ return {
228
+ kind: 'refused',
229
+ reason: offsetMs === null
230
+ ? 'This segment has no length to serve'
231
+ : `timeOffset ${offsetMs / 1000} is at or past the end of this track`,
232
+ };
233
+ }
234
+ return { kind: 'span', startMs, endMs };
235
+ }
236
+ /**
237
+ * What to do about one track that is a segment of `path`.
238
+ *
239
+ * `startMs` and `endMs` are the segment's own times, which the cue stage settled
240
+ * and the meta layer holds — or, when a client asked to start later, those times
241
+ * moved, which is `stretchOf`'s to work out.
242
+ */
243
+ export async function planSegment(input) {
244
+ const { path, ext, startMs, endMs, tags } = input;
245
+ const seconds = (ms) => (ms / 1000).toFixed(3);
246
+ if (NATIVE.has(ext)) {
247
+ const segment = ext === 'flac'
248
+ ? await flacSegment(path, startMs, endMs, tags)
249
+ : await mpegSegment(path, startMs, endMs, tags);
250
+ if (segment === null) {
251
+ return {
252
+ kind: 'unavailable',
253
+ reason: `The ${ext} stream could not be walked, so this segment cannot be cut from it`,
254
+ };
255
+ }
256
+ return { kind: 'bytes', segment };
257
+ }
258
+ if (THROUGH_FFMPEG.has(ext)) {
259
+ // No target: a segment of an image is re-encoded only when nothing else can
260
+ // be done with it, and what it is re-encoded into is the lossless default —
261
+ // a cue image of ALAC or Monkey's Audio decoded and written back out without
262
+ // losing anything. A client that named a format of its own goes through
263
+ // `planTarget` instead, which is where the ceiling and the container live.
264
+ return planOf(reencode(path, { startMs, endMs }, null, tags));
265
+ }
266
+ return { kind: 'unavailable', reason: `A segment of a .${ext} file cannot be served` };
267
+ }
268
+ /**
269
+ * The formats this server will make, and nothing else.
270
+ *
271
+ * **A closed list, and the refusal names it.** The protocol lets a client ask
272
+ * for any format string at all, and a server that accepted one it could not make
273
+ * would either fail inside ffmpeg with a message no client can act on or, worse,
274
+ * hand back something else. So an unknown name is refused with the list of what
275
+ * is on offer — the same shape `getOpenSubsonicExtensions` will take when it
276
+ * says which extensions exist (task:2866).
277
+ */
278
+ const TARGETS = {
279
+ mp3: { codec: 'libmp3lame', container: 'mp3', contentType: 'audio/mpeg', lossless: false },
280
+ opus: { codec: 'libopus', container: 'ogg', contentType: 'audio/ogg', lossless: false },
281
+ ogg: { codec: 'libvorbis', container: 'ogg', contentType: 'audio/ogg', lossless: false },
282
+ aac: { codec: 'aac', container: 'mp4', contentType: 'audio/mp4', lossless: false },
283
+ flac: { codec: 'flac', container: 'flac', contentType: 'audio/flac', lossless: true },
284
+ wav: { codec: 'pcm_s16le', container: 'wav', contentType: 'audio/wav', lossless: true },
285
+ };
286
+ /** The one the server falls back to on its own, and the only one it ever chose. */
287
+ const LOSSLESS_FORMAT = 'flac';
288
+ const LOSSLESS = TARGETS[LOSSLESS_FORMAT];
289
+ /**
290
+ * The lossy default when a ceiling is named and no format is.
291
+ *
292
+ * The protocol says a ceiling is "an attempt to limit the bitrate" and names no
293
+ * format to limit it *to* — and a lossless file cannot be limited without
294
+ * leaving lossless. So a ceiling alone is a request for the lossy format every
295
+ * client can play.
296
+ */
297
+ export const DEFAULT_LOSSY = 'mp3';
298
+ /** What this server can be asked to make, for a refusal that says so. */
299
+ export function formatsOffered() {
300
+ return Object.keys(TARGETS);
301
+ }
302
+ /** Whether a target keeps every sample. */
303
+ export function isLossless(format) {
304
+ return TARGETS[format]?.lossless === true;
305
+ }
306
+ /**
307
+ * The codecs that *are* a format, as a probe spells them.
308
+ *
309
+ * Asked so that a file already holding what a client asked for is not decoded
310
+ * and encoded again for nothing. What a file is called is not the question:
311
+ * `.m4a` is not `mp4` to a client, and the codec is what it is asking about.
312
+ *
313
+ * The PCM names are all here because a wav is a wav at any width — answering
314
+ * `format=wav` for a 24-bit file by re-encoding it to the 16-bit one this server
315
+ * would write throws bits away to no purpose.
316
+ */
317
+ const CODECS_OF = {
318
+ mp3: ['mp3', 'mp3float'],
319
+ opus: ['opus'],
320
+ ogg: ['vorbis'],
321
+ aac: ['aac', 'aac_latm'],
322
+ flac: ['flac'],
323
+ wav: [
324
+ 'pcm_s16le',
325
+ 'pcm_s24le',
326
+ 'pcm_s32le',
327
+ 'pcm_u8',
328
+ 'pcm_f32le',
329
+ 'pcm_s16be',
330
+ 'pcm_s24be',
331
+ 'pcm_mulaw',
332
+ 'pcm_alaw',
333
+ ],
334
+ };
335
+ /** Whether a file already holds the codec a format names. */
336
+ export function codecIs(format, codec) {
337
+ if (codec === null)
338
+ return false;
339
+ return CODECS_OF[format]?.includes(codec) === true;
340
+ }
341
+ /**
342
+ * Containers that are one container under more than one name.
343
+ *
344
+ * A client names the container, and the name a file carries is not the thing it
345
+ * is: `.m4a` and `.m4b` are ISO base media whatever they are called, and `.oga`
346
+ * and `.opus` are Ogg. Only the names that differ are here.
347
+ */
348
+ const CONTAINER_CLASS = {
349
+ m4a: 'mp4',
350
+ m4b: 'mp4',
351
+ mov: 'mp4',
352
+ oga: 'ogg',
353
+ opus: 'ogg',
354
+ };
355
+ /**
356
+ * The container a name means, where two names are one container.
357
+ *
358
+ * Asked by everything that compares a container a client named with a container
359
+ * this server holds or writes — a file's extension, a `format` parameter, or a
360
+ * profile in the `transcoding` extension's `ClientInfo`. Exported because
361
+ * `SourceStream` reports a container to a client in this vocabulary too: the
362
+ * `.m4a` on disk is an `mp4` in the protocol, and a client matching its own
363
+ * direct-play profile against `m4a` would match nothing.
364
+ */
365
+ export function containerOf(name) {
366
+ return CONTAINER_CLASS[name] ?? name;
367
+ }
368
+ /**
369
+ * Whether a file is held in the container a format names.
370
+ *
371
+ * The pair to `codecIs`, and never a substitute for it — see `alreadyIs`.
372
+ */
373
+ export function containerIs(format, ext) {
374
+ const named = TARGETS[format]?.container;
375
+ if (named === undefined)
376
+ return false;
377
+ return containerOf(named) === containerOf(ext);
378
+ }
379
+ /**
380
+ * The format this server would make of a stream a client named, or nothing.
381
+ *
382
+ * A client's transcoding profile names a container and a codec in the protocol's
383
+ * vocabulary; this server makes six things and no others. The question is both
384
+ * halves at once — a container this server writes holding a codec it writes —
385
+ * which is the same pair `alreadyIs` asks from the other direction, so it is
386
+ * asked with the same two predicates rather than a second table.
387
+ */
388
+ export function formatFor(container, codec) {
389
+ for (const format of Object.keys(TARGETS)) {
390
+ if (containerIs(format, container) && codecIs(format, codec))
391
+ return format;
392
+ }
393
+ return null;
394
+ }
395
+ /**
396
+ * What this server's answer would be, in the protocol's own words.
397
+ *
398
+ * `TARGETS` names ffmpeg's encoders and the containers ffmpeg writes; a client
399
+ * reads `StreamDetails` in the protocol's vocabulary, where the codec of an ogg
400
+ * is `vorbis` and not `libvorbis`, and where what an `.m4a` is written in is
401
+ * `mp4`. Two vocabularies, so the translation lives in one place — the same
402
+ * reason `metadata` sits beside `tags/encode.ts` instead of being guessed at
403
+ * each call.
404
+ *
405
+ * The codec is the *first* name `CODECS_OF` lists, and that is the one ffmpeg
406
+ * itself would write: the rest of each list is the other spellings a probe may
407
+ * report for the same codec in a file that already holds it.
408
+ */
409
+ export function streamOf(format) {
410
+ const made = TARGETS[format];
411
+ const codecs = CODECS_OF[format];
412
+ if (made === undefined || codecs === undefined)
413
+ return null;
414
+ // The transport is not here, and deliberately: this module knows what ffmpeg
415
+ // writes, and the protocol's one transport is a fact about the API. It is
416
+ // named once, where the protocol is spoken — `HTTP` in `transcode.ts`.
417
+ return { container: made.container, codec: codecs[0] };
418
+ }
419
+ /**
420
+ * Whether a stretch of this container is cut out by hand, or by ffmpeg.
421
+ *
422
+ * The question `stream` settles before it can call a cue track playable at all:
423
+ * a stretch of FLAC or mp3 is the file's own frames restated, and a stretch of
424
+ * anything else has to be decoded and written again — which is a transcode, and
425
+ * so not a thing a client can be told it will play as it is.
426
+ */
427
+ export function cutByHand(ext) {
428
+ return NATIVE.has(ext);
429
+ }
430
+ /**
431
+ * Whether the file already is what the client asked for.
432
+ *
433
+ * **Both questions, and both have to be yes.** The codec has to be the one the
434
+ * format names *and* the container has to be the one it would be written in.
435
+ * Either alone is a lie to a client: an `.ogg` holding vorbis is not the
436
+ * `format=opus` answer though its container is right, and a `.mp4` holding ALAC
437
+ * is not the `format=aac` answer *though its container is right too* — answering
438
+ * on the container alone handed a client that had said it decodes AAC the ALAC
439
+ * it could not, which is exactly the class of lie `playable` exists to prevent
440
+ * (measured, task:2865).
441
+ *
442
+ * A file whose bitrate nobody measured cannot be shown to be under a ceiling, so
443
+ * it is re-encoded: the safe answer is the one that obeys.
444
+ *
445
+ * **The channel cap is asked here too, and it is the one question this function
446
+ * cannot answer by looking at the format.** It is an agreement between the
447
+ * client and the *file* rather than between the client and the container: a
448
+ * six-channel FLAC asked for as `format=flac` is trivially the format that was
449
+ * named, and it is not what a client that decodes two channels asked for.
450
+ * Without this the shortcut below would hand that client the six channels the
451
+ * decision had just promised to fold down — one decision with two answers,
452
+ * which is the class `Plan.made` and `keyOf` exist to close, and which was found
453
+ * by the standards axis rather than by this suite (task:2896).
454
+ *
455
+ * A count nobody measured does *not* block, and that is the opposite of the
456
+ * ceiling above for a reason that was measured the hard way: a cap handed to
457
+ * ffmpeg is a number of channels to **produce**, so treating an unmeasured file
458
+ * as "not shown to be within the cap" made a stereo mp3 come out as a 52 MB 5.1
459
+ * FLAC against a client that had only said "no more than six" (task:2896). A cap
460
+ * is a limit on a file known to exceed it; the rest go as they are.
461
+ *
462
+ * `channels` is required rather than optional for the reason `keyOf`'s `made`
463
+ * is: a caller that forgot it would silently get the old, wrong answer.
464
+ */
465
+ export function alreadyIs(target, file) {
466
+ if (!codecIs(target.format, file.codec))
467
+ return false;
468
+ if (!containerIs(target.format, file.ext))
469
+ return false;
470
+ if (target.maxChannels != null && file.channels !== null && file.channels > target.maxChannels) {
471
+ return false;
472
+ }
473
+ if (target.maxBitRate === null)
474
+ return true;
475
+ if (isLossless(target.format))
476
+ return true;
477
+ return file.bitrate !== null && file.bitrate <= target.maxBitRate * 1000;
478
+ }
479
+ /**
480
+ * The song's tags in ffmpeg's own spelling.
481
+ *
482
+ * Lowercase names and no underscores, which is what ffmpeg's metadata keys are —
483
+ * `album_artist` for the album artist, `track` for the number — and the reason
484
+ * the mapping is written out rather than derived from `tags/encode.ts`'s table:
485
+ * two writers, two vocabularies, and a translation is one place for the
486
+ * difference to live instead of a guess at each call.
487
+ */
488
+ function metadata(tags) {
489
+ const wanted = [
490
+ ['title', tags.title],
491
+ ['artist', tags.artist],
492
+ ['album_artist', tags.albumArtist],
493
+ ['album', tags.album],
494
+ ['track', tags.trackNumber],
495
+ ['disc', tags.discNumber],
496
+ ['date', tags.date],
497
+ ['genre', tags.genre],
498
+ ];
499
+ return Object.fromEntries(wanted
500
+ .filter((entry) => entry[1] !== null && entry[1] !== '')
501
+ .map(([name, value]) => [name, String(value)]));
502
+ }
503
+ /**
504
+ * The plan a re-encode is, whichever question asked for it.
505
+ *
506
+ * Three callers and one shape, so it is written once. The plan's fields are the
507
+ * re-encode's own, and a field added to either would otherwise have to be added
508
+ * in three places that are easy to keep two of — the shape this module has
509
+ * already been bitten by once, when `made` gained its third component.
510
+ */
511
+ function planOf(made) {
512
+ return {
513
+ kind: 'transcode',
514
+ args: made.args,
515
+ contentType: made.contentType,
516
+ made: made.made,
517
+ container: made.container,
518
+ };
519
+ }
520
+ /** A plan that makes the format a client named, whatever the file holds. */
521
+ export function planTarget(input) {
522
+ return planOf(reencode(input.path, input.times, input.target, input.tags));
523
+ }
524
+ /**
525
+ * What to do about a whole file a client cannot play.
526
+ *
527
+ * The same answer a segment of an MP4 or ape image gets, without the times: the
528
+ * file is decoded and written out as FLAC. Lossless in, lossless out — ALAC and
529
+ * Monkey's Audio are both lossless, so re-encoding costs processor time and
530
+ * gives back exactly the audio that was asked for, and a lossy transcode would
531
+ * be a quality decision the collection never asked anyone to make.
532
+ *
533
+ * A range cannot be honoured on the answer, since its length is not known until
534
+ * ffmpeg has produced it; a client that seeks re-asks for the song.
535
+ */
536
+ export function planWholeFile(input) {
537
+ const { path, target = null, times = null, tags } = input;
538
+ return planOf(reencode(path, times, target, tags));
539
+ }
540
+ /**
541
+ * The arguments that make the format that was asked for out of whatever is there.
542
+ *
543
+ * Six possible answers and not one: `wanted` is the format a client named, or
544
+ * the lossless default when it named none. The header here used to promise FLAC
545
+ * and nothing else, which stopped being true the moment `format` arrived — and a
546
+ * comment that promises what the code no longer does weighs as much as a bug in
547
+ * this project (task:2865).
548
+ *
549
+ * `-ss` and `-t` are input options, so ffmpeg seeks before decoding rather than
550
+ * decoding from the start and discarding; `-map 0:a:0` keeps the audio of a file
551
+ * that may also carry a video stream, and `-vn` makes sure of it.
552
+ */
553
+ function reencode(path, times, wanted, tags) {
554
+ const seconds = (ms) => (ms / 1000).toFixed(3);
555
+ // What the answer *is* — which is not everything that was asked for, and the
556
+ // difference is the point. A re-encode a client named as `format=flac` and one
557
+ // the server fell back to with nothing asked are the same arguments and the
558
+ // same bytes — and two spellings of one identity stored the *same*
559
+ // 68 863 444-byte answer twice, 785 ms apart, under two names (measured,
560
+ // task:2865).
561
+ //
562
+ // The ceiling is dropped from it for a lossless target for the same reason it
563
+ // is dropped from the arguments below: it never reaches ffmpeg, so it is not
564
+ // part of the answer. `flac:128` and `flac:none` were two names for one
565
+ // answer. The channel limit is *not* dropped — it changes the bytes — and it
566
+ // is part of the identity even when it is absent, so that one answer has one
567
+ // spelling rather than two.
568
+ const identity = wanted ?? { format: LOSSLESS_FORMAT, maxBitRate: null, maxChannels: null };
569
+ const made = wanted === null ? LOSSLESS : TARGETS[wanted.format];
570
+ const line = [
571
+ '-v',
572
+ 'error',
573
+ '-nostdin',
574
+ ...(times === null
575
+ ? []
576
+ : ['-ss', seconds(times.startMs), '-t', seconds(Math.max(0, times.endMs - times.startMs))]),
577
+ '-i',
578
+ path,
579
+ '-map',
580
+ '0:a:0',
581
+ '-vn',
582
+ '-c:a',
583
+ made.codec,
584
+ // A ceiling on a lossless target would be a request to throw audio away
585
+ // while calling the answer lossless, so it is dropped where it cannot mean
586
+ // anything — and FLAC has no bitrate to cap in the first place.
587
+ ...(wanted === null || wanted.maxBitRate === null || made.lossless
588
+ ? []
589
+ : ['-b:a', `${wanted.maxBitRate}k`]),
590
+ // The channel limit, by contrast, is obeyed whatever the target is. It comes
591
+ // from the `transcoding` extension, where a client states what it can
592
+ // decode: one that cannot decode six channels cannot play six channels
593
+ // whether they arrive as mp3 or as FLAC, and answering a lossless format
594
+ // with them anyway would be a promise about the client's hardware.
595
+ ...(wanted?.maxChannels == null ? [] : ['-ac', String(wanted.maxChannels)]),
596
+ // What the answer says about itself. ffmpeg copies no metadata unless it is
597
+ // asked to (`-map 0:a:0` maps the audio and nothing else), so without these
598
+ // a re-encoded song arrives with no tags — the same defect the hand-cut
599
+ // segments had, in the branch that decodes (task:2895).
600
+ //
601
+ // `-map_metadata -1` first, and it is not redundant: `-metadata` *adds* to
602
+ // whatever the input carried, and the input here can be a cue image whose
603
+ // own tags name the disc rather than the track. Clearing first is what makes
604
+ // the file state the song and only the song.
605
+ ...['-map_metadata', '-1'],
606
+ ...Object.entries(metadata(tags)).flatMap(([name, value]) => ['-metadata', `${name}=${value}`]),
607
+ // **ID3v2.3, where ffmpeg would write 2.4.** The tags are the same either
608
+ // way, and the readers are not: 2.3 is understood by both generations of
609
+ // them, 2.4 only by the modern ones — a reader that knows 2.3 alone sees
610
+ // every tag except the year, which lives in `TDRC` in 2.4 and in `TYER` in
611
+ // 2.3. There is nothing in these tags that 2.4 says better, so the version
612
+ // that everybody reads is the one to write. Asked of ffmpeg only where it
613
+ // means something: the option belongs to the mp3 muxer.
614
+ ...(made.container === 'mp3' ? ['-id3v2_version', '3'] : []),
615
+ '-f',
616
+ made.container,
617
+ ];
618
+ return {
619
+ args: (target) => [...line, target],
620
+ contentType: made.contentType,
621
+ made: `${identity.format}:${made.lossless ? 'none' : (identity.maxBitRate ?? 'none')}:${identity.maxChannels ?? 'none'}`,
622
+ container: made.container,
623
+ };
624
+ }