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.
- package/.env.example +90 -0
- package/CHANGELOG.md +104 -0
- package/DEPLOY.md +375 -0
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/deploy/answers.schema.json +151 -0
- package/deploy/build.mjs +88 -0
- package/deploy/check-publishable.mjs +246 -0
- package/deploy/smoke.mjs +681 -0
- package/deploy/systemd/funoteka.env.example +41 -0
- package/deploy/systemd/funoteka.service +77 -0
- package/deploy/windows/funoteka-service.xml +58 -0
- package/deploy/windows/install-service.ps1 +158 -0
- package/dist/annotation/store.js +67 -0
- package/dist/api/admin-export.js +227 -0
- package/dist/api/admin-guard.js +181 -0
- package/dist/api/admin-library.js +113 -0
- package/dist/api/admin.js +1162 -0
- package/dist/api/annotation.js +193 -0
- package/dist/api/artistinfo.js +140 -0
- package/dist/api/audit.js +32 -0
- package/dist/api/auth.js +145 -0
- package/dist/api/bookmark.js +108 -0
- package/dist/api/browse.js +1241 -0
- package/dist/api/config-file.js +202 -0
- package/dist/api/config.js +285 -0
- package/dist/api/cover.js +304 -0
- package/dist/api/download.js +112 -0
- package/dist/api/envelope.js +162 -0
- package/dist/api/extensions.js +43 -0
- package/dist/api/genre.js +81 -0
- package/dist/api/health.js +35 -0
- package/dist/api/history.js +374 -0
- package/dist/api/idempotency.js +66 -0
- package/dist/api/keys.js +90 -0
- package/dist/api/log-file.js +65 -0
- package/dist/api/meta.js +1410 -0
- package/dist/api/playlist.js +311 -0
- package/dist/api/rescan.js +196 -0
- package/dist/api/roots.js +161 -0
- package/dist/api/router.js +133 -0
- package/dist/api/scanner.js +163 -0
- package/dist/api/search.js +82 -0
- package/dist/api/server.js +643 -0
- package/dist/api/settings.js +67 -0
- package/dist/api/stream.js +442 -0
- package/dist/api/stubs.js +183 -0
- package/dist/api/transcode.js +678 -0
- package/dist/api/user.js +73 -0
- package/dist/api/virtual.js +362 -0
- package/dist/api/visibility.js +21 -0
- package/dist/artist/apply.js +752 -0
- package/dist/artist/credit.js +95 -0
- package/dist/artist/folder.js +65 -0
- package/dist/artist/name.js +205 -0
- package/dist/artist/nfo.js +101 -0
- package/dist/artist/translit.js +79 -0
- package/dist/bookmark/store.js +54 -0
- package/dist/classify/classify.js +405 -0
- package/dist/classify/collision-name.js +109 -0
- package/dist/classify/folder-name.js +466 -0
- package/dist/classify/roles.js +277 -0
- package/dist/classify/shelf-name.js +178 -0
- package/dist/classify/tree.js +43 -0
- package/dist/cli/args.js +169 -0
- package/dist/cli/daemon.js +192 -0
- package/dist/cli/entry.js +25 -0
- package/dist/cli/keys.js +52 -0
- package/dist/cli.js +638 -0
- package/dist/cover/pick.js +58 -0
- package/dist/cover/picture.js +111 -0
- package/dist/cue/engine.js +759 -0
- package/dist/cue/match.js +142 -0
- package/dist/cue/parse.js +182 -0
- package/dist/cue/plan.js +286 -0
- package/dist/cue/track-name.js +120 -0
- package/dist/db/index.js +163 -0
- package/dist/db/issue.js +73 -0
- package/dist/db/ledger.js +84 -0
- package/dist/db/migrations/001_init.sql +196 -0
- package/dist/db/migrations/002_cue_track_flexible.sql +35 -0
- package/dist/db/migrations/003_cue_metadata.sql +14 -0
- package/dist/db/migrations/004_cue_performer.sql +12 -0
- package/dist/db/migrations/005_incremental.sql +23 -0
- package/dist/db/migrations/006_tags.sql +57 -0
- package/dist/db/migrations/007_artist_credit.sql +35 -0
- package/dist/db/migrations/008_issue_stage.sql +39 -0
- package/dist/db/migrations/009_cue_title.sql +20 -0
- package/dist/db/migrations/010_duration_source.sql +31 -0
- package/dist/db/migrations/011_track_title_source.sql +28 -0
- package/dist/db/migrations/012_file_container.sql +25 -0
- package/dist/db/migrations/013_cover_art.sql +28 -0
- package/dist/db/migrations/014_album_year.sql +17 -0
- package/dist/db/migrations/015_probe_method.sql +14 -0
- package/dist/db/migrations/016_tags_method.sql +20 -0
- package/dist/db/migrations/017_cover_indirect.sql +23 -0
- package/dist/db/migrations/018_release_year.sql +14 -0
- package/dist/db/migrations/019_file_tag_name_file_value.sql +20 -0
- package/dist/db/migrations/020_analyze_after_index.sql +16 -0
- package/dist/db/migrations/021_cue_index_files.sql +15 -0
- package/dist/db/migrations/022_sidecar_text.sql +19 -0
- package/dist/db/migrations/023_track_credit.sql +23 -0
- package/dist/db/migrations/024_playlists.sql +63 -0
- package/dist/db/migrations/025_playlist_sequence.sql +27 -0
- package/dist/db/migrations/026_playlist_source_file.sql +19 -0
- package/dist/db/migrations/027_lookup_indexes.sql +30 -0
- package/dist/db/migrations/028_annotations.sql +47 -0
- package/dist/db/migrations/029_scan_state_root_run.sql +28 -0
- package/dist/db/migrations/030_history.sql +108 -0
- package/dist/db/migrations/031_queue_position_and_plays.sql +46 -0
- package/dist/db/migrations/032_bookmarks.sql +27 -0
- package/dist/db/migrations/033_bookmark_order.sql +14 -0
- package/dist/db/migrations/034_junk.sql +33 -0
- package/dist/db/migrations/035_api_key.sql +38 -0
- package/dist/db/migrations/036_file_tag_first.sql +88 -0
- package/dist/db/migrations/037_admin_idempotency.sql +31 -0
- package/dist/db/migrations/038_cue_audio_index.sql +24 -0
- package/dist/db/sweep.js +109 -0
- package/dist/history/store.js +151 -0
- package/dist/inventory/inventory.js +294 -0
- package/dist/junk/marks.js +143 -0
- package/dist/junk/rule.js +93 -0
- package/dist/mcp/client.js +48 -0
- package/dist/mcp/server.js +175 -0
- package/dist/mcp/tools.js +348 -0
- package/dist/playlist/files.js +116 -0
- package/dist/playlist/import.js +260 -0
- package/dist/playlist/store.js +240 -0
- package/dist/probe/ffprobe.js +179 -0
- package/dist/run.js +120 -0
- package/dist/scan/kinds.js +49 -0
- package/dist/scan/scan.js +503 -0
- package/dist/scan/schedule.js +132 -0
- package/dist/scan/settle.js +172 -0
- package/dist/scan/walk.js +132 -0
- package/dist/search/index.js +41 -0
- package/dist/search/query.js +28 -0
- package/dist/stream/flac.js +541 -0
- package/dist/stream/mpeg.js +179 -0
- package/dist/stream/recode.js +393 -0
- package/dist/stream/rewrite.js +163 -0
- package/dist/stream/segment.js +624 -0
- package/dist/tags/apply.js +703 -0
- package/dist/tags/encode.js +164 -0
- package/dist/tags/first.js +130 -0
- package/dist/tags/flac.js +196 -0
- package/dist/tags/genres.js +123 -0
- package/dist/tags/id3v1.js +209 -0
- package/dist/tags/id3v2.js +754 -0
- package/dist/tags/mp4.js +534 -0
- package/dist/tags/mpeg.js +355 -0
- package/dist/tags/ogg.js +463 -0
- package/dist/tags/picture.js +71 -0
- package/dist/tags/read.js +269 -0
- package/dist/tags/types.js +51 -0
- package/dist/tags/vorbis-comment.js +155 -0
- package/dist/text/encoding.js +384 -0
- package/dist/text/site-name.js +37 -0
- package/dist/util/child.js +21 -0
- package/dist/util/names.js +97 -0
- package/funoteka.json.example +27 -0
- 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
|
+
}
|