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