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,681 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The acceptance checklist of requirements:44 and :47, walked over HTTP.
4
+ *
5
+ * A deployment is not "the process started" — it is a client being able to do
6
+ * every one of the things below against the collection that is actually on the
7
+ * disk. So this asks the running server the questions a client asks, in roughly
8
+ * the order a client asks them, and prints what came back. It is what to run
9
+ * after a deploy, and what to run first when a phone cannot play something.
10
+ *
11
+ * node deploy/smoke.mjs http://127.0.0.1:4533 demo sesame
12
+ *
13
+ * The three arguments fall back to FUNOTEKA_URL, FUNOTEKA_USER and
14
+ * FUNOTEKA_PASSWORD, so a service's own settings can be reused.
15
+ *
16
+ * Exit code 0 when nothing failed, 1 when something did, 2 when it was not told
17
+ * how to ask. A check that could not fail would be a report, not a check.
18
+ *
19
+ * Three outcomes and not two: a step that found nothing to test — no whole file
20
+ * in the collection to range, no album with art — is reported as skipped and
21
+ * does not fail the run. It is a fact about the collection, and calling it a
22
+ * pass would be the lie.
23
+ */
24
+
25
+ const [, , urlArg, userArg, passwordArg] = process.argv;
26
+
27
+ const base = (urlArg ?? process.env.FUNOTEKA_URL ?? 'http://127.0.0.1:4533').replace(/\/+$/, '');
28
+ const user = userArg ?? process.env.FUNOTEKA_USER ?? '';
29
+ const password = passwordArg ?? process.env.FUNOTEKA_PASSWORD ?? '';
30
+
31
+ if (user === '' || password === '') {
32
+ process.stderr.write('usage: node deploy/smoke.mjs [url] <user> <password>\n');
33
+ process.exit(2);
34
+ }
35
+
36
+ const auth = new URLSearchParams({ u: user, p: password, f: 'json' });
37
+
38
+ /**
39
+ * The parameters as the protocol spells them.
40
+ *
41
+ * A name given a list is repeated once per value rather than sent as one joined
42
+ * string: that is how the protocol carries a list — `createPlaylist` takes one
43
+ * `songId` per song — and the server's own reading of a POST body has to keep
44
+ * them all, which is a thing a client cannot check for itself.
45
+ */
46
+ function form(params) {
47
+ const query = new URLSearchParams(auth);
48
+ for (const [key, value] of Object.entries(params)) {
49
+ if (Array.isArray(value)) value.forEach((one) => query.append(key, String(one)));
50
+ else query.set(key, String(value));
51
+ }
52
+ return query;
53
+ }
54
+
55
+ function url(method, params = {}) {
56
+ return `${base}/rest/${method}?${form(params)}`;
57
+ }
58
+
59
+ async function api(method, params = {}) {
60
+ // POST, and the body rather than the URL: this deployment is checked through
61
+ // the road a client that would rather not spell a password in a URL takes,
62
+ // which is also the road a server that reads only the query string fails on.
63
+ const response = await fetch(`${base}/rest/${method}`, {
64
+ method: 'POST',
65
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
66
+ body: form(params),
67
+ });
68
+
69
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
70
+ const body = JSON.parse(await response.text())['subsonic-response'];
71
+ if (body.status !== 'ok') throw new Error(`code ${body.error?.code} — ${body.error?.message}`);
72
+ return body;
73
+ }
74
+
75
+ /**
76
+ * The head of an answer that is bytes: what it says it is, and the first of them.
77
+ *
78
+ * The connection is closed after the first chunk on purpose. A cue track is a
79
+ * stretch of an image and cannot be ranged, so asking one for `bytes=0-99` is
80
+ * answered with the whole track — thirty megabytes of a check that wanted to
81
+ * know whether the bytes flow at all.
82
+ */
83
+ async function peek(method, params = {}, headers = {}) {
84
+ const response = await fetch(url(method, params), { headers });
85
+ const reader = response.body.getReader();
86
+ const first = await reader.read();
87
+ await reader.cancel();
88
+
89
+ return {
90
+ status: response.status,
91
+ contentType: response.headers.get('content-type') ?? '',
92
+ acceptRanges: response.headers.get('accept-ranges'),
93
+ contentRange: response.headers.get('content-range'),
94
+ length: Number(response.headers.get('content-length') ?? '0'),
95
+ firstBytes: first.value?.length ?? 0,
96
+ };
97
+ }
98
+
99
+ /**
100
+ * A phone's capabilities, as the client this extension was built for states them.
101
+ *
102
+ * Three direct-play profiles rather than one, because that is what a real player
103
+ * sends — and it is what made the decision and the stream disagree on the live
104
+ * server the first time this was run: **all but one profile refuses any given
105
+ * file**, and a client told `canDirectPlay: true` beside a list of reasons has
106
+ * been told two things at once, of which the list is the half it acts on.
107
+ */
108
+ const PHONE = {
109
+ name: 'smoke',
110
+ platform: 'Android',
111
+ maxAudioBitrate: 0,
112
+ maxTranscodingAudioBitrate: 0,
113
+ directPlayProfiles: [
114
+ { containers: ['flac'], audioCodecs: ['flac'], protocols: ['http'], maxAudioChannels: 2 },
115
+ { containers: ['m4a', 'mp4'], audioCodecs: ['aac', 'alac'], protocols: ['http'], maxAudioChannels: 2 },
116
+ { containers: ['mp3'], audioCodecs: ['mp3'], protocols: ['http'] },
117
+ ],
118
+ transcodingProfiles: [
119
+ { container: 'mp3', audioCodec: 'mp3', protocol: 'http', maxAudioChannels: 2 },
120
+ { container: 'flac', audioCodec: 'flac', protocol: 'hls', maxAudioChannels: 2 },
121
+ ],
122
+ };
123
+
124
+ /**
125
+ * One `getTranscodeDecision`, asked for the way the specification says to ask.
126
+ *
127
+ * A POST with the capabilities as a JSON body — they are a nested list of
128
+ * profiles and do not fit in a query string, which is the whole reason the
129
+ * method is a POST in the first place.
130
+ */
131
+ async function decide(mediaId, capabilities) {
132
+ const response = await fetch(`${base}/rest/getTranscodeDecision?${form({ mediaId, mediaType: 'song' })}`, {
133
+ method: 'POST',
134
+ headers: { 'content-type': 'application/json' },
135
+ body: JSON.stringify(capabilities),
136
+ });
137
+ const body = JSON.parse(await response.text())['subsonic-response'];
138
+ if (body.status !== 'ok') throw new Error(`code ${body.error?.code} — ${body.error?.message}`);
139
+ return body.transcodeDecision;
140
+ }
141
+
142
+ const results = [];
143
+
144
+ async function step(name, work) {
145
+ try {
146
+ const detail = await work();
147
+ if (detail && typeof detail === 'object' && 'skipped' in detail) {
148
+ results.push(['skip', name, detail.skipped]);
149
+ return;
150
+ }
151
+ results.push(['ok', name, detail ?? '']);
152
+ } catch (err) {
153
+ results.push(['fail', name, err.message]);
154
+ }
155
+ }
156
+
157
+ await step('ping answers at all', async () => {
158
+ await api('ping');
159
+ return base;
160
+ });
161
+
162
+ await step('the license is valid, which clients check before anything else', async () => {
163
+ const body = await api('getLicense');
164
+ if (body.license?.valid !== true) throw new Error('the license is not valid');
165
+ return 'valid';
166
+ });
167
+
168
+ await step('the scan status says how big the library is', async () => {
169
+ const body = await api('getScanStatus');
170
+ if (!(body.scanStatus.count > 0)) throw new Error('no songs — has this database been scanned?');
171
+ return `${body.scanStatus.count} songs`;
172
+ });
173
+
174
+ const artists = [];
175
+ await step('artists are browsable, in every letter they fall under', async () => {
176
+ const body = await api('getIndexes');
177
+ for (const group of body.indexes.index ?? []) artists.push(...(group.artist ?? []));
178
+ if (artists.length === 0) throw new Error('no artists in the index');
179
+ return `${artists.length} artists under ${(body.indexes.index ?? []).length} letters`;
180
+ });
181
+
182
+ // Sampled across the collection rather than from its head. A record cut from a
183
+ // cue image and a record of whole files look nothing alike to a client, and a
184
+ // check that only ever met the first five artists would report the cue path as
185
+ // absent in a library that is mostly cue images — the one thing this server
186
+ // does that others do not.
187
+ const MEDLEY = 12;
188
+
189
+ const albums = [];
190
+ await step('an artist lists its records', async () => {
191
+ const spread = artists.filter((_, at) => at % Math.max(1, Math.floor(artists.length / MEDLEY)) === 0);
192
+ for (const artist of spread.slice(0, MEDLEY)) {
193
+ const body = await api('getArtist', { id: artist.id });
194
+ albums.push(...(body.artist.album ?? []));
195
+ }
196
+ if (albums.length === 0) throw new Error('no records under the artists sampled');
197
+ return `${albums.length} records under ${Math.min(spread.length, MEDLEY)} artists`;
198
+ });
199
+
200
+ const songs = [];
201
+ await step('a record lists its songs', async () => {
202
+ for (const album of albums.slice(0, MEDLEY)) {
203
+ const body = await api('getAlbum', { id: album.id });
204
+ songs.push(...(body.album.song ?? []));
205
+ }
206
+ if (songs.length === 0) throw new Error('no songs on the records sampled');
207
+ return `${songs.length} songs`;
208
+ });
209
+
210
+ await step('a playlist is made, edited, listed and taken away', async () => {
211
+ // The one part of this API that writes, and so the one part a deployment can
212
+ // get wrong without any answer looking different: a list saved short, a
213
+ // rename that emptied it, a deletion that left it behind. It is checked on
214
+ // the live library because that is where the client that makes playlists
215
+ // actually is — and it puts the library back the way it found it.
216
+ const first = songs[0].id;
217
+ const second = songs[1]?.id ?? first;
218
+
219
+ const made = await api('createPlaylist', { name: 'smoke — плейлист', songId: [first, second] });
220
+ const id = made.playlist?.id;
221
+ if (!id) throw new Error('createPlaylist answered without an id');
222
+ if (made.playlist.songCount !== 2) {
223
+ throw new Error(`made a playlist of ${made.playlist.songCount} songs out of two`);
224
+ }
225
+
226
+ const opened = await api('getPlaylist', { id });
227
+ if ((opened.playlist.entry ?? []).length !== 2) throw new Error('the playlist lost its songs');
228
+
229
+ await api('updatePlaylist', {
230
+ playlistId: id,
231
+ name: 'smoke — правленый',
232
+ songIndexToRemove: '0',
233
+ songIdToAdd: first,
234
+ });
235
+ const edited = await api('getPlaylist', { id });
236
+ if (edited.playlist.name !== 'smoke — правленый') throw new Error('the rename did not stick');
237
+ if (edited.playlist.entry?.[0]?.id !== second) throw new Error('the removal did not stick');
238
+
239
+ const listed = await api('getPlaylists');
240
+ if (!(listed.playlists.playlist ?? []).some((one) => one.id === id)) {
241
+ throw new Error('the playlist is not in the list');
242
+ }
243
+
244
+ await api('deletePlaylist', { id });
245
+ const after = await api('getPlaylists');
246
+ if ((after.playlists.playlist ?? []).some((one) => one.id === id)) {
247
+ throw new Error('the playlist is still there after being deleted');
248
+ }
249
+
250
+ return `made, renamed, edited and deleted ${id}`;
251
+ });
252
+
253
+ await step('a song can be starred and rated, and says so where a client looks', async () => {
254
+ // The rest of the writing surface, and the same acceptance question as the
255
+ // playlist above: a mark is worth nothing if only the method that set it knows
256
+ // about it. So it is set, read back through `getSong` (which is what a client
257
+ // draws a star from), found in the starred listing, and taken off again.
258
+ //
259
+ // **What was there before is read first and put back afterwards.** This is a
260
+ // library somebody listens to: a check that cleared a star the operator had
261
+ // set would be a check that costs them something, and "the library is left as
262
+ // it was found" would be false in the one case where it matters.
263
+ const song = songs[0].id;
264
+ const before = (await api('getSong', { id: song })).song;
265
+
266
+ await api('star', { id: song });
267
+ await api('setRating', { id: song, rating: '4' });
268
+
269
+ const one = await api('getSong', { id: song });
270
+ if (!one.song?.starred) throw new Error('the song does not say it is starred');
271
+ if (one.song.userRating !== 4) throw new Error(`rated ${one.song.userRating}, asked for 4`);
272
+
273
+ const starred = await api('getStarred2');
274
+ if (!(starred.starred2.song ?? []).some((entry) => entry.id === song)) {
275
+ throw new Error('the song is not among the starred');
276
+ }
277
+
278
+ await api('unstar', { id: song });
279
+ await api('setRating', { id: song, rating: '0' });
280
+ const cleared = await api('getSong', { id: song });
281
+ if (cleared.song.starred !== undefined || cleared.song.userRating !== undefined) {
282
+ throw new Error('the marks did not come off');
283
+ }
284
+
285
+ // Back the way it was: a star the operator had set is set again, and a rating
286
+ // is put back to what it was — including none at all.
287
+ if (before.starred !== undefined) await api('star', { id: song });
288
+ if (before.userRating !== undefined) {
289
+ await api('setRating', { id: song, rating: String(before.userRating) });
290
+ }
291
+
292
+ const marks = before.starred === undefined && before.userRating === undefined ? 'was unmarked' : 'was marked';
293
+ return `${song}: starred, rated 4, listed, cleared (${marks})`;
294
+ });
295
+
296
+ await step('a song is served as audio, and the bytes arrive', async () => {
297
+ const served = await peek('stream', { id: songs[0].id });
298
+ if (served.status !== 200) throw new Error(`HTTP ${served.status}`);
299
+ if (!served.contentType.startsWith('audio/')) throw new Error(`served as ${served.contentType}`);
300
+ if (served.firstBytes === 0) throw new Error('the answer had no bytes in it');
301
+ return `${served.firstBytes} bytes of ${served.contentType} (${served.length} in all)`;
302
+ });
303
+
304
+ await step('a whole file is rangeable, which is how seeking works', async () => {
305
+ for (const song of songs.slice(0, 60)) {
306
+ const ranged = await peek('stream', { id: song.id }, { range: 'bytes=0-99' });
307
+ if (ranged.status === 206) {
308
+ if (ranged.firstBytes !== 100) throw new Error(`206 answered with ${ranged.firstBytes} bytes`);
309
+ if (!/^bytes 0-99\//.test(ranged.contentRange ?? '')) {
310
+ throw new Error(`206 without a content-range saying which bytes: ${ranged.contentRange}`);
311
+ }
312
+ return `${song.title} — 206, ${ranged.contentRange}`;
313
+ }
314
+ if (ranged.status === 200 && ranged.acceptRanges !== 'bytes') continue; // a cue track
315
+ throw new Error(`${song.title}: HTTP ${ranged.status}, accept-ranges ${ranged.acceptRanges}`);
316
+ }
317
+ return { skipped: `no whole-file song among the ${Math.min(songs.length, 60)} sampled — every one is cut from an image` };
318
+ });
319
+
320
+ await step('a track cut out of an image is served as a track, and says so', async () => {
321
+ // Found rather than guessed at: several songs of one record that name **the
322
+ // same file**. A cue-split record is the only shape that looks like that —
323
+ // every other song is its own file.
324
+ //
325
+ // The detector used to look for songs of one record that all reported *the
326
+ // image's size*, which was the same fact seen through a defect: `size` is the
327
+ // song's length, and answering with the file's is what this step exists to
328
+ // catch one field over (`9b91ccf`). It skipped honestly for a day, and the
329
+ // skip is why the change was noticed in the smoke at all.
330
+ const page = await api('search3', { query: '', songCount: 500 });
331
+ const byAlbum = new Map();
332
+ for (const song of page.searchResult3.song ?? []) {
333
+ if (song.albumId === undefined) continue;
334
+ if (!byAlbum.has(song.albumId)) byAlbum.set(song.albumId, []);
335
+ byAlbum.get(song.albumId).push(song);
336
+ }
337
+
338
+ const cut = [...byAlbum.values()].find(
339
+ (group) => group.length >= 2 && group.every((song) => song.path === group[0].path),
340
+ );
341
+ if (cut === undefined) {
342
+ return { skipped: 'no cue-split record among the first 500 songs — this library is all whole files' };
343
+ }
344
+
345
+ // What a segment promises changed when seeking was made to work (`6fea2b6`):
346
+ // the answer is rebuilt from the frames the track covers, so a range is
347
+ // honoured and its total is the *segment's* length. The regression this step
348
+ // catches is the answer being the image instead — a total an order of
349
+ // magnitude over the track, which every other step here would pass.
350
+ const served = await peek('stream', { id: cut[0].id }, { range: 'bytes=0-99' });
351
+ const total = Number((served.contentRange ?? '').split('/')[1] ?? '0');
352
+
353
+ if (served.status !== 206) {
354
+ throw new Error(`${cut[0].title}: HTTP ${served.status}, expected 206 for a range`);
355
+ }
356
+ if (served.acceptRanges !== 'bytes') {
357
+ throw new Error(`${cut[0].title}: accept-ranges ${served.acceptRanges}, expected bytes`);
358
+ }
359
+ if (served.length !== 100) {
360
+ throw new Error(`${cut[0].title}: content-length ${served.length}, expected 100`);
361
+ }
362
+ if (total === 0 || total > cut[0].size * 1.5) {
363
+ throw new Error(
364
+ `${cut[0].title}: the range totals ${total} bytes against a song reported at ${cut[0].size} — the answer is the image, not the track`,
365
+ );
366
+ }
367
+
368
+ // And the field the operator found: songs of one image no longer answer with
369
+ // the image's length. Each is its own share of it, so whenever the lengths
370
+ // differ the sizes have to differ too — the one assertion that tells a fixed
371
+ // `size` from a merely smaller wrong one.
372
+ const durations = new Set(cut.map((song) => song.duration));
373
+ const sizes = new Set(cut.map((song) => song.size));
374
+ if (durations.size > 1 && sizes.size === 1) {
375
+ throw new Error(
376
+ `${cut.length} songs of one image of different lengths all report ${cut[0].size} bytes`,
377
+ );
378
+ }
379
+
380
+ return `${cut[0].title} (${cut.length} tracks of one image) — 206, a ${total}-byte track, reported at ${cut[0].size}`;
381
+ });
382
+
383
+ await step('a record has cover art', async () => {
384
+ let withArt = 0;
385
+ for (const album of albums.slice(0, 10)) {
386
+ const cover = await peek('getCoverArt', { id: album.id });
387
+ if (cover.status === 200 && cover.contentType.startsWith('image/')) withArt += 1;
388
+ }
389
+ if (withArt === 0) throw new Error('not one of the ten sampled records answered with an image');
390
+ return `${withArt}/${Math.min(albums.length, 10)} records of the sample`;
391
+ });
392
+
393
+ await step('a search finds what is in the collection', async () => {
394
+ const body = await api('search3', { query: '', songCount: 1 });
395
+ const found = body.searchResult3.song ?? [];
396
+ if (found.length === 0) throw new Error('an empty query must answer with the library — that is how a client syncs');
397
+ return `${found[0].title} — ${found[0].artist}`;
398
+ });
399
+
400
+ await step('a search by a word answers', async () => {
401
+ const body = await api('search3', { query: 'а', songCount: 1 });
402
+ return `${(body.searchResult3.song ?? []).length} song(s) for "а"`;
403
+ });
404
+
405
+ await step('the collection browses as folders too', async () => {
406
+ const body = await api('getMusicDirectory', { id: '-1' });
407
+ const roots = body.directory.child ?? [];
408
+ if (roots.length === 0) throw new Error('no roots');
409
+ return `${roots.length} root(s)`;
410
+ });
411
+
412
+ // The v1.2 surface: the listener's own layer, the delivery layer's parameters,
413
+ // and the two answers about the build itself. Each was added when its stage
414
+ // landed, and the deployment gate had gone without them — which is how a reader
415
+ // change that never reached a file (`tags/read.ts`, `TAGS_METHOD`) got past a
416
+ // green smoke run and had to be found by a review instead (task:2869).
417
+
418
+ await step('the queue is saved, read back, and put back as it was', async () => {
419
+ // **The queue is the listener's own state, and a check that clobbered it
420
+ // would be a check nobody dares run on a deployment.** So what was there is
421
+ // read first and restored at the end — including the empty case, which the
422
+ // protocol can express: no `id` at all clears the queue (`history.ts`, "send
423
+ // no id to clear"). The operator's queue was destroyed once by a test that
424
+ // cleaned up "after itself" and took his with it (task:2863).
425
+ const before = await api('getPlayQueue');
426
+ const was = (before.playQueue.entry ?? []).map((entry) => entry.id);
427
+
428
+ const { searchResult3 } = await api('search3', { query: '', songCount: 3 });
429
+ const songs = (searchResult3.song ?? []).map((song) => song.id);
430
+ if (songs.length < 2) {
431
+ return { skipped: 'the collection answers with fewer than two songs' };
432
+ }
433
+
434
+ await api('savePlayQueue', { id: songs, current: songs[0], position: 1234 });
435
+ const saved = await api('getPlayQueue');
436
+ const back = (saved.playQueue.entry ?? []).map((entry) => entry.id);
437
+ if (back.join(',') !== songs.join(',')) {
438
+ throw new Error(`saved ${songs.join(',')} and read back ${back.join(',')}`);
439
+ }
440
+ if (saved.playQueue.position !== 1234) {
441
+ throw new Error(`position came back as ${saved.playQueue.position}, not 1234`);
442
+ }
443
+
444
+ // Put it back, whatever it was.
445
+ if (was.length === 0) {
446
+ await api('savePlayQueue', {});
447
+ } else {
448
+ await api('savePlayQueue', { id: was, current: before.playQueue.current ?? was[0] });
449
+ }
450
+ return `${back.length} entries round-tripped; the queue it found is back`;
451
+ });
452
+
453
+ await step('a play is recorded, and what is on now says so', async () => {
454
+ const { searchResult3 } = await api('search3', { query: '', songCount: 1 });
455
+ const song = (searchResult3.song ?? [])[0];
456
+ if (song === undefined) return { skipped: 'no songs' };
457
+
458
+ const played = await api('getSong', { id: song.id });
459
+ const before = played.song.playCount ?? 0;
460
+
461
+ await api('scrobble', { id: song.id, submission: true });
462
+ const after = await api('getSong', { id: song.id });
463
+ if ((after.song.playCount ?? 0) !== before + 1) {
464
+ throw new Error(`playCount ${before} → ${after.song.playCount}, and a scrobble was sent`);
465
+ }
466
+ if (after.song.played === undefined || after.song.played === '') {
467
+ throw new Error('a scrobble left nothing saying when');
468
+ }
469
+
470
+ // `getNowPlaying` is a different question — what is on *now* — and it is
471
+ // answered even with nobody listening, which is what an idle server is.
472
+ const now = await api('getNowPlaying');
473
+ const on = (now.nowPlaying.entry ?? []).length;
474
+
475
+ // The scrobble is a row about this listener and is left behind on purpose:
476
+ // it is what the field is for, and undoing it would mean a second endpoint.
477
+ return `${song.title}: playCount ${before} → ${after.song.playCount}, ${on} playing now`;
478
+ });
479
+
480
+ await step('something to open on is drawn from the collection', async () => {
481
+ const body = await api('getRandomSongs', { size: 5 });
482
+ const drawn = body.randomSongs.song ?? [];
483
+ if (drawn.length === 0) throw new Error('a draw answered with nothing to play');
484
+ if (drawn.some((song) => song.id === undefined || song.id === '')) {
485
+ throw new Error('a drawn song has no id, so nothing can be done with it');
486
+ }
487
+ return `${drawn.length} drawn, first is ${drawn[0].title}`;
488
+ });
489
+
490
+ await step('a song can be taken away whole, named as a file', async () => {
491
+ const { searchResult3 } = await api('search3', { query: '', songCount: 1 });
492
+ const song = (searchResult3.song ?? [])[0];
493
+ if (song === undefined) return { skipped: 'no songs' };
494
+
495
+ const response = await fetch(url('download', { id: song.id }));
496
+ // One chunk and the connection is dropped, like `peek`: a check that wanted
497
+ // the header has no business pulling a whole record through the wire.
498
+ const reader = response.body.getReader();
499
+ await reader.read();
500
+ await reader.cancel();
501
+
502
+ const disposition = response.headers.get('content-disposition') ?? '';
503
+ if (response.status !== 200) throw new Error(`HTTP ${response.status}`);
504
+ if (!disposition.includes('attachment')) {
505
+ throw new Error(`no attachment name for a download: ${disposition}`);
506
+ }
507
+ if (disposition.includes('${')) {
508
+ throw new Error(`the file name is not a header value: ${disposition}`);
509
+ }
510
+ return `${song.title} — ${disposition.split('filename')[1]?.slice(0, 40) ?? ''}`;
511
+ });
512
+
513
+ await step('a format asked for by name is the format that comes back', async () => {
514
+ // The parameter pair a client actually sends. `format=raw` beside it means
515
+ // "do not transcode", which is the same promise `download` makes, so these
516
+ // two are the whole of what the delivery layer adds over the file.
517
+ const body = await api('getAlbumList2', { type: 'alphabeticalByName', size: 500 });
518
+ const albums = body.albumList2.album ?? [];
519
+
520
+ let tried = 0;
521
+ for (const album of albums) {
522
+ const detail = await api('getAlbum', { id: album.id });
523
+ const song = (detail.album.song ?? []).find((one) => one.suffix === 'flac');
524
+ if (song === undefined) continue;
525
+
526
+ tried += 1;
527
+ const got = await peek('stream', { id: song.id, format: 'mp3', maxBitRate: 128 });
528
+ if (got.status !== 200) throw new Error(`HTTP ${got.status} for ${song.title}`);
529
+ if (!got.contentType.startsWith('audio/mpeg')) {
530
+ throw new Error(`${song.title}: asked for mp3, got ${got.contentType}`);
531
+ }
532
+ return `${song.title} — flac in, ${got.contentType} out`;
533
+ }
534
+ return { skipped: `no flac song found among ${albums.length} records` };
535
+ });
536
+
537
+ await step('the server says what it is, to a client with no credentials', async () => {
538
+ // Public by the specification's own demand ("must be publicly accessible"),
539
+ // because a client asks this before it hands over a password.
540
+ const response = await fetch(`${base}/rest/getOpenSubsonicExtensions?f=json`);
541
+ const body = JSON.parse(await response.text())['subsonic-response'];
542
+ if (body.status !== 'ok') throw new Error(`code ${body.error?.code} without credentials`);
543
+ if (body.openSubsonic !== true) throw new Error('the envelope does not claim OpenSubsonic');
544
+ if (body.serverVersion === undefined || body.serverVersion === '') {
545
+ throw new Error('no serverVersion, which is how a client knows to ask this again');
546
+ }
547
+
548
+ const named = (body.openSubsonicExtensions ?? []).map((one) => one.name);
549
+ if (named.length === 0) throw new Error('a build that supports nothing says nothing');
550
+ return named.join(', ');
551
+ });
552
+
553
+ /** A flac song from the records sampled, which is what the three checks below want. */
554
+ const flacSong = songs.find((one) => one.suffix === 'flac');
555
+
556
+ await step('a client is told what would happen to a song, and what it would be given instead', async () => {
557
+ if (flacSong === undefined) return { skipped: 'no flac song among the records sampled' };
558
+
559
+ const decision = await decide(flacSong.id, PHONE);
560
+ if (decision.canDirectPlay !== true) {
561
+ throw new Error(`${flacSong.title}: a FLAC profile against a FLAC file answered canDirectPlay ${decision.canDirectPlay}`);
562
+ }
563
+ if (decision.transcodeReason !== undefined) {
564
+ throw new Error(`${flacSong.title}: nothing needs transcoding, yet reasons were given: ${decision.transcodeReason.join(', ')}`);
565
+ }
566
+ if (!decision.transcodeParams) throw new Error('a decision that can transcode carries a token');
567
+ if (decision.sourceStream?.codec !== 'flac') {
568
+ throw new Error(`${flacSong.title}: the source is described as ${decision.sourceStream?.codec}`);
569
+ }
570
+ if (decision.sourceStream?.container !== 'flac') {
571
+ throw new Error(`${flacSong.title}: the source container is ${decision.sourceStream?.container}`);
572
+ }
573
+ if (decision.transcodeStream?.codec !== 'mp3') {
574
+ throw new Error(`the answer would be ${decision.transcodeStream?.codec}, not the mp3 the profile names`);
575
+ }
576
+ return `${flacSong.title} — direct play, or ${decision.transcodeStream.container} as ${decision.transcodeStream.codec}`;
577
+ });
578
+
579
+ await step('the stream a decision promised is the stream that comes back', async () => {
580
+ // A FLAC profile against a FLAC song, so what `stream` answers is the file
581
+ // itself rather than a re-encode — what is being checked is the token's road
582
+ // from one call to the other, and not ffmpeg.
583
+ if (flacSong === undefined) return { skipped: 'no flac song among the records sampled' };
584
+
585
+ const decision = await decide(flacSong.id, {
586
+ name: 'smoke',
587
+ platform: 'Android',
588
+ directPlayProfiles: [{ containers: ['flac'], audioCodecs: ['flac'], protocols: ['http'] }],
589
+ transcodingProfiles: [{ container: 'flac', audioCodec: 'flac', protocol: 'http' }],
590
+ });
591
+ if (!decision.transcodeParams) throw new Error('no token to ask the stream with');
592
+
593
+ const got = await peek('getTranscodeStream', {
594
+ mediaId: flacSong.id,
595
+ mediaType: 'song',
596
+ transcodeParams: decision.transcodeParams,
597
+ });
598
+ if (got.status !== 200) throw new Error(`HTTP ${got.status}`);
599
+ if (!got.contentType.startsWith('audio/flac')) {
600
+ throw new Error(`${flacSong.title}: asked for flac, got ${got.contentType}`);
601
+ }
602
+ return `${flacSong.title} — ${got.firstBytes} bytes of ${got.contentType}`;
603
+ });
604
+
605
+ await step('a client whose profiles are all hls is refused, not promised a stream', async () => {
606
+ // The volume decision of the extension: this server produces no HLS, so a
607
+ // profile over that transport is skipped rather than answered with a token
608
+ // that leads to an error the client cannot read.
609
+ if (flacSong === undefined) return { skipped: 'no flac song among the records sampled' };
610
+
611
+ const decision = await decide(flacSong.id, {
612
+ name: 'smoke — hls only',
613
+ platform: 'Android',
614
+ directPlayProfiles: [],
615
+ transcodingProfiles: [{ container: 'flac', audioCodec: 'flac', protocol: 'hls' }],
616
+ });
617
+ if (decision.canTranscode !== false) throw new Error('hls was offered, and this server makes none');
618
+ if (decision.transcodeParams !== undefined) {
619
+ throw new Error('a token was issued for a stream that cannot be produced');
620
+ }
621
+ if (!/http/.test(String(decision.errorReason ?? ''))) {
622
+ throw new Error(`the refusal does not say what would work: ${decision.errorReason}`);
623
+ }
624
+ return 'canTranscode false, and the refusal names the transport that works';
625
+ });
626
+
627
+ await step('a song says how it is rated, even when the collection says nothing', async () => {
628
+ const { searchResult3 } = await api('search3', { query: '', songCount: 5 });
629
+ const songs = searchResult3.song ?? [];
630
+ if (songs.length === 0) return { skipped: 'no songs' };
631
+
632
+ // The field always answers — the protocol gives it a third value and it is
633
+ // the empty string — so `undefined` here is a client reading a field that is
634
+ // not there, which is a different thing from a song nobody rated.
635
+ const silent = songs.filter((song) => song.explicitStatus === undefined);
636
+ if (silent.length > 0) throw new Error(`${silent.length} of ${songs.length} songs have no explicitStatus at all`);
637
+
638
+ const rated = songs.filter((song) => song.explicitStatus !== '').length;
639
+ return `${songs.length} songs, ${rated} carry a rating, the rest answer ""`;
640
+ });
641
+
642
+ await step('what is not a record is hidden, and the switch shows it', async () => {
643
+ const asked = async (extra) => {
644
+ const body = await api('getAlbumList2', { type: 'alphabeticalByName', size: 500, ...extra });
645
+ return (body.albumList2.album ?? []).map((album) => album.name);
646
+ };
647
+
648
+ const hidden = await asked({});
649
+ const shown = await asked({ showJunk: 1 });
650
+ if (shown.length < hidden.length) {
651
+ throw new Error(`the switch showed fewer records (${shown.length}) than the default (${hidden.length})`);
652
+ }
653
+ const kept = shown.filter((name) => !hidden.includes(name));
654
+
655
+ // The collection may honestly hold nothing to hide — the filter would then be
656
+ // untested rather than passing, which is what `skip` is for.
657
+ if (kept.length === 0) return { skipped: 'nothing in this collection is marked as junk' };
658
+ return `${kept.length} hidden of ${shown.length}: ${kept.join(', ')}`;
659
+ });
660
+
661
+ await step('wrong credentials are refused', async () => {
662
+ const query = new URLSearchParams({ u: user, p: `${password}-wrong`, f: 'json' });
663
+ const response = await fetch(`${base}/rest/ping?${query}`);
664
+ const body = JSON.parse(await response.text())['subsonic-response'];
665
+ if (body.status !== 'failed') throw new Error('a wrong password was accepted');
666
+ return `code ${body.error?.code}`;
667
+ });
668
+
669
+ const marks = { ok: ' ok ', fail: ' FAIL ', skip: ' skip ' };
670
+ for (const [outcome, name, detail] of results) {
671
+ process.stdout.write(`${marks[outcome]} ${name}${detail === '' ? '' : ` — ${detail}`}\n`);
672
+ }
673
+
674
+ const failed = results.filter(([outcome]) => outcome === 'fail').length;
675
+ const passed = results.filter(([outcome]) => outcome === 'ok').length;
676
+ const skipped = results.filter(([outcome]) => outcome === 'skip').length;
677
+
678
+ process.stdout.write(
679
+ `\n${passed}/${results.length} passed${skipped === 0 ? '' : `, ${skipped} skipped`} against ${base}\n`,
680
+ );
681
+ process.exit(failed === 0 ? 0 : 1);