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,73 @@
1
+ import { ID } from "./browse.js";
2
+ import { ApiError, ERROR } from "./envelope.js";
3
+ import { roots } from "./meta.js";
4
+ /**
5
+ * Who the caller is, as the protocol asks it.
6
+ *
7
+ * A client asks this on the way in — Feishin asks it before it will add a
8
+ * server at all — and reads a record of roles out of the answer. This server
9
+ * has exactly one user, the one it was configured with, and that user owns the
10
+ * whole library: there is no sharing, no guest, and nothing a second account
11
+ * could be refused, so every role is granted. Answering with a smaller set
12
+ * would be describing a permission system that does not exist.
13
+ *
14
+ * What roles *mean* here is worth being plain about, since several of them name
15
+ * features this server does not have: `shareRole`, `jukeboxRole` and
16
+ * `videoConversionRole` are granted because the account is not restricted, not
17
+ * because the features are implemented. A client that reads them as promises
18
+ * will ask for something that is missing — and be refused by name, which is
19
+ * this API's answer to everything it cannot do.
20
+ *
21
+ * `getUsers` answers the same record inside a list, and that is the operator's
22
+ * call rather than the contract's. The contract put user *management* out of
23
+ * scope — «users (кроме getUser)» among the stubs — and a stub answering an
24
+ * empty list was written first. What that cost was visible in one session: a
25
+ * client asks `getUsers` and is told the server has no users, then asks `getUser`
26
+ * and is told about one. Asked which answer it should give, the operator said
27
+ * this one. `wiki:3640` records the reversal; nothing else changed, because a
28
+ * list of one is the same record.
29
+ */
30
+ function record(db, config) {
31
+ return {
32
+ username: config.user,
33
+ email: '',
34
+ scrobblingEnabled: false,
35
+ adminRole: true,
36
+ settingsRole: true,
37
+ downloadRole: true,
38
+ uploadRole: true,
39
+ playlistRole: true,
40
+ coverArtRole: true,
41
+ commentRole: true,
42
+ podcastRole: true,
43
+ streamRole: true,
44
+ jukeboxRole: true,
45
+ shareRole: true,
46
+ videoConversionRole: true,
47
+ // The music folders the account may read, by the same ids `getMusicFolders`
48
+ // hands out: a client that filters a search by one has to be able to learn
49
+ // which ones it is allowed to name.
50
+ folder: roots(db).map((row) => ID.root(row.id)),
51
+ };
52
+ }
53
+ export function getUser(db, config, requested) {
54
+ // "Only the current user is allowed to be retrieved", the protocol says, and
55
+ // the caller is already the configured one — so a name that is not that one
56
+ // asks about an account this server has never heard of, and saying so is
57
+ // truer than answering about somebody else.
58
+ if (requested !== null && requested !== '' && requested !== config.user) {
59
+ throw new ApiError(ERROR.notFound, `No such user: ${requested}`);
60
+ }
61
+ return { user: record(db, config) };
62
+ }
63
+ /**
64
+ * Every account, which is the one there is.
65
+ *
66
+ * The protocol requires administrative rights for this method, and there is
67
+ * nothing here for that to mean: the only account is the one that owns the
68
+ * server, and it is granted every role (see above). A refusal would describe a
69
+ * permission system this server does not have.
70
+ */
71
+ export function getUsers(db, config) {
72
+ return { users: { user: [record(db, config)] } };
73
+ }
@@ -0,0 +1,362 @@
1
+ import { artistName, artistOwning } from "../artist/name.js";
2
+ import { basenameOf } from "../util/names.js";
3
+ import { artists, childFolders, discPaths, recordsUnder, recordsUnderCount, recordsUnderCounts, roots, } from "./meta.js";
4
+ /**
5
+ * The library's own shape: what a client is shown at the top.
6
+ *
7
+ * The filesystem is raw material ([[wiki:3498]] §0), and the top of a
8
+ * collector's tree is the one place it shows undigested. `Slipknot`,
9
+ * `Slipknot AAC 320` and `Slipknot ALAC` are three folders for one artist;
10
+ * `The Cure` beside `The Cure - Assemblage - 1991 (12CD FLAC)` is one artist
11
+ * and one record of theirs that never got filed; and a thousand folders of
12
+ * installers sit among them, because the root of this collection is also the
13
+ * operator's Downloads.
14
+ *
15
+ * This answers what that level *is* and decides nothing deeper. Below the top
16
+ * the folders are the collector's own navigation — `Compilations/`, `Deluxe
17
+ * Editions/`, `Live Albums/` — and they stay exactly as they are. The operator's
18
+ * word on the whole arrangement is that they know what they put where, and the
19
+ * machine's part is to help a little.
20
+ *
21
+ * The rule is the one `shelf-name.ts` already spells out, read the other way
22
+ * round: a shelf's name is the artist's name *plus* something, so a folder whose
23
+ * name opens with an artist's name belongs to that artist. Where `shelf-name`
24
+ * subtracts the artist to name the record, this subtracts the folder to gather
25
+ * the artist.
26
+ *
27
+ * What it deliberately does not do is merge by script. `Кино` and `Kino` stay
28
+ * two nodes, and so do `Аквариум` and `Aquarium`: Cyrillic-to-Latin is a mapping
29
+ * between alphabets rather than a fold, and `translit.ts` is where that argument
30
+ * is made. Accents are a different matter and `artistName` has already folded
31
+ * them, which is why `Röyksopp` is one node here and not two.
32
+ */
33
+ /** What the virtual top calls itself. The client shows this as the library. */
34
+ export const LIBRARY = 'Музыка';
35
+ /**
36
+ * The nodes of the virtual top, in the order a client should show them.
37
+ *
38
+ * Gathered across every root rather than one at a time: the same artist may be
39
+ * filed under two of them, and two nodes called `Кино` would be the defect this
40
+ * exists to remove.
41
+ */
42
+ export function virtualNodes(db, rootId, visibility = 'records') {
43
+ const known = new Map();
44
+ for (const artist of artists(db, undefined, visibility)) {
45
+ known.set(artist.name_key, {
46
+ name: artist.name,
47
+ sort: artist.sort_key ?? artist.name,
48
+ id: artist.id,
49
+ });
50
+ }
51
+ const nodes = [];
52
+ const gathered = new Map();
53
+ for (const root of roots(db)) {
54
+ if (rootId !== undefined && root.id !== rootId)
55
+ continue;
56
+ for (const folder of childFolders(db, root.id, '', visibility)) {
57
+ // A folder holding nothing playable anywhere beneath it is not part of
58
+ // the library: on this collection that is 1029 of the 1558 folders, and
59
+ // every one of them an installer, a driver or a backup.
60
+ if (folder.role === 'empty')
61
+ continue;
62
+ const where = { rootId: root.id, relPath: folder.rel_path };
63
+ const owner = artistOwning(basenameOf(folder.rel_path), known.keys());
64
+ if (owner === null) {
65
+ // Not an artist's: a series, a compilation shelf, or something that is
66
+ // not music at all. It keeps its own name, because that is what the
67
+ // collector called it.
68
+ nodes.push({
69
+ artistKey: null,
70
+ artistId: null,
71
+ name: basenameOf(folder.rel_path),
72
+ folderId: folder.id,
73
+ folders: [where],
74
+ sort: basenameOf(folder.rel_path),
75
+ // Filled in below, once every node's folders are known.
76
+ records: 0,
77
+ });
78
+ continue;
79
+ }
80
+ const already = gathered.get(owner);
81
+ if (already !== undefined) {
82
+ already.folders.push(where);
83
+ continue;
84
+ }
85
+ const artist = known.get(owner);
86
+ const node = {
87
+ artistKey: owner,
88
+ artistId: artist?.id ?? null,
89
+ name: artist?.name ?? owner,
90
+ folderId: folder.id,
91
+ folders: [where],
92
+ sort: artist?.sort ?? owner,
93
+ records: 0,
94
+ };
95
+ gathered.set(owner, node);
96
+ nodes.push(node);
97
+ }
98
+ }
99
+ // Counted before the nodes are handed out, because a node with nothing under
100
+ // it is not a place a client should be sent. See `VirtualNode.records`.
101
+ const counts = virtualRecordCounts(db, nodes, visibility);
102
+ const held = nodes
103
+ .map((node, at) => ({ ...node, records: counts[at] ?? 0 }))
104
+ .filter((node) => node.records > 0);
105
+ // Code units rather than `localeCompare`, whose ordering depends on the host's
106
+ // ICU data: the same unchanged collection must not list artists differently
107
+ // on two machines. Same rule as the artist stage's display name.
108
+ return held.sort((a, b) => cmp(a.sort, b.sort) || cmp(a.name, b.name) || a.folderId - b.folderId);
109
+ }
110
+ function cmp(a, b) {
111
+ return a < b ? -1 : a > b ? 1 : 0;
112
+ }
113
+ /**
114
+ * The records a node holds, gathered from every folder that folded into it.
115
+ *
116
+ * A node may stand for folders in more than one root, so the question is asked
117
+ * per root and the answers joined in the order the folders were gathered — the
118
+ * roots' own order, which is the order the operator configured them in.
119
+ */
120
+ export function virtualRecords(db, node, visibility = 'records') {
121
+ const records = [];
122
+ for (const [rootId, paths] of pathsByRoot(node)) {
123
+ records.push(...recordsUnder(db, rootId, paths, visibility));
124
+ }
125
+ return records;
126
+ }
127
+ /**
128
+ * How many records a node holds, without reading one of them.
129
+ *
130
+ * The plural of `virtualRecords` for the callers that want the size alone — see
131
+ * `recordsUnderCount` for why that is a different query rather than a smaller
132
+ * one. `getIndexes` is the caller that made it worth writing: it asks per node
133
+ * and shows only the number.
134
+ *
135
+ * It is the same answer `virtualRecords(...).length` gives, and the two are
136
+ * pinned together by a test rather than by this comment.
137
+ */
138
+ export function virtualRecordCount(db, node, visibility = 'records') {
139
+ let total = 0;
140
+ for (const [rootId, paths] of pathsByRoot(node)) {
141
+ total += recordsUnderCount(db, rootId, paths, visibility);
142
+ }
143
+ return total;
144
+ }
145
+ /**
146
+ * What each of these nodes holds, in the order they were given.
147
+ *
148
+ * The plural of `virtualRecordCount`, for `getIndexes`: it asks every node for
149
+ * its number and nothing else, and asking one at a time ran the grouping the
150
+ * count needs once per node. A node whose folders span two roots contributes
151
+ * two scopes and one number, which is why this gathers them here rather than
152
+ * handing the nodes to the query.
153
+ */
154
+ export function virtualRecordCounts(db, nodes, visibility = 'records') {
155
+ const scope = [];
156
+ const at = nodes.map((node) => {
157
+ const mine = [];
158
+ for (const [rootId, paths] of pathsByRoot(node)) {
159
+ mine.push(scope.length);
160
+ scope.push({ rootId, paths });
161
+ }
162
+ return mine;
163
+ });
164
+ const counted = recordsUnderCounts(db, scope, visibility);
165
+ return at.map((mine) => mine.reduce((total, one) => total + (counted[one] ?? 0), 0));
166
+ }
167
+ /**
168
+ * Every record the library holds — what the nodes gather, gathered once.
169
+ *
170
+ * This is the `vn:` node's answer, and it is the whole collection. Asking node
171
+ * by node ran `ALBUM_SELECT` twenty-five times over, genres and totals and all,
172
+ * to put the same records in one list: measured at 851 ms on the live
173
+ * collection, with the single-threaded server stopped behind it for 483 of
174
+ * them. The paths are gathered per root instead, which is two queries.
175
+ *
176
+ * The order is not the query's, though, and cannot be left to it. The library
177
+ * is shown as the tree is — one node after another, and each node's records by
178
+ * year — so what one query returns interleaved by year is put back into nodes
179
+ * here. `recordsUnder` has already ordered each node's rows; this only decides
180
+ * which node each row belongs to, and the two agree because a record lies under
181
+ * exactly one node's folders — the property the counts rely on too, and one a
182
+ * test measures rather than assumes.
183
+ *
184
+ * A record under no node is dropped, which is what asking node by node did with
185
+ * it as well: the library holds what the tree reaches.
186
+ */
187
+ export function libraryRecords(db, rootId, visibility = 'records') {
188
+ const nodes = virtualNodes(db, rootId, visibility);
189
+ const gathered = nodes.map(() => []);
190
+ const byRoot = new Map();
191
+ for (const node of nodes) {
192
+ for (const [root, paths] of pathsByRoot(node)) {
193
+ const at = byRoot.get(root) ?? [];
194
+ at.push(...paths);
195
+ byRoot.set(root, at);
196
+ }
197
+ }
198
+ const loose = [];
199
+ for (const [root, paths] of byRoot)
200
+ loose.push(...recordsUnder(db, root, paths, visibility));
201
+ for (const record of loose) {
202
+ const at = nodes.findIndex((node) => node.folders.some((where) => where.rootId === record.root_id && underPath(where.relPath, record.rel_path)));
203
+ if (at !== -1)
204
+ gathered[at]?.push(record);
205
+ }
206
+ return gathered.flat();
207
+ }
208
+ /** Whether a path lies at or beneath a folder's. */
209
+ function underPath(folder, path) {
210
+ return path === folder || path.startsWith(`${folder}/`);
211
+ }
212
+ /**
213
+ * A node's folders, gathered by the root each one is in.
214
+ *
215
+ * Asked per root because that is the shape the queries above take: one call per
216
+ * root with all of that root's paths, rather than one call per folder.
217
+ */
218
+ function pathsByRoot(node) {
219
+ const byRoot = new Map();
220
+ for (const where of node.folders) {
221
+ const paths = byRoot.get(where.rootId) ?? [];
222
+ paths.push(where.relPath);
223
+ byRoot.set(where.rootId, paths);
224
+ }
225
+ return byRoot;
226
+ }
227
+ /**
228
+ * What a node lists: the collector's own shelves, with a loose record beside
229
+ * them.
230
+ *
231
+ * An artist's folder is not a flat bag of records. `The Cure/` is filed into
232
+ * `Compilations/`, `Deluxe Editions/`, `Live Albums/`, `Side Projects/`,
233
+ * `Singles and EPs/` and `Studio Albums/`, and the operator asked for that to
234
+ * stay: the structure inside is theirs, and the fold above is the only thing
235
+ * this decides. So a child that is a folder is listed as the folder it is, and
236
+ * opening it shows what it always showed.
237
+ *
238
+ * What the fold adds is the record left at the *top* of the root beside the
239
+ * artist's folder — `The Cure - Assemblage - 1991 (12CD FLAC)`, which has no
240
+ * shelf to be under. It belongs to the artist and lands directly under them.
241
+ *
242
+ * A child that *is* a record is listed as the record, not as the folder it is
243
+ * filed in. The difference is what a client draws: `1983 - Japanese Whispers`
244
+ * rather than a folder named `1983 - Japanese Whispers [1990 reissue EU
245
+ * Polydor 817 470-2]`, and opening it plays the record rather than listing a
246
+ * directory holding one thing.
247
+ *
248
+ * Discs of a release are not listed. A box's discs sit inside the record they
249
+ * are discs of, and listing them here would offer that record twice.
250
+ *
251
+ * Folders come first, then records by year — the order every file manager uses,
252
+ * and this is a folder view. A record with no year goes last among records,
253
+ * which is where a missing number belongs.
254
+ */
255
+ export function virtualEntries(db, node, visibility = 'records') {
256
+ const byPath = new Map();
257
+ for (const row of virtualRecords(db, node, visibility)) {
258
+ byPath.set(keyOf(row.root_id, row.rel_path), row);
259
+ }
260
+ const discs = new Set();
261
+ for (const where of node.folders) {
262
+ for (const path of discPaths(db, where.rootId, [where.relPath])) {
263
+ discs.add(keyOf(where.rootId, path));
264
+ }
265
+ }
266
+ const entries = [];
267
+ const seen = new Set();
268
+ for (const where of node.folders) {
269
+ const at = keyOf(where.rootId, where.relPath);
270
+ const own = byPath.get(at);
271
+ if (own !== undefined && !seen.has(at)) {
272
+ seen.add(at);
273
+ entries.push({ record: own, folder: null, credit: null });
274
+ }
275
+ for (const child of childFolders(db, where.rootId, where.relPath, visibility)) {
276
+ // A folder with nothing playable anywhere beneath it is not part of the
277
+ // library: `scans/`, `Artwork/`, a stray `@eaDir`. The same rule that
278
+ // keeps 1029 installers out of the top keeps them out of here.
279
+ if (child.role === 'empty')
280
+ continue;
281
+ const childAt = keyOf(where.rootId, child.rel_path);
282
+ if (seen.has(childAt) || discs.has(childAt))
283
+ continue;
284
+ seen.add(childAt);
285
+ const record = byPath.get(childAt);
286
+ entries.push(record === undefined
287
+ ? { record: null, folder: child, credit: null }
288
+ : { record, folder: null, credit: null });
289
+ }
290
+ }
291
+ // Grouped only when the node shows records and nothing else. A node holding
292
+ // shelves — `The Cure/` with `Compilations/` and the rest — keeps them, and
293
+ // grouping its records as well would list the same record twice: once under
294
+ // the shelf, once under the artist.
295
+ if (entries.some((entry) => entry.folder !== null))
296
+ return entries.sort(byListing);
297
+ const credits = byCredit(entries);
298
+ if (credits.length <= 1)
299
+ return entries.sort(byListing);
300
+ return credits.map((credit) => ({ record: null, folder: null, credit }));
301
+ }
302
+ /**
303
+ * A node's records, gathered by whoever they are credited to.
304
+ *
305
+ * An empty list when a record states no artist at all: a node whose records are
306
+ * not all attributable is not one to sort into drawers, and the flat listing is
307
+ * the honest answer.
308
+ */
309
+ function byCredit(entries) {
310
+ const byKey = new Map();
311
+ for (const entry of entries) {
312
+ const row = entry.record;
313
+ if (row === null)
314
+ continue;
315
+ if (row.artist_id === null)
316
+ return [];
317
+ const name = row.artist_name ?? '';
318
+ const key = artistName(name).key;
319
+ if (key === '')
320
+ return [];
321
+ const at = byKey.get(key) ?? { artistKey: key, name, artistId: row.artist_id, records: [] };
322
+ at.records.push(row);
323
+ // The briefest spelling names the drawer, as it names an artist everywhere
324
+ // else: decoration is what tends to be added.
325
+ if (name.length < at.name.length)
326
+ at.name = name;
327
+ byKey.set(key, at);
328
+ }
329
+ for (const credit of byKey.values())
330
+ credit.records.sort(byListingRecord);
331
+ return [...byKey.values()].sort((a, b) => cmp(a.name, b.name));
332
+ }
333
+ /** A record's place in a run: by year, then title — a missing year last. */
334
+ function byListingRecord(a, b) {
335
+ if ((a.year === null) !== (b.year === null))
336
+ return a.year === null ? 1 : -1;
337
+ if (a.year !== null && b.year !== null && a.year !== b.year)
338
+ return a.year - b.year;
339
+ return cmp(a.title ?? '', b.title ?? '') || cmp(a.rel_path, b.rel_path) || a.id - b.id;
340
+ }
341
+ /** A path is only a path within its root — the same spelling may be in two. */
342
+ function keyOf(rootId, relPath) {
343
+ return `${rootId} ${relPath}`;
344
+ }
345
+ function byListing(a, b) {
346
+ if ((a.folder === null) !== (b.folder === null))
347
+ return a.folder === null ? 1 : -1;
348
+ if (a.folder !== null && b.folder !== null) {
349
+ return cmp(basenameOf(a.folder.rel_path), basenameOf(b.folder.rel_path));
350
+ }
351
+ // The number is read through the same `COALESCE` the query orders by, so a
352
+ // release that states the year and a disc that does not are one record.
353
+ const ay = a.record?.year ?? null;
354
+ const by = b.record?.year ?? null;
355
+ if ((ay === null) !== (by === null))
356
+ return ay === null ? 1 : -1;
357
+ if (ay !== null && by !== null && ay !== by)
358
+ return ay - by;
359
+ return (cmp(a.record?.title ?? '', b.record?.title ?? '') ||
360
+ cmp(a.record?.rel_path ?? '', b.record?.rel_path ?? '') ||
361
+ (a.record?.id ?? 0) - (b.record?.id ?? 0));
362
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The parameter a client may set, spelled here so that the one place which reads
3
+ * it and the one place that documents it cannot disagree.
4
+ *
5
+ * Not a Subsonic method and not an OpenSubsonic extension: the protocol has no
6
+ * way to ask this, and inventing a field inside a response would be answering a
7
+ * question no client asked. A parameter is what an operator can put in a browser
8
+ * address bar, which is the whole of what it is for.
9
+ */
10
+ export const SHOW_JUNK = 'showJunk';
11
+ /** Whether the request asked to be shown everything, by parameter or by setting. */
12
+ export function visibilityOf(query, config) {
13
+ const asked = query.get(SHOW_JUNK);
14
+ if (asked !== null && asked !== '')
15
+ return isOn(asked) ? 'all' : 'records';
16
+ return config.showJunk ? 'all' : 'records';
17
+ }
18
+ /** Whether a value that was given reads as a yes — the reading `config.ts` keeps. */
19
+ function isOn(value) {
20
+ return !['0', 'false', 'no', 'off'].includes(value.toLowerCase());
21
+ }