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,678 @@
1
+ import { join } from 'node:path';
2
+ import { codecOf, containerOf, cutByHand, formatFor, isLossless, playable, streamOf, } from "../stream/segment.js";
3
+ import { parseId, required } from "./browse.js";
4
+ import { ApiError, ERROR } from "./envelope.js";
5
+ import { song } from "./meta.js";
6
+ import { serveSong } from "./stream.js";
7
+ /**
8
+ * The `transcoding` extension: a client asks what to do with a song, and then
9
+ * asks for it.
10
+ *
11
+ * The protocol's older way of transcoding is a query string — `format=mp3` and
12
+ * `maxBitRate=128` — and it works because a client that knows what it can play
13
+ * can name it in two parameters. The extension replaces that with a
14
+ * conversation, and the reason is that a real client's capabilities are not two
15
+ * parameters: Symfonium, Sonos and every other player that has an opinion about
16
+ * containers carries a *list* of profiles, each with codecs, channel counts and
17
+ * codec-specific limits on sample rate and bit depth.
18
+ *
19
+ * So `getTranscodeDecision` is a POST, because that list does not fit in a URL
20
+ * — the specification says so in as many words — and the server answers with
21
+ * what it would do: play it as it is, or convert it, and if convert, what into.
22
+ * `getTranscodeStream` then answers with the stream, and it is handed the
23
+ * decision back as an opaque `transcodeParams` so that the two calls cannot
24
+ * disagree about what was decided.
25
+ *
26
+ * **What is here is only what this server can actually do**, and that is the
27
+ * whole of the volume decision: `hls` is skipped, because this server produces
28
+ * no HLS and `requirements:47` puts the method in the stubs. A client whose
29
+ * profiles are all HLS is told `canTranscode: false`, which is true, rather than
30
+ * promised a stream that would arrive as an error.
31
+ *
32
+ * **One place where the specification leaves the consequence to the server**,
33
+ * and it is decided here: `required: false` on a limitation means a breach does
34
+ * not block. The specification defines the field — "Whether this limitation must
35
+ * be met" — and marks it required, and never says what a breach of a non-required
36
+ * one costs. The reading taken here is the one with the cheaper failure mode:
37
+ * the alternative transcodes for a limitation the client itself said need not
38
+ * hold, on a phone, for every song. A profile that omits the field entirely gets
39
+ * the same treatment, which is the tolerance the rest of this module shows a
40
+ * client that spells something wrongly — it is answered as though it had said
41
+ * nothing.
42
+ *
43
+ * (`protocols: []` stood here as a second such place until the contract axis
44
+ * read the schema: `openapi/schemas/DirectPlayProfile.json` says "An empty array
45
+ * means any protocols", in the same breath as the containers and the codecs. The
46
+ * code was right and the claim about its source was not, which is the class of
47
+ * thing this project weighs as much as a bug.)
48
+ */
49
+ /** The protocol's one transport, and the only one this server produces. */
50
+ const HTTP = 'http';
51
+ /** The version of the decision this server issues, and of the token it signs. */
52
+ const TOKEN_VERSION = 1;
53
+ /**
54
+ * A ceiling no stream this server makes could reach, and so no ceiling at all.
55
+ *
56
+ * It is here to keep the token's space finite: the value a client sends back is
57
+ * checked against it, and a number the decision would never have issued is a
58
+ * refusal rather than an ffmpeg argument. Ten megabits per second is four times
59
+ * the largest thing a CD-resolution stream can be.
60
+ */
61
+ const MAX_CEILING_KBPS = 10_000;
62
+ /**
63
+ * The widest channel count a decision will ever put in a token.
64
+ *
65
+ * A cap at or above this cannot limit any recording, so it is not treated as a
66
+ * cap at all — the same reasoning as `MAX_CEILING_KBPS` above, and it is what
67
+ * keeps the two ends of the token honest: `readToken` refuses a channel count
68
+ * this high, so a decision that passed one on unclamped would hand a client a
69
+ * token its own server then rejected, with "ask getTranscodeDecision for one"
70
+ * as the advice — a loop. That was a real defect, found by the standards axis
71
+ * and not by this suite: a client naming 33 channels was issued the token
72
+ * `readToken` refuses (task:2896).
73
+ */
74
+ const MAX_CHANNELS = 32;
75
+ function isRecord(value) {
76
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
77
+ }
78
+ function recordsOf(value) {
79
+ return Array.isArray(value) ? value.filter(isRecord) : [];
80
+ }
81
+ function stringsOf(value) {
82
+ return Array.isArray(value) ? value.filter((item) => typeof item === 'string') : [];
83
+ }
84
+ /**
85
+ * The numbers a limitation compares against, as the strings the spec types them
86
+ * as.
87
+ *
88
+ * The specification writes the field's type as `string` and its own examples
89
+ * send arrays of strings — and one of them sends integers. Both reach here, so
90
+ * both are read, and anything else is dropped rather than coerced.
91
+ */
92
+ function valuesOf(value) {
93
+ if (!Array.isArray(value))
94
+ return [];
95
+ return value
96
+ .map((item) => typeof item === 'string'
97
+ ? item
98
+ : typeof item === 'number' && Number.isFinite(item)
99
+ ? String(item)
100
+ : null)
101
+ .filter((item) => item !== null);
102
+ }
103
+ function intOf(value) {
104
+ return typeof value === 'number' && Number.isFinite(value) ? Math.trunc(value) : null;
105
+ }
106
+ function textOf(value) {
107
+ return typeof value === 'string' ? value : '';
108
+ }
109
+ /**
110
+ * The client's capabilities, off the request body.
111
+ *
112
+ * A body that is not there, or is not JSON, is refused with a sentence rather
113
+ * than answered with a decision made up from nothing: an empty `ClientInfo`
114
+ * means "this client has told me nothing", and the honest answer to that is not
115
+ * `canDirectPlay: false` — it is that the question was not asked.
116
+ */
117
+ function clientInfoOf(body) {
118
+ if (body === null || body.trim() === '') {
119
+ throw new ApiError(ERROR.missingParameter, 'getTranscodeDecision needs the client capabilities in the request body: the specification puts them there because they do not fit in a query string');
120
+ }
121
+ let parsed;
122
+ try {
123
+ parsed = JSON.parse(body);
124
+ }
125
+ catch (err) {
126
+ throw new ApiError(ERROR.generic, `The request body is not JSON: ${err instanceof Error ? err.message : String(err)}`);
127
+ }
128
+ if (!isRecord(parsed)) {
129
+ throw new ApiError(ERROR.generic, 'The request body is not a JSON object of client capabilities');
130
+ }
131
+ return {
132
+ maxAudioBitrate: intOf(parsed['maxAudioBitrate']),
133
+ maxTranscodingAudioBitrate: intOf(parsed['maxTranscodingAudioBitrate']),
134
+ directPlayProfiles: recordsOf(parsed['directPlayProfiles']).map((profile) => ({
135
+ containers: stringsOf(profile['containers']),
136
+ audioCodecs: stringsOf(profile['audioCodecs']),
137
+ protocols: stringsOf(profile['protocols']),
138
+ maxAudioChannels: intOf(profile['maxAudioChannels']),
139
+ })),
140
+ transcodingProfiles: recordsOf(parsed['transcodingProfiles']).map((profile) => ({
141
+ container: textOf(profile['container']),
142
+ audioCodec: textOf(profile['audioCodec']),
143
+ protocol: textOf(profile['protocol']),
144
+ maxAudioChannels: intOf(profile['maxAudioChannels']),
145
+ })),
146
+ codecProfiles: recordsOf(parsed['codecProfiles']).map((profile) => ({
147
+ name: textOf(profile['name']),
148
+ limitations: recordsOf(profile['limitations']).map((limitation) => ({
149
+ name: textOf(limitation['name']),
150
+ comparison: textOf(limitation['comparison']),
151
+ values: valuesOf(limitation['values']),
152
+ required: limitation['required'] === true,
153
+ })),
154
+ })),
155
+ };
156
+ }
157
+ /**
158
+ * What the file's audio is at, in bits per second.
159
+ *
160
+ * The meta layer's own reading where the probe took one — and it took one for a
161
+ * sixth of this collection, so where there is none it is worked out from the two
162
+ * things the scan always has: the file's size and its measured length. That is
163
+ * an average over the whole file and is only ever read as one; it is still the
164
+ * number that answers the client's question, which is whether this file is more
165
+ * than the link it is being asked for across.
166
+ *
167
+ * **Only for a whole file.** A cue track's row carries the *image's* size beside
168
+ * its own duration, so dividing one by the other would give a disc's bytes
169
+ * divided by a track's length — inflated by the number of tracks on the record,
170
+ * and worse than no number at all.
171
+ */
172
+ function bitrateOf(row) {
173
+ if (row.bitrate !== null)
174
+ return row.bitrate;
175
+ if (row.segment_start_ms !== null)
176
+ return null;
177
+ if (row.duration_ms === null || row.duration_ms <= 0)
178
+ return null;
179
+ return Math.round((row.size * 8 * 1000) / row.duration_ms);
180
+ }
181
+ /**
182
+ * Whether the server would send this media's own bytes, untranscoded.
183
+ *
184
+ * The same question `stream` answers before it reaches for ffmpeg, asked with
185
+ * the same two predicates — `playable` for a whole file and `cutByHand` for a
186
+ * stretch of an image — so that the decision and the delivery cannot disagree.
187
+ *
188
+ * **This is the half of `canDirectPlay` that is about the server.** The other
189
+ * half is the client's own profiles, and both are needed: a client that plays
190
+ * ALAC would be told it can play this file directly, and then handed FLAC by a
191
+ * server whose own default is to re-encode what a browser cannot read. A
192
+ * `canDirectPlay` a client acts on by calling `stream` has to mean what `stream`
193
+ * will do, and not merely what the client is capable of.
194
+ */
195
+ function handsOver(row, codec) {
196
+ return row.segment_start_ms === null ? playable(codec, row.ext) : cutByHand(row.ext);
197
+ }
198
+ /** Why the server would not, in the words of the one method that decides it. */
199
+ function whyServerReencodes(row, codec) {
200
+ return row.segment_start_ms === null
201
+ ? `ServerReencodesUnplayable: ${codec} in ${containerOf(row.ext)}`
202
+ : `ServerReencodesSegmentOf: ${containerOf(row.ext)}`;
203
+ }
204
+ function detailsOf(source) {
205
+ return {
206
+ protocol: HTTP,
207
+ container: source.container,
208
+ codec: source.codec,
209
+ ...(source.channels === null ? {} : { audioChannels: source.channels }),
210
+ ...(source.bitrate === null ? {} : { audioBitrate: source.bitrate }),
211
+ ...(source.samplerate === null ? {} : { audioSamplerate: source.samplerate }),
212
+ };
213
+ }
214
+ /**
215
+ * The value of the parameter a limitation names, or nothing.
216
+ *
217
+ * Nothing means "this server cannot tell you", and it is returned for two
218
+ * different reasons that are deliberately not distinguished: the file's own
219
+ * reading is missing (see `Source`), and the limitation names something no
220
+ * reading would give — `audioProfile` is a string and this server stores none,
221
+ * and `audioBitdepth` is read by nothing here.
222
+ *
223
+ * A limitation that cannot be evaluated is **not** a breach. The alternative is
224
+ * to call every file with an unmeasured sample rate unfit for direct play, which
225
+ * on this collection is two files in three — and would answer "you must
226
+ * transcode" about music that needs no transcoding, to a phone on a battery. The
227
+ * cost of being wrong the other way is one failed attempt at a play, after which
228
+ * a client asks again; the cost of this way is a transcode of everything.
229
+ */
230
+ function parameterOf(name, source) {
231
+ switch (name) {
232
+ case 'audioChannels':
233
+ return source.channels;
234
+ case 'audioBitrate':
235
+ return source.bitrate;
236
+ case 'audioSamplerate':
237
+ return source.samplerate;
238
+ default:
239
+ return null;
240
+ }
241
+ }
242
+ /** Whether a limitation holds of the file, or nothing where it cannot be told. */
243
+ function holds(limitation, source) {
244
+ const value = parameterOf(limitation.name, source);
245
+ const first = limitation.values[0];
246
+ if (value === null || first === undefined)
247
+ return null;
248
+ switch (limitation.comparison) {
249
+ case 'Equals':
250
+ return limitation.values.includes(String(value));
251
+ case 'NotEquals':
252
+ return !limitation.values.includes(String(value));
253
+ case 'LessThanEqual': {
254
+ const bound = Number(first);
255
+ return Number.isNaN(bound) ? null : value <= bound;
256
+ }
257
+ case 'GreaterThanEqual': {
258
+ const bound = Number(first);
259
+ return Number.isNaN(bound) ? null : value >= bound;
260
+ }
261
+ // A comparison this server does not know is not a breach for the same reason
262
+ // an unreadable parameter is not one: it cannot be shown to fail.
263
+ default:
264
+ return null;
265
+ }
266
+ }
267
+ /**
268
+ * Why this client's direct-play profile does not take the file — or nothing,
269
+ * which is the answer when it does.
270
+ *
271
+ * **One string per profile, which is what the specification asks for**: its
272
+ * `transcodeReason` is "server specific made for logging purpose" and the server
273
+ * "should return 1 string per direct play profile". The strings are therefore
274
+ * about *profiles* and not about the file, and the same file can be refused for
275
+ * three different reasons by three profiles.
276
+ */
277
+ function refusesProfile(profile, client, source) {
278
+ // An empty list is no restriction, and here the specification says so itself:
279
+ // "The list of supported protocols. An empty array means any protocols"
280
+ // (`openapi/schemas/DirectPlayProfile.json`), which is what it says of the
281
+ // containers and the codecs beside it.
282
+ if (profile.protocols.length > 0 && !profile.protocols.includes(HTTP)) {
283
+ return `ProtocolNotSupported: ${profile.protocols.join(', ')}`;
284
+ }
285
+ if (profile.containers.length > 0 &&
286
+ !profile.containers.some((name) => containerOf(name) === source.container)) {
287
+ return `ContainerNotSupported: ${source.container}`;
288
+ }
289
+ if (profile.audioCodecs.length > 0 && !profile.audioCodecs.includes(source.codec)) {
290
+ return `AudioCodecNotSupported: ${source.codec}`;
291
+ }
292
+ if (profile.maxAudioChannels !== null &&
293
+ source.channels !== null &&
294
+ source.channels > profile.maxAudioChannels) {
295
+ return `AudioChannelsNotSupported: ${source.channels}`;
296
+ }
297
+ if (client.maxAudioBitrate !== null &&
298
+ client.maxAudioBitrate > 0 &&
299
+ source.bitrate !== null &&
300
+ source.bitrate > client.maxAudioBitrate) {
301
+ return `AudioBitrateNotSupported: ${source.bitrate}`;
302
+ }
303
+ for (const named of client.codecProfiles) {
304
+ // A codec profile with no name is about every codec; one that names another
305
+ // codec is not about this file at all.
306
+ if (named.name !== '' && named.name !== source.codec)
307
+ continue;
308
+ for (const limitation of named.limitations) {
309
+ if (!limitation.required)
310
+ continue;
311
+ if (holds(limitation, source) === false)
312
+ return `LimitationNotMet: ${limitation.name}`;
313
+ }
314
+ }
315
+ return null;
316
+ }
317
+ /**
318
+ * What the client asked the answer to be, out of its own priority list.
319
+ *
320
+ * The transcoding profiles are an ordered list and the specification says the
321
+ * server "should evaluate these in the order they are listed, as a priority
322
+ * list", so the first one this server can actually produce wins.
323
+ *
324
+ * **A profile over any transport but `http` is skipped**, and that is the honest
325
+ * reading of what this server is: it produces no HLS, and `requirements:47` puts
326
+ * `hls` among the stubs. A client whose list is all HLS therefore gets no target
327
+ * and `canTranscode: false`, which is a sentence it can act on — rather than a
328
+ * `transcodeParams` that leads to a stream that never comes.
329
+ */
330
+ function targetFor(client, source) {
331
+ for (const profile of client.transcodingProfiles) {
332
+ if (profile.protocol !== HTTP)
333
+ continue;
334
+ const format = formatFor(profile.container, profile.audioCodec);
335
+ if (format === null)
336
+ continue;
337
+ return {
338
+ format,
339
+ maxBitRate: ceilingOf(client),
340
+ maxChannels: channelsOf(profile, source),
341
+ };
342
+ }
343
+ return null;
344
+ }
345
+ /**
346
+ * The ceiling the client's own numbers put on the answer, in kilobits.
347
+ *
348
+ * The two fields are bits per second and mean different things: `maxAudioBitrate`
349
+ * is what the client can handle at all, `maxTranscodingAudioBitrate` what it
350
+ * will take a *converted* stream at. This is a converted stream, so the second
351
+ * wins where it is given. Both are the protocol's "0 or missing means no
352
+ * limitation", and neither is the protocol's `maxBitRate` — that one is in
353
+ * kilobits and this one is in bits, which is a difference of a factor of a
354
+ * thousand in a field nobody reads twice.
355
+ */
356
+ function ceilingOf(client) {
357
+ const bits = client.maxTranscodingAudioBitrate ?? client.maxAudioBitrate;
358
+ if (bits === null || bits <= 0)
359
+ return null;
360
+ const kbps = Math.max(1, Math.round(bits / 1000));
361
+ return kbps >= MAX_CEILING_KBPS ? null : kbps;
362
+ }
363
+ /**
364
+ * The channel limit to pass down, which is only ever a limit on a *measured*
365
+ * file.
366
+ *
367
+ * A client's `maxAudioChannels` is a ceiling, not a target — and this first
368
+ * passed it down whenever the source's own count was unknown, on the reasoning
369
+ * that a cap which cannot be shown to be unnecessary cannot be shown to be
370
+ * needed either. **That reasoning was wrong, and live.** ffmpeg reads `-ac N` as
371
+ * the number of channels to *produce*, so a stereo mp3 whose channel count
372
+ * nobody had measured and a profile that said "no more than six" came out as
373
+ * **5.1** — four channels invented for a client that had asked for no such
374
+ * thing (measured on the operator's own Symfonium, task:2896). What the cap cost
375
+ * was the audio and not the bytes: the answer is 52 MB with it and 51 MB
376
+ * without, because a FLAC of a decoded mp3 is that size and no ceiling makes it
377
+ * smaller.
378
+ *
379
+ * So the cap is passed only where the file is *known* to exceed it. Where the
380
+ * count is unknown there is no cap, which is the same rule this module already
381
+ * follows for a limitation it cannot evaluate — an unmeasured value is not a
382
+ * breach — and the same one `alreadyIs` follows for a bitrate nobody measured.
383
+ * What it costs: a file that really does hold six channels and was never
384
+ * measured goes out as it is. This collection has **no** such file (measured:
385
+ * zero of 3173 probes report more than two channels), so that is a hypothesis
386
+ * about a library nobody here has.
387
+ *
388
+ * A cap at or above `MAX_CHANNELS` is not passed either, for the reason the
389
+ * ceiling above is not: nothing can reach it. See `MAX_CHANNELS`.
390
+ */
391
+ function channelsOf(profile, source) {
392
+ const cap = profile.maxAudioChannels;
393
+ if (cap === null || cap <= 0 || cap >= MAX_CHANNELS)
394
+ return null;
395
+ if (source.channels === null || source.channels <= cap)
396
+ return null;
397
+ return cap;
398
+ }
399
+ /** What the answer will be, in the protocol's own words. */
400
+ function targetDetails(target, source) {
401
+ const named = streamOf(target.format);
402
+ if (named === null)
403
+ throw new ApiError(ERROR.generic, `This server cannot describe ${target.format}`);
404
+ // Sampling rate and channels survive a re-encode, so where the source's are
405
+ // known the answer's are too — and where a cap is being passed it is the cap
406
+ // that will be produced, whatever the file holds.
407
+ const channels = target.maxChannels ?? source.channels;
408
+ // The ceiling is reported only where it will be obeyed: `reencode` drops it on
409
+ // a lossless target, and a target that says it is capped at 128 kbps and
410
+ // arrives at 900 is the kind of promise this file exists not to make.
411
+ const bitrate = target.maxBitRate === null || isLossless(target.format) ? null : target.maxBitRate * 1000;
412
+ return {
413
+ protocol: HTTP,
414
+ container: named.container,
415
+ codec: named.codec,
416
+ ...(channels === null ? {} : { audioChannels: channels }),
417
+ ...(bitrate === null ? {} : { audioBitrate: bitrate }),
418
+ ...(source.samplerate === null ? {} : { audioSamplerate: source.samplerate }),
419
+ };
420
+ }
421
+ // --- the token --------------------------------------------------------------
422
+ /**
423
+ * The decision, in a form the client can hand back.
424
+ *
425
+ * **Self-contained and not a map with a time-to-live**, which the specification
426
+ * explicitly allows ("should be kept valid by the server for a reasonable
427
+ * duration *if stored in memory*"). Three reasons, in order of weight. A map
428
+ * lives in a process, and this daemon is stopped and started constantly — no
429
+ * autostart, and the end of an agent's session takes it down — while the client
430
+ * is a phone that goes to the background and comes back an hour later. A map can
431
+ * be *revoked*, and there is nothing here to revoke: the token names a transcode
432
+ * setting, not a permission, and everything a forged one could ask for
433
+ * (`format=mp3&maxBitRate=128`) a client can already ask for through `stream`.
434
+ * And a map grows with every decision issued and never claimed.
435
+ *
436
+ * **So it is not signed, and that is a decision rather than an omission.** Every
437
+ * field is parsed strictly against what this server would have issued — the
438
+ * format against the closed list of `TARGETS`, the numbers against their bounds
439
+ * — so a token nobody issued cannot name anything a client could not have named
440
+ * on its own. A signature would buy the right to trust the value, and the value
441
+ * is not trusted anyway.
442
+ *
443
+ * **The song is in it.** `getTranscodeStream` is given the `mediaId` as well, and
444
+ * the token carries its own copy: the two disagreeing is a refusal rather than a
445
+ * silent transcode of one song by another song's decision.
446
+ *
447
+ * It is *deterministic* — two identical decisions produce one identical token —
448
+ * where the specification says "the value is unique". Nothing is lost by that:
449
+ * the token is not an identity anywhere (the cache key is `keyOf`'s, from what
450
+ * was asked for), no client can be shown to depend on two of them differing, and
451
+ * the uniqueness the sentence is protecting — a value that cannot be confused
452
+ * with another request's — is kept by the song and the settings being in it.
453
+ * Recorded rather than left as a silent departure from a literal word.
454
+ */
455
+ function issueToken(id, target) {
456
+ const payload = [
457
+ TOKEN_VERSION,
458
+ id,
459
+ target.format,
460
+ target.maxBitRate ?? 0,
461
+ target.maxChannels ?? 0,
462
+ ];
463
+ return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
464
+ }
465
+ /** The decision a client handed back, or a refusal naming what is wrong with it. */
466
+ function readToken(text, id) {
467
+ const refuse = (why) => {
468
+ throw new ApiError(ERROR.generic, `transcodeParams is not one this server issued: ${why}. Ask getTranscodeDecision for one.`);
469
+ };
470
+ let payload;
471
+ try {
472
+ payload = JSON.parse(Buffer.from(text, 'base64url').toString('utf8'));
473
+ }
474
+ catch {
475
+ return refuse('it is not readable');
476
+ }
477
+ if (!Array.isArray(payload) || payload.length !== 5)
478
+ return refuse('it is not a decision');
479
+ const [version, media, format, kbps, channels] = payload;
480
+ if (version !== TOKEN_VERSION)
481
+ return refuse(`it was issued by version ${String(version)}, not this one`);
482
+ if (media !== id)
483
+ return refuse('it was issued for another song');
484
+ if (typeof format !== 'string' || streamOf(format) === null) {
485
+ return refuse(`this server makes no ${String(format)}`);
486
+ }
487
+ if (typeof kbps !== 'number' ||
488
+ !Number.isInteger(kbps) ||
489
+ kbps < 0 ||
490
+ kbps >= MAX_CEILING_KBPS) {
491
+ return refuse('its bitrate is not one this server would have issued');
492
+ }
493
+ // The same bound at both ends, and that is the point: everything `issueToken`
494
+ // can write, this reads — and nothing else. `channelsOf` never passes a cap at
495
+ // or above `MAX_CHANNELS`, so the two sets are exactly each other.
496
+ if (typeof channels !== 'number' ||
497
+ !Number.isInteger(channels) ||
498
+ channels < 0 ||
499
+ channels >= MAX_CHANNELS) {
500
+ return refuse('its channel count is not one this server would have issued');
501
+ }
502
+ return { format, maxBitRate: kbps === 0 ? null : kbps, maxChannels: channels === 0 ? null : channels };
503
+ }
504
+ // --- the routes -------------------------------------------------------------
505
+ /**
506
+ * The song a `mediaId` names.
507
+ *
508
+ * `mediaId` and not `id`: these are the protocol's only two methods that spell
509
+ * it that way, and a server reading `id` here would refuse every call the
510
+ * specification describes. The form of the value is the one every other id in
511
+ * this API takes — `tr:123` — so it is parsed by the same function.
512
+ */
513
+ function mediaIdOf(query) {
514
+ const raw = required(query, 'mediaId');
515
+ const parsed = parseId(raw);
516
+ if (parsed === undefined || parsed.kind !== 'tr') {
517
+ throw new ApiError(ERROR.notFound, `No such media: ${raw}`);
518
+ }
519
+ return parsed.n;
520
+ }
521
+ /**
522
+ * The `mediaType` parameter, checked.
523
+ *
524
+ * Required by the specification, and its two values are `song` and `podcast`.
525
+ * This server has no podcasts and no way to acquire one: a `mediaId` is a track
526
+ * id and a podcast has none. So a client asking about a podcast is told there is
527
+ * no such media, which is true, rather than handed a decision about a song.
528
+ */
529
+ function mediaTypeOf(query) {
530
+ const type = required(query, 'mediaType');
531
+ if (type !== 'song') {
532
+ throw new ApiError(ERROR.notFound, `This server has no ${type} media: mediaId names a song`);
533
+ }
534
+ }
535
+ /**
536
+ * `getTranscodeDecision` — what this server would do with this song for this
537
+ * client.
538
+ *
539
+ * The body is the `ClientInfo`; `mediaId` and `mediaType` are in the query,
540
+ * which is what the specification's own example URL shows despite its body being
541
+ * where the interesting half of the request lives.
542
+ */
543
+ export async function getTranscodeDecision(context) {
544
+ const id = mediaIdOf(context.query);
545
+ mediaTypeOf(context.query);
546
+ const client = clientInfoOf(context.body);
547
+ const row = song(context.db, id);
548
+ if (row === undefined)
549
+ throw new ApiError(ERROR.notFound, `No such song: ${id}`);
550
+ // Asked of the file where the scan's own reading is not a codec this server
551
+ // knows — a `.m4a` is AAC or ALAC and only the file says which, which is 1425
552
+ // of this collection's files. It is the same reading `stream` takes before it
553
+ // sends such a file, cached the same way, so the two agree by construction.
554
+ const path = join(row.root_path, row.rel_path);
555
+ const codec = await codecOf(path, row.ext, row.codec);
556
+ if (codec === null) {
557
+ // `StreamDetails.codec` is `Req. Yes`, and this is the one place where that
558
+ // cannot be honoured. Answering with the container's name instead is exactly
559
+ // the defect migration 015 was written to undo: `codec` was filled with
560
+ // `mp4` for two thousand files, and every one of them was a wrong answer
561
+ // that looked like a right one.
562
+ throw new ApiError(ERROR.generic, `This server could not read the audio of ${row.rel_path}, so it cannot say how it would be played`);
563
+ }
564
+ const source = {
565
+ container: containerOf(row.ext),
566
+ codec,
567
+ channels: row.channels,
568
+ samplerate: row.sample_rate,
569
+ bitrate: bitrateOf(row),
570
+ };
571
+ const refusals = client.directPlayProfiles.map((profile) => refusesProfile(profile, client, source));
572
+ // Both halves: some profile has to accept the file, *and* the server has to be
573
+ // willing to send it as it is. A client that sent no profiles accepts nothing.
574
+ const canDirectPlay = refusals.some((refusal) => refusal === null) && handsOver(row, codec);
575
+ // **One string per direct-play profile, in the order the client sent them**,
576
+ // which is the shape the specification asks for ("the server should return 1
577
+ // string per direct play profile") — and the reason for that shape is that the
578
+ // position *is* the profile: a reader holding a sentence and a list of
579
+ // capabilities has nothing else to correlate the two by. A profile that
580
+ // accepted gets the server's own reason where the server will re-encode
581
+ // anyway, that being the reason which applies to it.
582
+ //
583
+ // **Empty when direct play is fine**, and that is not an optimisation: the
584
+ // field is "reasons why transcoding is necessary", so a decision saying
585
+ // `canDirectPlay: true` beside a list of reasons tells a client two things at
586
+ // once, and the list is the half it acts on. A client carries several profiles
587
+ // and all but one of them refuse any given file, so this was the ordinary case
588
+ // rather than a corner (found live: a FLAC file against Symfonium's three
589
+ // profiles answered `canDirectPlay: true` with two refusals beside it).
590
+ //
591
+ // A client that sent no profiles gets no entries — there is no profile to
592
+ // explain, and `canDirectPlay: false` is the whole of the answer to it.
593
+ const reasons = canDirectPlay
594
+ ? []
595
+ : refusals.map((refusal) => refusal ?? whyServerReencodes(row, codec));
596
+ const target = targetFor(client, source);
597
+ const decision = {
598
+ canDirectPlay,
599
+ canTranscode: target !== null,
600
+ sourceStream: detailsOf(source),
601
+ ...(reasons.length === 0 ? {} : { transcodeReason: reasons }),
602
+ };
603
+ if (target === null) {
604
+ // **Only where something actually went wrong.** `errorReason` is "a
605
+ // description of an error that occurred", and a client whose direct-play
606
+ // profile works while it named no transcoding profile has been granted
607
+ // exactly what it asked for: an error beside `canDirectPlay: true` is the
608
+ // same false statement the reasons above are not allowed to make (found by
609
+ // the contract axis, which read the same field's documentation).
610
+ if (!canDirectPlay) {
611
+ decision.errorReason =
612
+ client.transcodingProfiles.length === 0
613
+ ? 'The client named no transcoding profiles'
614
+ : 'This server produces no stream over any transport but http, and no profile the client named is one it can make';
615
+ }
616
+ }
617
+ else {
618
+ decision.transcodeParams = issueToken(id, target);
619
+ decision.transcodeStream = targetDetails(target, source);
620
+ }
621
+ return { transcodeDecision: decision };
622
+ }
623
+ /**
624
+ * The `offset` parameter: seconds into the song, and nought by default.
625
+ *
626
+ * Not `timeOffset` — this extension spells the same idea its own way, which is
627
+ * the one place the two spellings meet. `stream`'s is a client's offset on a
628
+ * song; this is a decision's.
629
+ */
630
+ function offsetOf(query) {
631
+ const raw = query.get('offset');
632
+ if (raw === null || raw === '')
633
+ return null;
634
+ const seconds = Number(raw);
635
+ if (!Number.isFinite(seconds) || seconds < 0) {
636
+ throw new ApiError(ERROR.generic, `offset is not an offset: ${raw}`);
637
+ }
638
+ return seconds === 0 ? null : seconds * 1000;
639
+ }
640
+ /**
641
+ * `getTranscodeStream` — the stream the decision decided on.
642
+ *
643
+ * The bytes are `serveSong`'s, which is `stream`'s own path: a client that got
644
+ * `transcodeParams` from the decision and a client that named `format` in the
645
+ * query get the same answer out of the same code, and the cache entry is the
646
+ * same entry.
647
+ *
648
+ * **`transcodeParams` is the only thing that says what to make.** `mediaId` is
649
+ * here too and is checked against the token rather than used to decide anything
650
+ * — the specification says a client "should not try to reconstruct the
651
+ * `transcodeParams`", and the way to mean that is to give the token no
652
+ * competition.
653
+ *
654
+ * **A refusal here carries a status, and this is the only route that does.** Its
655
+ * page asks for it in as many words — "In case of an error, a standard HTTP
656
+ * error code is returned with a descriptive message" — and the OpenAPI document
657
+ * declares 400, 401, 404 and 500 beside it. Every other byte route of this
658
+ * server answers a refusal the way `send` documents, from `stream` and
659
+ * `download` to the three stubbed ones, because a Subsonic client reads `status`
660
+ * out of the body; this one method is the exception, and `STATUS_REFUSALS` in
661
+ * `server.ts` is where the exception is drawn and where the argument for it is
662
+ * written out (task:2913).
663
+ *
664
+ * The envelope is the body either way: the status is *added*, not substituted,
665
+ * so a client that reads the code and the message still finds both.
666
+ */
667
+ export async function getTranscodeStream(context, request, response) {
668
+ const id = mediaIdOf(context.query);
669
+ mediaTypeOf(context.query);
670
+ // Deferred, like `stream`'s parameters: the token is read after the song is
671
+ // looked up, so that a request naming an id that is not there is told that
672
+ // rather than told about its token. One order for both routes into `serveSong`.
673
+ await serveSong(context, request, response, {
674
+ id,
675
+ asked: () => readToken(required(context.query, 'transcodeParams'), id),
676
+ offsetMs: () => offsetOf(context.query),
677
+ });
678
+ }