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,643 @@
1
+ import { createServer as createHttpServer, } from 'node:http';
2
+ import { authenticate, coverSignature } from "./auth.js";
3
+ import { keyHolds } from "./keys.js";
4
+ import { ApiError, ERROR, failed, ok, parseFormat, render, } from "./envelope.js";
5
+ import { health } from "./health.js";
6
+ import { binaryRoute, route } from "./router.js";
7
+ import { visibilityOf } from "./visibility.js";
8
+ /**
9
+ * The API as an HTTP server.
10
+ *
11
+ * Built rather than started: the caller decides when it listens, so a test can
12
+ * put one on a port the kernel picks and a deployment can put one on the port
13
+ * the operator chose, and both are the same server. Nothing here reads the
14
+ * environment or the command line — that is config's business, and keeping it
15
+ * out is what makes this callable from a test at all.
16
+ *
17
+ * A server with nothing to check credentials against is refused here, at the
18
+ * only point where one comes into being. The alternative — answering everyone
19
+ * because no password was configured — is a music library on the open network,
20
+ * and it is the kind of misconfiguration that looks like it worked.
21
+ */
22
+ export function createServer(db, config) {
23
+ if (config.user === '' || (config.password === '' && config.apiKey === '')) {
24
+ throw new Error('no credentials: set FUNOTEKA_USER with FUNOTEKA_PASSWORD or FUNOTEKA_APIKEY');
25
+ }
26
+ return createHttpServer((request, response) => {
27
+ if (config.logRequests)
28
+ note(request, response);
29
+ // Every path out of `handle` is accounted for, and this catch is the one
30
+ // that covers the part before its own try: reading a request body can fail
31
+ // when a client hangs up mid-POST, and an unhandled rejection in Node does
32
+ // not end a request — it ends the process. A server that the whole household
33
+ // listens to must not be killable by one phone losing signal.
34
+ handle(db, config, request, response).catch((err) => {
35
+ // The format is not known here — the failure may have been before the
36
+ // request was read far enough to say — so the refusal is rendered in the
37
+ // protocol's own default, which is what a client that sent no `f` expects.
38
+ refuse(response, 'request', '', 'xml', err);
39
+ });
40
+ });
41
+ }
42
+ /**
43
+ * The credentials a request may carry, which never reach the log.
44
+ *
45
+ * `p` is the password plainly or `enc:`-hexed, `t` and `s` are the halves of a
46
+ * salted login, `apiKey` is the OpenSubsonic key. `u` is deliberately not here:
47
+ * it names the listener rather than proving them, and a line that does not say
48
+ * who asked answers half of what this line exists for.
49
+ *
50
+ * Masking four names is what makes the rest of the query writable, and that is
51
+ * the whole point — see `note` below.
52
+ */
53
+ const SECRET_PARAMETERS = new Set(['p', 't', 's', 'apikey']);
54
+ /**
55
+ * The methods the protocol requires to be reachable without credentials.
56
+ *
57
+ * One, and the specification is emphatic about it: "Unlike all other APIs
58
+ * `getOpenSubsonicExtensions` **must** be publicly accessible". The reason is
59
+ * the order a client works in — it asks what a server supports *before* it
60
+ * hands that server a password, and a server that answered this with a refusal
61
+ * would leave it choosing between guessing and not asking.
62
+ *
63
+ * It costs the rule above nothing. That rule is that a stranger must not read
64
+ * this server's *surface* — must not learn which methods exist by asking for
65
+ * them — and this method is a fixed list of names about the build, which says
66
+ * nothing about the collection and cannot be made to say anything by the
67
+ * request. Everything else still refuses an unauthenticated caller, and
68
+ * everything else still refuses to admit it exists.
69
+ */
70
+ const PUBLIC = new Set(['getopensubsonicextensions']);
71
+ /**
72
+ * A value a log line can carry without lying about where it ends.
73
+ *
74
+ * Two things a client can do to a decoded value, and both were live before this
75
+ * existed. A newline — `?x=a%0Afunoteka: GET /rest/ping 200 1ms` — writes a
76
+ * *second* line, so a client decides what the log says happened. And an encoded
77
+ * ampersand — `?id=tr:1%26p=hunter2` — forges the parameter that follows it,
78
+ * which is a reader being shown a password that was never sent. Neither leaks a
79
+ * secret; both make the log a thing a stranger has a hand in.
80
+ *
81
+ * So the value is written with control characters and ampersands escaped, and
82
+ * nothing else touched: `tr:109911` and `Кино` stay readable, which is the
83
+ * whole reason the query is written at all.
84
+ */
85
+ function readable(value) {
86
+ return value.replace(/[\u0000-\u001f\u007f&]/gu, (character) => `%${character.charCodeAt(0).toString(16).padStart(2, '0').toUpperCase()}`);
87
+ }
88
+ /**
89
+ * How much of a query is written.
90
+ *
91
+ * A log line is not the place for five hundred ids: `savePlayQueue` names every
92
+ * song of the queue and a scrobble of an album names all of it. The first few
93
+ * are what tell a reader what the call was, and the rest is a file that grows
94
+ * without ever answering a question the front of it did not.
95
+ */
96
+ const LOGGED_QUERY = 240;
97
+ /**
98
+ * What a client asked, for the log line.
99
+ *
100
+ * **The parameters are here because their absence produced a wrong conclusion.**
101
+ * This line used to carry the method, the status and the time and nothing else,
102
+ * on the reasoning that every client spells its password in the query — true,
103
+ * and the reason `SECRET_PARAMETERS` masks four names rather than a reason to
104
+ * write none of them. What that cost: "what does this client actually send?"
105
+ * was unanswerable from the log, and unanswerable is not the same as empty. A
106
+ * slice of the log by method frequency showed no `savePlayQueue` and no
107
+ * `getPlayQueue`, and the conclusion drawn from it was that the operator's
108
+ * client does not use the play queue. It uses both, constantly (task:2863).
109
+ *
110
+ * Exported for its own test, which is about the four names above: a password in
111
+ * a log file is a password in every copy of that file.
112
+ */
113
+ export function askedOf(url) {
114
+ const at = url.indexOf('?');
115
+ if (at === -1)
116
+ return '';
117
+ // Built up to the budget and no further. `savePlayQueue` names every song of
118
+ // the queue, and escaping five hundred of them to throw 99% of the result away
119
+ // is work a request pays for on its own thread — 353 µs against 3.5 µs for an
120
+ // ordinary call, and none of the difference reaches the file.
121
+ let query = '';
122
+ for (const [name, value] of new URLSearchParams(url.slice(at + 1))) {
123
+ // Lowered on both sides, and that is not tidiness: the protocol spells its
124
+ // parameters in camelCase and a client is free to spell them any way it
125
+ // likes, so `P=`, `ApiKey=` and `apikey=` all reach the same handler
126
+ // (`auth.ts`) and all have to reach the same mask. An exact comparison here
127
+ // wrote `P=hunter2` into the log in full.
128
+ const shown = `${name}=${SECRET_PARAMETERS.has(name.toLowerCase()) ? '<masked>' : readable(value)}`;
129
+ if (query.length + shown.length + 2 > LOGGED_QUERY)
130
+ return `${query}…`;
131
+ query += `${query === '' ? '?' : '&'}${shown}`;
132
+ }
133
+ return query;
134
+ }
135
+ /**
136
+ * The responses whose envelope said `failed`, so that the log can say so too.
137
+ *
138
+ * A refusal is not an HTTP status here — `send` answers 200 whatever happened,
139
+ * because a Subsonic client reads the error out of the body — so a line carrying
140
+ * only the status said nothing about whether the call worked. (The one
141
+ * exception is the method `STATUS_REFUSALS` names, and this mark is on its
142
+ * refusals too: a line can carry a 404 and still say `failed`.) That cost three
143
+ * investigations in one day: a client reporting a server version it could not
144
+ * determine when the truth was a refused key, a client reporting that it could
145
+ * not load an artist beside a log that looked healthy, and a question about
146
+ * whether refusals should be statuses at all (task:2896). One word makes all
147
+ * three readable.
148
+ *
149
+ * A `WeakSet` keyed by the response rather than a header: a header is the
150
+ * client's business and this is the server's, and nothing about the answer
151
+ * should change in order to make it legible.
152
+ */
153
+ const refusals = new WeakSet();
154
+ /**
155
+ * The line itself, so that what a log says about a request can be read without a
156
+ * log — the same reason `askedOf` is exported for its own test.
157
+ */
158
+ export function noteLine(line) {
159
+ return (`funoteka: ${line.method} ${line.path}${line.asked} ${line.status}` +
160
+ `${line.failed === true ? ' failed' : ''}${line.aborted === true ? ' aborted' : ''} ` +
161
+ `${line.ms}ms`);
162
+ }
163
+ /**
164
+ * One line per request, when the operator asked for them.
165
+ *
166
+ * Which method a client called, what it asked with, and how the server answered
167
+ * — the three things "why will this phone not sync" is answered from, and the
168
+ * middle one was missing until it cost an answer (see `askedOf`), and the third
169
+ * was half-missing until it cost three (see `refusals`).
170
+ *
171
+ * **A request that never finished is the fourth silence.** A line is written
172
+ * when the response finishes, so a client that hung up mid-answer left nothing
173
+ * at all — and that is exactly the shape of "it says nought bytes in its offline
174
+ * cache" beside a log full of 200s: the download was cut somewhere, and the
175
+ * server's own record of it did not exist. The bytes may have arrived at the
176
+ * socket and gone nowhere, and this cannot tell those apart; what it can say is
177
+ * that the answer was not written out in full.
178
+ */
179
+ function note(request, response) {
180
+ const started = Date.now();
181
+ const asked = askedOf(request.url ?? '');
182
+ const write = (ended) => {
183
+ process.stderr.write(`${noteLine({
184
+ method: request.method ?? '?',
185
+ path: request.url?.split('?')[0] ?? '?',
186
+ asked,
187
+ status: response.statusCode,
188
+ ...ended,
189
+ ms: Date.now() - started,
190
+ })}\n`);
191
+ };
192
+ response.on('finish', () => write({ failed: refusals.has(response) }));
193
+ // `close` follows `finish` on every answer that was written out, so the guard
194
+ // is what tells the two apart rather than the event.
195
+ response.on('close', () => {
196
+ if (!response.writableFinished)
197
+ write({ aborted: true });
198
+ });
199
+ }
200
+ /**
201
+ * One request, from the path to the last byte.
202
+ *
203
+ * Kept async because `stream` is: it hands the response to a file, and the
204
+ * request is not over when the handler returns. Everything here runs before
205
+ * that hand-over, so a refusal is always still a refusal the client can read.
206
+ */
207
+ async function handle(db, config, request, response) {
208
+ // The base is a placeholder: only the path and the query are ever read, and
209
+ // a request line carries those without a scheme or an authority.
210
+ // Before anything is decided, because the browser decides first: a page that
211
+ // is not allowed to read this answer never gets to send the request that
212
+ // would have asked for it.
213
+ if (config.cors)
214
+ allowCrossOrigin(response);
215
+ const url = new URL(request.url ?? '/', 'http://localhost');
216
+ // Health, and before anything else is decided about this request. It is not
217
+ // the API: it belongs to no method, it carries no credentials, it is answered
218
+ // in no envelope and it negotiates no format — the asker is a supervisor that
219
+ // wants one word about the process, and every rule below is about a client
220
+ // making a call. See `health.ts` for why it is public.
221
+ if (url.pathname === HEALTH_PATH) {
222
+ answerHealth(response, db, request.method);
223
+ return;
224
+ }
225
+ const method = methodName(url.pathname);
226
+ if (method === null) {
227
+ notFound(response);
228
+ return;
229
+ }
230
+ if (request.method === 'OPTIONS') {
231
+ // A preflight asks what may be done, not for a method to be run. Answering
232
+ // it with an envelope would be answering a question nobody asked — and the
233
+ // browser reads the headers and the empty body, not the payload.
234
+ response.writeHead(204);
235
+ response.end();
236
+ return;
237
+ }
238
+ const { query, body } = await parameters(request, url.searchParams);
239
+ const format = parseFormat(query.get('f'));
240
+ // `.view` is the suffix the protocol's own URLs carry and the bare name is
241
+ // what its documentation calls the method. Both are in the wild — clients
242
+ // that predate the shorthand kept using it — and they are one method.
243
+ //
244
+ // `.m3u8` is the same fact with a different tail: the HLS method's URL is
245
+ // `/rest/hls.m3u8`, spelled with a file extension a player recognises rather
246
+ // than with `.view`, and the method it names is `hls`. Left unstripped it is a
247
+ // name nothing matches, and the client that asks for a stream would be told
248
+ // there is no such method — which is exactly what a stub exists to avoid.
249
+ const name = method.replace(/\.view$/, '').replace(/\.m3u8$/, '');
250
+ // Credentials first, and before the method is looked up: a stranger on the
251
+ // network should not be able to read the server's surface by asking for
252
+ // methods and hearing which ones exist. It is also the only place to check
253
+ // them, since a stream never passes through the envelope at all.
254
+ // The one route that hands picture links out also accepts them back without
255
+ // credentials — see `coverSignature`. The name is already lowered here, and
256
+ // this is the only place that decides it.
257
+ // The registered keys are read per request rather than cached at startup: the
258
+ // registry is what a person edits while the server runs, and a key revoked a
259
+ // minute ago must stop working now, not at the next restart. The query is one
260
+ // indexed scan of a table with a handful of rows, and it runs only for a
261
+ // request that presented a key at all.
262
+ const verdict = authenticate(query, config, name.toLowerCase() === 'getcoverart', (given) => keyHolds(db, given));
263
+ if (!verdict.ok && !PUBLIC.has(name.toLowerCase())) {
264
+ send(response, failed(verdict.code, verdict.message), format, name);
265
+ return;
266
+ }
267
+ const context = {
268
+ db,
269
+ config,
270
+ query,
271
+ body,
272
+ origin: originOf(request, config),
273
+ visibility: visibilityOf(query, config),
274
+ };
275
+ const binary = binaryRoute(name);
276
+ try {
277
+ if (binary !== undefined) {
278
+ await binary(context, request, response);
279
+ return;
280
+ }
281
+ send(response, await envelope(name, context), format, name);
282
+ }
283
+ catch (err) {
284
+ refuse(response, method, name, format, err);
285
+ }
286
+ }
287
+ /**
288
+ * What a page from another origin needs in order to be allowed to call this.
289
+ *
290
+ * The API is a set of endpoints a client app calls, and one of the kinds of
291
+ * client it is meant to be used from is a page: a web player is loaded from its
292
+ * own site and calls the server from there. The browser asks the server first
293
+ * whether that is allowed, and a server that does not answer never hears the
294
+ * request. So `*` — every page may ask, and every page that asks is still
295
+ * refused unless it knows the credentials, which is what the API's own auth is
296
+ * for and is not weakened by this.
297
+ *
298
+ * Two headers are here for reasons that are not obvious. `Range` is allowed
299
+ * because seeking is a range request, and a browser blocks a header it was not
300
+ * told about — a player whose seeking silently fails looks like a broken
301
+ * server. And `Content-Range` and `Accept-Ranges` are *exposed* because
302
+ * JavaScript cannot read a response header it was not granted, and a client
303
+ * that cannot read `Content-Range` cannot tell how long a track is.
304
+ */
305
+ function allowCrossOrigin(response) {
306
+ response.setHeader('access-control-allow-origin', '*');
307
+ response.setHeader('access-control-allow-methods', 'GET, POST, HEAD, OPTIONS');
308
+ response.setHeader('access-control-allow-headers', 'Content-Type, Range');
309
+ response.setHeader('access-control-expose-headers', 'Content-Range, Accept-Ranges, Content-Length');
310
+ response.setHeader('access-control-max-age', '86400');
311
+ }
312
+ /**
313
+ * The parameters, whether the client spelled them in the URL or in the body.
314
+ *
315
+ * The protocol allows both and clients use both: a phone would rather not put a
316
+ * password in a URL, and some clients POST everything. So a POST whose body is
317
+ * form-encoded is read and merged, and the body wins where a name appears in
318
+ * both — it is the half of the request that was written for this call.
319
+ *
320
+ * **A name may appear more than once, and that is not a repeat.** The protocol
321
+ * spells a list as the parameter repeated — `createPlaylist` takes one `songId`
322
+ * per song — and the first version of this merged with `set`, which keeps the
323
+ * last value and drops the rest. Nothing called it with a repeated name until
324
+ * playlists did, so a client asking for a playlist of five songs would have got
325
+ * one song: the last one, with nothing on the wire to say four were lost.
326
+ *
327
+ * Every POST body is read even when it is not form-encoded, and that is not
328
+ * tidiness: a request body nobody reads stays in the socket, and the connection
329
+ * cannot be reused for the next call until it is gone. The cap is the other
330
+ * half of the same thought — a Subsonic request is a handful of short
331
+ * parameters, so a body of a megabyte is not a request this server has any
332
+ * question to ask about.
333
+ *
334
+ * The body is handed back as well as merged, and the reason is the one thing it
335
+ * is not: form encoding is not the only thing a body can be. The `transcoding`
336
+ * extension's `ClientInfo` is nested JSON that a query string has no room for,
337
+ * and a route that expects it parses it from here — see `RouteContext.body`.
338
+ */
339
+ const MAX_BODY = 1024 * 1024;
340
+ async function parameters(request, fromUrl) {
341
+ if (request.method !== 'POST')
342
+ return { query: fromUrl, body: null };
343
+ const chunks = [];
344
+ let length = 0;
345
+ for await (const chunk of request) {
346
+ const buffer = chunk;
347
+ length += buffer.length;
348
+ if (length > MAX_BODY) {
349
+ request.destroy();
350
+ return { query: fromUrl, body: null };
351
+ }
352
+ chunks.push(buffer);
353
+ }
354
+ const text = Buffer.concat(chunks).toString('utf8');
355
+ const type = request.headers['content-type'] ?? '';
356
+ if (!type.startsWith('application/x-www-form-urlencoded'))
357
+ return { query: fromUrl, body: text };
358
+ // The body's values for a name replace the URL's *all at once*, and then keep
359
+ // their own order: a parameter the client put in both places is one parameter
360
+ // written twice, while one it repeated in the body is a list.
361
+ const merged = new URLSearchParams(fromUrl);
362
+ const replacedNames = new Set();
363
+ for (const [name, value] of new URLSearchParams(text)) {
364
+ if (!replacedNames.has(name)) {
365
+ merged.delete(name);
366
+ replacedNames.add(name);
367
+ }
368
+ merged.append(name, value);
369
+ }
370
+ return { query: merged, body: text };
371
+ }
372
+ /**
373
+ * Where the client reached this server, as the client itself spelled it.
374
+ *
375
+ * A deployed server binds `0.0.0.0`, which is not an address anyone can call
376
+ * back, so an answer that has to name the server must use the authority the
377
+ * request arrived with. `Host` is exactly that, and it is what the client
378
+ * already used to get here; a URL built from the config would name a host it
379
+ * never called.
380
+ *
381
+ * The scheme is `http` because it is the only one this server speaks — it
382
+ * terminates no TLS of its own — and the fallback is for an HTTP/1.0 request
383
+ * that carried no `Host` at all, which is a client talking to us directly and
384
+ * would have nowhere else to be answered from.
385
+ */
386
+ function originOf(request, config) {
387
+ return `http://${request.headers.host ?? `localhost:${config.port}`}`;
388
+ }
389
+ async function envelope(name, context) {
390
+ const handler = route(name);
391
+ if (handler === undefined) {
392
+ // An unknown method is answered as a failed call and not as a missing page.
393
+ // Clients read the error out of the body and would take a 404 as a server
394
+ // too broken to talk to, rather than as a method that does not exist.
395
+ return failed(ERROR.generic, `unknown method: ${name}`);
396
+ }
397
+ // Awaited because a route may answer later — see `Route`. The try around this
398
+ // is the caller's, and it is what keeps a rejected route a refusal a client can
399
+ // read rather than a request that never ends.
400
+ return ok(signedImageLinks(await handler(context), context));
401
+ }
402
+ /**
403
+ * Every picture link in an answer, signed on its way out.
404
+ *
405
+ * **Here, once, rather than where each link is built.** A URL to a picture is
406
+ * named by a field ending in `ImageUrl`, and there are two builders of one (an
407
+ * artist's, shared by five listings, and `getArtistInfo2`'s three sizes in
408
+ * another module) — a rule that catches every field named that way cannot forget
409
+ * the next one, and threading a secret through six functions to sign four
410
+ * strings is how one of them ends up unsigned.
411
+ *
412
+ * The signature is what lets a client's *image loader* fetch a picture at all:
413
+ * it does not authenticate. Measured on the operator's Symfonium, the artist
414
+ * picture is asked for with no `u`, `t` or `s`, and a server that guards that
415
+ * route like the rest answers a refusal the client can only draw a placeholder
416
+ * over (task:2916).
417
+ *
418
+ * Only our own links are touched: the value has to be on this server's own
419
+ * authority and name `getCoverArt`, so a URL a route built for somewhere else is
420
+ * left exactly as it was.
421
+ */
422
+ function signedImageLinks(payload, context) {
423
+ const sign = (value, field) => {
424
+ if (typeof value === 'string') {
425
+ if (!field.endsWith('ImageUrl'))
426
+ return value;
427
+ if (!value.startsWith(`${context.origin}${API_PREFIX}getCoverArt`))
428
+ return value;
429
+ const link = new URL(value);
430
+ const id = link.searchParams.get('id');
431
+ if (id === null)
432
+ return value;
433
+ link.searchParams.set('sig', coverSignature(id, context.config));
434
+ return link.toString();
435
+ }
436
+ if (Array.isArray(value))
437
+ return value.map((item) => sign(item, field));
438
+ if (value !== null && typeof value === 'object') {
439
+ return Object.fromEntries(Object.entries(value).map(([name, inner]) => [name, sign(inner, name)]));
440
+ }
441
+ return value;
442
+ };
443
+ return sign(payload, '');
444
+ }
445
+ function refuse(response, method, route, format, err) {
446
+ // A stream that already wrote a header owes the client bytes, not an
447
+ // envelope, and there is no way to take a status back. The connection is the
448
+ // only thing left to end.
449
+ if (response.headersSent) {
450
+ response.destroy();
451
+ return;
452
+ }
453
+ // A refusal is an envelope, and an envelope is not a file. `download` sets a
454
+ // name for what it is about to send *before* it knows it can send it — the
455
+ // header has to be in place before `serveBytes` writes the head — so a refusal
456
+ // that arrives afterwards would carry `attachment; filename="…m4a"` beside
457
+ // "this cannot be produced without re-encoding", and a client that trusts the
458
+ // 200 saves the error under the song's name (task:2864).
459
+ response.removeHeader('content-disposition');
460
+ if (err instanceof ApiError) {
461
+ send(response, failed(err.code, err.message), format, route);
462
+ return;
463
+ }
464
+ // A client that hung up is not a fault of this server and is not worth a line
465
+ // on stderr: an aborted request is what a phone losing signal looks like, and
466
+ // reporting it as an internal error would train whoever reads the log to
467
+ // ignore the one message that matters.
468
+ if (err !== null && typeof err === 'object' && 'code' in err && isDisconnect(err)) {
469
+ response.destroy();
470
+ return;
471
+ }
472
+ // The other writer held the file for longer than this server is willing to
473
+ // wait — the stages of a scan, in practice, which hold it for seconds at a
474
+ // time (task:2880).
475
+ //
476
+ // It is not a fault of this server: the meta layer has two writers by design,
477
+ // and the answer can say what happened rather than reading "Internal error",
478
+ // which would send whoever reads it looking for a bug in the API.
479
+ //
480
+ // **The answer says the write did not happen, and it says so on purpose**
481
+ // (task:2884). The protocol has no code for "busy", so this is a generic
482
+ // failure; a generic failure a client is told only to retry is one it may take
483
+ // as having gone through — and a Subsonic client does not retry on its own.
484
+ // Measured on the live daemon: 4 saves in 100 were refused during a scan, and
485
+ // every one of them was simply lost. This message is the only place that loss
486
+ // can be stated, so it states it.
487
+ //
488
+ // **The alternative was considered and rejected.** The other way out is to let
489
+ // the daemon wait longer, and the wait here is synchronous — it blocks the one
490
+ // thread that answers everybody. Measured: `ping` came back after 5133 ms
491
+ // while a single request waited on the lock. Shortening what holds it is the
492
+ // real answer and is task:2880; until that lands, this is what the client is
493
+ // owed.
494
+ if (err instanceof Error && err.message.includes('database is locked')) {
495
+ process.stderr.write(`funoteka: ${method} found the database locked\n`);
496
+ send(response, failed(ERROR.generic, 'The library is being written by another process — nothing was saved; try again'), format, route);
497
+ return;
498
+ }
499
+ // Anything else is a fault in this server rather than a request it can
500
+ // refuse, and a throw that escaped a handler would take the whole process
501
+ // down with it. It is said on stderr for whoever runs the server, and
502
+ // answered vaguely, because a stranger is owed no account of our internals.
503
+ process.stderr.write(`funoteka: ${method} failed: ${String(err)}\n`);
504
+ send(response, failed(ERROR.generic, 'Internal error'), format, route);
505
+ }
506
+ /**
507
+ * The ways a client goes away mid-request, which are not this server's failures.
508
+ *
509
+ * `ECONNRESET` is the socket dying under a read, `ERR_STREAM_PREMATURE_CLOSE`
510
+ * is a body that stopped arriving before its `Content-Length` promised, and
511
+ * `ECANCELED` is Node's own name for the same thing on a destroyed request.
512
+ */
513
+ function isDisconnect(err) {
514
+ const code = err.code ?? '';
515
+ return code === 'ECONNRESET' || code === 'ERR_STREAM_PREMATURE_CLOSE' || code === 'ECANCELED';
516
+ }
517
+ /** Where methods live. Everything outside it is not the API and is not answered as one. */
518
+ const API_PREFIX = '/rest/';
519
+ /** The one path outside the API this server answers, and it is not a method. */
520
+ const HEALTH_PATH = '/health';
521
+ /**
522
+ * Answer a liveness probe.
523
+ *
524
+ * `no-store`, because a cached "healthy" is the one answer a health check must
525
+ * never be given: a supervisor asking about a server that has been wedged for an
526
+ * hour must not be handed the answer a proxy wrote down when it was working.
527
+ *
528
+ * HEAD is answered as HEAD — the same status and headers with no body — because
529
+ * that is what `wget --spider` sends, and a probe that cannot ask is a probe that
530
+ * gets written off as a broken server.
531
+ */
532
+ function answerHealth(response, db, method) {
533
+ if (method !== 'GET' && method !== 'HEAD') {
534
+ const body = 'method not allowed\n';
535
+ response.writeHead(405, {
536
+ 'content-type': 'text/plain; charset=utf-8',
537
+ 'content-length': Buffer.byteLength(body),
538
+ allow: 'GET, HEAD',
539
+ });
540
+ response.end(body);
541
+ return;
542
+ }
543
+ const answer = health(db);
544
+ response.writeHead(answer.status, {
545
+ 'content-type': 'application/json; charset=utf-8',
546
+ 'content-length': Buffer.byteLength(answer.body),
547
+ 'cache-control': 'no-store',
548
+ });
549
+ response.end(method === 'HEAD' ? undefined : answer.body);
550
+ }
551
+ /**
552
+ * The method name the client asked for, decoded — or null if it did not ask the
553
+ * API at all.
554
+ *
555
+ * Decoding is what makes the name comparable to the protocol's own spellings and
556
+ * makes the error faithful when it is not one of them. A path that is not valid
557
+ * encoding has no decoded form to prefer, so it is taken as it came: refusing
558
+ * the request would hide which name the client got wrong.
559
+ */
560
+ function methodName(pathname) {
561
+ if (!pathname.startsWith(API_PREFIX))
562
+ return null;
563
+ const raw = pathname.slice(API_PREFIX.length);
564
+ try {
565
+ return decodeURIComponent(raw);
566
+ }
567
+ catch {
568
+ return raw;
569
+ }
570
+ }
571
+ /**
572
+ * The methods whose refusals carry an HTTP status instead of the usual 200.
573
+ *
574
+ * **One method, and deliberately one.** `send` answers 200 for every answer
575
+ * including a refusal, because a Subsonic client reads the error out of the
576
+ * body, and a client that had to handle transport failures as well would have
577
+ * two ways to be told the same thing. Every route keeps that convention, the
578
+ * stubbed byte routes included, and the operator has seen it and kept it.
579
+ *
580
+ * `getTranscodeStream` is the exception its own page asks for: "In case of an
581
+ * error, a standard HTTP error code is returned with a descriptive message",
582
+ * with 400, 401, 404 and 500 declared beside it in the OpenAPI document.
583
+ * Measured before this: all three ways that route can refuse — an id that names
584
+ * no song, a token issued for another song, and a token this server never issued
585
+ * — came back a 200. A client written against that document branches on the
586
+ * status line, read every refusal as success, and would go on to play the
587
+ * envelope as audio (task:2913).
588
+ *
589
+ * The body is the envelope either way. The status is *added*, not substituted:
590
+ * the code and the message a Subsonic client reads are still there.
591
+ */
592
+ const STATUS_REFUSALS = new Set(['gettranscodestream']);
593
+ /** What the status line says for each kind of refusal, from the OpenAPI document. */
594
+ const STATUS_OF_ERROR = new Map([
595
+ [ERROR.missingParameter, 400],
596
+ [ERROR.wrongCredentials, 401],
597
+ [ERROR.tokenAuthRefused, 401],
598
+ [ERROR.unsupportedAuthMechanism, 401],
599
+ [ERROR.conflictingAuthMechanisms, 401],
600
+ [ERROR.invalidApiKey, 401],
601
+ [ERROR.notAuthorized, 401],
602
+ [ERROR.notFound, 404],
603
+ ]);
604
+ /**
605
+ * The status line for one answer, given the method it answers.
606
+ *
607
+ * Exported for its own test, the same reason `noteLine` and `askedOf` are: what
608
+ * a status *is* should be readable without a server. Everything but the one
609
+ * method above answers 200, whatever happened — see `send`.
610
+ */
611
+ export function statusFor(method, envelope) {
612
+ if (envelope.status !== 'failed')
613
+ return 200;
614
+ if (!STATUS_REFUSALS.has(method.toLowerCase()))
615
+ return 200;
616
+ const { code } = (envelope.error ?? {});
617
+ // Unmapped is a fault rather than something a client can act on, and 500 is
618
+ // both what the document declares for it and what it is.
619
+ return code === undefined ? 500 : (STATUS_OF_ERROR.get(code) ?? 500);
620
+ }
621
+ function send(response, envelope, format, method = '') {
622
+ const { contentType, body } = render(envelope, format);
623
+ // Kept for the log line, which is written when the response finishes. Every
624
+ // refusal goes through here — a route's, an unknown method's, a credential's —
625
+ // so one mark covers all of them.
626
+ if (envelope.status === 'failed')
627
+ refusals.add(response);
628
+ // 200 for every answer, including a refusal — except the methods
629
+ // `STATUS_REFUSALS` names, whose own specification asks for a status.
630
+ response.writeHead(statusFor(method, envelope), {
631
+ 'content-type': contentType,
632
+ 'content-length': Buffer.byteLength(body),
633
+ });
634
+ response.end(body);
635
+ }
636
+ function notFound(response) {
637
+ const body = 'not found\n';
638
+ response.writeHead(404, {
639
+ 'content-type': 'text/plain; charset=utf-8',
640
+ 'content-length': Buffer.byteLength(body),
641
+ });
642
+ response.end(body);
643
+ }