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,304 @@
1
+ import { createReadStream, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { pickCover } from "../cover/pick.js";
4
+ import { pictureInRegion } from "../cover/picture.js";
5
+ import { parseId, required } from "./browse.js";
6
+ import { ApiError, ERROR } from "./envelope.js";
7
+ import { album, albumPlacesOfArtist, artist, artistOwnFolders, embeddedCoverInFolder, fileFoldersOfAlbums, folder, picturesInFolder, root, song, } from "./meta.js";
8
+ /**
9
+ * The cover a client shows beside a record.
10
+ *
11
+ * The protocol asks for one image by the id of whatever is on screen — an
12
+ * album, an artist, a song, a folder — so the id is a way of *asking*, not a
13
+ * statement about where the picture is. What this file does is turn each kind
14
+ * of id into the folders worth looking in, in the order worth looking, and hand
15
+ * the first picture found to the client.
16
+ *
17
+ * Only the folder's own pictures are considered. Descending into a subfolder
18
+ * looks tempting — the scans of a box often sit in a `Full scans` or `Artwork`
19
+ * directory — but those directories are where the *back*, the disc and the
20
+ * booklet live, and a rule that reached into one would as often answer with the
21
+ * back of the record as with its front. A folder that keeps its cover nowhere
22
+ * answers "no cover", and the client draws its own placeholder.
23
+ *
24
+ * Nothing is scaled. The protocol allows a `size`, and this server answers with
25
+ * the collection's own file whatever size was asked for: nothing in the runtime
26
+ * can decode an image, and the file the operator scanned is a truer answer than
27
+ * a resize made by guessing. A client that wants a thumbnail makes one.
28
+ */
29
+ /** What an image file is, so the client knows what it is about to receive. */
30
+ const IMAGE_TYPES = {
31
+ jpg: 'image/jpeg',
32
+ jpeg: 'image/jpeg',
33
+ png: 'image/png',
34
+ gif: 'image/gif',
35
+ bmp: 'image/bmp',
36
+ webp: 'image/webp',
37
+ tif: 'image/tiff',
38
+ tiff: 'image/tiff',
39
+ };
40
+ export function imageContentType(ext) {
41
+ return IMAGE_TYPES[ext] ?? 'application/octet-stream';
42
+ }
43
+ /**
44
+ * The folders an id asks about, best first.
45
+ *
46
+ * Several, because the meta layer's answer is not always one folder: a song on
47
+ * no album still has the folder it sits in, and an album whose row names a file
48
+ * rather than a directory — what a loose download becomes — keeps its pictures
49
+ * in the folder that file is in. An artist is the one case where the list is
50
+ * genuinely long: it has no art of its own, so every one of its records offers
51
+ * what it has.
52
+ *
53
+ * The order is the caller's own and is kept, but it is not what decides first:
54
+ * these folders are a *set* to be ranked once, so the record that happens to
55
+ * come first does not answer for the artist — a record whose picture is named
56
+ * `front` does, wherever in the list it sits. The order decides only the case
57
+ * no name decides, and `foldersOfAlbums` preserves it for exactly that; a
58
+ * comment here once said the caller must not read the list as ordered at all,
59
+ * which the ranking below contradicts.
60
+ */
61
+ function candidateFolders(db, id) {
62
+ const parsed = parseId(id);
63
+ if (parsed === undefined)
64
+ return [];
65
+ switch (parsed.kind) {
66
+ case 'al': {
67
+ const row = album(db, parsed.n);
68
+ return row === undefined ? [] : foldersOfAlbums(db, [row]);
69
+ }
70
+ case 'tr': {
71
+ const row = song(db, parsed.n);
72
+ if (row === undefined)
73
+ return [];
74
+ const own = { rootId: row.root_id, relPath: row.folder_rel_path };
75
+ if (row.album_id === null)
76
+ return [own];
77
+ const record = album(db, row.album_id);
78
+ return record === undefined
79
+ ? [own]
80
+ : distinct([{ rootId: record.root_id, relPath: record.rel_path }, own]);
81
+ }
82
+ case 'ar': {
83
+ const row = artist(db, parsed.n);
84
+ if (row === undefined)
85
+ return [];
86
+ // Through the same expansion an album gets, and for the same reason: a
87
+ // record whose row names a *file* — a flat rip, a `.ape` beside its cue —
88
+ // keeps its pictures in the folder that file is in, and an artist asking
89
+ // for art is asking about its records either way.
90
+ //
91
+ // Asked for the artist's records *together*, because the answer is a set:
92
+ // the folders of thirty records are one join, where asking each record on
93
+ // its own read that record's whole song list to learn the same thing. And
94
+ // asked for their *places* rather than for the records, because a cover is
95
+ // not about what a record holds — the genre and the running time this
96
+ // route never reads are built for every album in the collection before the
97
+ // artist's are picked out.
98
+ return foldersOfAlbums(db, albumPlacesOfArtist(db, row.id));
99
+ }
100
+ case 'fd': {
101
+ const row = folder(db, parsed.n);
102
+ return row === undefined ? [] : [{ rootId: row.root_id, relPath: row.rel_path }];
103
+ }
104
+ case 'ro': {
105
+ const row = root(db, parsed.n);
106
+ // A root is a folder like any other — `''` is the row that stands for it
107
+ // (`db/migrations/001_init.sql`) — so the pictures an operator dropped in
108
+ // their download folder are reachable the same way a record's are.
109
+ return row === undefined ? [] : [{ rootId: row.id, relPath: '' }];
110
+ }
111
+ }
112
+ }
113
+ /**
114
+ * The folders several records' pictures could be in, best first.
115
+ *
116
+ * The album's row is its folder by the identity rule, and the songs are where
117
+ * that stops being true: a flat rip whose every file became its own album names
118
+ * the file, not a directory (`.ape` beside its cue is the shape this collection
119
+ * has). Both are offered per record, the row's own first, so that a record keyed
120
+ * on a file still finds the folder the file sits in.
121
+ *
122
+ * The records are taken as many at once rather than one at a time, and the
123
+ * caller's own order is what the result is in — a record's row, then its files,
124
+ * then the next record's. That order is the tie-break when no picture's name
125
+ * says which side it is, so it is preserved rather than sorted.
126
+ */
127
+ function foldersOfAlbums(db, records) {
128
+ const files = fileFoldersOfAlbums(db, records.map((row) => row.id));
129
+ return distinct(records.flatMap((row) => [
130
+ { rootId: row.root_id, relPath: row.rel_path },
131
+ ...(files.get(row.id) ?? []),
132
+ ]));
133
+ }
134
+ function distinct(folders) {
135
+ const seen = new Set();
136
+ return folders.filter(({ rootId, relPath }) => {
137
+ const key = `${rootId}\u0000${relPath}`;
138
+ if (seen.has(key))
139
+ return false;
140
+ seen.add(key);
141
+ return true;
142
+ });
143
+ }
144
+ /** A picture sitting on disk, as the cover to serve. */
145
+ function fileCover(picture) {
146
+ return { kind: 'file', rootPath: picture.root_path, relPath: picture.rel_path, ext: picture.ext };
147
+ }
148
+ /**
149
+ * The picture a single folder holds, or nothing when it holds none.
150
+ *
151
+ * One folder's own pictures ranked on their own — the reader `resolve` uses for
152
+ * its first question, which asks a folder named for the artist before it asks
153
+ * anything of the artist's records.
154
+ */
155
+ function coverInFolder(db, { rootId, relPath }) {
156
+ const chosen = pickCover(picturesInFolder(db, rootId, relPath));
157
+ return chosen === undefined ? undefined : fileCover(chosen);
158
+ }
159
+ function resolve(db, id) {
160
+ // An artist's own folder answers for the artist, and only for the artist.
161
+ //
162
+ // It is asked apart from the ranking below rather than put at the front of
163
+ // its list, because "first among equals" would not decide anything: a folder
164
+ // named for the artist holds a `folder.jpg`, a record's folder holds a
165
+ // `front.jpg`, and `front` outranks `folder` — so an album cover would still
166
+ // have won the tie it was never party to. A picture of the artist is a fact
167
+ // about the artist, and where the collection states one it is the answer.
168
+ const asked = parseId(id);
169
+ if (asked !== undefined && asked.kind === 'ar') {
170
+ for (const where of artistOwnFolders(db, asked.n)) {
171
+ const own = coverInFolder(db, where);
172
+ if (own !== undefined)
173
+ return { picture: own, folders: [where] };
174
+ }
175
+ }
176
+ const folders = candidateFolders(db, id);
177
+ // Every folder's pictures as one set, ranked once — not "the first folder
178
+ // holding anything answers".
179
+ //
180
+ // A box keeps its front cover inside each disc and its paperwork in the box
181
+ // folder, and the folder that answers first is the box's: `Кинохроники` has an
182
+ // `info.png` at the top and `front.jpg` one level down, so the sheet about the
183
+ // release was served as its cover. Ranking across the folders lets a name that
184
+ // says `front` win wherever it is, and the folder order still decides when no
185
+ // name says anything — `pickCover` falls back to the first picture it was
186
+ // given, and the record's own folder is first in the list.
187
+ const pictures = folders.flatMap(({ rootId, relPath }) => picturesInFolder(db, rootId, relPath));
188
+ const chosen = pickCover(pictures);
189
+ if (chosen !== undefined) {
190
+ return { picture: fileCover(chosen), folders };
191
+ }
192
+ for (const { rootId, relPath } of folders) {
193
+ const embedded = embeddedCoverInFolder(db, rootId, relPath);
194
+ if (embedded === undefined)
195
+ continue;
196
+ // Two kinds of place, and the row says which. A picture the file carries as
197
+ // its own bytes is a range a client can be sent; one it carries base64'd
198
+ // inside a comment is a range that has to be read and parsed first — see
199
+ // `src/cover/picture.ts`.
200
+ const picture = embedded.kind === 'indirect'
201
+ ? {
202
+ kind: 'indirect',
203
+ rootPath: embedded.root_path,
204
+ relPath: embedded.rel_path,
205
+ container: embedded.container,
206
+ offset: embedded.offset,
207
+ length: embedded.length,
208
+ }
209
+ : {
210
+ kind: 'embedded',
211
+ rootPath: embedded.root_path,
212
+ relPath: embedded.rel_path,
213
+ mime: embedded.mime,
214
+ offset: embedded.offset,
215
+ length: embedded.length,
216
+ };
217
+ return { picture, folders };
218
+ }
219
+ return { picture: undefined, folders };
220
+ }
221
+ /**
222
+ * The route.
223
+ *
224
+ * Bytes, not an envelope, so it is a `BinaryRoute` like `stream` — but with one
225
+ * difference that matters: it knows the whole answer before the first header is
226
+ * written. An image is a file, and a file's size is known. So a refusal is
227
+ * always still a refusal the client can read, and the response is never a
228
+ * connection that ends without saying why.
229
+ */
230
+ export async function coverArt(context, request, response) {
231
+ const id = required(context.query, 'id');
232
+ const { picture, folders } = resolve(context.db, id);
233
+ if (picture === undefined) {
234
+ // The folder is named when one was looked in, because "this record has no
235
+ // cover" and "there is no such record" are different things to go and fix,
236
+ // and the message is the only place a caller learns which happened. The
237
+ // folders come from the lookup that just failed rather than a second walk of
238
+ // the same ground.
239
+ const where = folders.map((f) => f.relPath || '/');
240
+ throw new ApiError(ERROR.notFound, where.length === 0
241
+ ? `No such id: ${id}`
242
+ : `No cover art for ${id}: ${where.join(', ')} holds no picture`);
243
+ }
244
+ const path = join(picture.rootPath, picture.relPath);
245
+ // The kind of place that is not a range. Its bytes have to be derived before
246
+ // anything can be promised about them — including whether there are any — so
247
+ // this is the one branch that is not a `readStream` over a slice.
248
+ if (picture.kind === 'indirect') {
249
+ const derived = pictureInRegion(path, picture.container, picture.offset, picture.length);
250
+ if (derived === null) {
251
+ // The row said a picture was there and it could not be derived: the file
252
+ // has changed since the scan, or its comment is damaged. Saying so is the
253
+ // same answer as having none, because that is what the client gets — but
254
+ // the message names the file, which is where to go and look.
255
+ throw new ApiError(ERROR.notFound, `No cover art for ${id}: the picture inside ${picture.relPath} could not be read`);
256
+ }
257
+ response.writeHead(200, {
258
+ 'content-type': derived.mime,
259
+ 'content-length': derived.data.length,
260
+ });
261
+ if (request.method === 'HEAD' || derived.data.length === 0) {
262
+ response.end();
263
+ return;
264
+ }
265
+ // Already in memory, so it goes out as it is: a stream over a buffer is a
266
+ // second copy of a picture that was just decoded into the first.
267
+ response.end(derived.data);
268
+ return;
269
+ }
270
+ // Where in that file the bytes are: the whole of it for a picture sitting
271
+ // beside the record, and a named range of it for one the record carries.
272
+ const [from, to, mime] = picture.kind === 'file'
273
+ ? [0, statSync(path).size - 1, imageContentType(picture.ext)]
274
+ : [picture.offset, picture.offset + picture.length - 1, picture.mime];
275
+ const length = to - from + 1;
276
+ response.writeHead(200, { 'content-type': mime, 'content-length': length });
277
+ // A HEAD asks what a GET would bring. Reading the file to throw the bytes
278
+ // away is the one thing the method exists to avoid.
279
+ if (request.method === 'HEAD' || length <= 0) {
280
+ response.end();
281
+ return;
282
+ }
283
+ await pipeRange(path, from, to, response);
284
+ }
285
+ /**
286
+ * The picture, as a stream that ends with the response.
287
+ *
288
+ * Bounded by the last byte the header already promised, and that bound is not
289
+ * decoration: `createReadStream(path)` alone reads until the file says it is
290
+ * over, so a picture replaced between the `statSync` above and this read would
291
+ * put more bytes on the wire than `Content-Length` declared and leave the
292
+ * connection out of step for whatever the client sent next. Measured on this
293
+ * repository: with the unbounded form the connection stayed open for three
294
+ * seconds after the last byte, which is how the difference was noticed at all —
295
+ * it looked exactly like a slow server.
296
+ */
297
+ function pipeRange(path, from, to, response) {
298
+ return new Promise((resolve, reject) => {
299
+ const file = createReadStream(path, { start: from, end: to });
300
+ file.on('error', reject);
301
+ response.on('close', () => file.destroy());
302
+ file.pipe(response).on('finish', resolve).on('error', reject);
303
+ });
304
+ }
@@ -0,0 +1,112 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { basename, join } from 'node:path';
3
+ import { stretchOf } from "../stream/segment.js";
4
+ import { contentType, trackId } from "./browse.js";
5
+ import { ApiError, ERROR } from "./envelope.js";
6
+ import { song } from "./meta.js";
7
+ import { serveBytes, serveSegment } from "./stream.js";
8
+ /**
9
+ * The file itself, for a client that means to keep it.
10
+ *
11
+ * `stream`'s plainer sibling, and the difference is the whole of what this
12
+ * method is for: `stream` answers with something a client can *play* — a cue
13
+ * track cut out of an image, a re-encode of a format the client cannot read —
14
+ * and `download` answers with the original media data, "without transcoding or
15
+ * downsampling", which is what a client needs to put a record on a phone for a
16
+ * train. So there is one path here and no plan: the file the row names, sent as
17
+ * it is.
18
+ *
19
+ * **A song cut from a cue image is sent as its own frames, not as the image.**
20
+ * The first version of this handed back the whole file, on the reasoning that
21
+ * anything else would be "cutting" what the method promises to deliver whole —
22
+ * and that reasoning was wrong, in the way that matters: the saved file played
23
+ * from the top of the disc, so a client that asked for track nine got the wrong
24
+ * song in 510 MB, for 1 789 of this collection's 4 969 tracks (task:2864).
25
+ *
26
+ * Copying a frame range is not a transcode. Nothing is decoded, nothing is
27
+ * resampled, and `stream/segment.ts` calls that path lossless for the same
28
+ * reason `stream` uses it. What this method refuses is ffmpeg — so for the few
29
+ * images whose frames cannot be restated (ape, MP4) the segment is refused with
30
+ * a reason rather than served as something else, and the sender is told to ask
31
+ * `stream` or to take the image deliberately.
32
+ *
33
+ * The bytes and the ranging are `stream.ts`'s — `serveBytes` and `serveSegment`
34
+ * are the same functions the stream route uses, so seeking a download works for
35
+ * the same reason seeking a stream does and cannot drift away from it.
36
+ */
37
+ export async function download(context, request, response) {
38
+ const id = trackId(context.query);
39
+ const row = song(context.db, id);
40
+ if (row === undefined)
41
+ throw new ApiError(ERROR.notFound, `No such song: ${id}`);
42
+ // The path is the meta layer's own, never the client's — the rule `stream.ts`
43
+ // states, and the reason neither of these can be talked into reading a file
44
+ // the scan never saw.
45
+ const path = join(row.root_path, row.rel_path);
46
+ // Asked here rather than left to `serveBytes`, which would find it out by
47
+ // throwing from `statSync`. The difference is the header below: a refusal is
48
+ // rendered into the same response, and an `attachment` beside "Internal
49
+ // error" is a client saving a refusal under a track's name.
50
+ if (!existsSync(path)) {
51
+ throw new ApiError(ERROR.notFound, `No such file on disk: ${row.rel_path}`);
52
+ }
53
+ // Set before `serveBytes` writes the head, which keeps what was set here:
54
+ // this is the method a client saves with, and a name is what makes a saved
55
+ // file recognisable.
56
+ // A whole file is saved under its own name; a stretch of one is saved under
57
+ // the song's, because that is what it is — `Tool - Undertow.flac` is a file
58
+ // somebody can find again, and `Tool - Undertow [61422-33010-2].flac` is a
59
+ // 510 MB disc they did not ask for.
60
+ response.setHeader('content-disposition', attachmentOf(row.segment_start_ms === null
61
+ ? basename(path)
62
+ : `${row.title ?? basename(path)}.${row.ext}`));
63
+ if (row.segment_start_ms !== null) {
64
+ // A song cut from an image is a stretch of it, and the stretch is what this
65
+ // method is asked for. `serveSegment` copies the frames — nothing is decoded
66
+ // and nothing is resampled, so it is the original data and not a transcode
67
+ // — and the one branch it is not allowed here is ffmpeg's, which would make
68
+ // the answer a different file from the one on disk.
69
+ // The stretch is the song's own, and there is no offset here to move it:
70
+ // download has no `timeOffset`, and the file a client asked to keep is the
71
+ // track, not a later part of it.
72
+ const stretch = stretchOf(row, null);
73
+ if (stretch.kind !== 'span') {
74
+ throw new ApiError(ERROR.generic, stretch.kind === 'refused' ? stretch.reason : 'This song is not a stretch of its file');
75
+ }
76
+ await serveSegment(context, row, path, request, response, {
77
+ times: stretch,
78
+ as: 'download',
79
+ });
80
+ return;
81
+ }
82
+ await serveBytes(path, contentType(row.ext), request, response);
83
+ }
84
+ /**
85
+ * A `Content-Disposition` a header can actually carry.
86
+ *
87
+ * **Two filenames, and the second is not decoration.** A header value is
88
+ * latin-1, and this collection's names are not: a folder called `Кино` would
89
+ * make `setHeader` throw and take the whole request down with it. So the plain
90
+ * `filename` is the name with everything outside ASCII replaced, and
91
+ * `filename*` carries the real one percent-encoded, which is what RFC 5987
92
+ * added and what every client that matters reads. A client that understands
93
+ * only the first gets a file it can still recognise; one that reads the second
94
+ * gets the name the file has.
95
+ */
96
+ function attachmentOf(name) {
97
+ const safe = name.replace(/[^\x20-\x7e]/gu, '_').replace(/["\\]/gu, '_');
98
+ return `attachment; filename="${safe}"; filename*=UTF-8''${extValue(name)}`;
99
+ }
100
+ /**
101
+ * A string as RFC 5987's `ext-value`, which `encodeURIComponent` is not.
102
+ *
103
+ * The standard's `attr-char` leaves out `'`, `(`, `)`, `*` and `!` — and
104
+ * `encodeURIComponent` escapes none of the first four. `'` is the one that
105
+ * bites: it is the quote around the charset in `UTF-8''`, so a name like
106
+ * `Guns N' Roses (Live).flac` emitted an encoding whose own separator appears
107
+ * inside it, and a strict client drops the whole parameter and falls back to
108
+ * the ASCII stand-in.
109
+ */
110
+ function extValue(name) {
111
+ return encodeURIComponent(name).replace(/['()*]/gu, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
112
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * The Subsonic response envelope, and the two ways it is rendered.
3
+ *
4
+ * Every answer the API gives — a method's payload or an error — travels inside
5
+ * one `subsonic-response` object, and the client decides whether the call
6
+ * worked by reading `status` out of that object rather than by reading the HTTP
7
+ * status line. So the envelope is the one shape every route agrees on, and the
8
+ * one place a route can be wrong about the protocol while being right about its
9
+ * own data. It gets a module of its own for that reason.
10
+ *
11
+ * Rendering is generic, and deliberately so. In the protocol's XML a field is
12
+ * an attribute when it holds a value and a child element when it holds a record,
13
+ * which makes the two formats one answer seen twice — so a route hands over a
14
+ * plain object and neither chooses a format nor knows one is being chosen.
15
+ */
16
+ /** The protocol version this server speaks. Clients refuse a server they outrank. */
17
+ export const API_VERSION = '1.16.1';
18
+ /**
19
+ * What this server is called, and which of its own versions this is.
20
+ *
21
+ * **Both are required of every answer, and neither is about the answer.**
22
+ * `type` is how a client adapts to what a server calling itself Subsonic
23
+ * actually supports; `serverVersion` is how it knows *to ask again which
24
+ * extensions exist* after a deployment — the spec is explicit that this is what
25
+ * the field is for. `version` above is the API version and answers a third
26
+ * question, so it stands in for neither.
27
+ *
28
+ * Pinned to `package.json` by a test rather than read from it at import: a
29
+ * constant that cannot drift silently is worth more here than a file read in a
30
+ * module every route depends on.
31
+ */
32
+ export const SERVER_TYPE = 'funoteka';
33
+ export const SERVER_VERSION = '0.1.0';
34
+ /**
35
+ * The fields every answer carries, whatever the answer is.
36
+ *
37
+ * `openSubsonic` is the one that matters most and the least obvious: it is how
38
+ * a client learns that the extension list is worth asking for at all, and a
39
+ * server that answers `getOpenSubsonicExtensions` without it has called a
40
+ * client to a door the client cannot see.
41
+ */
42
+ function identity() {
43
+ return { type: SERVER_TYPE, serverVersion: SERVER_VERSION, openSubsonic: true };
44
+ }
45
+ /** The one element every answer hangs from, in either format. */
46
+ const ROOT = 'subsonic-response';
47
+ const NAMESPACE = 'http://subsonic.org/restapi';
48
+ /**
49
+ * The protocol's own error codes.
50
+ *
51
+ * Transcribed complete rather than trimmed to the ones routes use today: a code
52
+ * is a promise to the client about what went wrong, and the surest way to keep
53
+ * that promise is to pick from the standard's list instead of inventing a
54
+ * number that means something else to somebody's client.
55
+ */
56
+ export const ERROR = {
57
+ generic: 0,
58
+ missingParameter: 10,
59
+ clientTooOld: 20,
60
+ serverTooOld: 30,
61
+ wrongCredentials: 40,
62
+ tokenAuthRefused: 41,
63
+ unsupportedAuthMechanism: 42,
64
+ conflictingAuthMechanisms: 43,
65
+ invalidApiKey: 44,
66
+ notAuthorized: 50,
67
+ trialOver: 60,
68
+ notFound: 70,
69
+ };
70
+ export function ok(payload = {}) {
71
+ return { status: 'ok', version: API_VERSION, ...identity(), ...payload };
72
+ }
73
+ export function failed(code, message) {
74
+ return { status: 'failed', version: API_VERSION, ...identity(), error: { code, message } };
75
+ }
76
+ /**
77
+ * A refusal a route decided on, on its way out.
78
+ *
79
+ * A route answers with the fields of a successful call, so the one thing it
80
+ * cannot do is answer with a failure — and a route is exactly where the reason
81
+ * for a failure is known: which parameter was missing, which id names nothing.
82
+ * Throwing carries the code and the message from where the reason is to the one
83
+ * place that renders envelopes.
84
+ */
85
+ export class ApiError extends Error {
86
+ code;
87
+ constructor(code, message) {
88
+ super(message);
89
+ this.name = 'ApiError';
90
+ this.code = code;
91
+ }
92
+ }
93
+ /** Anything but an explicit `f=json` is XML, which is what the protocol defaults to. */
94
+ export function parseFormat(value) {
95
+ return value === 'json' ? 'json' : 'xml';
96
+ }
97
+ /**
98
+ * The envelope, rendered.
99
+ *
100
+ * Both formats wrap it in `subsonic-response` — that name is how a client finds
101
+ * the answer at all, in either — and only the XML carries a namespace, because
102
+ * a namespace is an XML device and the JSON form has nowhere to put one.
103
+ */
104
+ export function render(envelope, format) {
105
+ return format === 'json'
106
+ ? {
107
+ contentType: 'application/json; charset=utf-8',
108
+ body: JSON.stringify({ [ROOT]: envelope }),
109
+ }
110
+ : { contentType: 'application/xml; charset=utf-8', body: toXml(envelope) };
111
+ }
112
+ function toXml(envelope) {
113
+ const fields = [['xmlns', NAMESPACE], ...Object.entries(envelope)];
114
+ return element(ROOT, fields);
115
+ }
116
+ function element(name, fields) {
117
+ const attributes = scalarFields(fields)
118
+ .map(([field, value]) => ` ${field}="${escape(String(value))}"`)
119
+ .join('');
120
+ const children = recordFields(fields)
121
+ .map(([field, value]) =>
122
+ // An array is the same field repeated. A record standing alone is one
123
+ // such field. A bare value in an array has no field to carry it, so the
124
+ // element holds the value itself — which is how a protocol that writes
125
+ // lyrics as element text is written.
126
+ asArray(value)
127
+ .map((item) => isRecord(item)
128
+ ? element(field, Object.entries(item))
129
+ : `<${field}>${escape(String(item))}</${field}>`)
130
+ .join(''))
131
+ .join('');
132
+ return children === ''
133
+ ? `<${name}${attributes}/>`
134
+ : `<${name}${attributes}>${children}</${name}>`;
135
+ }
136
+ /** The fields that hold a value, in the order they were given. Absent ones are dropped. */
137
+ function scalarFields(fields) {
138
+ return fields.filter(([, value]) => !isRecord(value) && !Array.isArray(value) && value != null);
139
+ }
140
+ function recordFields(fields) {
141
+ return fields.filter(([, value]) => (isRecord(value) || Array.isArray(value)) && value != null);
142
+ }
143
+ function isRecord(value) {
144
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
145
+ }
146
+ function asArray(value) {
147
+ return Array.isArray(value) ? value : [value];
148
+ }
149
+ /**
150
+ * `&` first, or the ampersands an escape introduces would be escaped again.
151
+ *
152
+ * This is not decoration: the error message quotes the method name the client
153
+ * asked for, and that name comes off the wire. Unescaped, it would be a way to
154
+ * put arbitrary elements into a document the client parses as XML.
155
+ */
156
+ function escape(value) {
157
+ return value
158
+ .replace(/&/g, '&amp;')
159
+ .replace(/</g, '&lt;')
160
+ .replace(/>/g, '&gt;')
161
+ .replace(/"/g, '&quot;');
162
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The extensions this server implements, each with the endpoint that carries it.
3
+ *
4
+ * `transcoding` was deliberately absent until it was built, and the entry below
5
+ * is what that discipline looks like once it holds: the two endpoints exist, so
6
+ * the name does. A client that read the name before the code would call
7
+ * `getTranscodeDecision` and get "unknown method" from a server that had just
8
+ * told it the method existed — and nothing in the protocol lets it ask again or
9
+ * tell it whose fault that was.
10
+ */
11
+ const EXTENSIONS = [
12
+ // getPlayQueueByIndex / savePlayQueueByIndex — the same queue, addressed by
13
+ // position rather than by id, because an id cannot say which of two identical
14
+ // entries is playing.
15
+ { name: 'indexBasedQueue', versions: [1] },
16
+ // reportPlayback — what a player says about a song it has just played, which
17
+ // is the history the server did not witness.
18
+ { name: 'playbackReport', versions: [1] },
19
+ // The `timeOffset` parameter on `stream` — `Transcode Offset`: start the
20
+ // answer later than the song does.
21
+ { name: 'transcodeOffset', versions: [1] },
22
+ // getTranscodeDecision / getTranscodeStream — a client states what it can play
23
+ // and is told what this server would do with a song, instead of naming a
24
+ // format and a ceiling and hoping. Over http only: the client's HLS profiles
25
+ // are skipped and answered with `canTranscode: false`, because this server
26
+ // produces no HLS (task:2896).
27
+ { name: 'transcoding', versions: [1] },
28
+ // `apiKey` as a whole credential, and the pair of promises the extension is:
29
+ // a key that arrives alone is accepted, and a server that accepts one offers
30
+ // a way to see the keys it holds and to take one back. The first half worked
31
+ // from the beginning and the second did not exist, which is why the name was
32
+ // withheld: the key lived in a variable in `start.cmd` and revoking it meant
33
+ // editing that file and restarting the daemon (task:2915).
34
+ //
35
+ // It is declared now because both halves are true — the registry and the two
36
+ // CLI verbs are `keys.ts` and `cli/keys.ts`. The environment's key is outside
37
+ // the registry on purpose and `list` says so; see the migration.
38
+ { name: 'apiKeyAuthentication', versions: [1] },
39
+ ];
40
+ /** The answer to `getOpenSubsonicExtensions`, and the whole of it. */
41
+ export function openSubsonicExtensions() {
42
+ return { openSubsonicExtensions: EXTENSIONS.map(({ name, versions }) => ({ name, versions })) };
43
+ }