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