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
package/dist/api/meta.js
ADDED
|
@@ -0,0 +1,1410 @@
|
|
|
1
|
+
import { splitCredit } from "../artist/credit.js";
|
|
2
|
+
import { artistName, artistOwning } from "../artist/name.js";
|
|
3
|
+
import { basenameOf } from "../util/names.js";
|
|
4
|
+
/**
|
|
5
|
+
* The meta layer, as the API reads it.
|
|
6
|
+
*
|
|
7
|
+
* Routes answer in the protocol's shapes; the SQL that reaches the classified
|
|
8
|
+
* collection lives here. That split is what lets a route's file be about the
|
|
9
|
+
* protocol and this one about the library, and it is why the API's reads can be
|
|
10
|
+
* read in one place when the question is "what does the server know".
|
|
11
|
+
*
|
|
12
|
+
* Read-only, and it stays that way. The classified model has one author — the
|
|
13
|
+
* scanner — and a second writer of *that* would make the database describe two
|
|
14
|
+
* collections. The listener's own rows are not the model and are not written
|
|
15
|
+
* here: playlists are `src/playlist/store.ts` and the marks are
|
|
16
|
+
* `src/annotation/store.ts` — the two writers this server has.
|
|
17
|
+
*
|
|
18
|
+
* Identity is the path, so nothing here merges: two rows are two rows, and two
|
|
19
|
+
* albums that read the same are still two albums, exactly as the contract's
|
|
20
|
+
* first rule requires.
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* Rows in the shape this module declares them.
|
|
24
|
+
*
|
|
25
|
+
* The driver hands back an untyped record per row, and the two casts below are
|
|
26
|
+
* the whole of the seam: every declared shape is a claim about the SQL right
|
|
27
|
+
* beside it, checkable by reading the two together, and nothing else in the API
|
|
28
|
+
* has to know the driver's type.
|
|
29
|
+
*/
|
|
30
|
+
function rows(db, sql, ...args) {
|
|
31
|
+
return db.prepare(sql).all(...args);
|
|
32
|
+
}
|
|
33
|
+
function row(db, sql, ...args) {
|
|
34
|
+
return db.prepare(sql).get(...args);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* One query for a page of ids, answered in the order the ids were asked for.
|
|
38
|
+
*
|
|
39
|
+
* A page of ids is ranked already — a search's by relevance, a listing's by its
|
|
40
|
+
* own sort — and asking for them one at a time is not that request repeated: it
|
|
41
|
+
* is a separate execution of the whole select per id. `ALBUM_SELECT` carries the
|
|
42
|
+
* genre window table, so on a search page that difference is measured in
|
|
43
|
+
* seconds. See `albumsById`.
|
|
44
|
+
*
|
|
45
|
+
* SQLite answers a set, so the order comes back from the caller's list, which is
|
|
46
|
+
* the only place it is known. An id that names nothing drops out rather than
|
|
47
|
+
* becoming a hole — but *what* names nothing is the caller's query and not this
|
|
48
|
+
* function's. `albumsById` matches a row's own `id`, while `album` resolves the
|
|
49
|
+
* group, so a box's second disc opens the record through one and drops out of
|
|
50
|
+
* the other. Both are answering the question they were asked; a caller that
|
|
51
|
+
* wants a disc's id to open its record asks the way `album` does.
|
|
52
|
+
*/
|
|
53
|
+
function byIds(ids, query) {
|
|
54
|
+
if (ids.length === 0)
|
|
55
|
+
return [];
|
|
56
|
+
const found = query(ids.map(() => '?').join(', '));
|
|
57
|
+
const byId = new Map(found.map((found) => [found.id, found]));
|
|
58
|
+
return ids.flatMap((id) => {
|
|
59
|
+
const found = byId.get(id);
|
|
60
|
+
return found === undefined ? [] : [found];
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Whether a scan is running, and how big the library is.
|
|
65
|
+
*
|
|
66
|
+
* `count` is the audio files the meta layer holds rather than every file the
|
|
67
|
+
* walk met: the covers, cue sheets and logs beside a record are the scanner's
|
|
68
|
+
* business and never become a song a client could ask for, so a count that
|
|
69
|
+
* included them would describe a library larger than the one the client sees.
|
|
70
|
+
*
|
|
71
|
+
* `scanning` reads the last run's status rather than answering `false`: a scan
|
|
72
|
+
* that died without settling its row would otherwise be reported as finished,
|
|
73
|
+
* and a client that polled this to decide when to refresh would wait forever.
|
|
74
|
+
*/
|
|
75
|
+
export function scanStatus(db) {
|
|
76
|
+
const run = row(db, 'SELECT status FROM scan_run ORDER BY id DESC LIMIT 1');
|
|
77
|
+
return {
|
|
78
|
+
scanning: run?.status === 'running',
|
|
79
|
+
count: count(db, "SELECT COUNT(*) AS n FROM file WHERE kind = 'audio'"),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
export function count(db, sql, ...args) {
|
|
83
|
+
return row(db, sql, ...args)?.n ?? 0;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The roles an artist has in this library, as OpenSubsonic lists them.
|
|
87
|
+
*
|
|
88
|
+
* `ArtistID3.roles` is "the list of all roles this artist has in the library",
|
|
89
|
+
* and a client groups its views by it: the operator's Symfonium has an "album
|
|
90
|
+
* artists" view and a "composers" one, and the second came up empty — because
|
|
91
|
+
* this server sent no roles at all, so no artist was a composer or anything
|
|
92
|
+
* else (task:2896).
|
|
93
|
+
*
|
|
94
|
+
* **Two roles are claimed, and only two are derivable honestly.** `albumartist`
|
|
95
|
+
* for an artist a record is credited to — owning it, or credited on it beside
|
|
96
|
+
* somebody else, which is what `artist_credit` is for — and `artist` for one
|
|
97
|
+
* credited on a track. In this model a record's artist *is* its tracks' artist,
|
|
98
|
+
* so the second follows from the first and there is no second lookup behind it.
|
|
99
|
+
*
|
|
100
|
+
* **`composer` is deliberately not among them.** This collection states a
|
|
101
|
+
* composer on 80 files of 3350, in sixteen distinct names, and **one** of those
|
|
102
|
+
* names is an artist this library knows: the rest are people who would have to
|
|
103
|
+
* exist as artists before a role could put them in a list. Matching a composer
|
|
104
|
+
* tag's *value* — a credit string, `Виктор Цой & Кино` — against artist names is
|
|
105
|
+
* the kind of guessing `artist/credit.ts` exists to avoid, and one wrong match
|
|
106
|
+
* here does not lose a picture, it moves an artist into a view it is not in.
|
|
107
|
+
*
|
|
108
|
+
* Asked once for a whole page rather than once per artist: a listing is a page,
|
|
109
|
+
* and a query per row is the shape `search3` was already fixed for.
|
|
110
|
+
*/
|
|
111
|
+
export function rolesOf(db, ids) {
|
|
112
|
+
const roles = new Map();
|
|
113
|
+
if (ids.length === 0)
|
|
114
|
+
return roles;
|
|
115
|
+
const placeholders = ids.map(() => '?').join(', ');
|
|
116
|
+
const stated = rows(db, `SELECT stated.artist_id, stated.role FROM (
|
|
117
|
+
SELECT artist_id, 'albumartist' AS role FROM album WHERE artist_id IS NOT NULL
|
|
118
|
+
UNION SELECT artist_id, 'albumartist' FROM artist_credit
|
|
119
|
+
UNION SELECT artist_id, 'artist' FROM track_credit
|
|
120
|
+
UNION SELECT artist_id, 'artist' FROM album WHERE artist_id IS NOT NULL
|
|
121
|
+
) stated WHERE stated.artist_id IN (${placeholders})`, ...ids);
|
|
122
|
+
for (const { artist_id, role } of stated) {
|
|
123
|
+
const held = roles.get(artist_id);
|
|
124
|
+
if (held === undefined)
|
|
125
|
+
roles.set(artist_id, [role]);
|
|
126
|
+
else if (!held.includes(role))
|
|
127
|
+
held.push(role);
|
|
128
|
+
}
|
|
129
|
+
// One order for one set of roles, so that two listings of the same artist
|
|
130
|
+
// cannot differ in a field a client compares.
|
|
131
|
+
for (const held of roles.values())
|
|
132
|
+
held.sort();
|
|
133
|
+
return roles;
|
|
134
|
+
}
|
|
135
|
+
/** The same rows with their roles attached, in one query for the page. */
|
|
136
|
+
function attachRoles(db, all) {
|
|
137
|
+
const roles = rolesOf(db, all.map((one) => one.id));
|
|
138
|
+
return all.map((one) => ({ ...one, roles: roles.get(one.id) ?? [] }));
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* The artists a client can browse to, which is those owning at least one record.
|
|
142
|
+
*
|
|
143
|
+
* An artist known only from a credit — a guest on a compilation, the audience on
|
|
144
|
+
* a live disc — is a real credit and a dead end: a client that opened them would
|
|
145
|
+
* find nothing to play. `artist_credit` keeps them; the index does not offer
|
|
146
|
+
* them.
|
|
147
|
+
*
|
|
148
|
+
* The count is of *records*, through the group's representative, so it is the
|
|
149
|
+
* number of entries `albumsOfArtist` will actually list — a box counts once, not
|
|
150
|
+
* once per disc.
|
|
151
|
+
*/
|
|
152
|
+
export function artists(db, rootId, visibility = 'records') {
|
|
153
|
+
const all = attachRoles(db, rows(db, `SELECT ar.id, ar.name, ar.name_key, ar.sort_key, aa.starred_at, aa.rating,
|
|
154
|
+
COUNT(*) AS album_count
|
|
155
|
+
FROM artist ar
|
|
156
|
+
LEFT JOIN artist_annotation aa ON aa.artist_id = ar.id
|
|
157
|
+
JOIN (
|
|
158
|
+
SELECT COALESCE(rel.artist_id, rep.artist_id) AS artist_id
|
|
159
|
+
FROM ${ALBUM_GROUPS} grp
|
|
160
|
+
JOIN album rep ON rep.id = grp.id
|
|
161
|
+
LEFT JOIN release rel ON rel.id = rep.release_id
|
|
162
|
+
WHERE grp.rn = 1${recorded(visibility)}
|
|
163
|
+
) record ON record.artist_id = ar.id
|
|
164
|
+
GROUP BY ar.id
|
|
165
|
+
ORDER BY COALESCE(ar.sort_key, ar.name), ar.id`));
|
|
166
|
+
const owned = ownedArtistKeys(db, all, rootId);
|
|
167
|
+
for (const key of albumArtistKeys(db, rootId))
|
|
168
|
+
owned.add(key);
|
|
169
|
+
return all.filter((row) => owned.has(row.name_key));
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* The artists the collection's own `albumartist` tags name.
|
|
173
|
+
*
|
|
174
|
+
* This is a second way onto the list, and it was the operator's call after the
|
|
175
|
+
* first one left out somebody real. `Виктор Цой` leads Кино, his records are
|
|
176
|
+
* filed in Кино's folder, and no rule reading folders will ever offer him — but
|
|
177
|
+
* the files themselves say `albumartist = Виктор Цой`, and that tag is the
|
|
178
|
+
* statement "this record is his".
|
|
179
|
+
*
|
|
180
|
+
* What buys it its place is what it *refuses*. The rule it replaces would have
|
|
181
|
+
* admitted every artist who is the only credit on a record, and that admits
|
|
182
|
+
* `All Out Life` — a single whose folder name parsed as an artist while its
|
|
183
|
+
* files say `albumartist = Slipknot`. It would have admitted `Merzbow`, whose
|
|
184
|
+
* split carries no `albumartist` at all, only `artist = Merzbow & Cock E.S.P.`:
|
|
185
|
+
* a track credit saying who plays, not whose record it is. Neither has the tag.
|
|
186
|
+
*
|
|
187
|
+
* The operator weighed the cost and took it: the list grows by the tags' own
|
|
188
|
+
* words, which is how `Various Artists` and the series' names arrive beside the
|
|
189
|
+
* performers. That is the trade this makes — the folder still decides, and now
|
|
190
|
+
* a tag may also nominate. See `wiki:3590` for the boundary argument this
|
|
191
|
+
* replaces, and [[task:2822]] for the decision to cross it.
|
|
192
|
+
*/
|
|
193
|
+
function albumArtistKeys(db, rootId) {
|
|
194
|
+
const values = rootId === undefined
|
|
195
|
+
? rows(db, `SELECT DISTINCT ft.value AS value
|
|
196
|
+
FROM file_tag ft JOIN file f ON f.id = ft.file_id
|
|
197
|
+
WHERE ft.name = 'albumartist'`)
|
|
198
|
+
: rows(db, `SELECT DISTINCT ft.value AS value
|
|
199
|
+
FROM file_tag ft JOIN file f ON f.id = ft.file_id
|
|
200
|
+
WHERE ft.name = 'albumartist' AND f.root_id = ?`, rootId);
|
|
201
|
+
const keys = new Set();
|
|
202
|
+
for (const { value } of values) {
|
|
203
|
+
for (const name of namesInTag(value)) {
|
|
204
|
+
const key = artistName(name).key;
|
|
205
|
+
if (key !== '')
|
|
206
|
+
keys.add(key);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return keys;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* The names one tag value holds.
|
|
213
|
+
*
|
|
214
|
+
* `splitCredit` reads the joiners a track credit uses — the symbols, and the
|
|
215
|
+
* whole words — and a comma is not among them. What a comma would break is
|
|
216
|
+
* `Cure, The`, where the article has moved to the tail and the name is still
|
|
217
|
+
* one name; that rule lives in `name.ts`, not in `credit.ts`, and an earlier
|
|
218
|
+
* version of this comment credited `splitCredit` with a decision it never made.
|
|
219
|
+
*
|
|
220
|
+
* A tag that lists two artists writes them with a comma, and the operator
|
|
221
|
+
* confirmed both count: `Аквариум, Kyiv Virtuosi` is two artists, not one
|
|
222
|
+
* called that. So this reader splits them, after `splitCredit` has taken the
|
|
223
|
+
* joiners — having a tag that is a list rather than a name.
|
|
224
|
+
*
|
|
225
|
+
* The split is unguarded, and `Earth, Wind & Fire` would come out as two
|
|
226
|
+
* artists. Nothing in this collection is spelled that way — the one comma among
|
|
227
|
+
* the fourteen values is a list — but the rule is not safe beyond the collection
|
|
228
|
+
* that produced it, and a test pinning a `Cure, The`-shaped tag would say so.
|
|
229
|
+
*/
|
|
230
|
+
function namesInTag(value) {
|
|
231
|
+
return splitCredit(value)
|
|
232
|
+
.flatMap((entry) => entry.name.split(','))
|
|
233
|
+
.map((name) => name.trim())
|
|
234
|
+
.filter((name) => name !== '');
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* The artists that own a folder of their own.
|
|
238
|
+
*
|
|
239
|
+
* One of the two ways onto the list — `albumArtistKeys` is the other, and
|
|
240
|
+
* `artists` unions them. This one is the older and the stricter: a folder named
|
|
241
|
+
* for the artist is the artist's, and so is a folder with a shelf's tail on it,
|
|
242
|
+
* `Slipknot AAC 320`.
|
|
243
|
+
*
|
|
244
|
+
* On its own it left out somebody real, and the arguments above it are where
|
|
245
|
+
* that used to be justified. This collection credits eleven different acts
|
|
246
|
+
* across the splits filed in `Cock E.S.P/`, one Slipknot single to `All Out
|
|
247
|
+
* Life`, and four Кино records to `Виктор Цой` — and every one of those is
|
|
248
|
+
* reachable through the folder their record sits in, which is why the rule read
|
|
249
|
+
* as sufficient. `Виктор Цой` is the case that showed it was not: he leads
|
|
250
|
+
* Кино, his records are filed in Кино's folder, and the files say
|
|
251
|
+
* `albumartist = Виктор Цой`. A tag offers him; this function never would.
|
|
252
|
+
*
|
|
253
|
+
* What it still decides is everything it always did — which folders fold into
|
|
254
|
+
* which artist's node, and so which folder names can offer an artist at all.
|
|
255
|
+
*/
|
|
256
|
+
function ownedArtistKeys(db, all, rootId) {
|
|
257
|
+
const keys = all.map((row) => row.name_key);
|
|
258
|
+
const tops = rootId === undefined
|
|
259
|
+
? rows(db, `SELECT rel_path FROM folder WHERE parent_rel_path = '' AND role <> 'empty'`)
|
|
260
|
+
: rows(db, `SELECT rel_path FROM folder WHERE root_id = ? AND parent_rel_path = '' AND role <> 'empty'`, rootId);
|
|
261
|
+
const owned = new Set();
|
|
262
|
+
for (const top of tops) {
|
|
263
|
+
const owner = artistOwning(basenameOf(top.rel_path), keys);
|
|
264
|
+
if (owner !== null)
|
|
265
|
+
owned.add(owner);
|
|
266
|
+
}
|
|
267
|
+
return owned;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* The folders that are an artist's own — the ones named for them.
|
|
271
|
+
*
|
|
272
|
+
* Whose a folder is follows the rule `ownedArtistKeys` above and `virtualNodes`
|
|
273
|
+
* keep: a folder whose name opens with the artist's name belongs to that artist.
|
|
274
|
+
* Here it comes with an order. `Slipknot`, `Slipknot AAC 320` and `Slipknot
|
|
275
|
+
* ALAC` all belong to Slipknot, and the one spelled exactly like the artist
|
|
276
|
+
* comes first — it is the one a picture of the artist, or a note about them,
|
|
277
|
+
* would have been put in; the shelves behind it are asked only when it holds
|
|
278
|
+
* nothing.
|
|
279
|
+
*
|
|
280
|
+
* Two readers want this and neither owns it: `cover.ts` asks it where an
|
|
281
|
+
* artist's picture is, and `artistinfo.ts` where their `artist.nfo` is. It is
|
|
282
|
+
* the same folder both times, and the same rule `ownedArtistKeys` already
|
|
283
|
+
* states — so it is stated once, here, rather than once per reader.
|
|
284
|
+
*/
|
|
285
|
+
export function artistOwnFolders(db, artistId) {
|
|
286
|
+
const row = artist(db, artistId);
|
|
287
|
+
if (row === undefined)
|
|
288
|
+
return [];
|
|
289
|
+
const exact = [];
|
|
290
|
+
const shelved = [];
|
|
291
|
+
for (const root of roots(db)) {
|
|
292
|
+
for (const folder of childFolders(db, root.id, '')) {
|
|
293
|
+
if (folder.role === 'empty')
|
|
294
|
+
continue;
|
|
295
|
+
const name = basenameOf(folder.rel_path);
|
|
296
|
+
if (artistOwning(name, [row.name_key]) !== row.name_key)
|
|
297
|
+
continue;
|
|
298
|
+
const where = { rootId: root.id, relPath: folder.rel_path };
|
|
299
|
+
if (artistName(name).key === row.name_key)
|
|
300
|
+
exact.push(where);
|
|
301
|
+
else
|
|
302
|
+
shelved.push(where);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return [...exact, ...shelved];
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* The `artist.nfo` sitting in a folder, when the collection has one there.
|
|
309
|
+
*
|
|
310
|
+
* The scan records the file and never parses it, which is what makes this a
|
|
311
|
+
* path rather than a column: what the document says is read where it is asked
|
|
312
|
+
* for, and the meta layer's part is only to say which file that is.
|
|
313
|
+
*/
|
|
314
|
+
export function artistNfoIn(db, where) {
|
|
315
|
+
const found = row(db, `SELECT f.rel_path, r.path AS root_path
|
|
316
|
+
FROM file f JOIN root r ON r.id = f.root_id
|
|
317
|
+
WHERE f.root_id = ? AND f.folder_rel_path = ? AND f.name = 'artist.nfo'`, where.rootId, where.relPath);
|
|
318
|
+
return found === undefined ? null : { rootPath: found.root_path, relPath: found.rel_path };
|
|
319
|
+
}
|
|
320
|
+
/** One artist row, by whatever names them — an id, or the fold of their name. */
|
|
321
|
+
function artistWhere(db, column, value, visibility = 'records') {
|
|
322
|
+
const found = row(db, `SELECT ar.id, ar.name, ar.name_key, ar.sort_key, aa.starred_at, aa.rating,
|
|
323
|
+
COUNT(grp.id) AS album_count
|
|
324
|
+
FROM artist ar
|
|
325
|
+
LEFT JOIN artist_annotation aa ON aa.artist_id = ar.id
|
|
326
|
+
LEFT JOIN album al ON al.artist_id = ar.id
|
|
327
|
+
LEFT JOIN ${ALBUM_GROUPS} grp ON grp.id = al.id AND grp.rn = 1${recorded(visibility)}
|
|
328
|
+
WHERE ${column} = ?
|
|
329
|
+
GROUP BY ar.id`, value);
|
|
330
|
+
if (found === undefined)
|
|
331
|
+
return undefined;
|
|
332
|
+
return { ...found, roles: rolesOf(db, [found.id]).get(found.id) ?? [] };
|
|
333
|
+
}
|
|
334
|
+
export function artist(db, id) {
|
|
335
|
+
return artistWhere(db, 'ar.id', id);
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* The artist a folded name belongs to, when the collection has one.
|
|
339
|
+
*
|
|
340
|
+
* Offering and answering are different questions, and this is the second one.
|
|
341
|
+
* `artists` above is who a client can browse to; this answers about a name some
|
|
342
|
+
* other row already holds — a credit on a record, or the drawer an artist's
|
|
343
|
+
* folder gathers — whether or not that name is offered.
|
|
344
|
+
*
|
|
345
|
+
* `Виктор Цой` needed both, and needed only the second before the albumartist
|
|
346
|
+
* tag earned him a place in the list. That is the difference worth keeping
|
|
347
|
+
* straight: being answerable never depended on being offered.
|
|
348
|
+
*/
|
|
349
|
+
export function artistByKey(db, nameKey) {
|
|
350
|
+
return artistWhere(db, 'ar.name_key', nameKey);
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* The same answer for a page of ids, in one query.
|
|
354
|
+
*
|
|
355
|
+
* The plural of `artist`, for the reason `byIds` gives: a search's artist
|
|
356
|
+
* section is twenty ranked ids, and twenty executions of the join above is
|
|
357
|
+
* twenty of the same answer.
|
|
358
|
+
*
|
|
359
|
+
* **The count goes through the group, and leaving it out is not a smaller
|
|
360
|
+
* version of the same answer.** Counting `album.id` counted every disc of every
|
|
361
|
+
* box: a search page said `albumCount: 2` beside an artist whose page listed one
|
|
362
|
+
* record, and a client that reconciled the two had no way to tell which was
|
|
363
|
+
* lying. Both numbers being the same ordinal in the same payload is the whole
|
|
364
|
+
* contract between the search and the artist page, so this reads exactly what
|
|
365
|
+
* `artist` above reads.
|
|
366
|
+
*/
|
|
367
|
+
export function artistsById(db, ids) {
|
|
368
|
+
return attachRoles(db, byIds(ids, (placeholders) => rows(db, `SELECT ar.id, ar.name, ar.name_key, ar.sort_key, aa.starred_at, aa.rating,
|
|
369
|
+
COUNT(grp.id) AS album_count
|
|
370
|
+
FROM artist ar
|
|
371
|
+
LEFT JOIN artist_annotation aa ON aa.artist_id = ar.id
|
|
372
|
+
LEFT JOIN album al ON al.artist_id = ar.id
|
|
373
|
+
LEFT JOIN ${ALBUM_GROUPS} grp ON grp.id = al.id AND grp.rn = 1
|
|
374
|
+
WHERE ar.id IN (${placeholders})
|
|
375
|
+
GROUP BY ar.id`, ...ids)));
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* The characters taken off the edge of a genre value, for a named alias.
|
|
379
|
+
*
|
|
380
|
+
* **`TRIM(X)` is not enough and this was wrong at first.** SQLite's one-argument
|
|
381
|
+
* `TRIM` removes space and nothing else — not a tab, not a newline, not a
|
|
382
|
+
* non-breaking space — so a file stating `"Rock\t"` would still list as a second
|
|
383
|
+
* genre beside `"Rock"`, and a value that was *only* a tab would pass the
|
|
384
|
+
* emptiness guard and be listed as a genre whose name is invisible. The set is
|
|
385
|
+
* spelled out here so that the claim the comments make is the claim the code
|
|
386
|
+
* keeps.
|
|
387
|
+
*
|
|
388
|
+
* A function of the alias rather than one string, because the same expression
|
|
389
|
+
* has to hold in more than one place at once — the list's group key, both
|
|
390
|
+
* lookups, and the album predicate — and the agreement between those is the
|
|
391
|
+
* whole contract the two methods have: a client hands back a value it was given,
|
|
392
|
+
* and a genre that lists but cannot be opened is the failure that prevents.
|
|
393
|
+
*/
|
|
394
|
+
const trimmed = (alias) => `TRIM(${alias}.value, ' ' || CHAR(9) || CHAR(10) || CHAR(13) || CHAR(160) || CHAR(12288))`;
|
|
395
|
+
/**
|
|
396
|
+
* Which record an album row belongs to, as one value two rows compare equal by.
|
|
397
|
+
*
|
|
398
|
+
* A function of the alias because three queries group by it and they have to
|
|
399
|
+
* agree; a fourth spelling of the same rule is how a listing and a count come to
|
|
400
|
+
* disagree.
|
|
401
|
+
*
|
|
402
|
+
* **The prefix is not decoration.** `album.id` and `release.id` are two
|
|
403
|
+
* independent `INTEGER PRIMARY KEY` sequences, so a bare
|
|
404
|
+
* `COALESCE(release_id, id)` puts the standalone album `al:9` in the group of
|
|
405
|
+
* release 9 — different tables, same integer, and nothing in the value says
|
|
406
|
+
* which one it came from. It happened: measured on this collection, a Cock
|
|
407
|
+
* E.S.P record (album 9) was listed as the record of a Kino box (release 9) and
|
|
408
|
+
* the box itself disappeared from its artist, because the row standing for the
|
|
409
|
+
* group was the one with an artist of its own. Both id spaces are in use, so the
|
|
410
|
+
* key carries which one it is.
|
|
411
|
+
*/
|
|
412
|
+
const grouped = (alias) => `CASE WHEN ${alias}.release_id IS NULL THEN 'a' || ${alias}.id ELSE 'r' || ${alias}.release_id END`;
|
|
413
|
+
/**
|
|
414
|
+
* Every record's genre, as a table to be joined rather than asked per record.
|
|
415
|
+
*
|
|
416
|
+
* **This was a correlated subquery and it took the server down.** Written the
|
|
417
|
+
* obvious way — a `SELECT … LIMIT 1` inside `ALBUM_SELECT` — it was evaluated
|
|
418
|
+
* once per album *before* the listing's `ORDER BY` and `LIMIT` could discard
|
|
419
|
+
* anything, so every album listing in the whole API paid it 268 times: measured
|
|
420
|
+
* at 1321 ms for the collection, against 24 ms for this. The operator's client
|
|
421
|
+
* stopped answering, which is what a browse call that takes seconds and a genre
|
|
422
|
+
* call that takes thirty looks like from a sofa.
|
|
423
|
+
*
|
|
424
|
+
* The cost was not the work but its shape. A correlated subquery here cannot use
|
|
425
|
+
* the index that would make it cheap: its two conditions came from the *album*
|
|
426
|
+
* row. Computed once, the same question is a single pass over the genre rows.
|
|
427
|
+
*
|
|
428
|
+
* `ROW_NUMBER() OVER (PARTITION BY …)` is what keeps the rule: the first file
|
|
429
|
+
* that states a genre answers for the record, in path order — and within one
|
|
430
|
+
* file, the first genre tag by position. Files of one record do disagree — this
|
|
431
|
+
* collection has compilations whose tracks each carry their own — and some
|
|
432
|
+
* answer has to be first.
|
|
433
|
+
*
|
|
434
|
+
* **Keyed by record, not by folder, and reached through the track.** A record is
|
|
435
|
+
* a box with its discs (see `ALBUM_GROUPS`), so its genre is the first file by
|
|
436
|
+
* path across *all* its discs — which a per-folder key cannot express. The file
|
|
437
|
+
* reaches its record through the track it became rather than through the folder
|
|
438
|
+
* it sits in, because a disc of a flat rip is keyed on the image *file* and no
|
|
439
|
+
* folder names it: binding by folder loses those. Measured on this collection,
|
|
440
|
+
* that is 6 genre-tagged files, and the binding gains nothing — every
|
|
441
|
+
* genre-tagged file here has a track, and no file's tracks belong to more than
|
|
442
|
+
* one record (both measured).
|
|
443
|
+
*
|
|
444
|
+
* What is left costs about 15 ms on this collection, measured 2026-09-13 warm
|
|
445
|
+
* with the baseline interleaved: the joins are about 8 of it and the window
|
|
446
|
+
* function the rest. A cheaper form was measured rather than assumed — grouping
|
|
447
|
+
* with `MIN()` over a concatenated `rel_path` and `position` is about 12 ms — and
|
|
448
|
+
* it was declined, **but not on price**. The figures this paragraph used to quote
|
|
449
|
+
* (150 ms against 25) were true when they were written and are not any more; it
|
|
450
|
+
* is the same rows several times faster. What still holds is the reason: that
|
|
451
|
+
* form reads the bare `value` column out of whichever row the `MIN()` chose,
|
|
452
|
+
* which SQLite guarantees only while the subquery holds *exactly one* aggregate —
|
|
453
|
+
* so a later `COUNT(*)` added beside it would turn the genre into an arbitrary
|
|
454
|
+
* row's genre, silently, with a test that catches it only half the time. Six
|
|
455
|
+
* milliseconds is not worth that, and the `MIN()` figure above is the grouping
|
|
456
|
+
* alone: fetching the winning row's value safely needs a join back, which nobody
|
|
457
|
+
* has measured.
|
|
458
|
+
*/
|
|
459
|
+
const albumGenres = (scope) => `
|
|
460
|
+
(SELECT group_id, value FROM (
|
|
461
|
+
SELECT ${grouped('a')} AS group_id,
|
|
462
|
+
${trimmed('ft')} AS value,
|
|
463
|
+
ROW_NUMBER() OVER (PARTITION BY ${grouped('a')}
|
|
464
|
+
ORDER BY f.rel_path, ft.position) AS rn
|
|
465
|
+
FROM file f
|
|
466
|
+
JOIN track t ON t.file_id = f.id
|
|
467
|
+
JOIN album a ON a.id = t.album_id
|
|
468
|
+
JOIN file_tag ft ON ft.file_id = f.id
|
|
469
|
+
WHERE ft.name = 'genre' AND ${trimmed('ft')} <> '' ${scope}
|
|
470
|
+
) WHERE rn = 1)`;
|
|
471
|
+
/**
|
|
472
|
+
* The same, for every record at once — see `albumTotals` on why it is a function.
|
|
473
|
+
*
|
|
474
|
+
* The window function is what the join cannot plan around, and it is kept for the
|
|
475
|
+
* reason stated at length above: the cheaper form reads the value out of whichever
|
|
476
|
+
* row it happened to choose.
|
|
477
|
+
*/
|
|
478
|
+
const ALBUM_GENRES = albumGenres('');
|
|
479
|
+
/**
|
|
480
|
+
* Which record each album row belongs to, and which row stands for it.
|
|
481
|
+
*
|
|
482
|
+
* A box is a `release` with one `album` row per disc, so the unit a client
|
|
483
|
+
* opens — an album — is the *group*, not the row. This is the one place that
|
|
484
|
+
* grouping is decided; everything below reads it rather than repeating the rule.
|
|
485
|
+
*
|
|
486
|
+
* The row that stands for a group is its **first disc**: lowest stated disc
|
|
487
|
+
* number, then path, then id. Deliberately not `release.id`, because an `al:` id
|
|
488
|
+
* carries an `album.id` and the two tables number themselves independently — an
|
|
489
|
+
* `al:5` that sometimes meant a release would be a client handing back an id it
|
|
490
|
+
* was given and being shown a different record. A representative keeps the id
|
|
491
|
+
* in the namespace it has always been in, and keeps it stable: disc 1 is disc 1
|
|
492
|
+
* as long as the box is on disk, whatever the scan does to the other rows.
|
|
493
|
+
*
|
|
494
|
+
* `rep_id` rides on every row of the group rather than only on the
|
|
495
|
+
* representative, so a song can name the record it is on without a second join.
|
|
496
|
+
*/
|
|
497
|
+
/**
|
|
498
|
+
* Which row of a group stands for it: the lowest disc, then path, then id.
|
|
499
|
+
*
|
|
500
|
+
* **A row with no disc number goes last, not first.** `COALESCE(disc_number, 0)`
|
|
501
|
+
* reads as "no number is zero", and a zero sorts in front of disc 1 — so a row
|
|
502
|
+
* that is not a disc at all could represent the record, and everything a client
|
|
503
|
+
* is shown for it comes off that row: the `al:` id, the name, the year. Measured
|
|
504
|
+
* on the live collection there is no such row — 0 albums carry a release without
|
|
505
|
+
* a disc number, and no album's path lies inside a disc folder — so this orders
|
|
506
|
+
* nothing differently today; it is there so that the ordering is not the thing
|
|
507
|
+
* that decides it when the shape appears (task:2843).
|
|
508
|
+
*
|
|
509
|
+
* What a group with *only* unnumbered rows does is unchanged: the lowest of them
|
|
510
|
+
* by path is still the representative. There is nothing else it could be.
|
|
511
|
+
*/
|
|
512
|
+
const REPRESENTATIVE_ORDER = `(a.disc_number IS NULL), a.disc_number, a.rel_path, a.id`;
|
|
513
|
+
const ALBUM_GROUPS = `
|
|
514
|
+
(SELECT a.id, ${grouped('a')} AS group_id,
|
|
515
|
+
FIRST_VALUE(a.id) OVER (PARTITION BY ${grouped('a')}
|
|
516
|
+
ORDER BY ${REPRESENTATIVE_ORDER}) AS rep_id,
|
|
517
|
+
ROW_NUMBER() OVER (PARTITION BY ${grouped('a')}
|
|
518
|
+
ORDER BY ${REPRESENTATIVE_ORDER}) AS rn,
|
|
519
|
+
a.junk_reason AS junk_reason
|
|
520
|
+
FROM album a)`;
|
|
521
|
+
/**
|
|
522
|
+
* The clause that keeps what is not a record out of a listing.
|
|
523
|
+
*
|
|
524
|
+
* Every listing in this file already joins the record group by `grp`, so the
|
|
525
|
+
* answer is carried there once (`ALBUM_GROUPS` above) and read here — rather than
|
|
526
|
+
* a subquery per row, which is the shape that took the server down once already
|
|
527
|
+
* (`ALBUM_GENRES`). A caller told to show everything gets the empty string, and
|
|
528
|
+
* an empty string in a `WHERE` changes nothing.
|
|
529
|
+
*
|
|
530
|
+
* **The line this draws, stated once so no listing has to guess it.** Hiding is
|
|
531
|
+
* about what the server *offers*: the listings a client browses, the tree, what
|
|
532
|
+
* a search turns up, what a shuffle draws. It is not about what the listener
|
|
533
|
+
* already holds — a playlist or a star that names a hidden album keeps naming it,
|
|
534
|
+
* because a client whose own list silently lost an entry is worse off than one
|
|
535
|
+
* shown something it marked. And it is not about a record reached by an id
|
|
536
|
+
* already in hand: `album`, `song` and their plurals answer what they are asked,
|
|
537
|
+
* so a client with a stale entry gets a cover rather than an error. What is
|
|
538
|
+
* hidden is that nobody is *offered* it.
|
|
539
|
+
*/
|
|
540
|
+
function recorded(visibility, alias = 'grp') {
|
|
541
|
+
return visibility === 'all' ? '' : ` AND ${alias}.junk_reason IS NULL`;
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* What a record holds, summed over its discs.
|
|
545
|
+
*
|
|
546
|
+
* Its own pass rather than a subquery per row, for the reason above, and
|
|
547
|
+
* aggregated *before* it is joined so a record with no songs still comes back as
|
|
548
|
+
* itself — which an inner join to `track` would not do.
|
|
549
|
+
*/
|
|
550
|
+
const albumTotals = (scope) => `
|
|
551
|
+
(SELECT ${grouped('a')} AS group_id,
|
|
552
|
+
COUNT(*) AS song_count,
|
|
553
|
+
SUM(t.duration_ms) AS duration_ms
|
|
554
|
+
FROM track t JOIN album a ON a.id = t.album_id
|
|
555
|
+
${scope}
|
|
556
|
+
GROUP BY ${grouped('a')})`;
|
|
557
|
+
/**
|
|
558
|
+
* The same, for every record at once.
|
|
559
|
+
*
|
|
560
|
+
* A function of the scope rather than one string, because a reader that already
|
|
561
|
+
* knows which record it wants can say so — and then this pass touches that
|
|
562
|
+
* record's tracks instead of the collection's. The body is written once; only the
|
|
563
|
+
* `WHERE` differs, which is how `grouped` and `trimmed` above are built too.
|
|
564
|
+
*/
|
|
565
|
+
const ALBUM_TOTALS = albumTotals('');
|
|
566
|
+
/**
|
|
567
|
+
* Every genre the collection states, and how much of it states each.
|
|
568
|
+
*
|
|
569
|
+
* Counted over `track` rather than over files, because a song is what a client
|
|
570
|
+
* is being offered: a genre list whose numbers were files would overcount every
|
|
571
|
+
* cue image, which is one file holding an album's worth of tracks.
|
|
572
|
+
*
|
|
573
|
+
* There is no list of known genres here and there should not be — the files
|
|
574
|
+
* name their own, and this project has one table of genre names only because
|
|
575
|
+
* ID3 lets a tag be a *number* that points into one (`tags/genres.ts`). What a
|
|
576
|
+
* file states in words is what this returns, trimmed and nothing else.
|
|
577
|
+
*/
|
|
578
|
+
export function genres(db, visibility = 'records') {
|
|
579
|
+
// A song on a record nobody is offered is not part of what the collection
|
|
580
|
+
// states: a genre whose every file lives in a folder of screenshots would
|
|
581
|
+
// otherwise be listed with nothing behind it. `al` is joined `LEFT` because a
|
|
582
|
+
// song's album may be missing, and such a song is kept — it is not something
|
|
583
|
+
// that was hidden, it is something that was never classified.
|
|
584
|
+
const dropJunk = visibility === 'all' ? '' : 'AND (al.id IS NULL OR al.junk_reason IS NULL)';
|
|
585
|
+
return rows(db, `SELECT ${trimmed('ft')} AS value,
|
|
586
|
+
COUNT(DISTINCT t.id) AS song_count,
|
|
587
|
+
COUNT(DISTINCT ${grouped('al')}) AS album_count
|
|
588
|
+
FROM file_tag ft
|
|
589
|
+
JOIN track t ON t.file_id = ft.file_id
|
|
590
|
+
LEFT JOIN album al ON al.id = t.album_id
|
|
591
|
+
WHERE ft.name = 'genre' AND ${trimmed('ft')} <> '' ${dropJunk}
|
|
592
|
+
GROUP BY ${trimmed('ft')}
|
|
593
|
+
ORDER BY ${trimmed('ft')} COLLATE NOCASE`);
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* The genre predicate, written once.
|
|
597
|
+
*
|
|
598
|
+
* `EXISTS` rather than a join to `file_tag` so a genre cannot multiply a song
|
|
599
|
+
* into several rows — a file is free to state a genre twice, and a listing that
|
|
600
|
+
* showed it twice would be a listing that invented a duplicate. Two queries ask
|
|
601
|
+
* it now (one genre's songs, and a random draw filtered by genre), and the two
|
|
602
|
+
* have to agree about what "this song is that genre" means.
|
|
603
|
+
*/
|
|
604
|
+
function inGenre(fileAlias) {
|
|
605
|
+
return `EXISTS (SELECT 1 FROM file_tag ft
|
|
606
|
+
WHERE ft.file_id = ${fileAlias}.id AND ft.name = 'genre' AND ${trimmed('ft')} = ?)`;
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* A random draw of songs, from what the filters name.
|
|
610
|
+
*
|
|
611
|
+
* **`ORDER BY RANDOM()` reads every matching row and sorts it**, which is why
|
|
612
|
+
* this shape is usually the wrong one — and it is written here with the number
|
|
613
|
+
* in hand rather than by habit. The alternative, picking a random rowid, cannot
|
|
614
|
+
* honour a filter: `genre` is a tag on the file, `year` belongs to the record,
|
|
615
|
+
* and a folder is a root — none of them is a column of `track`. So the choice is
|
|
616
|
+
* between reading the rows and refusing the parameters, and the protocol has the
|
|
617
|
+
* parameters.
|
|
618
|
+
*
|
|
619
|
+
* **Measured on the live collection** (11 541 files, 4 969 songs), warmed,
|
|
620
|
+
* against the running daemon: a default draw of ten is **15–33 ms**, five
|
|
621
|
+
* hundred songs **45 ms**, a genre filter **20 ms** and a year filter **23 ms**.
|
|
622
|
+
* The scan and the sort are the whole of it.
|
|
623
|
+
*
|
|
624
|
+
* **Two of those numbers have since been taken again, and one of them was read
|
|
625
|
+
* wrong here.** The year filter is genuinely about free — 17.2 ms against an
|
|
626
|
+
* unfiltered 16.3 ms — but the *genre* filter is **twice** the unfiltered draw:
|
|
627
|
+
* 34.0 ms against 16.3, medians of thirty warm samples each, on a machine whose
|
|
628
|
+
* `ping` floor was 20 ms in the same window (task:2869). The plan says why, and
|
|
629
|
+
* it is the opposite of what this comment used to claim: `genre` is a tag on the
|
|
630
|
+
* *file*, so the filter is an `EXISTS` evaluated for every one of the 4 972
|
|
631
|
+
* tracks — a covering-index search each, but a search *per row*, before
|
|
632
|
+
* `ORDER BY RANDOM()` sorts what is left. Fewer rows reach the sort; more work
|
|
633
|
+
* happens getting them there. The old sentence — "a filter is *cheaper* than no
|
|
634
|
+
* filter because there are fewer rows left to sort" — was true of the year and
|
|
635
|
+
* false of the genre, and it was written as though it were true of both.
|
|
636
|
+
*
|
|
637
|
+
* **Warmed is the word that matters.** The first version of this comment gave
|
|
638
|
+
* 76–100 ms, taken moments after a restart: a cold page cache is not what the
|
|
639
|
+
* method costs, it is what the disk costs, and the two were reported as the
|
|
640
|
+
* same number until a reviewer measured it again (task:2864).
|
|
641
|
+
*
|
|
642
|
+
* What it costs the *other* clients is the loop, not the call: on one thread
|
|
643
|
+
* eight draws back to back take `ping` from a half-millisecond to 25 ms, because
|
|
644
|
+
* every draw holds the thread for its whole scan.
|
|
645
|
+
*
|
|
646
|
+
* `size` is chosen by the caller, which caps it at the protocol's five hundred;
|
|
647
|
+
* the `LIMIT` itself is this function's.
|
|
648
|
+
*/
|
|
649
|
+
export function randomSongs(db, wanted) {
|
|
650
|
+
const filters = [];
|
|
651
|
+
const args = [];
|
|
652
|
+
const junk = recorded(wanted.visibility ?? 'records');
|
|
653
|
+
if (wanted.genre !== undefined) {
|
|
654
|
+
filters.push(inGenre('f'));
|
|
655
|
+
args.push(wanted.genre);
|
|
656
|
+
}
|
|
657
|
+
// The record's year, spelled the way the select above reads it — a release's
|
|
658
|
+
// when there is one, the album's otherwise — so a filter and the field it
|
|
659
|
+
// filters cannot disagree about which year a song has.
|
|
660
|
+
if (wanted.fromYear !== undefined) {
|
|
661
|
+
filters.push('COALESCE(rel.year, rep.year) >= ?');
|
|
662
|
+
args.push(wanted.fromYear);
|
|
663
|
+
}
|
|
664
|
+
if (wanted.toYear !== undefined) {
|
|
665
|
+
filters.push('COALESCE(rel.year, rep.year) <= ?');
|
|
666
|
+
args.push(wanted.toYear);
|
|
667
|
+
}
|
|
668
|
+
if (wanted.rootId !== undefined) {
|
|
669
|
+
filters.push('r.id = ?');
|
|
670
|
+
args.push(wanted.rootId);
|
|
671
|
+
}
|
|
672
|
+
return rows(db, `${SONG_SELECT}
|
|
673
|
+
WHERE 1 = 1${filters.length === 0 ? '' : ` AND ${filters.join(' AND ')}`}${junk}
|
|
674
|
+
ORDER BY RANDOM()
|
|
675
|
+
LIMIT ?`, ...args, wanted.size);
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* The songs one genre names, in a stable order.
|
|
679
|
+
*
|
|
680
|
+
* A song is listed under *every* genre its file states, while the `genre` in its
|
|
681
|
+
* own payload is the first one by position. Those two answers would disagree for
|
|
682
|
+
* a file stating two different genres, and no file in this collection does
|
|
683
|
+
* (measured: not one has even two genre rows). Left unreconciled rather than
|
|
684
|
+
* guessed at, because reconciling means choosing — a song under one genre only,
|
|
685
|
+
* or a payload that cannot say which of several it is — and that choice should
|
|
686
|
+
* be made on a file that has the shape.
|
|
687
|
+
*/
|
|
688
|
+
export function songsByGenre(db, genre, count, offset, visibility = 'records') {
|
|
689
|
+
return rows(db, `${SONG_SELECT}
|
|
690
|
+
WHERE ${inGenre('f')}${recorded(visibility)}
|
|
691
|
+
ORDER BY al.rel_path, t.ordinal, t.id
|
|
692
|
+
LIMIT ? OFFSET ?`, genre, count, offset);
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* The records at least one of whose files states this genre.
|
|
696
|
+
*
|
|
697
|
+
* Matched through `track`, which is the same relation `genres()` counts its
|
|
698
|
+
* records by, so the number and the listing are one answer rather than two that
|
|
699
|
+
* happen to agree. The hit is a derived table joined on the *group*, so a box is
|
|
700
|
+
* listed once however many of its discs carry the genre, and no record is
|
|
701
|
+
* multiplied by the genre rows that found it — a file is free to state a genre
|
|
702
|
+
* twice.
|
|
703
|
+
*/
|
|
704
|
+
export function albumListByGenre(db, genre, size, offset, rootId, visibility = 'records') {
|
|
705
|
+
const confined = rootId === undefined ? '' : 'WHERE COALESCE(rel.root_id, al.root_id) = ?';
|
|
706
|
+
const junk = rootId === undefined ? `WHERE 1 = 1${recorded(visibility)}` : recorded(visibility);
|
|
707
|
+
return rows(db, `${ALBUM_SELECT}
|
|
708
|
+
JOIN (SELECT DISTINCT ${grouped('a')} AS group_id
|
|
709
|
+
FROM track t
|
|
710
|
+
JOIN album a ON a.id = t.album_id
|
|
711
|
+
JOIN file_tag ft ON ft.file_id = t.file_id
|
|
712
|
+
WHERE ft.name = 'genre' AND ${trimmed('ft')} = ?) hit ON hit.group_id = grp.group_id
|
|
713
|
+
${confined}${junk}
|
|
714
|
+
ORDER BY COALESCE(rel.rel_path, al.rel_path), al.id
|
|
715
|
+
LIMIT ? OFFSET ?`, ...(rootId === undefined ? [genre, size, offset] : [genre, rootId, size, offset]));
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* The records whose year falls between two the client named, inclusive.
|
|
719
|
+
*
|
|
720
|
+
* The second of the protocol's two filters, shaped like the genre one beside it:
|
|
721
|
+
* a client names the axis and the bounds, and the bounds are required arguments
|
|
722
|
+
* rather than a page — defaulting them would answer "everything" to a client that
|
|
723
|
+
* asked for a decade and forgot to say which.
|
|
724
|
+
*
|
|
725
|
+
* The year is the record's own, the release's where it has one and its folder's
|
|
726
|
+
* otherwise, which is the value `albumId3` shows. So a record lists under the
|
|
727
|
+
* year it displays, and a range that finds it is the range that shows it.
|
|
728
|
+
*/
|
|
729
|
+
export function albumListByYear(db, fromYear, toYear, size, offset, rootId, visibility = 'records') {
|
|
730
|
+
const confine = rootId === undefined ? '' : 'AND COALESCE(rel.root_id, al.root_id) = ?';
|
|
731
|
+
return rows(db, `${ALBUM_SELECT}
|
|
732
|
+
WHERE COALESCE(rel.year, al.year) BETWEEN ? AND ? ${confine}${recorded(visibility)}
|
|
733
|
+
ORDER BY COALESCE(rel.year, al.year), COALESCE(rel.rel_path, al.rel_path), al.id
|
|
734
|
+
LIMIT ? OFFSET ?`, ...(rootId === undefined
|
|
735
|
+
? [fromYear, toYear, size, offset]
|
|
736
|
+
: [fromYear, toYear, rootId, size, offset]));
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* What a record is, however it was reached — by artist, by id, by listing.
|
|
740
|
+
*
|
|
741
|
+
* **One row per record, not per album row.** A box is several `album` rows and
|
|
742
|
+
* one record, so the select reads the representative of each group
|
|
743
|
+
* (`ALBUM_GROUPS`) and answers for the whole of it: the title is the release's,
|
|
744
|
+
* the counts are summed over its discs, and `disc_number` is null because a
|
|
745
|
+
* record is not a disc. Without this a client is shown `CD1 ● Альбом`,
|
|
746
|
+
* `CD2 ● …`, `CD3 ● …` as three albums and the record's own name — the one its
|
|
747
|
+
* folder states — nowhere at all. Measured on this collection: 61 disc rows
|
|
748
|
+
* standing in for 19 records.
|
|
749
|
+
*
|
|
750
|
+
* `rel_path` and `root_id` are the *record's* folder, which for a box is the
|
|
751
|
+
* release's — where its cover is, and where its genre and year come from. The
|
|
752
|
+
* representative's own path is still reachable as `al.rel_path`, which is what
|
|
753
|
+
* the orderings use: a deterministic order does not care which of the two
|
|
754
|
+
* prefixes it sorts by, and the queries that do care say which they mean.
|
|
755
|
+
*
|
|
756
|
+
* The counts are a left join to a table aggregated before the join, rather than
|
|
757
|
+
* a subquery per row: a record with no songs still comes back as itself, and
|
|
758
|
+
* nothing multiplies — while a correlated subquery here is what took the server
|
|
759
|
+
* down once already (see `ALBUM_GENRES`).
|
|
760
|
+
*/
|
|
761
|
+
/**
|
|
762
|
+
* The rows a record is read from, before the columns are chosen.
|
|
763
|
+
*
|
|
764
|
+
* Split out because other queries want these rows and none of the columns:
|
|
765
|
+
* `recordsUnderCount` counts what `ALBUM_SELECT` reads, and
|
|
766
|
+
* `albumPlacesOfArtist` wants only where a record is. The one invariant all
|
|
767
|
+
* three depend on — no join here multiplies a record — is not something any of
|
|
768
|
+
* them can check. Writing the `FROM` clause once is the nearest thing to a
|
|
769
|
+
* guard: a join added for a column stays below, and a join that changed *which*
|
|
770
|
+
* rows there are cannot be.
|
|
771
|
+
*/
|
|
772
|
+
const ALBUM_ROWS = `
|
|
773
|
+
FROM album al
|
|
774
|
+
JOIN ${ALBUM_GROUPS} grp ON grp.id = al.id AND grp.rn = 1
|
|
775
|
+
LEFT JOIN release rel ON rel.id = al.release_id`;
|
|
776
|
+
const albumSelect = (totals, genres) => `
|
|
777
|
+
SELECT al.id,
|
|
778
|
+
aa.starred_at, aa.rating,
|
|
779
|
+
COALESCE(rel.root_id, al.root_id) AS root_id,
|
|
780
|
+
COALESCE(rel.rel_path, al.rel_path) AS rel_path,
|
|
781
|
+
COALESCE(rel.title, al.title) AS title,
|
|
782
|
+
COALESCE(rel.artist_id, al.artist_id) AS artist_id,
|
|
783
|
+
ar.name AS artist_name, ar.sort_key AS artist_sort,
|
|
784
|
+
rel.title AS release_title,
|
|
785
|
+
CASE WHEN al.release_id IS NULL THEN al.disc_number END AS disc_number,
|
|
786
|
+
COALESCE(rel.year, al.year) AS year,
|
|
787
|
+
g.value AS genre,
|
|
788
|
+
COALESCE(tot.song_count, 0) AS song_count,
|
|
789
|
+
tot.duration_ms AS duration_ms
|
|
790
|
+
${ALBUM_ROWS}
|
|
791
|
+
LEFT JOIN artist ar ON ar.id = COALESCE(rel.artist_id, al.artist_id)
|
|
792
|
+
LEFT JOIN album_annotation aa ON aa.album_id = al.id
|
|
793
|
+
LEFT JOIN ${totals} tot ON tot.group_id = grp.group_id
|
|
794
|
+
LEFT JOIN ${genres} g ON g.group_id = grp.group_id
|
|
795
|
+
`;
|
|
796
|
+
/**
|
|
797
|
+
* The columns are written once and the two aggregate clauses are handed in.
|
|
798
|
+
*
|
|
799
|
+
* Which keeps the property `ALBUM_ROWS` above is written once to keep: a reader
|
|
800
|
+
* that knows which record it wants and one that lists them all answer with the
|
|
801
|
+
* same columns from the same rows, and only the scope of the aggregates differs.
|
|
802
|
+
*/
|
|
803
|
+
const ALBUM_SELECT = albumSelect(ALBUM_TOTALS, ALBUM_GENRES);
|
|
804
|
+
/**
|
|
805
|
+
* The same columns for a reader that already knows which record it wants.
|
|
806
|
+
*
|
|
807
|
+
* Both aggregates walk the whole collection — every track for the counts, every
|
|
808
|
+
* genre-tagged file for the genre — and neither is narrowed by whatever `WHERE` or
|
|
809
|
+
* `LIMIT` a caller puts after them: the plan materialises them, and only then does
|
|
810
|
+
* the outer query choose rows. Measured on this collection the floor is about
|
|
811
|
+
* 25 ms and it is flat: `getAlbumList2` cost 24.3 ms at `size=10` and 31.7 ms at
|
|
812
|
+
* `size=500`. A listing pays that once for hundreds of records; opening one album
|
|
813
|
+
* paid it for one.
|
|
814
|
+
*
|
|
815
|
+
* So the aggregates are told which record to build, through a `target` named once
|
|
816
|
+
* and read by both. The rules do not fork — `albumTotals` and `albumGenres` above
|
|
817
|
+
* write the bodies, and this hands each a `WHERE`.
|
|
818
|
+
*/
|
|
819
|
+
const ONE_GROUP = `${grouped('a')} = (SELECT group_id FROM target)`;
|
|
820
|
+
const ALBUM_SELECT_ONE = `WITH target(group_id) AS (
|
|
821
|
+
SELECT ${grouped('b')} FROM album b WHERE b.id = ?
|
|
822
|
+
)
|
|
823
|
+
${albumSelect(albumTotals(`WHERE ${ONE_GROUP}`), albumGenres(`AND ${ONE_GROUP}`))}
|
|
824
|
+
WHERE grp.group_id = (SELECT group_id FROM target)`;
|
|
825
|
+
/**
|
|
826
|
+
* The records an artist is credited on, or only those in one root.
|
|
827
|
+
*
|
|
828
|
+
* `rootId` is what a client asking for one music folder means. Without it this
|
|
829
|
+
* answers about the whole collection, which is right for an artist page and
|
|
830
|
+
* wrong for a confined one: `getArtists` narrows its list to the folder asked
|
|
831
|
+
* about, so a page that ignored the same parameter would list records from a
|
|
832
|
+
* folder the client is not looking at.
|
|
833
|
+
*/
|
|
834
|
+
export function albumsOfArtist(db, artistId, rootId, visibility = 'records') {
|
|
835
|
+
const confined = rootId === undefined ? '' : 'AND al.root_id = ?';
|
|
836
|
+
return rows(db, `${ALBUM_SELECT} WHERE COALESCE(rel.artist_id, al.artist_id) = ? ${confined}${recorded(visibility)}
|
|
837
|
+
ORDER BY COALESCE(al.disc_number, 0), al.rel_path, al.id`, ...(rootId === undefined ? [artistId] : [artistId, rootId]));
|
|
838
|
+
}
|
|
839
|
+
export function albumPlacesOfArtist(db, artistId, visibility = 'records') {
|
|
840
|
+
return rows(db, `SELECT al.id,
|
|
841
|
+
COALESCE(rel.root_id, al.root_id) AS root_id,
|
|
842
|
+
COALESCE(rel.rel_path, al.rel_path) AS rel_path
|
|
843
|
+
${ALBUM_ROWS}
|
|
844
|
+
WHERE COALESCE(rel.artist_id, al.artist_id) = ?${recorded(visibility)}
|
|
845
|
+
ORDER BY COALESCE(al.disc_number, 0), al.rel_path, al.id`, artistId);
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* The folders that are a *disc* of a release rather than a record of their own.
|
|
849
|
+
*
|
|
850
|
+
* The tree lists what is directly inside a folder, and a box's discs are not
|
|
851
|
+
* that: `Slipknot AAC 320/2014 - .5 The Gray Chapter - CD 1 [JP - WPCR-16130]`
|
|
852
|
+
* is the first disc of the record keyed on `Slipknot AAC 320` itself, and a
|
|
853
|
+
* listing that showed it as a folder would offer the same record twice — once
|
|
854
|
+
* as itself and once as its own first CD.
|
|
855
|
+
*/
|
|
856
|
+
export function discPaths(db, rootId, paths) {
|
|
857
|
+
if (paths.length === 0)
|
|
858
|
+
return [];
|
|
859
|
+
const under = paths
|
|
860
|
+
.map(() => `(al.rel_path = ? OR al.rel_path LIKE ? || '/%')`)
|
|
861
|
+
.join(' OR ');
|
|
862
|
+
const args = [];
|
|
863
|
+
for (const path of paths)
|
|
864
|
+
args.push(path, path);
|
|
865
|
+
return rows(db, `SELECT al.rel_path FROM album al
|
|
866
|
+
WHERE al.root_id = ? AND al.release_id IS NOT NULL AND (${under})`, rootId, ...args).map((row) => row.rel_path);
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* The records lying under a set of physical folders.
|
|
870
|
+
*
|
|
871
|
+
* This is the question the virtual tree asks and `albumsOfArtist` cannot: a
|
|
872
|
+
* record belongs to the folder it was filed in, not to whoever its tags credit.
|
|
873
|
+
* `Cock E.S.P` holds eleven splits its files credit to Merzbow, Aube and nine
|
|
874
|
+
* others, and the operator's word on that is that the folder decides — the
|
|
875
|
+
* person knows what they put where, and everything else is navigation or
|
|
876
|
+
* search.
|
|
877
|
+
*
|
|
878
|
+
* A folder's records are the ones whose own path *is* it (a record folder at
|
|
879
|
+
* the top of a root) or lies beneath it.
|
|
880
|
+
*
|
|
881
|
+
* Ordered by year, and deliberately not by path. Path order is the collector's
|
|
882
|
+
* *shelves* — `Compilations/`, `Deluxe Editions/`, `Live Albums/` — so an
|
|
883
|
+
* artist listed every compilation from 1983 to 2004 and then every deluxe
|
|
884
|
+
* edition from 2004 on, with the year jumping at each boundary. What a person
|
|
885
|
+
* reads a shelf of records for is when they came out. A record with no year
|
|
886
|
+
* goes last rather than first, which is where a missing number belongs.
|
|
887
|
+
*/
|
|
888
|
+
export function recordsUnder(db, rootId, paths, visibility = 'records') {
|
|
889
|
+
if (paths.length === 0)
|
|
890
|
+
return [];
|
|
891
|
+
const under = underPaths(paths);
|
|
892
|
+
return rows(db, `${ALBUM_SELECT} WHERE COALESCE(rel.root_id, al.root_id) = ? AND (${under.sql})${recorded(visibility)}
|
|
893
|
+
ORDER BY COALESCE(rel.year, al.year) IS NULL, COALESCE(rel.year, al.year),
|
|
894
|
+
COALESCE(rel.title, al.title), COALESCE(rel.rel_path, al.rel_path), al.id`, rootId, ...under.args);
|
|
895
|
+
}
|
|
896
|
+
/**
|
|
897
|
+
* The predicate that says a record lies under one of these paths.
|
|
898
|
+
*
|
|
899
|
+
* Written once because two queries now read it — the one that fetches the
|
|
900
|
+
* records and the one that only counts them — and a path predicate that drifted
|
|
901
|
+
* between the two would make a list and its own length disagree, which is the
|
|
902
|
+
* defect this project keeps finding.
|
|
903
|
+
*/
|
|
904
|
+
function underPaths(paths) {
|
|
905
|
+
const sql = paths
|
|
906
|
+
.map(() => `(COALESCE(rel.rel_path, al.rel_path) = ? OR COALESCE(rel.rel_path, al.rel_path) LIKE ? || '/%')`)
|
|
907
|
+
.join(' OR ');
|
|
908
|
+
const args = [];
|
|
909
|
+
for (const path of paths)
|
|
910
|
+
args.push(path, path);
|
|
911
|
+
return { sql, args };
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* How many records lie under a set of folders, without reading one of them.
|
|
915
|
+
*
|
|
916
|
+
* The same question `recordsUnder` answers, asked for its size alone — and the
|
|
917
|
+
* difference is not a smaller version of the same query. `ALBUM_SELECT` carries
|
|
918
|
+
* `ALBUM_GENRES` and `ALBUM_TOTALS`, aggregates over `file`, `track` and
|
|
919
|
+
* `file_tag` built for *every* album before the join narrows anything; a caller
|
|
920
|
+
* that wants only the length pays for all of it and reads one number.
|
|
921
|
+
*
|
|
922
|
+
* `getIndexes` is that caller, and it asks once per node: thirty-one nodes at
|
|
923
|
+
* roughly twenty milliseconds each was most of a second, on a single-threaded
|
|
924
|
+
* server, spent producing a list of counts. What a record *is* is not needed to
|
|
925
|
+
* count it, so the genres and the totals are not joined here — only
|
|
926
|
+
* `ALBUM_GROUPS`, which decides how many records there are rather than what
|
|
927
|
+
* they hold.
|
|
928
|
+
*/
|
|
929
|
+
export function recordsUnderCount(db, rootId, paths, visibility = 'records') {
|
|
930
|
+
if (paths.length === 0)
|
|
931
|
+
return 0;
|
|
932
|
+
const under = underPaths(paths);
|
|
933
|
+
const found = row(db, `SELECT COUNT(*) AS n
|
|
934
|
+
${ALBUM_ROWS}
|
|
935
|
+
WHERE COALESCE(rel.root_id, al.root_id) = ? AND (${under.sql})${recorded(visibility)}`, rootId, ...under.args);
|
|
936
|
+
return found?.n ?? 0;
|
|
937
|
+
}
|
|
938
|
+
/**
|
|
939
|
+
* The same counts, all at once.
|
|
940
|
+
*
|
|
941
|
+
* Asking `recordsUnderCount` per group is what `getIndexes` did, and each call
|
|
942
|
+
* built `ALBUM_GROUPS` — a window function over every album — for its own
|
|
943
|
+
* handful of paths. Twenty-five of those was most of the 104 ms that route
|
|
944
|
+
* still cost after the records stopped being read. One pass over the album rows
|
|
945
|
+
* with the groups as a `VALUES` list gives the same numbers; measured on a copy
|
|
946
|
+
* of the collection, 63–144 ms became 4–8.
|
|
947
|
+
*
|
|
948
|
+
* The scope is a `VALUES` list in a CTE rather than a temporary table: SQLite
|
|
949
|
+
* has to be told about temp tables, and `WITH s(...) AS (VALUES ...)` is a
|
|
950
|
+
* literal the planner sees. It is written this way and not as a
|
|
951
|
+
* `(VALUES ...)` join because SQLite refuses a bare `VALUES` as a table
|
|
952
|
+
* expression — measured, `near "(": syntax error` — while a CTE over one is
|
|
953
|
+
* accepted. The paths are bound, never interpolated: they are folder names, and
|
|
954
|
+
* a folder name is a thing a person chose.
|
|
955
|
+
*
|
|
956
|
+
* Groups whose paths overlap count a record once for each, which is the answer
|
|
957
|
+
* the per-group calls gave as well, so nothing here depends on overlap being
|
|
958
|
+
* absent.
|
|
959
|
+
*/
|
|
960
|
+
export function recordsUnderCounts(db, scope, visibility = 'records') {
|
|
961
|
+
const counts = new Array(scope.length).fill(0);
|
|
962
|
+
if (scope.length === 0)
|
|
963
|
+
return counts;
|
|
964
|
+
const bound = [];
|
|
965
|
+
const values = [];
|
|
966
|
+
scope.forEach((group, at) => {
|
|
967
|
+
for (const path of group.paths) {
|
|
968
|
+
values.push('(?, ?, ?)');
|
|
969
|
+
bound.push(at, group.rootId, path);
|
|
970
|
+
}
|
|
971
|
+
});
|
|
972
|
+
if (values.length === 0)
|
|
973
|
+
return counts;
|
|
974
|
+
// The path predicate is spelled out here rather than taken from `underPaths`,
|
|
975
|
+
// and the reason is that the two are not the same shape: `underPaths` builds a
|
|
976
|
+
// disjunction over N *bound* paths, one comparison each, while this compares
|
|
977
|
+
// one path *column* per joined row. A shared form would have to take the
|
|
978
|
+
// expression as an argument, which is a parameter whose only two values are
|
|
979
|
+
// `?` and `s.path` — more machinery than the rule it would protect, and a
|
|
980
|
+
// reader of either is still looking at the same three conditions.
|
|
981
|
+
const found = rows(db, `WITH s(at, root_id, path) AS (VALUES ${values.join(', ')})
|
|
982
|
+
SELECT s.at AS at, COUNT(*) AS n
|
|
983
|
+
${ALBUM_ROWS}
|
|
984
|
+
JOIN s ON COALESCE(rel.root_id, al.root_id) = s.root_id
|
|
985
|
+
AND (COALESCE(rel.rel_path, al.rel_path) = s.path
|
|
986
|
+
OR COALESCE(rel.rel_path, al.rel_path) LIKE s.path || '/%')
|
|
987
|
+
WHERE 1 = 1${recorded(visibility)}
|
|
988
|
+
GROUP BY s.at`, ...bound);
|
|
989
|
+
for (const { at, n } of found)
|
|
990
|
+
counts[at] = n;
|
|
991
|
+
return counts;
|
|
992
|
+
}
|
|
993
|
+
/**
|
|
994
|
+
* The record an id names, whichever of its rows the id belongs to.
|
|
995
|
+
*
|
|
996
|
+
* The id is resolved through the group rather than matched, so a client that
|
|
997
|
+
* stored `al:` for the second disc of a box — which is what every client did
|
|
998
|
+
* while the discs were the albums — opens the record rather than being told
|
|
999
|
+
* there is no such album. An id naming nothing resolves to nothing: the inner
|
|
1000
|
+
* select is empty, the comparison is null, and no row matches.
|
|
1001
|
+
*/
|
|
1002
|
+
export function album(db, id) {
|
|
1003
|
+
return row(db, ALBUM_SELECT_ONE, id);
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* The same answer for a page of ids, in one query.
|
|
1007
|
+
*
|
|
1008
|
+
* This is where asking one at a time stopped being affordable. `ALBUM_SELECT`
|
|
1009
|
+
* computes `ALBUM_GENRES` — a window function over every genre row — and the
|
|
1010
|
+
* plan is built per execution, so a search page of 250 albums ran it 250 times:
|
|
1011
|
+
* measured on the live collection at about **2 s**, against a few milliseconds
|
|
1012
|
+
* for the single `IN` below. The listing routes never had the problem, because
|
|
1013
|
+
* they ask once and let `LIMIT` decide; only the search resolved ids it already
|
|
1014
|
+
* held.
|
|
1015
|
+
*/
|
|
1016
|
+
export function albumsById(db, ids) {
|
|
1017
|
+
return byIds(ids, (placeholders) => rows(db, `${ALBUM_SELECT} WHERE al.id IN (${placeholders})`, ...ids));
|
|
1018
|
+
}
|
|
1019
|
+
/** Where the listener's own marks and plays are read from — see the migrations. */
|
|
1020
|
+
const MARKS = '(SELECT aa.starred_at FROM album_annotation aa WHERE aa.album_id = al.id)';
|
|
1021
|
+
const RATING = '(SELECT aa.rating FROM album_annotation aa WHERE aa.album_id = al.id)';
|
|
1022
|
+
const LAST_PLAY = '(SELECT MAX(tp.played_at) FROM track_play tp JOIN track t ON t.id = tp.track_id WHERE t.album_id = al.id)';
|
|
1023
|
+
const PLAYS = '(SELECT SUM(tp.play_count) FROM track_play tp JOIN track t ON t.id = tp.track_id WHERE t.album_id = al.id)';
|
|
1024
|
+
const ORDERS = {
|
|
1025
|
+
// The *record's* name, which for a box is its release folder — the row's own
|
|
1026
|
+
// title is the disc's (`CD1 ● Альбом`), and sorting the collection by that
|
|
1027
|
+
// would file every box under `C`, beside the alphabet's other disc ones.
|
|
1028
|
+
alphabeticalByName: { by: 'COALESCE(rel.title, al.title, al.rel_path), al.id' },
|
|
1029
|
+
alphabeticalByArtist: {
|
|
1030
|
+
by: 'COALESCE(ar.sort_key, ar.name), COALESCE(rel.title, al.title, al.rel_path), al.id',
|
|
1031
|
+
},
|
|
1032
|
+
// The id rises with the order the scan met a folder in, which is the nearest
|
|
1033
|
+
// thing to "recently added" a derived layer can offer: nothing records when a
|
|
1034
|
+
// record was acquired, only when it was first seen.
|
|
1035
|
+
newest: { by: 'al.id DESC' },
|
|
1036
|
+
random: { by: 'RANDOM()' },
|
|
1037
|
+
// The albums the listener marked, most recently marked first.
|
|
1038
|
+
starred: { by: `${MARKS} DESC`, where: `AND ${MARKS} IS NOT NULL` },
|
|
1039
|
+
// A record is rated as a record — the rating of its songs is a different
|
|
1040
|
+
// question — and one nobody rated is not "highly rated".
|
|
1041
|
+
highest: { by: `${RATING} DESC, al.id`, where: `AND ${RATING} IS NOT NULL` },
|
|
1042
|
+
// And the two the history answers: when anything on the record was last
|
|
1043
|
+
// played, and what the plays on it add up to.
|
|
1044
|
+
recent: { by: `${LAST_PLAY} DESC`, where: `AND ${LAST_PLAY} IS NOT NULL` },
|
|
1045
|
+
frequent: { by: `${PLAYS} DESC, al.id`, where: `AND COALESCE(${PLAYS}, 0) > 0` },
|
|
1046
|
+
};
|
|
1047
|
+
export function albumList(db, order, size, offset, rootId, visibility = 'records') {
|
|
1048
|
+
const confined = rootId === undefined ? '' : 'COALESCE(rel.root_id, al.root_id) = ?';
|
|
1049
|
+
const ordered = ORDERS[order];
|
|
1050
|
+
const where = `WHERE 1 = 1 ${confined === '' ? '' : `AND ${confined}`}${recorded(visibility)} ` +
|
|
1051
|
+
`${ordered.where ?? ''}`;
|
|
1052
|
+
const args = rootId === undefined ? [size, offset] : [rootId, size, offset];
|
|
1053
|
+
return rows(db, `${ALBUM_SELECT} ${where} ORDER BY ${ordered.by} LIMIT ? OFFSET ?`, ...args);
|
|
1054
|
+
}
|
|
1055
|
+
/**
|
|
1056
|
+
* A song, with the album it is on and the artist who owns that album.
|
|
1057
|
+
*
|
|
1058
|
+
* The artist is the album's and not the song's, because the meta layer keeps no
|
|
1059
|
+
* per-song artist: `track.artist_id` is left empty on purpose (see
|
|
1060
|
+
* `artist/apply.ts`), and the cue's own PERFORMER is a credit — a string a cue
|
|
1061
|
+
* wrote — which the inventory dump shows beside the track rather than in place
|
|
1062
|
+
* of the record's artist.
|
|
1063
|
+
*
|
|
1064
|
+
* **The eight tag fields come off a joined row and are not asked of `file_tag`
|
|
1065
|
+
* here.** They used to be eight correlated subqueries, which was not wrong so
|
|
1066
|
+
* much as eight seeks a song: a page of five hundred costs 10.04 ms that way and
|
|
1067
|
+
* 6.37 ms off this row, measured, with every alternative shape worse
|
|
1068
|
+
* (`tags/first.ts` has the numbers and the alternatives). `tg` is one row a file,
|
|
1069
|
+
* written by the stage that writes the tags it is derived from, so it cannot
|
|
1070
|
+
* disagree with them — and a file nobody has read has no row, which is the null
|
|
1071
|
+
* the subqueries answered too.
|
|
1072
|
+
*/
|
|
1073
|
+
const SONG_SELECT = `
|
|
1074
|
+
SELECT t.id, t.ordinal, t.title, grp.rep_id AS album_id,
|
|
1075
|
+
ta.starred_at, ta.rating,
|
|
1076
|
+
tp.play_count, tp.played_at,
|
|
1077
|
+
COALESCE(rel.title, rep.title) AS album_title,
|
|
1078
|
+
COALESCE(rel.artist_id, rep.artist_id) AS artist_id,
|
|
1079
|
+
ar.name AS artist_name, al.disc_number,
|
|
1080
|
+
COALESCE(rel.year, rep.year) AS album_year,
|
|
1081
|
+
tg.genre AS genre, tg.track_artist AS track_artist,
|
|
1082
|
+
tg.advisory_itunes AS advisory_itunes, tg.advisory_mp4 AS advisory_mp4,
|
|
1083
|
+
tg.rg_track_gain AS rg_track_gain,
|
|
1084
|
+
tg.rg_album_gain AS rg_album_gain,
|
|
1085
|
+
tg.rg_track_peak AS rg_track_peak,
|
|
1086
|
+
tg.rg_album_peak AS rg_album_peak,
|
|
1087
|
+
t.duration_ms, t.segment_start_ms, t.segment_end_ms,
|
|
1088
|
+
r.id AS root_id, r.path AS root_path, f.rel_path, f.folder_rel_path, f.size, f.ext,
|
|
1089
|
+
p.codec AS codec, p.sample_rate AS sample_rate, p.channels AS channels,
|
|
1090
|
+
p.bitrate AS bitrate
|
|
1091
|
+
FROM track t
|
|
1092
|
+
JOIN file f ON f.id = t.file_id
|
|
1093
|
+
JOIN root r ON r.id = f.root_id
|
|
1094
|
+
LEFT JOIN album al ON al.id = t.album_id
|
|
1095
|
+
LEFT JOIN ${ALBUM_GROUPS} grp ON grp.id = al.id
|
|
1096
|
+
LEFT JOIN album rep ON rep.id = grp.rep_id
|
|
1097
|
+
LEFT JOIN release rel ON rel.id = rep.release_id
|
|
1098
|
+
LEFT JOIN artist ar ON ar.id = COALESCE(rep.artist_id, rel.artist_id)
|
|
1099
|
+
LEFT JOIN audio_probe p ON p.file_id = f.id
|
|
1100
|
+
LEFT JOIN file_tag_first tg ON tg.file_id = f.id
|
|
1101
|
+
LEFT JOIN track_annotation ta ON ta.track_id = t.id
|
|
1102
|
+
LEFT JOIN track_play tp ON tp.track_id = t.id
|
|
1103
|
+
`;
|
|
1104
|
+
/**
|
|
1105
|
+
* Every song of a record, in playing order — disc by disc, then within a disc.
|
|
1106
|
+
*
|
|
1107
|
+
* The id is resolved through the group, so opening *any* disc of a box opens the
|
|
1108
|
+
* record, and what comes back is all of it. Each song still says which disc it
|
|
1109
|
+
* is on: that is the field the protocol gives a client to draw its separators
|
|
1110
|
+
* with, and the reason the discs do not have to be albums of their own.
|
|
1111
|
+
*/
|
|
1112
|
+
export function songsOfAlbum(db, albumId) {
|
|
1113
|
+
return rows(db, `${SONG_SELECT}
|
|
1114
|
+
WHERE al.id IN (SELECT id FROM album a
|
|
1115
|
+
WHERE ${grouped('a')} =
|
|
1116
|
+
(SELECT ${grouped('b')} FROM album b WHERE b.id = ?))
|
|
1117
|
+
ORDER BY COALESCE(al.disc_number, 0), t.ordinal, t.id`, albumId);
|
|
1118
|
+
}
|
|
1119
|
+
/**
|
|
1120
|
+
* Which of these artists have a record in this root.
|
|
1121
|
+
*
|
|
1122
|
+
* The one question a music-folder filter can ask about an artist: an artist is
|
|
1123
|
+
* not *in* a folder — their records are, and they may sit in several — so the
|
|
1124
|
+
* artists a folder holds are the ones it has a record of, credited either on
|
|
1125
|
+
* the record itself or on the release a box belongs to.
|
|
1126
|
+
*
|
|
1127
|
+
* Chunked for the reason `knownTrackIds` is: one placeholder per id, and SQLite
|
|
1128
|
+
* caps how many a statement may carry.
|
|
1129
|
+
*/
|
|
1130
|
+
export function artistIdsInRoot(db, ids, rootId) {
|
|
1131
|
+
const found = new Set();
|
|
1132
|
+
const CHUNK = 500;
|
|
1133
|
+
for (let at = 0; at < ids.length; at += CHUNK) {
|
|
1134
|
+
const chunk = ids.slice(at, at + CHUNK);
|
|
1135
|
+
const matched = rows(db, `SELECT DISTINCT COALESCE(rel.artist_id, al.artist_id) AS id
|
|
1136
|
+
FROM album al
|
|
1137
|
+
LEFT JOIN release rel ON rel.id = al.release_id
|
|
1138
|
+
WHERE al.root_id = ? AND COALESCE(rel.artist_id, al.artist_id) IN (${chunk.map(() => '?').join(', ')})`, rootId, ...chunk);
|
|
1139
|
+
for (const row of matched)
|
|
1140
|
+
if (row.id !== null)
|
|
1141
|
+
found.add(row.id);
|
|
1142
|
+
}
|
|
1143
|
+
return found;
|
|
1144
|
+
}
|
|
1145
|
+
/**
|
|
1146
|
+
* The songs of a playlist, in the order the playlist holds them.
|
|
1147
|
+
*
|
|
1148
|
+
* The one listing in this API whose order was chosen by a person rather than by
|
|
1149
|
+
* the collection: `position` is the whole of what a playlist says beyond which
|
|
1150
|
+
* songs are in it, so the `ORDER BY` is the answer and not a convenience.
|
|
1151
|
+
*
|
|
1152
|
+
* Read here rather than in the playlist module so that a song in a playlist is
|
|
1153
|
+
* the same song as everywhere else — one select, one shape, and no second
|
|
1154
|
+
* opinion about what a song is.
|
|
1155
|
+
*/
|
|
1156
|
+
export function songsOfPlaylist(db, playlistId) {
|
|
1157
|
+
return rows(db, `${SONG_SELECT}
|
|
1158
|
+
JOIN playlist_track pt ON pt.track_id = t.id
|
|
1159
|
+
WHERE pt.playlist_id = ?
|
|
1160
|
+
ORDER BY pt.position`, playlistId);
|
|
1161
|
+
}
|
|
1162
|
+
/**
|
|
1163
|
+
* Which of these ids name a song the collection still has.
|
|
1164
|
+
*
|
|
1165
|
+
* The question `createPlaylist` and `updatePlaylist` ask before they write
|
|
1166
|
+
* anything, and asked of the collection rather than of the playlist module for
|
|
1167
|
+
* the reason the reading above lives here: whether a track exists is a fact
|
|
1168
|
+
* about the classified model, and the model has one reader.
|
|
1169
|
+
*
|
|
1170
|
+
* Asked in chunks, and the chunking is not tidiness. The ids are bound one
|
|
1171
|
+
* placeholder per song, and SQLite caps how many parameters a statement may
|
|
1172
|
+
* carry — 32766 in the build this runs on — so a client saving a very long
|
|
1173
|
+
* playlist would be answered with an internal error rather than with an answer
|
|
1174
|
+
* about songs. A chunk well under that ceiling keeps every statement ordinary,
|
|
1175
|
+
* and the protocol sets no limit on how many songs a playlist may hold.
|
|
1176
|
+
*
|
|
1177
|
+
* An id that names nothing is simply absent from the answer: which of them did
|
|
1178
|
+
* is the caller's to report, since only the caller knows what it asked for.
|
|
1179
|
+
*/
|
|
1180
|
+
export function knownTrackIds(db, ids) {
|
|
1181
|
+
const known = new Set();
|
|
1182
|
+
const CHUNK = 500;
|
|
1183
|
+
for (let at = 0; at < ids.length; at += CHUNK) {
|
|
1184
|
+
const chunk = ids.slice(at, at + CHUNK);
|
|
1185
|
+
const found = rows(db, `SELECT id FROM track WHERE id IN (${chunk.map(() => '?').join(', ')})`, ...chunk);
|
|
1186
|
+
for (const { id } of found)
|
|
1187
|
+
known.add(id);
|
|
1188
|
+
}
|
|
1189
|
+
return known;
|
|
1190
|
+
}
|
|
1191
|
+
/**
|
|
1192
|
+
* Where the files of several records sit, for all of them at once.
|
|
1193
|
+
*
|
|
1194
|
+
* `songsOfAlbum` answers "what is on this record", and reads it through the
|
|
1195
|
+
* group so a box's other discs come with it. A caller that wants only *where*
|
|
1196
|
+
* the files are paid that whole select per record: the cover route asking about
|
|
1197
|
+
* an artist ran it once per record of the artist — measured on the live
|
|
1198
|
+
* collection at 30 executions for `getCoverArt?id=ar:15`, and 277 ms of a route
|
|
1199
|
+
* that serves no other request while it runs. The question needs no order of
|
|
1200
|
+
* tracks and no titles, only `track → file`, which is one statement either way.
|
|
1201
|
+
*
|
|
1202
|
+
* Keyed by the record each row belongs to, and within a record in the order
|
|
1203
|
+
* `songsOfAlbum` would have given it, so a caller that walks its own records in
|
|
1204
|
+
* order and appends what it finds gets the list it would have built one record
|
|
1205
|
+
* at a time. That order is not decoration: it decides which folder answers when
|
|
1206
|
+
* no picture's *name* says which side it is.
|
|
1207
|
+
*/
|
|
1208
|
+
export function fileFoldersOfAlbums(db, albumIds) {
|
|
1209
|
+
const found = new Map();
|
|
1210
|
+
if (albumIds.length === 0)
|
|
1211
|
+
return found;
|
|
1212
|
+
const asked = albumIds.map(() => '?').join(', ');
|
|
1213
|
+
const where = rows(db, `SELECT grp.rep_id AS album_id, r.id AS root_id, f.folder_rel_path AS rel_path
|
|
1214
|
+
FROM track t
|
|
1215
|
+
JOIN file f ON f.id = t.file_id
|
|
1216
|
+
JOIN root r ON r.id = f.root_id
|
|
1217
|
+
LEFT JOIN album al ON al.id = t.album_id
|
|
1218
|
+
JOIN ${ALBUM_GROUPS} grp ON grp.id = al.id
|
|
1219
|
+
WHERE grp.rep_id IN (${asked})
|
|
1220
|
+
ORDER BY grp.rep_id, COALESCE(al.disc_number, 0), t.ordinal, t.id`, ...albumIds);
|
|
1221
|
+
for (const { album_id: albumId, root_id: rootId, rel_path: relPath } of where) {
|
|
1222
|
+
const already = found.get(albumId);
|
|
1223
|
+
const folder = { rootId, relPath };
|
|
1224
|
+
if (already === undefined)
|
|
1225
|
+
found.set(albumId, [folder]);
|
|
1226
|
+
else
|
|
1227
|
+
already.push(folder);
|
|
1228
|
+
}
|
|
1229
|
+
return found;
|
|
1230
|
+
}
|
|
1231
|
+
/**
|
|
1232
|
+
* The discs of a record, in order, for `discTitles`.
|
|
1233
|
+
*
|
|
1234
|
+
* A disc folder called `CD2 ● Ранний вариант` says what that disc *is* — an
|
|
1235
|
+
* early version, a bonus disc, a live set — and the protocol has a field for
|
|
1236
|
+
* exactly that. The title comes back verbatim; stripping the number in front of
|
|
1237
|
+
* it is `discSubtitle`'s job, and a disc whose folder names nothing but its
|
|
1238
|
+
* number is reported as it is and dropped by the caller.
|
|
1239
|
+
*/
|
|
1240
|
+
export function discsOfAlbum(db, albumId) {
|
|
1241
|
+
return rows(db, `SELECT al.disc_number AS disc, al.title AS title FROM album al
|
|
1242
|
+
WHERE al.disc_number IS NOT NULL
|
|
1243
|
+
AND ${grouped('al')} = (SELECT ${grouped('b')} FROM album b WHERE b.id = ?)
|
|
1244
|
+
ORDER BY al.disc_number, al.rel_path, al.id`, albumId);
|
|
1245
|
+
}
|
|
1246
|
+
export function song(db, trackId) {
|
|
1247
|
+
return row(db, `${SONG_SELECT} WHERE t.id = ?`, trackId);
|
|
1248
|
+
}
|
|
1249
|
+
/**
|
|
1250
|
+
* The same answer for a page of ids, in one query.
|
|
1251
|
+
*
|
|
1252
|
+
* The plural of `song`, and the one the starred listing needs: what the listener
|
|
1253
|
+
* starred is a set of ids with no order the collection knows, and asking for
|
|
1254
|
+
* them one at a time is one execution of `SONG_SELECT` per song.
|
|
1255
|
+
*/
|
|
1256
|
+
export function songsByIds(db, ids) {
|
|
1257
|
+
return byIds(ids, (placeholders) => rows(db, `${SONG_SELECT} WHERE t.id IN (${placeholders})`, ...ids));
|
|
1258
|
+
}
|
|
1259
|
+
/** The songs held in one folder, which is one album's worth by construction. */
|
|
1260
|
+
export function songsInFolder(db, rootId, relPath) {
|
|
1261
|
+
return rows(db, `${SONG_SELECT} WHERE al.root_id = ? AND al.rel_path = ? ORDER BY t.ordinal, t.id`, rootId, relPath);
|
|
1262
|
+
}
|
|
1263
|
+
export function roots(db) {
|
|
1264
|
+
return rows(db, 'SELECT id, path, alias FROM root ORDER BY id');
|
|
1265
|
+
}
|
|
1266
|
+
export function root(db, id) {
|
|
1267
|
+
return row(db, 'SELECT id, path, alias FROM root WHERE id = ?', id);
|
|
1268
|
+
}
|
|
1269
|
+
export function folder(db, id) {
|
|
1270
|
+
return row(db, 'SELECT id, root_id, rel_path, parent_rel_path, role FROM folder WHERE id = ?', id);
|
|
1271
|
+
}
|
|
1272
|
+
export function picturesInFolder(db, rootId, relPath) {
|
|
1273
|
+
return rows(db, `SELECT f.rel_path, f.ext, r.path AS root_path
|
|
1274
|
+
FROM file f
|
|
1275
|
+
JOIN root r ON r.id = f.root_id
|
|
1276
|
+
WHERE f.root_id = ? AND f.folder_rel_path = ? AND f.kind = 'image'
|
|
1277
|
+
ORDER BY f.rel_path`, rootId, relPath);
|
|
1278
|
+
}
|
|
1279
|
+
/**
|
|
1280
|
+
* The songs a search finds, best first.
|
|
1281
|
+
*
|
|
1282
|
+
* The rank comes from the index rather than from this query: how well a row
|
|
1283
|
+
* answers is the index's to say, and the id beside it is only there so that two
|
|
1284
|
+
* equally good answers come back in the same order twice.
|
|
1285
|
+
*/
|
|
1286
|
+
export function searchSongs(db, page) {
|
|
1287
|
+
// The root, when one was asked for, is a condition on every one of the three
|
|
1288
|
+
// sections — a search confined to a library that answered about another one
|
|
1289
|
+
// would be answering about the wrong music.
|
|
1290
|
+
const inRoot = page.rootId === undefined ? '' : ' AND r.id = ?';
|
|
1291
|
+
const root = page.rootId === undefined ? [] : [page.rootId];
|
|
1292
|
+
const junk = recorded(page.visibility ?? 'records');
|
|
1293
|
+
if (page.match === null) {
|
|
1294
|
+
return rows(db, `${SONG_SELECT} WHERE 1 = 1${inRoot}${junk} ORDER BY t.id LIMIT ? OFFSET ?`, ...root, page.size, page.offset);
|
|
1295
|
+
}
|
|
1296
|
+
return rows(db, `${SONG_SELECT}
|
|
1297
|
+
JOIN track_fts ON track_fts.rowid = t.id
|
|
1298
|
+
WHERE track_fts MATCH ?${inRoot}${junk}
|
|
1299
|
+
ORDER BY track_fts.rank, t.id
|
|
1300
|
+
LIMIT ? OFFSET ?`, page.match, ...root, page.size, page.offset);
|
|
1301
|
+
}
|
|
1302
|
+
/**
|
|
1303
|
+
* The records a query finds, as the ids `albumsById` expects: the
|
|
1304
|
+
* representative of each group, never a disc.
|
|
1305
|
+
*
|
|
1306
|
+
* A disc id here would be a second answer to a question `getAlbumList2` answers
|
|
1307
|
+
* with records — a search that offered `CD1 ● Альбом` while the browse tab
|
|
1308
|
+
* offered the box is the disagreement this file exists to avoid. The ranking is
|
|
1309
|
+
* kept: a match is ordered by its best rank, everything else by the record's
|
|
1310
|
+
* name.
|
|
1311
|
+
*/
|
|
1312
|
+
export function searchAlbumIds(db, page) {
|
|
1313
|
+
const inRoot = page.rootId === undefined ? '' : ' AND al.root_id = ?';
|
|
1314
|
+
const root = page.rootId === undefined ? [] : [page.rootId];
|
|
1315
|
+
const junk = recorded(page.visibility ?? 'records');
|
|
1316
|
+
const ids = page.match === null
|
|
1317
|
+
? rows(db, `SELECT grp.rep_id AS id FROM album al
|
|
1318
|
+
JOIN ${ALBUM_GROUPS} grp ON grp.id = al.id AND grp.rn = 1
|
|
1319
|
+
LEFT JOIN release rel ON rel.id = al.release_id
|
|
1320
|
+
WHERE 1 = 1${inRoot}${junk}
|
|
1321
|
+
ORDER BY COALESCE(rel.title, al.title, al.rel_path), grp.rep_id
|
|
1322
|
+
LIMIT ? OFFSET ?`, ...root, page.size, page.offset)
|
|
1323
|
+
: rows(db, `SELECT MIN(grp.rep_id) AS id, MIN(track_fts.rank) AS best
|
|
1324
|
+
FROM track_fts
|
|
1325
|
+
JOIN track t ON t.id = track_fts.rowid
|
|
1326
|
+
JOIN album al ON al.id = t.album_id
|
|
1327
|
+
JOIN ${ALBUM_GROUPS} grp ON grp.id = al.id
|
|
1328
|
+
WHERE track_fts MATCH ? AND t.album_id IS NOT NULL${inRoot}${junk}
|
|
1329
|
+
GROUP BY grp.group_id
|
|
1330
|
+
ORDER BY best, MIN(grp.rep_id)
|
|
1331
|
+
LIMIT ? OFFSET ?`, page.match, ...root, page.size, page.offset);
|
|
1332
|
+
return ids.map((row) => row.id);
|
|
1333
|
+
}
|
|
1334
|
+
export function searchArtistIds(db, page) {
|
|
1335
|
+
const inRoot = page.rootId === undefined ? '' : ' AND al.root_id = ?';
|
|
1336
|
+
const root = page.rootId === undefined ? [] : [page.rootId];
|
|
1337
|
+
// Spelled out rather than `recorded()`, because that reads the clause off the
|
|
1338
|
+
// record group's alias `grp` and this query has no group to join — it is
|
|
1339
|
+
// answering "which artists have a song", which is a question about `album`
|
|
1340
|
+
// rows, and joining the group would multiply every row it counts.
|
|
1341
|
+
const junk = page.visibility === 'all' ? '' : ' AND al.junk_reason IS NULL';
|
|
1342
|
+
const ids = page.match === null
|
|
1343
|
+
? rows(db, `SELECT ar.id FROM artist ar
|
|
1344
|
+
JOIN album al ON al.artist_id = ar.id
|
|
1345
|
+
WHERE 1 = 1${inRoot}${junk}
|
|
1346
|
+
GROUP BY ar.id
|
|
1347
|
+
ORDER BY COALESCE(ar.sort_key, ar.name), ar.id
|
|
1348
|
+
LIMIT ? OFFSET ?`, ...root, page.size, page.offset)
|
|
1349
|
+
: rows(db, `SELECT al.artist_id AS id, MIN(track_fts.rank) AS best
|
|
1350
|
+
FROM track_fts
|
|
1351
|
+
JOIN track t ON t.id = track_fts.rowid
|
|
1352
|
+
JOIN album al ON al.id = t.album_id
|
|
1353
|
+
WHERE track_fts MATCH ? AND al.artist_id IS NOT NULL${inRoot}${junk}
|
|
1354
|
+
GROUP BY al.artist_id
|
|
1355
|
+
ORDER BY best, al.artist_id
|
|
1356
|
+
LIMIT ? OFFSET ?`, page.match, ...root, page.size, page.offset);
|
|
1357
|
+
return ids.map((row) => row.id);
|
|
1358
|
+
}
|
|
1359
|
+
export function embeddedCoverInFolder(db, rootId, relPath) {
|
|
1360
|
+
return row(db, `SELECT f.rel_path, r.path AS root_path, c.mime, c.offset, c.length,
|
|
1361
|
+
c.kind, f.tags_container AS container
|
|
1362
|
+
FROM file f
|
|
1363
|
+
JOIN root r ON r.id = f.root_id
|
|
1364
|
+
JOIN cover_art c ON c.file_id = f.id
|
|
1365
|
+
WHERE f.root_id = ? AND f.folder_rel_path = ?
|
|
1366
|
+
ORDER BY f.rel_path
|
|
1367
|
+
LIMIT 1`, rootId, relPath);
|
|
1368
|
+
}
|
|
1369
|
+
/**
|
|
1370
|
+
* The folders directly inside one folder.
|
|
1371
|
+
*
|
|
1372
|
+
* The row that stands for a root has `''` for both its path and its parent, so
|
|
1373
|
+
* the third condition is what keeps the root from being listed among its own
|
|
1374
|
+
* children. It is written as a comparison rather than as a special case for `''`
|
|
1375
|
+
* because it says the general thing: a folder is never its own child.
|
|
1376
|
+
*/
|
|
1377
|
+
export function childFolders(db, rootId, parentRelPath, visibility = 'records') {
|
|
1378
|
+
// A folder the scanner called junk is not offered as a place to go.
|
|
1379
|
+
//
|
|
1380
|
+
// **This is what makes hiding complete, and it is not the album filter
|
|
1381
|
+
// repeated.** A record is hidden by the clause every listing carries, but the
|
|
1382
|
+
// *tree* is built from folders, not from records: the node for `Telegram
|
|
1383
|
+
// Desktop` would still be built, still be listed by `getIndexes`, and still
|
|
1384
|
+
// open onto an empty directory — a row reading nought, which is the shape the
|
|
1385
|
+
// operator would still see in his client. Dropping the folder drops the node
|
|
1386
|
+
// with it, everywhere, because every tree in this file is built from here.
|
|
1387
|
+
//
|
|
1388
|
+
// What it does not cover, stated because the rule is not general: a folder
|
|
1389
|
+
// that is *both* junk and a shelf over other records takes those records with
|
|
1390
|
+
// it. No folder in this collection is both — a dumping ground holds no
|
|
1391
|
+
// records, and the one that does is the root itself, which is never a child of
|
|
1392
|
+
// anything — and a rule that had to tell the two apart would have to walk the
|
|
1393
|
+
// subtree to answer.
|
|
1394
|
+
//
|
|
1395
|
+
// **The subquery is correlated, which this file otherwise refuses.** The shape
|
|
1396
|
+
// is the one that took the server down once (`ALBUM_GENRES`), and it is kept
|
|
1397
|
+
// here because its cost is bounded by something the other had not: this runs
|
|
1398
|
+
// once per *child folder* of one folder, not once per album in the collection,
|
|
1399
|
+
// and it reads `album (root_id, rel_path)`, which is that table's UNIQUE index
|
|
1400
|
+
// — a lookup, not a scan. `getIndexes` walks the whole tree and is measured;
|
|
1401
|
+
// it did not move.
|
|
1402
|
+
const hideJunk = visibility === 'all'
|
|
1403
|
+
? ''
|
|
1404
|
+
: `AND NOT EXISTS (SELECT 1 FROM album al
|
|
1405
|
+
WHERE al.root_id = f.root_id AND al.rel_path = f.rel_path
|
|
1406
|
+
AND al.junk_reason IS NOT NULL)`;
|
|
1407
|
+
return rows(db, `SELECT f.id, f.root_id, f.rel_path, f.parent_rel_path, f.role FROM folder f
|
|
1408
|
+
WHERE f.root_id = ? AND f.parent_rel_path = ? AND f.rel_path <> ? ${hideJunk}
|
|
1409
|
+
ORDER BY f.rel_path`, rootId, parentRelPath, parentRelPath);
|
|
1410
|
+
}
|