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,1241 @@
1
+ import { discSubtitle, folderNote, unsaidNote } from "../classify/folder-name.js";
2
+ import { ApiError, ERROR } from "./envelope.js";
3
+ import { LIBRARY, virtualEntries, virtualNodes, libraryRecords, virtualRecordCounts, virtualRecords, } from "./virtual.js";
4
+ import { album, albumList, albumListByGenre, albumListByYear, albumsOfArtist, artist, artists, childFolders, discsOfAlbum, folder, randomSongs, root, roots, song, songsInFolder, songsOfAlbum, } from "./meta.js";
5
+ export const ID = {
6
+ artist: (id) => `ar-${id}`,
7
+ album: (id) => `al-${id}`,
8
+ track: (id) => `tr-${id}`,
9
+ folder: (id) => `fd-${id}`,
10
+ root: (id) => `ro-${id}`,
11
+ /**
12
+ * The virtual nodes and the top above them.
13
+ *
14
+ * The payload is a fold key — an artist's name, folded — so this is the one
15
+ * id kind that is not a number, and it is read before `parseId` rather than
16
+ * by it. Keys carry no `:`: `foldName` keeps letters, digits and combining
17
+ * marks and turns everything else into a space.
18
+ */
19
+ virtualPrefix: 'vn:',
20
+ virtual: (key) => `vn:${key}`,
21
+ /** The virtual top itself, which is the prefix with no key after it. */
22
+ library: 'vn:',
23
+ /** The protocol's own name for the top of the tree. */
24
+ top: '-1',
25
+ /**
26
+ * A playlist, which is not part of the tree at all.
27
+ *
28
+ * Named here because it is an id this API hands a client and takes back, so
29
+ * it is spelled the way every other one is — while `KINDS` below stays the
30
+ * tree's own set: `parseId` refusing `pl:` is what makes `getSong` answer
31
+ * "no such id" about a playlist, which is true.
32
+ */
33
+ playlist: (id) => `pl-${id}`,
34
+ };
35
+ const KINDS = new Set(['ar', 'al', 'tr', 'fd', 'ro']);
36
+ /**
37
+ * The row an id names, or nothing.
38
+ *
39
+ * A wrong kind is the same as no row at all: a client that asked for `al:`+a
40
+ * track id is not asking a question the server can answer, and saying "not
41
+ * found" is truer than answering about a track.
42
+ */
43
+ export function parseId(raw) {
44
+ // **Both separators are read, and only one is written.** The hyphen is what
45
+ // this API hands out; the colon is what it handed out before, and a client
46
+ // holding one of those reads the same row it always did rather than a
47
+ // not-found.
48
+ //
49
+ // The change is not cosmetic. The specification says an id is an opaque
50
+ // string, and a client is entitled to assume otherwise — one does: Castafiore
51
+ // builds its stream URL only when the id matches `[a-zA-Z0-9-]`, and answers
52
+ // with **the id itself** when it does not, so `tr:112384` became the URL the
53
+ // player was told to fetch and nothing ever reached this server (task:2896).
54
+ // The kind is still named with a hyphen; it just stops being a character that
55
+ // a client may read as something else.
56
+ const at = raw.search(/[-:]/);
57
+ if (at === -1)
58
+ return undefined;
59
+ const kind = raw.slice(0, at);
60
+ if (!KINDS.has(kind))
61
+ return undefined;
62
+ const n = Number(raw.slice(at + 1));
63
+ return Number.isInteger(n) ? { kind: kind, n } : undefined;
64
+ }
65
+ function idOf(query, kind) {
66
+ const raw = required(query, 'id');
67
+ const parsed = parseId(raw);
68
+ if (parsed === undefined || parsed.kind !== kind) {
69
+ throw new ApiError(ERROR.notFound, `No such id: ${raw}`);
70
+ }
71
+ return parsed.n;
72
+ }
73
+ /** The track an `id` names, for the one method that asks for a track by itself. */
74
+ export function trackId(query) {
75
+ return idOf(query, 'tr');
76
+ }
77
+ export function required(query, name) {
78
+ const value = query.get(name);
79
+ if (value === null || value === '') {
80
+ throw new ApiError(ERROR.missingParameter, `Required parameter is missing: ${name}`);
81
+ }
82
+ return value;
83
+ }
84
+ // Shaping --------------------------------------------------------------------
85
+ const CONTENT_TYPES = {
86
+ flac: 'audio/flac',
87
+ mp3: 'audio/mpeg',
88
+ m4a: 'audio/mp4',
89
+ mp4: 'audio/mp4',
90
+ ogg: 'audio/ogg',
91
+ oga: 'audio/ogg',
92
+ // Not `audio/opus`. A `.opus` file is an Ogg stream carrying Opus, and RFC
93
+ // 7845 §9 is explicit: "The RECOMMENDED mime-type for Ogg Opus files is
94
+ // `audio/ogg`", with the `codecs` parameter (RFC 6381) as the way to say more
95
+ // — `audio/ogg; codecs=opus`. `audio/opus` is RFC 7587's, and that one is the
96
+ // RTP payload format, not a file in a container. Xiph's own table keeps
97
+ // `.opus` under `audio/ogg` and reserves `audio/opus` for "Opus without
98
+ // container", which is not a thing this server stores.
99
+ //
100
+ // It was `audio/opus`, and this server contradicted itself about it: its own
101
+ // transcoding target for opus (`TARGETS` in `stream/segment.ts`) has said
102
+ // `audio/ogg` all along. Found by being asked whether Opus was supported at
103
+ // all, and then looking at what the two halves of the same answer said
104
+ // (task:2920).
105
+ opus: 'audio/ogg',
106
+ wav: 'audio/wav',
107
+ aif: 'audio/aiff',
108
+ aiff: 'audio/aiff',
109
+ };
110
+ /** What a file of this kind is, so a client knows what it is about to receive. */
111
+ export function contentType(ext) {
112
+ return CONTENT_TYPES[ext] ?? 'application/octet-stream';
113
+ }
114
+ /**
115
+ * The protocol counts in whole seconds; the meta layer measured milliseconds.
116
+ *
117
+ * Read by everything that shows a length — an album, a song, and a playlist,
118
+ * which is the sum of its songs' — so the rounding is stated once.
119
+ */
120
+ export function seconds(ms) {
121
+ return ms === null ? 0 : Math.round(ms / 1000);
122
+ }
123
+ /**
124
+ * The last folder of a path, whichever separator it is written with.
125
+ *
126
+ * Both, because the two arrive in the same answer: a folder's path is relative
127
+ * and always uses forward slashes, while a root's is the absolute path the
128
+ * operator configured and on Windows that is written with backslashes. Reading
129
+ * only one of them named a root by its entire path.
130
+ */
131
+ function lastSegment(path) {
132
+ return path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1) || path;
133
+ }
134
+ /** Where the file is, in the one spelling a path has on the wire. */
135
+ function pathOf(rootPath, relPath) {
136
+ return `${rootPath.replace(/\\/g, '/')}/${relPath}`;
137
+ }
138
+ /**
139
+ * What a song with no name is called on the wire.
140
+ *
141
+ * The meta layer keeps null there on purpose — a cue that says `(empty)` has
142
+ * stated that this division of the disc has no name, and the projection leaves
143
+ * it unnamed rather than dressing the hole up (with an issue filed saying how
144
+ * many it left). The protocol has no way to say "no name", and a client does not
145
+ * read an empty title as an absence: Symfonium fails a whole sync over one song
146
+ * without a title. So the API says the same word the dump says, and a track the
147
+ * collection never named is shown as one.
148
+ */
149
+ const UNNAMED = '(untitled)';
150
+ /**
151
+ * The id `getCoverArt` is asked with, for a song.
152
+ *
153
+ * A song's art is its record's, and a song on no record answers for itself. It
154
+ * is read for every listing that shows a song — the folder, the album, the
155
+ * search.
156
+ *
157
+ * Named apart from `coverOf` below, which answers the same question about a
158
+ * *node* of the tree: two kinds of subject, and an id that says which.
159
+ */
160
+ function songCoverOf(row) {
161
+ return row.album_id === null ? ID.track(row.id) : ID.album(row.album_id);
162
+ }
163
+ /**
164
+ * The `Child` a song is, with the parent it sits under.
165
+ *
166
+ * The parent is the record, or the top of the tree for a song whose file sits in
167
+ * no record at all. Written once because every listing that answers with songs
168
+ * needs it — the starred listings, the history's now-playing and queue, the
169
+ * bookmarks — and the expression it hides is the one that has to agree with
170
+ * `ID.album` everywhere or a client is drawn a parent it cannot ask about.
171
+ */
172
+ export function childOf(row) {
173
+ return songChild(row, row.album_id === null ? ID.top : ID.album(row.album_id));
174
+ }
175
+ /** 1, per the specification's `ITUNESADVISORY` line. */
176
+ const EXPLICIT_ITUNES = [1];
177
+ /** 1 or 4, per its `rtng` line — where 4 is explicit and in the other tag means nothing. */
178
+ const EXPLICIT_RTNG = [1, 4];
179
+ /** The rating one numbering names: `explicit` is what it lists, `clean` is 2. */
180
+ function ratingOf(value, explicit) {
181
+ if (!Number.isInteger(value))
182
+ return '';
183
+ if (explicit.includes(value))
184
+ return 'explicit';
185
+ return value === 2 ? 'clean' : '';
186
+ }
187
+ /**
188
+ * What the tags say about a song's content rating, in the protocol's words.
189
+ *
190
+ * Two tags and **two numberings**, and the specification names both: "For songs
191
+ * extracted from tags ITUNESADVISORY: 1 = explicit, 2 = clean, MP4 rtng: 1 or 4
192
+ * = explicit, 2 = clean". They are not one numbering — `4` is explicit in one
193
+ * and means nothing in the other — so which tag a value came from is part of
194
+ * what it says, and the two arrive here separately for that reason.
195
+ *
196
+ * `""` when neither says anything, which is the protocol's own third value for
197
+ * this field and not an absence: it is the answer a client is owed for a
198
+ * collection nobody has rated, and this one is exactly that — not one
199
+ * `itunesadvisory` and not one `rtng` in any of its `file_tag` rows, measured.
200
+ */
201
+ export function explicitStatusOf(itunes, mp4) {
202
+ if (itunes !== null)
203
+ return ratingOf(Number(itunes), EXPLICIT_ITUNES);
204
+ if (mp4 !== null)
205
+ return ratingOf(Number(mp4), EXPLICIT_RTNG);
206
+ return '';
207
+ }
208
+ /**
209
+ * The `size` field of a `Child`, which is the length of the *song*.
210
+ *
211
+ * For a whole file the song and the file are the same thing, and this is the
212
+ * file's own length. For a song cut out of an image they are not, and this used
213
+ * to answer with the image's: 465 MB for a six-minute song, where 1789 of this
214
+ * collection's tracks are cut from images. A client that sizes a download, or
215
+ * decides by size whether it will stream something at all, was being told a
216
+ * number two orders of magnitude wrong — the operator found it bringing clients
217
+ * up against the server.
218
+ *
219
+ * **The exact length of a cut is a number nothing holds.** It comes out of
220
+ * walking the image's frames, which no listing may do — that walk is the one
221
+ * `task:2897` had to take off the request thread, and doing it per song in an
222
+ * album listing would be a thousand times worse. What the row *does* hold is
223
+ * enough to estimate it: the file's bitrate, which is the encoding the track is
224
+ * cut from and so the same bytes per second, and the track's own length.
225
+ * Measured against a real cut of this collection — Whole Lotta Love out of a
226
+ * 510 MB image — **68 334 081 against 68 863 475 bytes, 0.8% low**, the
227
+ * difference being the header the cut rebuilds and the frame headers it adds.
228
+ *
229
+ * Where the bitrate was never measured there is no estimate to make, and the
230
+ * field is left out rather than filled with the image's size again: `size` is
231
+ * `Req. No`, and a wrong number is worse than none — the rule the rest of this
232
+ * API already follows for a bitrate nobody measured.
233
+ */
234
+ function sizeOf(row) {
235
+ if (row.segment_start_ms === null)
236
+ return { size: row.size };
237
+ if (row.bitrate === null || row.duration_ms === null)
238
+ return {};
239
+ return { size: Math.round((row.bitrate * row.duration_ms) / 8000) };
240
+ }
241
+ /**
242
+ * A gain, as a file writes it — `-7.66 dB` — or nothing.
243
+ *
244
+ * The unit is in the string and not in the number, and it is always decibels:
245
+ * the tag names the unit because a tag is a line of text a person may read.
246
+ * OpenSubsonic's field is a number in dB, so the suffix is dropped rather than
247
+ * carried — and a value this cannot read is *nothing*, not nought, because a
248
+ * gain of zero means "leave the loudness alone" and that is a real thing to say.
249
+ */
250
+ function decibels(value) {
251
+ if (value === null)
252
+ return undefined;
253
+ const written = value.trim().replace(/\s*dB$/i, '').trim();
254
+ // **The emptiness check is the whole of the rule above.** `Number('')` is `0`,
255
+ // and `0` is finite — so a tag holding nothing but its unit (`' dB'`) would
256
+ // have arrived as "leave the loudness alone", which is the one answer this
257
+ // function exists to avoid giving (found by the review umbrella, task:2926).
258
+ if (written === '')
259
+ return undefined;
260
+ const parsed = Number(written);
261
+ return Number.isFinite(parsed) ? parsed : undefined;
262
+ }
263
+ /**
264
+ * A peak, which a file writes bare — `0.388123` — and the specification
265
+ * requires not to be negative.
266
+ *
267
+ * A negative peak is not a quiet song, it is a value that cannot be one; it is
268
+ * left out rather than sent, since a client that scaled by it would invert the
269
+ * audio.
270
+ */
271
+ function peak(value) {
272
+ if (value === null)
273
+ return undefined;
274
+ const written = value.trim();
275
+ // Same trap as `decibels`: `Number('')` is `0`, and a peak of nought is a
276
+ // value a client would scale by rather than an absence.
277
+ if (written === '')
278
+ return undefined;
279
+ const parsed = Number(written);
280
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
281
+ }
282
+ /**
283
+ * The song's ReplayGain data, in the shape OpenSubsonic gives it.
284
+ *
285
+ * **Always an object, even when it is empty.** The specification's own note
286
+ * settles the apparent contradiction in its schema — "If the data is not present
287
+ * the field must be omitted in the answer. (But the `replayGain` field on
288
+ * [`Child`] must always be present)" — so what is left out is a *property*, and
289
+ * the field a client checks for is always there.
290
+ *
291
+ * `baseGain` is not here because nothing reads one: it is Opus's output gain,
292
+ * and `tags/ogg.ts` takes the pre-skip out of `OpusHead` and stops before it.
293
+ * `fallbackGain` is a client's or an operator's setting, and this server has no
294
+ * opinion to state as one.
295
+ *
296
+ * **This is why the tags stopped being invisible.** They were read, stored, and
297
+ * never handed to anybody: 74 files of this collection carry a track gain, and
298
+ * the ReplayGain presets in Symfonium had nothing behind them (task:2921).
299
+ */
300
+ export function replayGainOf(row) {
301
+ const trackGain = decibels(row.rg_track_gain);
302
+ const albumGain = decibels(row.rg_album_gain);
303
+ const trackPeak = peak(row.rg_track_peak);
304
+ const albumPeak = peak(row.rg_album_peak);
305
+ return {
306
+ ...(trackGain === undefined ? {} : { trackGain }),
307
+ ...(albumGain === undefined ? {} : { albumGain }),
308
+ ...(trackPeak === undefined ? {} : { trackPeak }),
309
+ ...(albumPeak === undefined ? {} : { albumPeak }),
310
+ };
311
+ }
312
+ export function songChild(row, parent) {
313
+ return {
314
+ id: ID.track(row.id),
315
+ parent,
316
+ isDir: false,
317
+ title: row.title ?? UNNAMED,
318
+ // The id to ask `getCoverArt` for — see `songCoverOf`. A client that is not
319
+ // told which id to ask with draws a placeholder, which is how the whole
320
+ // library looked until this was here.
321
+ coverArt: songCoverOf(row),
322
+ album: row.album_title ?? '',
323
+ // The song's own artist — its file's tag — and the record's only when the
324
+ // file states none.
325
+ //
326
+ // This was the other way round, and the argument for it was that preferring
327
+ // the file's "would replace a band with whoever guested on one track". True,
328
+ // and the answer is the field below: a band is named in `albumArtist`, which
329
+ // is the record's own field in this protocol. The order that was here made
330
+ // `artist` answer for the *record*, and it was wrong for 1492 songs in 113
331
+ // records — every compilation and every split. Measured on `al:128`:
332
+ // `Round Midnight` is by Duran Y Garcia and the answer was `Various
333
+ // Artists`; on a split both halves were named after whichever act the
334
+ // record was filed under (task:2850).
335
+ artist: row.track_artist ?? row.artist_name ?? '',
336
+ // The record's artist, which is what a client draws beside the album rather
337
+ // than beside the track. Sent even when it equals `artist`, because that is
338
+ // the ordinary case and a client that wants the record's name should not
339
+ // have to guess whether its absence means anything.
340
+ ...(row.artist_name === null ? {} : { albumArtist: row.artist_name }),
341
+ // The song's own genre, which is its file's — see `SongRow.genre`. Absent
342
+ // rather than empty when the file states none: a client that is handed
343
+ // `genre: ""` has a genre whose name is nothing, and one that is handed no
344
+ // genre at all has a song whose genre nobody wrote.
345
+ ...(row.genre === null ? {} : { genre: row.genre }),
346
+ // Always sent, unlike the genre above, because the protocol gives this field
347
+ // a third value and it is the empty string: a client is owed "nobody rated
348
+ // this" rather than a field whose absence it has to interpret.
349
+ explicitStatus: explicitStatusOf(row.advisory_itunes, row.advisory_mp4),
350
+ track: row.ordinal,
351
+ ...(row.album_id === null ? {} : { albumId: ID.album(row.album_id) }),
352
+ ...(row.artist_id === null ? {} : { artistId: ID.artist(row.artist_id) }),
353
+ ...(row.disc_number === null ? {} : { discNumber: row.disc_number }),
354
+ // The record's year, not the song's: a song is as old as the record it is
355
+ // on, and the meta layer keeps no date per track.
356
+ ...(row.album_year === null ? {} : { year: row.album_year }),
357
+ // The size of the *song*, which for a song cut out of an image is that
358
+ // song's share of it rather than the image. See `sizeOf`.
359
+ ...sizeOf(row),
360
+ // What the file actually is, which the protocol puts on every `Child` and
361
+ // this server left out entirely: a client deciding whether it can play
362
+ // something needs more than the codec's name, and `stream`'s own
363
+ // `maxBitRate` cannot obey a ceiling without knowing what the file is at.
364
+ // `bitRate` is kilobits per second, which is the protocol's unit, while the
365
+ // probe measures bits — the same division `seconds` does for a duration.
366
+ ...(row.bitrate === null ? {} : { bitRate: Math.round(row.bitrate / 1000) }),
367
+ ...(row.sample_rate === null ? {} : { samplingRate: row.sample_rate }),
368
+ ...(row.channels === null ? {} : { channelCount: row.channels }),
369
+ duration: seconds(row.duration_ms),
370
+ suffix: row.ext,
371
+ contentType: contentType(row.ext),
372
+ path: pathOf(row.root_path, row.rel_path),
373
+ isVideo: false,
374
+ type: 'music',
375
+ // The listener's own marks on this song, absent when there are none.
376
+ //
377
+ // `starred` has no empty form — it is a date or it is nothing — and
378
+ // `userRating: 0` is the protocol's own way of saying *there is no rating*
379
+ // (`setRating` takes nought for exactly that). So a client handed a zero
380
+ // would be drawn a rating nobody gave, and the two fields are left out
381
+ // rather than sent empty.
382
+ ...(row.starred_at === null ? {} : { starred: row.starred_at }),
383
+ ...(row.rating === null ? {} : { userRating: row.rating }),
384
+ // And what the listener's plays add up to. `playCount` is a count and is
385
+ // sent as one: nought is a fact about a song nobody has played rather than
386
+ // the absence of a fact, which is the difference between this and
387
+ // `userRating` above — nought there is the protocol's spelling of "no
388
+ // rating", and here it is the count. `played` is a date or it is nothing, so
389
+ // it is left out rather than sent empty, the rule `starred` follows.
390
+ playCount: row.play_count ?? 0,
391
+ // What the file says its loudness is, so a client that normalises has
392
+ // something to normalise by — always present, sometimes empty (task:2921).
393
+ replayGain: replayGainOf(row),
394
+ ...(row.played_at === null ? {} : { played: row.played_at }),
395
+ };
396
+ }
397
+ /**
398
+ * The name a record is shown by in the folder tree: the year it came out, then
399
+ * its title.
400
+ *
401
+ * The tree is the only list that draws no `year` field, so its name is the only
402
+ * place the year can be said. Everywhere else that field is on screen beside
403
+ * the name, and a name carrying one says the same thing twice — see
404
+ * `listedName` for those lists. The operator reported the duplicate in the
405
+ * albums view and confirmed the other three draw the year themselves, which is
406
+ * what moved the boundary to here.
407
+ *
408
+ * A record with no year says nothing rather than leading with a placeholder.
409
+ * The year stays in the protocol's own field as well, and there it is not a
410
+ * spare: for every list that is handed `listedName` instead, that field is the
411
+ * only place the year is read from at all.
412
+ *
413
+ * The note the folder states goes in too, and in **the brackets the folder wrote
414
+ * it in** — `1990 - Entreat [1991 issue AU Warner 903174106-2]` is what the
415
+ * folder says, and square brackets are how this collection writes a pressing.
416
+ * Outside the tree the same note is stated in round ones; that is `albumId3`,
417
+ * which has done this since `a826eb0`. The tree was the reader left without it,
418
+ * and a record whose title a tag or a cue wrote lost its pressing there
419
+ * entirely — 180 of the 244 records whose folder states a note (task:2783).
420
+ */
421
+ function named(row) {
422
+ const title = row.title ?? '';
423
+ // Said once — and `unsaidNote` is the only place that decides, because the
424
+ // album list has to decide it the same way. A record its own folder named
425
+ // carries the note inside its title already, and carries it in *round*
426
+ // brackets with the pressing's year off, which is not how the folder wrote it.
427
+ const folder = lastSegment(row.rel_path);
428
+ const unsaid = unsaidNote(title, folder);
429
+ const written = folderNote(folder);
430
+ const withNote = unsaid === null || written === null ? title : `${title} ${written}`;
431
+ return row.year === null ? withNote : `${row.year} - ${withNote}`;
432
+ }
433
+ /**
434
+ * The name a record is shown by outside the folder tree: its title alone.
435
+ *
436
+ * Every list but the tree renders the protocol's `year` field, so the name does
437
+ * not repeat it. That is the albums view, the artist page, the album page and
438
+ * search — the operator found the year twice in the first and confirmed the
439
+ * other three draw it themselves, which is what put the boundary at the tree.
440
+ *
441
+ * The year is still in the protocol's own field beside the name, and the
442
+ * record's title is untouched either way.
443
+ */
444
+ function listedName(row) {
445
+ return row.title ?? '';
446
+ }
447
+ /** An album where the protocol asks for `AlbumID3` — by artist, or in a list. */
448
+ export function albumId3(row) {
449
+ // What the name still has to say, asked once for every reader of a record's
450
+ // name — see `unsaidNote`, which is where the comparison lives and why it is
451
+ // of the note's words rather than of the folder's brackets.
452
+ //
453
+ // It used to be asked here alone, and asked of the brackets: `carried` was
454
+ // `parseFolderName(title).format`, which cannot read a round bracket that does
455
+ // not look like a format — so 18 records whose title carries a catalogue
456
+ // number `recordTitle` had written came back as saying it, were told it again,
457
+ // and read `Eponymous (EU Polydor 981 463-0) (EU Polydor 981 463-0)` in the
458
+ // album list (task:2845).
459
+ const unsaid = unsaidNote(row.title ?? '', lastSegment(row.rel_path));
460
+ // The note goes in the name as well, and this is the half that was missing.
461
+ // A record its own folder named keeps the note there already — `recordTitle`
462
+ // reads the folder — but one a *tag* or a cue named had its title replaced, and
463
+ // the note survived only as `version`, a field the client this library is read
464
+ // in does not render. Measured: of 132 records whose folder states a note, 78
465
+ // names did not carry it, and fourteen of one artist's records read identically
466
+ // to a client because of it (task:2813, task:2814).
467
+ //
468
+ // `version` is still sent beside it. A client that renders the field is not made
469
+ // worse by the name saying it too, and the alternative — the note in one place
470
+ // only — is what produced this defect.
471
+ const named = listedName(row);
472
+ const name = unsaid === null ? named : `${named} (${unsaid})`;
473
+ return {
474
+ id: ID.album(row.id),
475
+ name,
476
+ // The record is what `getCoverArt` is asked about, and it answers with the
477
+ // folder's picture — see `api/cover.ts` for which picture that is.
478
+ coverArt: ID.album(row.id),
479
+ artist: row.artist_name ?? '',
480
+ ...(row.artist_id === null ? {} : { artistId: ID.artist(row.artist_id) }),
481
+ songCount: row.song_count,
482
+ duration: seconds(row.duration_ms),
483
+ // A disc of a box carries the release's title like its siblings do, so this
484
+ // number is the only thing on the answer that tells the two apart.
485
+ ...(row.disc_number === null ? {} : { discNumber: row.disc_number }),
486
+ // Which edition of the record this is, when its folder says something its
487
+ // name does not.
488
+ //
489
+ // The protocol keeps `version` for exactly this — "Remastered, Anniversary
490
+ // Box Set" — and this is where the note lives **only when no name carries
491
+ // it**: a note nobody is shown is not worth taking out of a name, and the
492
+ // client this library is read in does not render the field at all. So a
493
+ // record named by its folder keeps the note in the name (`recordTitle`) and
494
+ // is told once; what reaches this line is a record some *other* stage named
495
+ // — a tag or a cue that wrote its own title, note already dropped.
496
+ //
497
+ // Read off the record's folder rather than stored, because it is the folder
498
+ // that says it — the same string `recordTitle` reads its name from.
499
+ //
500
+ // Not said twice, and the answer is the same `unsaid` the name was built
501
+ // from — so the field and the name cannot disagree about whether the record
502
+ // has been told. The note's own year goes the way the name's did:
503
+ // `2019, Maschina Records, MASHCD-099` dates the pressing, not the record.
504
+ //
505
+ // Two notes that differ are both shown, and that is not a repetition: a folder
506
+ // stating `MKK891CD` beside a tag stating `MKM891CD` is two sources saying
507
+ // different things, and dropping either would hide the disagreement.
508
+ // Suppressing a differing note is not the act this line performs; suppressing
509
+ // a repeated one is.
510
+ ...(unsaid === null ? {} : { version: unsaid }),
511
+ // A record with no year says nothing rather than saying 0: the protocol's
512
+ // clients show the field when it is there, and a zero would be a year.
513
+ ...(row.year === null ? {} : { year: row.year }),
514
+ // Likewise, and for a stronger reason: a record whose files state no genre
515
+ // has no genre, and `genre: ""` would name one.
516
+ ...(row.genre === null ? {} : { genre: row.genre }),
517
+ // The listener's marks, on the record rather than on any of its songs — see
518
+ // `songChild` for why they are absent rather than empty.
519
+ ...(row.starred_at === null ? {} : { starred: row.starred_at }),
520
+ ...(row.rating === null ? {} : { userRating: row.rating }),
521
+ };
522
+ }
523
+ /** An album where the protocol asks for a `Child` — in a directory listing. */
524
+ export function albumChild(row, parent) {
525
+ return {
526
+ id: ID.album(row.id),
527
+ parent,
528
+ isDir: true,
529
+ title: named(row),
530
+ artist: row.artist_name ?? '',
531
+ coverArt: ID.album(row.id),
532
+ songCount: row.song_count,
533
+ duration: seconds(row.duration_ms),
534
+ ...(row.disc_number === null ? {} : { discNumber: row.disc_number }),
535
+ ...(row.year === null ? {} : { year: row.year }),
536
+ ...(row.genre === null ? {} : { genre: row.genre }),
537
+ // The listener's marks. A record is shown as a `Child` in the folder tree,
538
+ // in an artist's page and in the v1 starred listing, and one carried only
539
+ // by the ID3 shape would be a star that disappears when a client browses
540
+ // the other way.
541
+ ...(row.starred_at === null ? {} : { starred: row.starred_at }),
542
+ ...(row.rating === null ? {} : { userRating: row.rating }),
543
+ };
544
+ }
545
+ /**
546
+ * Where a client fetches an artist's picture.
547
+ *
548
+ * The OpenSubsonic field `artistImageUrl` asks for "an url to an external image
549
+ * source". This server has no external provider, and the picture it has is the
550
+ * one `getCoverArt` already answers with for the artist's id — so that is what
551
+ * the field names, as a URL because a URL is what the field is. Built from the
552
+ * authority the request arrived by rather than from the config: a deployed
553
+ * server binds `0.0.0.0`, which is not an address anyone can call back.
554
+ *
555
+ * One field is offered whether or not there is a picture to fetch, which is what
556
+ * `coverArt` beside it has always done — ten of this collection's forty-eight
557
+ * artists have no picture in any of their folders, and the client is the one
558
+ * that finds that out by asking.
559
+ */
560
+ export function artistImageUrlOf(id, origin) {
561
+ return `${origin}/rest/getCoverArt?id=${encodeURIComponent(ID.artist(id))}`;
562
+ }
563
+ export function artistId3(row, origin) {
564
+ return {
565
+ id: ID.artist(row.id),
566
+ name: row.name,
567
+ albumCount: row.album_count,
568
+ ...(row.starred_at === null ? {} : { starred: row.starred_at }),
569
+ ...(row.rating === null ? {} : { userRating: row.rating }),
570
+ // An artist has no picture of its own, and `getCoverArt` answers for one
571
+ // with the best picture among all its records — a client showing an artist
572
+ // in a list wants a picture, and which record supplied it is not its
573
+ // business. See `api/cover.ts`.
574
+ coverArt: ID.artist(row.id),
575
+ // And the same picture again as a URL, under the field OpenSubsonic added
576
+ // for clients that want an artist image rather than a cover: one is an id
577
+ // to ask with, the other is an address to load. Reported missing from the
578
+ // operator's Symfonium, which reads this field and finds nothing there.
579
+ artistImageUrl: artistImageUrlOf(row.id, origin),
580
+ // What this artist is in the library — `albumartist`, `artist`. A client
581
+ // buckets its views by this field, and one of them came up empty for want of
582
+ // it; see `rolesOf` for which roles this server will and will not claim.
583
+ ...(row.roles.length === 0 ? {} : { roles: row.roles }),
584
+ };
585
+ }
586
+ /**
587
+ * One entry of a virtual node: a shelf the collector filed, or a record.
588
+ *
589
+ * A record's entry leads with its year, and the entry alone — the record keeps
590
+ * the name the scanner settled on and `getAlbum` still answers with it. What a
591
+ * client is shown here is the shape the operator asked for, `Год - Альбом`, and
592
+ * a client drawing a list of entries is not obliged to render the year field
593
+ * beside it.
594
+ */
595
+ export function entryChild(entry, parent, nodeKey) {
596
+ if (entry.record !== null)
597
+ return albumChild(entry.record, parent);
598
+ // A drawer of one credited artist's records. Its id is the node's key and the
599
+ // artist's, because `vn:кино` already names the node itself and a node whose
600
+ // own name is credited to itself would otherwise collide with its drawer.
601
+ // Fold keys carry no `|` — `foldName` turns every separator into a space.
602
+ //
603
+ // The picture is the credit's *artist id*, not the drawer's own — the same id
604
+ // `getArtistInfo2` hands a client as a related artist, so one person is shown
605
+ // the same picture wherever the server names them. A drawer with no picture at
606
+ // all was the one place that differed, and a client cannot tell an artist the
607
+ // server has nothing for from one it simply forgot (task:2847).
608
+ if (entry.credit !== null) {
609
+ return {
610
+ id: ID.virtual(`${nodeKey}|${entry.credit.artistKey}`),
611
+ parent,
612
+ isDir: true,
613
+ title: entry.credit.name,
614
+ coverArt: ID.artist(entry.credit.artistId),
615
+ };
616
+ }
617
+ const folder = entry.folder;
618
+ if (folder === null)
619
+ throw new ApiError(ERROR.generic, 'An entry that is none of the three');
620
+ return folderChild(folder, parent);
621
+ }
622
+ export function folderChild(row, parent) {
623
+ return { id: ID.folder(row.id), parent, isDir: true, title: lastSegment(row.rel_path) };
624
+ }
625
+ /** The virtual top, which a client sees beside the roots it was told to scan. */
626
+ export function libraryChild() {
627
+ return { id: ID.library, parent: ID.top, isDir: true, title: LIBRARY };
628
+ }
629
+ /**
630
+ * A node of the virtual top.
631
+ *
632
+ * An artist's node and a folder that is nobody's are the same shape to a client
633
+ * — a directory it can open — but they are not the same id. The artist's is a
634
+ * `vn:` key, because what it holds is gathered from folders that may sit in
635
+ * several roots and no one folder stands for it. A series or a shelf keeps its
636
+ * own `fd:` and opens exactly as it always did.
637
+ */
638
+ export function virtualChild(node, parent) {
639
+ return {
640
+ id: node.artistKey === null ? ID.folder(node.folderId) : ID.virtual(node.artistKey),
641
+ parent,
642
+ isDir: true,
643
+ title: node.name,
644
+ };
645
+ }
646
+ export function rootChild(row) {
647
+ return {
648
+ id: ID.root(row.id),
649
+ parent: ID.top,
650
+ isDir: true,
651
+ title: row.alias ?? lastSegment(row.path),
652
+ };
653
+ }
654
+ // Routes ---------------------------------------------------------------------
655
+ /**
656
+ * The articles the server moved to file an artist — `The Cure` under C.
657
+ *
658
+ * Sent with the answer because the client is the one arranging what it was
659
+ * given: a client that did not know the server had ignored `The` would file
660
+ * everything else on its own rules and the two would disagree. Only `The`, and
661
+ * only because that is what the meta layer actually drops (`src/artist/name.ts`).
662
+ */
663
+ const IGNORED_ARTICLES = 'The';
664
+ /**
665
+ * The root a client confined its request to, or nothing when it confined none.
666
+ *
667
+ * The id is the one `getMusicFolders` gave out, and a client is emphatic about
668
+ * using the ids it was given rather than inventing them — which is why an id
669
+ * naming no root is refused rather than ignored: answering with the whole
670
+ * library would answer a question about some other music.
671
+ *
672
+ * Every browsing endpoint takes this. Feishin offers the choice and passes it on
673
+ * `getIndexes`, `getArtists`, `getAlbumList2`, `getMusicDirectory` and
674
+ * `search3`, and a server that ignored it answered "everything" to a question
675
+ * about one folder — which is what the operator found when they picked one.
676
+ */
677
+ export function confinedTo(db, query) {
678
+ const raw = query.get('musicFolderId');
679
+ if (raw === null || raw === '')
680
+ return undefined;
681
+ // The library is one of the choices this server offers, and choosing it means
682
+ // the whole library, which is what confining nothing means. A client is
683
+ // emphatic about using the ids it was given, and refusing one of ours is the
684
+ // defect rather than the request: the operator picked `Музыка` in the client
685
+ // and the server answered `No such music folder: vn:`.
686
+ //
687
+ // It is `getIndexes` that offers it, as the `child` the tree opens on — not
688
+ // `getMusicFolders`, which answers with the roots and nothing else. An
689
+ // earlier version of this comment said the opposite, and the function below
690
+ // it said so too.
691
+ if (raw === ID.library)
692
+ return undefined;
693
+ const parsed = parseId(raw);
694
+ if (parsed === undefined || parsed.kind !== 'ro') {
695
+ throw new ApiError(ERROR.notFound, `No such music folder: ${raw}`);
696
+ }
697
+ if (root(db, parsed.n) === undefined) {
698
+ throw new ApiError(ERROR.notFound, `No such music folder: ${raw}`);
699
+ }
700
+ return parsed.n;
701
+ }
702
+ /** The letter an artist is browsed under. Anything that is not a word lands on `#`. */
703
+ function indexLetter(sortName) {
704
+ const first = [...sortName][0];
705
+ return first !== undefined && /[\p{L}\p{N}]/u.test(first) ? first.toUpperCase() : '#';
706
+ }
707
+ export function getIndexes(db, query, visibility = 'records') {
708
+ const rootId = confinedTo(db, query);
709
+ return {
710
+ indexes: {
711
+ ignoredArticles: IGNORED_ARTICLES,
712
+ index: folderIndex(db, rootId, visibility),
713
+ // The library, then the roots as they were configured. A client that
714
+ // builds its folder tree from here — and this one does — saw only the
715
+ // roots, so the virtual top was reachable through `getMusicDirectory(-1)`
716
+ // and nowhere it looked. `getMusicFolders` below answers the same list,
717
+ // because the two are read as one question.
718
+ child: [
719
+ libraryChild(),
720
+ ...roots(db)
721
+ .filter((row) => rootId === undefined || row.id === rootId)
722
+ .map(rootChild),
723
+ ],
724
+ },
725
+ };
726
+ }
727
+ /**
728
+ * The music folders, which for this server are the roots it was told to scan.
729
+ *
730
+ * A client with more than one library to connect to — a phone that switches
731
+ * between servers, a household with two collections — asks this before it asks
732
+ * anything else, and it is emphatic about one thing: it uses the ids it is given
733
+ * and does not invent them. So an id that is not offered here is an id that
734
+ * cannot be sent back, and a server that answers this with nothing has no way to
735
+ * be told which library is meant.
736
+ *
737
+ * The id is the same one `getIndexes` gives the same root, because they are the
738
+ * same folder seen from two ends: one lists it to browse into, this one lists it
739
+ * to choose between.
740
+ */
741
+ /**
742
+ * The folders on disk a client may choose between.
743
+ *
744
+ * The roots the scanner was given, and nothing else. The library is a *view* of
745
+ * the collection rather than a folder in it, and offering it here is a claim
746
+ * about the disk that is not true — the operator picked it and said so: "такой
747
+ * папки же у нас нет". `getIndexes` still offers it, because that is a question
748
+ * about what to browse, and `confinedTo` accepts it, because an id this server
749
+ * has handed out is one it must not then refuse.
750
+ */
751
+ export function getMusicFolders(db) {
752
+ return {
753
+ musicFolders: {
754
+ musicFolder: roots(db).map((row) => ({
755
+ id: ID.root(row.id),
756
+ name: row.alias ?? lastSegment(row.path),
757
+ })),
758
+ },
759
+ };
760
+ }
761
+ /**
762
+ * The artists, in the protocol's ID3 shape.
763
+ *
764
+ * `getIndexes` and this are the same list read two ways — the protocol grew
765
+ * `getArtists` when it moved from browsing folders to browsing tags — and a
766
+ * client is free to prefer either. The difference is that this one carries no
767
+ * folders: an artist is an artist, and the directory a record sits in is not a
768
+ * thing this server asks clients to care about.
769
+ */
770
+ export function getArtists(db, query, visibility, origin) {
771
+ const rootId = confinedTo(db, query);
772
+ return {
773
+ artists: {
774
+ ignoredArticles: IGNORED_ARTICLES,
775
+ index: indexGroups(db, rootId, visibility, origin),
776
+ },
777
+ };
778
+ }
779
+ /**
780
+ * The library's top, grouped by the letter it files under.
781
+ *
782
+ * This is `getIndexes`'s `index`, and the client this library is read in draws
783
+ * its *Folders* view from exactly this field — a table of whatever is here,
784
+ * seventeen rows of artists, with TITLE/ALBUM/GENRE/YEAR columns. It never
785
+ * walks `getMusicDirectory`, so anything not in this list cannot be reached in
786
+ * that view at all, and the operator's series — `VA - Saint-Germain-des-Pres
787
+ * Cafe (2001-2015) [AAC]`, `Серия «Подлинная история отечественной легкой
788
+ * музыки»` — were unreachable however the tree was shaped.
789
+ *
790
+ * So the whole top is offered here, series and folders included, and not only
791
+ * the artists. `getArtists` below keeps answering with artists alone: the two
792
+ * are different questions, and a client that wants the tag-based artist list
793
+ * asks the other one.
794
+ *
795
+ * An artist keeps its `ar:` id, so a client that follows it lands on the artist
796
+ * page it always did; a series keeps its `fd:` and opens as a folder.
797
+ */
798
+ function folderIndex(db, rootId, visibility = 'records') {
799
+ const groups = new Map();
800
+ const nodes = virtualNodes(db, rootId, visibility);
801
+ for (const node of nodes) {
802
+ const letter = indexLetter(node.sort);
803
+ // The id is the node's own, the one `getMusicDirectory(vn:)` answers to,
804
+ // and the count is of what that answer holds. An `ar:` here would promise
805
+ // the artist page's count and open the artist page — `Cock E.S.P.` holds
806
+ // twenty-four records in its folder and thirteen carry its credit, and a
807
+ // row saying one number and opening the other is the defect this project
808
+ // keeps finding. The artist is still whose *name* it is; only the count and
809
+ // the drill-down come from the folder.
810
+ const id = node.artistKey === null ? ID.folder(node.folderId) : ID.virtual(node.artistKey);
811
+ const entry = {
812
+ id,
813
+ name: node.name,
814
+ // Counted, not gathered. The number is the whole of what this row shows,
815
+ // and reading the records to take their length cost most of a second
816
+ // across twenty-five nodes, at roughly thirty milliseconds each — the
817
+ // count is asked of a query that does not have to build the records to
818
+ // answer, and the node carries it (`VirtualNode.records`).
819
+ albumCount: node.records,
820
+ // The artist's own picture where there is an artist; a folder's otherwise,
821
+ // which `getCoverArt` answers for the same way.
822
+ coverArt: node.artistId === null ? id : ID.artist(node.artistId),
823
+ };
824
+ const bucket = groups.get(letter);
825
+ if (bucket === undefined)
826
+ groups.set(letter, [entry]);
827
+ else
828
+ bucket.push(entry);
829
+ }
830
+ return [...groups].map(([name, artist]) => ({ name, artist }));
831
+ }
832
+ /** The artists grouped by the letter they are filed under, in the order built. */
833
+ function indexGroups(db, rootId, visibility, origin) {
834
+ const groups = new Map();
835
+ const held = nodeCounts(db, rootId, visibility);
836
+ for (const row of artists(db, rootId, visibility)) {
837
+ const owned = held.get(row.name_key);
838
+ const letter = indexLetter(row.sort_key ?? row.name);
839
+ // Where the artist owns a folder, the folder's number is the one shown, for
840
+ // the reason `folderIndex` gives above: the row is a promise about the page
841
+ // behind it, and this is the page `getArtist` will answer with. The two read
842
+ // the same field, so leaving this to the tag's count parted them on 7 of the
843
+ // collection's 21 artists — and all seven the same way round, because the
844
+ // direction is not a coin toss: a folder can hold more than its own artist's
845
+ // records and never fewer. `Кино` read as 31 and opened to 36, its folder
846
+ // holding five records of Виктор Цой, and every other one undercounted too.
847
+ //
848
+ // The other direction is real, and lives in the fixture rather than in the
849
+ // collection: a record credited to one artist and filed on another's shelf.
850
+ // It is where the test below gets its second half, and the only place this
851
+ // was ever observed — worth saying, because a comment that hands the reader
852
+ // a live example it cannot find is the defect this file keeps collecting.
853
+ const entry = artistId3(owned === undefined ? row : { ...row, album_count: owned }, origin);
854
+ const bucket = groups.get(letter);
855
+ if (bucket === undefined)
856
+ groups.set(letter, [entry]);
857
+ else
858
+ bucket.push(entry);
859
+ }
860
+ return [...groups].map(([name, artist]) => ({ name, artist }));
861
+ }
862
+ /**
863
+ * What each artist's node holds, by the artist's `name_key`.
864
+ *
865
+ * Only artists the folders named appear. One the tree has no node for is
866
+ * answered for by their credit, which `artists` has already counted, and
867
+ * `getArtist` falls back to the same count — the two have to part ways nowhere.
868
+ * Four of the collection's twenty-one take that branch, so it is not a corner,
869
+ * and the test below pins one of them rather than trusting that the two counts
870
+ * agree by construction.
871
+ *
872
+ * The first node wins a key, mirroring `nodeOf`, which finds the first node of a
873
+ * key as well — so a count and the page behind it cannot pick a different node
874
+ * for the same artist.
875
+ *
876
+ * Counted rather than gathered: `virtualRecordCounts` asks the question of a
877
+ * query that does not have to build the records to answer — the shape that took
878
+ * `getIndexes` off `virtualRecords` and out of the hundreds of milliseconds
879
+ * ([[task:2825]]). What it costs here is that same walk, once per call instead of
880
+ * once per artist, and it is the walk `getIndexes` already pays on every client
881
+ * sync.
882
+ */
883
+ function nodeCounts(db, rootId, visibility = 'records') {
884
+ const byKey = new Map();
885
+ for (const node of virtualNodes(db, rootId, visibility)) {
886
+ if (node.artistKey === null || byKey.has(node.artistKey))
887
+ continue;
888
+ byKey.set(node.artistKey, node.records);
889
+ }
890
+ return byKey;
891
+ }
892
+ /**
893
+ * What an id is, and what is on it — in the protocol's `ArtistID3` shape.
894
+ *
895
+ * Three kinds of id reach here and all three answer the same question. An `ar:`
896
+ * is an artist by tag; a `vn:` is a node of the library and an `fd:` a folder
897
+ * that belongs to no artist. A client that read its ids out of `getIndexes`
898
+ * will ask about the last two, and refusing them refuses an id this server
899
+ * handed out — which is the defect the operator hit twice in one day.
900
+ *
901
+ * Where an artist owns a folder, the folder answers: see `nodeOf`.
902
+ */
903
+ export function getArtist(db, query, visibility, origin) {
904
+ const raw = required(query, 'id');
905
+ const rootId = confinedTo(db, query);
906
+ if (raw.startsWith(ID.virtualPrefix)) {
907
+ const key = raw.slice(ID.virtualPrefix.length);
908
+ if (key === '') {
909
+ return {
910
+ artist: described(ID.library, LIBRARY, ID.library, libraryRecords(db, rootId, visibility)),
911
+ };
912
+ }
913
+ const node = nodeOf(db, key, rootId, visibility);
914
+ if (node === undefined)
915
+ throw new ApiError(ERROR.notFound, `No such id: ${raw}`);
916
+ const id = ID.virtual(key);
917
+ return {
918
+ artist: described(id, node.name, coverOf(node), virtualRecords(db, node, visibility)),
919
+ };
920
+ }
921
+ const parsed = parseId(raw);
922
+ if (parsed === undefined)
923
+ throw new ApiError(ERROR.notFound, `No such id: ${raw}`);
924
+ if (parsed.kind === 'fd') {
925
+ const node = virtualNodes(db, rootId, visibility).find((one) => one.artistKey === null && one.folderId === parsed.n);
926
+ if (node === undefined)
927
+ throw new ApiError(ERROR.notFound, `No such id: ${raw}`);
928
+ return { artist: described(raw, node.name, raw, virtualRecords(db, node, visibility)) };
929
+ }
930
+ if (parsed.kind !== 'ar')
931
+ throw new ApiError(ERROR.notFound, `No such id: ${raw}`);
932
+ const row = artist(db, parsed.n);
933
+ if (row === undefined)
934
+ throw new ApiError(ERROR.notFound, `No such id: ${raw}`);
935
+ const node = nodeOf(db, row.name_key, rootId, visibility);
936
+ const records = node === undefined
937
+ ? albumsOfArtist(db, row.id, rootId, visibility)
938
+ : virtualRecords(db, node, visibility);
939
+ return {
940
+ artist: {
941
+ ...artistId3({ ...row, album_count: records.length }, origin),
942
+ album: records.map(albumId3),
943
+ },
944
+ };
945
+ }
946
+ /** The node a library key names, when the library holds one. */
947
+ function nodeOf(db, key, rootId, visibility = 'records') {
948
+ return virtualNodes(db, rootId, visibility).find((one) => one.artistKey === key);
949
+ }
950
+ /** The picture an artist's own id answers for; a folder's answers for itself. */
951
+ function coverOf(node) {
952
+ return node.artistId === null ? ID.folder(node.folderId) : ID.artist(node.artistId);
953
+ }
954
+ function described(id, name, coverArt, records) {
955
+ return { id, name, albumCount: records.length, coverArt, album: records.map(albumId3) };
956
+ }
957
+ export function getAlbum(db, query) {
958
+ const id = idOf(query, 'al');
959
+ const row = album(db, id);
960
+ if (row === undefined)
961
+ throw new ApiError(ERROR.notFound, `No such album: ${id}`);
962
+ // The record's own id, not the one that was asked with: a box answers the same
963
+ // whether it was opened by its first disc or its second, and a `parent` that
964
+ // echoed the question would make the two answers differ in a field nothing
965
+ // asked about.
966
+ const record = ID.album(row.id);
967
+ // What each disc of the record calls itself, for the protocol's `discTitles`:
968
+ // `CD2 ● Ранний вариант` is a disc that is more than its number. Only discs
969
+ // whose name says something are reported — the field exists so a client can
970
+ // label a disc, and a label of nothing is not one.
971
+ //
972
+ // Answered here and not in a listing: a list shows records, not their discs,
973
+ // and asking per row is the query-per-result shape that made a search page
974
+ // take two seconds.
975
+ const discTitles = discsOfAlbum(db, id)
976
+ .flatMap((disc) => {
977
+ const title = discSubtitle(disc.title ?? '');
978
+ // A disc whose name repeats the record's says nothing about the disc.
979
+ return title === null || title === row.title ? [] : [{ disc: disc.disc, title }];
980
+ });
981
+ return {
982
+ album: {
983
+ ...albumId3(row),
984
+ ...(discTitles.length === 0 ? {} : { discTitles }),
985
+ song: songsOfAlbum(db, id).map((s) => songChild(s, record)),
986
+ },
987
+ };
988
+ }
989
+ export function getSong(db, query) {
990
+ const id = idOf(query, 'tr');
991
+ const row = song(db, id);
992
+ if (row === undefined)
993
+ throw new ApiError(ERROR.notFound, `No such song: ${id}`);
994
+ return { song: childOf(row) };
995
+ }
996
+ /**
997
+ * Something to open the app on.
998
+ *
999
+ * The one listing here whose answer is not the same twice, and the filters are
1000
+ * the point of it: a client drawing a "shuffle everything" or a "shuffle this
1001
+ * genre" screen asks for exactly this. The folder is the protocol's own filter
1002
+ * and is honoured the way every other listing honours it — through `confinedTo`,
1003
+ * which refuses a folder this server never handed out.
1004
+ *
1005
+ * The draw itself is `meta.ts`'s, where the cost of it is stated.
1006
+ */
1007
+ export function getRandomSongs(db, query, visibility = 'records') {
1008
+ const songs = randomSongs(db, {
1009
+ size: drawSize(query),
1010
+ // `|| undefined` and not `?? undefined`: an empty `genre=` is a client that
1011
+ // named no genre, which is the same as not naming the parameter — the rule
1012
+ // `drawSize`, `yearIn` and `confinedTo` in this same call all follow, and
1013
+ // `getSongsByGenre` refuses outright. `??` would pass `''` through and draw
1014
+ // from `file_tag` rows whose value is nothing, which is not a genre.
1015
+ genre: query.get('genre') || undefined,
1016
+ fromYear: yearIn(query, 'fromYear'),
1017
+ toYear: yearIn(query, 'toYear'),
1018
+ rootId: confinedTo(db, query),
1019
+ visibility,
1020
+ });
1021
+ return { randomSongs: { song: songs.map(childOf) } };
1022
+ }
1023
+ /**
1024
+ * How many songs to draw: the protocol's default of ten, and its ceiling of five
1025
+ * hundred.
1026
+ *
1027
+ * The ceiling is applied rather than refused — a client that asked for a
1028
+ * thousand asked for "more than you have", which is a request this server can
1029
+ * honour, and a refusal would break it over its own arithmetic.
1030
+ */
1031
+ function drawSize(query) {
1032
+ const raw = query.get('size');
1033
+ if (raw === null || raw === '')
1034
+ return 10;
1035
+ const size = Number(raw);
1036
+ if (!Number.isInteger(size) || size < 1) {
1037
+ throw new ApiError(ERROR.generic, `size is not a count: ${raw}`);
1038
+ }
1039
+ return Math.min(size, 500);
1040
+ }
1041
+ /** A year the client named, or nothing when it named none. */
1042
+ function yearIn(query, name) {
1043
+ const raw = query.get(name);
1044
+ if (raw === null || raw === '')
1045
+ return undefined;
1046
+ const year = Number(raw);
1047
+ if (!Number.isInteger(year))
1048
+ throw new ApiError(ERROR.generic, `${name} is not a year: ${raw}`);
1049
+ return year;
1050
+ }
1051
+ /** The listings this server can answer, which are the meta layer's own orderings. */
1052
+ const ORDERABLE = new Set([
1053
+ 'alphabeticalByName',
1054
+ 'alphabeticalByArtist',
1055
+ 'newest',
1056
+ 'random',
1057
+ // The listener's own: what they starred, what they rated, what they played and
1058
+ // what they played most. Refused until v1.2's marks and history made them
1059
+ // answerable — see `AlbumOrder`.
1060
+ 'starred',
1061
+ 'highest',
1062
+ 'recent',
1063
+ 'frequent',
1064
+ ]);
1065
+ const DEFAULT_PAGE = 10;
1066
+ /** The protocol's own ceiling on a page, so a client cannot ask for everything at once. */
1067
+ const MAX_PAGE = 500;
1068
+ function numeric(query, name, fallback) {
1069
+ const raw = query.get(name);
1070
+ if (raw === null || raw === '')
1071
+ return fallback;
1072
+ const value = Number(raw);
1073
+ if (!Number.isInteger(value)) {
1074
+ throw new ApiError(ERROR.generic, `${name} is not a whole number: ${raw}`);
1075
+ }
1076
+ return value;
1077
+ }
1078
+ /**
1079
+ * A whole number the client has to send, refused rather than defaulted.
1080
+ *
1081
+ * `numeric` above answers a fallback, which is right for a page — a client that
1082
+ * says nothing about `size` wants the default page. It is wrong for a bound: a
1083
+ * range with a silently invented end is not the range the client asked for, so
1084
+ * its absence is an error and says which parameter is missing, as a missing
1085
+ * `genre` does.
1086
+ */
1087
+ function requiredNumber(query, name) {
1088
+ const raw = query.get(name);
1089
+ if (raw === null || raw === '') {
1090
+ throw new ApiError(ERROR.generic, `Required parameter is missing: ${name}`);
1091
+ }
1092
+ const value = Number(raw);
1093
+ if (!Number.isInteger(value)) {
1094
+ throw new ApiError(ERROR.generic, `${name} is not a whole number: ${raw}`);
1095
+ }
1096
+ return value;
1097
+ }
1098
+ export function getAlbumList2(db, query, visibility = 'records') {
1099
+ const type = required(query, 'type');
1100
+ const rootId = confinedTo(db, query);
1101
+ // `byGenre` is the protocol's own name for it, and it is not an ordering —
1102
+ // it is a filter, and the genre it filters by is a required argument. Handled
1103
+ // before the orderings because it shares nothing with them but its shape.
1104
+ if (type === 'byGenre') {
1105
+ const genre = required(query, 'genre');
1106
+ const size = Math.min(Math.max(numeric(query, 'size', DEFAULT_PAGE), 1), MAX_PAGE);
1107
+ const offset = Math.max(numeric(query, 'offset', 0), 0);
1108
+ return {
1109
+ albumList2: {
1110
+ album: albumListByGenre(db, genre, size, offset, rootId, visibility).map(albumId3),
1111
+ },
1112
+ };
1113
+ }
1114
+ // `byYear` is the other filter, and its bounds are required for the reason the
1115
+ // genre beside it is: a decade is what the client asked for, and answering with
1116
+ // the whole collection would answer a different question.
1117
+ if (type === 'byYear') {
1118
+ const fromYear = requiredNumber(query, 'fromYear');
1119
+ const toYear = requiredNumber(query, 'toYear');
1120
+ const size = Math.min(Math.max(numeric(query, 'size', DEFAULT_PAGE), 1), MAX_PAGE);
1121
+ const offset = Math.max(numeric(query, 'offset', 0), 0);
1122
+ return {
1123
+ albumList2: {
1124
+ album: albumListByYear(db, fromYear, toYear, size, offset, rootId, visibility).map(albumId3),
1125
+ },
1126
+ };
1127
+ }
1128
+ if (!ORDERABLE.has(type)) {
1129
+ // Not an empty list. Nothing in the meta layer records a star or a play
1130
+ // count, and an empty answer would claim the collection holds none of them —
1131
+ // a different thing from "this server cannot say".
1132
+ throw new ApiError(ERROR.generic, `getAlbumList2 cannot list by ${type}`);
1133
+ }
1134
+ const size = Math.min(Math.max(numeric(query, 'size', DEFAULT_PAGE), 1), MAX_PAGE);
1135
+ const offset = Math.max(numeric(query, 'offset', 0), 0);
1136
+ return {
1137
+ albumList2: {
1138
+ album: albumList(db, type, size, offset, rootId, visibility).map(albumId3),
1139
+ },
1140
+ };
1141
+ }
1142
+ function directory(id, name, child) {
1143
+ return { directory: { id, name, child } };
1144
+ }
1145
+ /**
1146
+ * A directory and what is under it.
1147
+ *
1148
+ * `-1` is the protocol's way of asking for the top of the tree, which for this
1149
+ * server is the roots it was told to scan. Below that, a folder's children are
1150
+ * its subfolders and the songs of any album sitting in it — one rule, so that a
1151
+ * category of folders, a box, a disc and a plain album all browse the same way
1152
+ * and none of them needs a special case.
1153
+ */
1154
+ export function getMusicDirectory(db, query, visibility = 'records') {
1155
+ const raw = required(query, 'id');
1156
+ const rootId = confinedTo(db, query);
1157
+ // Two entries, and both are reachable on purpose. The library is what the
1158
+ // collection *is* — its artists, with the shelves they were filed on folded
1159
+ // in. The roots below it are what is on the disk, kept as [[wiki:3498]] §5
1160
+ // asks, so a client that wants the filesystem is not told to configure
1161
+ // anything to get it.
1162
+ if (raw === ID.top) {
1163
+ return directory(ID.top, '', [
1164
+ libraryChild(),
1165
+ ...roots(db)
1166
+ .filter((row) => rootId === undefined || row.id === rootId)
1167
+ .map(rootChild),
1168
+ ]);
1169
+ }
1170
+ // Read before `parseId`, which would take the key for a number: this is the
1171
+ // one id kind whose payload is a string.
1172
+ if (raw.startsWith(ID.virtualPrefix)) {
1173
+ const key = raw.slice(ID.virtualPrefix.length);
1174
+ if (key === '') {
1175
+ return directory(ID.library, LIBRARY, virtualNodes(db, rootId, visibility).map((node) => virtualChild(node, ID.library)));
1176
+ }
1177
+ const bar = key.indexOf('|');
1178
+ const nodeKey = bar === -1 ? key : key.slice(0, bar);
1179
+ const creditKey = bar === -1 ? null : key.slice(bar + 1);
1180
+ const node = virtualNodes(db, rootId, visibility).find((one) => one.artistKey === nodeKey);
1181
+ if (node === undefined)
1182
+ throw new ApiError(ERROR.notFound, `No such id: ${raw}`);
1183
+ if (creditKey !== null) {
1184
+ const credit = virtualEntries(db, node, visibility)
1185
+ .flatMap((entry) => (entry.credit === null ? [] : [entry.credit]))
1186
+ .find((one) => one.artistKey === creditKey);
1187
+ if (credit === undefined)
1188
+ throw new ApiError(ERROR.notFound, `No such id: ${raw}`);
1189
+ const drawer = ID.virtual(`${nodeKey}|${creditKey}`);
1190
+ return directory(drawer, credit.name, credit.records.map((row) => albumChild(row, drawer)));
1191
+ }
1192
+ const id = ID.virtual(nodeKey);
1193
+ return directory(id, node.name, virtualEntries(db, node, visibility).map((entry) => entryChild(entry, id, nodeKey)));
1194
+ }
1195
+ const parsed = parseId(raw);
1196
+ if (parsed === undefined)
1197
+ throw new ApiError(ERROR.notFound, `No such id: ${raw}`);
1198
+ if (parsed.kind === 'ro') {
1199
+ const row = root(db, parsed.n);
1200
+ if (row === undefined)
1201
+ throw new ApiError(ERROR.notFound, `No such root: ${raw}`);
1202
+ const id = ID.root(row.id);
1203
+ return directory(id, row.alias ?? lastSegment(row.path), childFolders(db, row.id, '').map((f) => folderChild(f, id)));
1204
+ }
1205
+ if (parsed.kind === 'fd') {
1206
+ const row = folder(db, parsed.n);
1207
+ if (row === undefined)
1208
+ throw new ApiError(ERROR.notFound, `No such folder: ${raw}`);
1209
+ const id = ID.folder(row.id);
1210
+ return directory(id, lastSegment(row.rel_path), [
1211
+ ...childFolders(db, row.root_id, row.rel_path).map((f) => folderChild(f, id)),
1212
+ ...songsInFolder(db, row.root_id, row.rel_path).map((s) => songChild(s, id)),
1213
+ ]);
1214
+ }
1215
+ if (parsed.kind === 'al') {
1216
+ const row = album(db, parsed.n);
1217
+ if (row === undefined)
1218
+ throw new ApiError(ERROR.notFound, `No such album: ${raw}`);
1219
+ const id = ID.album(row.id);
1220
+ return directory(id, row.title ?? '', songsOfAlbum(db, row.id).map((s) => songChild(s, id)));
1221
+ }
1222
+ if (parsed.kind === 'ar') {
1223
+ const row = artist(db, parsed.n);
1224
+ if (row === undefined)
1225
+ throw new ApiError(ERROR.notFound, `No such artist: ${raw}`);
1226
+ // The folder decides where the artist owns one, so an artist who owns a
1227
+ // folder answers exactly as their node does. `ar:` was the tag's answer and
1228
+ // `vn:` the folder's, and one artist reading as two different artists to
1229
+ // two clients that came by different doors is the whole of what was wrong
1230
+ // with that: `Cock E.S.P.` holds twenty-four records in its folder and
1231
+ // thirteen carry its credit.
1232
+ const node = nodeOf(db, row.name_key, rootId, visibility);
1233
+ if (node !== undefined) {
1234
+ const id = ID.virtual(row.name_key);
1235
+ return directory(id, node.name, virtualEntries(db, node, visibility).map((entry) => entryChild(entry, id, row.name_key)));
1236
+ }
1237
+ const id = ID.artist(row.id);
1238
+ return directory(id, row.name, albumsOfArtist(db, row.id, rootId, visibility).map((a) => albumChild(a, id)));
1239
+ }
1240
+ throw new ApiError(ERROR.notFound, `No such id: ${raw}`);
1241
+ }