funoteka 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +90 -0
- package/CHANGELOG.md +104 -0
- package/DEPLOY.md +375 -0
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/deploy/answers.schema.json +151 -0
- package/deploy/build.mjs +88 -0
- package/deploy/check-publishable.mjs +246 -0
- package/deploy/smoke.mjs +681 -0
- package/deploy/systemd/funoteka.env.example +41 -0
- package/deploy/systemd/funoteka.service +77 -0
- package/deploy/windows/funoteka-service.xml +58 -0
- package/deploy/windows/install-service.ps1 +158 -0
- package/dist/annotation/store.js +67 -0
- package/dist/api/admin-export.js +227 -0
- package/dist/api/admin-guard.js +181 -0
- package/dist/api/admin-library.js +113 -0
- package/dist/api/admin.js +1162 -0
- package/dist/api/annotation.js +193 -0
- package/dist/api/artistinfo.js +140 -0
- package/dist/api/audit.js +32 -0
- package/dist/api/auth.js +145 -0
- package/dist/api/bookmark.js +108 -0
- package/dist/api/browse.js +1241 -0
- package/dist/api/config-file.js +202 -0
- package/dist/api/config.js +285 -0
- package/dist/api/cover.js +304 -0
- package/dist/api/download.js +112 -0
- package/dist/api/envelope.js +162 -0
- package/dist/api/extensions.js +43 -0
- package/dist/api/genre.js +81 -0
- package/dist/api/health.js +35 -0
- package/dist/api/history.js +374 -0
- package/dist/api/idempotency.js +66 -0
- package/dist/api/keys.js +90 -0
- package/dist/api/log-file.js +65 -0
- package/dist/api/meta.js +1410 -0
- package/dist/api/playlist.js +311 -0
- package/dist/api/rescan.js +196 -0
- package/dist/api/roots.js +161 -0
- package/dist/api/router.js +133 -0
- package/dist/api/scanner.js +163 -0
- package/dist/api/search.js +82 -0
- package/dist/api/server.js +643 -0
- package/dist/api/settings.js +67 -0
- package/dist/api/stream.js +442 -0
- package/dist/api/stubs.js +183 -0
- package/dist/api/transcode.js +678 -0
- package/dist/api/user.js +73 -0
- package/dist/api/virtual.js +362 -0
- package/dist/api/visibility.js +21 -0
- package/dist/artist/apply.js +752 -0
- package/dist/artist/credit.js +95 -0
- package/dist/artist/folder.js +65 -0
- package/dist/artist/name.js +205 -0
- package/dist/artist/nfo.js +101 -0
- package/dist/artist/translit.js +79 -0
- package/dist/bookmark/store.js +54 -0
- package/dist/classify/classify.js +405 -0
- package/dist/classify/collision-name.js +109 -0
- package/dist/classify/folder-name.js +466 -0
- package/dist/classify/roles.js +277 -0
- package/dist/classify/shelf-name.js +178 -0
- package/dist/classify/tree.js +43 -0
- package/dist/cli/args.js +169 -0
- package/dist/cli/daemon.js +192 -0
- package/dist/cli/entry.js +25 -0
- package/dist/cli/keys.js +52 -0
- package/dist/cli.js +638 -0
- package/dist/cover/pick.js +58 -0
- package/dist/cover/picture.js +111 -0
- package/dist/cue/engine.js +759 -0
- package/dist/cue/match.js +142 -0
- package/dist/cue/parse.js +182 -0
- package/dist/cue/plan.js +286 -0
- package/dist/cue/track-name.js +120 -0
- package/dist/db/index.js +163 -0
- package/dist/db/issue.js +73 -0
- package/dist/db/ledger.js +84 -0
- package/dist/db/migrations/001_init.sql +196 -0
- package/dist/db/migrations/002_cue_track_flexible.sql +35 -0
- package/dist/db/migrations/003_cue_metadata.sql +14 -0
- package/dist/db/migrations/004_cue_performer.sql +12 -0
- package/dist/db/migrations/005_incremental.sql +23 -0
- package/dist/db/migrations/006_tags.sql +57 -0
- package/dist/db/migrations/007_artist_credit.sql +35 -0
- package/dist/db/migrations/008_issue_stage.sql +39 -0
- package/dist/db/migrations/009_cue_title.sql +20 -0
- package/dist/db/migrations/010_duration_source.sql +31 -0
- package/dist/db/migrations/011_track_title_source.sql +28 -0
- package/dist/db/migrations/012_file_container.sql +25 -0
- package/dist/db/migrations/013_cover_art.sql +28 -0
- package/dist/db/migrations/014_album_year.sql +17 -0
- package/dist/db/migrations/015_probe_method.sql +14 -0
- package/dist/db/migrations/016_tags_method.sql +20 -0
- package/dist/db/migrations/017_cover_indirect.sql +23 -0
- package/dist/db/migrations/018_release_year.sql +14 -0
- package/dist/db/migrations/019_file_tag_name_file_value.sql +20 -0
- package/dist/db/migrations/020_analyze_after_index.sql +16 -0
- package/dist/db/migrations/021_cue_index_files.sql +15 -0
- package/dist/db/migrations/022_sidecar_text.sql +19 -0
- package/dist/db/migrations/023_track_credit.sql +23 -0
- package/dist/db/migrations/024_playlists.sql +63 -0
- package/dist/db/migrations/025_playlist_sequence.sql +27 -0
- package/dist/db/migrations/026_playlist_source_file.sql +19 -0
- package/dist/db/migrations/027_lookup_indexes.sql +30 -0
- package/dist/db/migrations/028_annotations.sql +47 -0
- package/dist/db/migrations/029_scan_state_root_run.sql +28 -0
- package/dist/db/migrations/030_history.sql +108 -0
- package/dist/db/migrations/031_queue_position_and_plays.sql +46 -0
- package/dist/db/migrations/032_bookmarks.sql +27 -0
- package/dist/db/migrations/033_bookmark_order.sql +14 -0
- package/dist/db/migrations/034_junk.sql +33 -0
- package/dist/db/migrations/035_api_key.sql +38 -0
- package/dist/db/migrations/036_file_tag_first.sql +88 -0
- package/dist/db/migrations/037_admin_idempotency.sql +31 -0
- package/dist/db/migrations/038_cue_audio_index.sql +24 -0
- package/dist/db/sweep.js +109 -0
- package/dist/history/store.js +151 -0
- package/dist/inventory/inventory.js +294 -0
- package/dist/junk/marks.js +143 -0
- package/dist/junk/rule.js +93 -0
- package/dist/mcp/client.js +48 -0
- package/dist/mcp/server.js +175 -0
- package/dist/mcp/tools.js +348 -0
- package/dist/playlist/files.js +116 -0
- package/dist/playlist/import.js +260 -0
- package/dist/playlist/store.js +240 -0
- package/dist/probe/ffprobe.js +179 -0
- package/dist/run.js +120 -0
- package/dist/scan/kinds.js +49 -0
- package/dist/scan/scan.js +503 -0
- package/dist/scan/schedule.js +132 -0
- package/dist/scan/settle.js +172 -0
- package/dist/scan/walk.js +132 -0
- package/dist/search/index.js +41 -0
- package/dist/search/query.js +28 -0
- package/dist/stream/flac.js +541 -0
- package/dist/stream/mpeg.js +179 -0
- package/dist/stream/recode.js +393 -0
- package/dist/stream/rewrite.js +163 -0
- package/dist/stream/segment.js +624 -0
- package/dist/tags/apply.js +703 -0
- package/dist/tags/encode.js +164 -0
- package/dist/tags/first.js +130 -0
- package/dist/tags/flac.js +196 -0
- package/dist/tags/genres.js +123 -0
- package/dist/tags/id3v1.js +209 -0
- package/dist/tags/id3v2.js +754 -0
- package/dist/tags/mp4.js +534 -0
- package/dist/tags/mpeg.js +355 -0
- package/dist/tags/ogg.js +463 -0
- package/dist/tags/picture.js +71 -0
- package/dist/tags/read.js +269 -0
- package/dist/tags/types.js +51 -0
- package/dist/tags/vorbis-comment.js +155 -0
- package/dist/text/encoding.js +384 -0
- package/dist/text/site-name.js +37 -0
- package/dist/util/child.js +21 -0
- package/dist/util/names.js +97 -0
- package/funoteka.json.example +27 -0
- package/package.json +55 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import * as annotations from "../annotation/store.js";
|
|
2
|
+
import { withTransaction } from "../db/index.js";
|
|
3
|
+
import { albumChild, albumId3, artistId3, childOf, confinedTo, ID, parseId } from "./browse.js";
|
|
4
|
+
import { ApiError, ERROR } from "./envelope.js";
|
|
5
|
+
import { album, albumsById, artist, artistIdsInRoot, artistsById, songsByIds, song, } from "./meta.js";
|
|
6
|
+
/**
|
|
7
|
+
* What the listener marked, in the protocol's shapes.
|
|
8
|
+
*
|
|
9
|
+
* The second set of routes that write — playlists are the first — and the same
|
|
10
|
+
* kind of thing: a statement about the music that no file states and no scan can
|
|
11
|
+
* rebuild. What it is *about*, though, is the collection, so a mark goes with
|
|
12
|
+
* the row it is on and nothing here has to clean up after a deleted record.
|
|
13
|
+
*
|
|
14
|
+
* The two halves of the protocol live here together: the methods that set the
|
|
15
|
+
* marks, and the listings that show them. A client that stars a song and one
|
|
16
|
+
* that draws a star are looking at the same row of the same table.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* The protocol's three prefixes, and what each of them marks.
|
|
20
|
+
*
|
|
21
|
+
* `id` — the parameter `star`, `unstar` and `setRating` all take — names a
|
|
22
|
+
* *subject*, and the protocol's own words for it are "the file (song) or folder
|
|
23
|
+
* (album/artist)": which of the three it is comes from the id itself, not from
|
|
24
|
+
* the parameter. The two named parameters beside it (`albumId`, `artistId`)
|
|
25
|
+
* exist for clients that would rather say it outright.
|
|
26
|
+
*/
|
|
27
|
+
const MARKS = { tr: 'track', al: 'album', ar: 'artist' };
|
|
28
|
+
/**
|
|
29
|
+
* The subject an id names, or nothing — and the *row's* id, not the one asked
|
|
30
|
+
* with.
|
|
31
|
+
*
|
|
32
|
+
* That second half is the point of going through the model here rather than
|
|
33
|
+
* taking the id apart and keeping it: `album` resolves a record through its
|
|
34
|
+
* group, so a client that holds a box's second disc id — which is what every
|
|
35
|
+
* client did while the discs were the albums — is answered about the record,
|
|
36
|
+
* and the mark has to land on the row the listings read. `browse.ts` makes the
|
|
37
|
+
* same distinction for the same reason ("the record's own id, not the one that
|
|
38
|
+
* was asked with").
|
|
39
|
+
*/
|
|
40
|
+
function subjectOf(db, raw) {
|
|
41
|
+
const parsed = parseId(raw);
|
|
42
|
+
const kind = parsed === undefined ? undefined : MARKS[parsed.kind];
|
|
43
|
+
if (parsed === undefined || kind === undefined)
|
|
44
|
+
return null;
|
|
45
|
+
const found = kind === 'track'
|
|
46
|
+
? song(db, parsed.n)
|
|
47
|
+
: kind === 'album'
|
|
48
|
+
? album(db, parsed.n)
|
|
49
|
+
: artist(db, parsed.n);
|
|
50
|
+
return found === undefined ? null : { kind, id: found.id };
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Every subject the request names, checked before anything is written.
|
|
54
|
+
*
|
|
55
|
+
* The protocol spells a batch as the parameter repeated — `id` once per song —
|
|
56
|
+
* and a client starring an album's worth of songs sends exactly that. Each is
|
|
57
|
+
* resolved against the model first, and the whole batch is written in one
|
|
58
|
+
* transaction after: a call naming one song that is not here changes nothing
|
|
59
|
+
* rather than half of what it named, and a call naming forty does not pay forty
|
|
60
|
+
* commits — measured on the live database, forty stars written one at a time
|
|
61
|
+
* held this single-threaded server for **2967 ms**, against 21 ms for the same
|
|
62
|
+
* forty inside one transaction.
|
|
63
|
+
*/
|
|
64
|
+
function mark(db, query, setting) {
|
|
65
|
+
const named = new Map();
|
|
66
|
+
// **The three parameters are read as one, and that is a decision.** The
|
|
67
|
+
// protocol distinguishes them — `id` for "a song, album or artist", `albumId`
|
|
68
|
+
// and `artistId` for clients that browse by tags — and this server's ids
|
|
69
|
+
// already say which kind they are, so the parameter a client chose adds
|
|
70
|
+
// nothing to the question. It used to be *enforced*, though: `albumId` had to
|
|
71
|
+
// name an album, and a client that put a track id there was refused with "No
|
|
72
|
+
// such album" — and refused as a whole call, so the track it named in `id` was
|
|
73
|
+
// never starred and its heart never appeared.
|
|
74
|
+
//
|
|
75
|
+
// That is measured, not imagined: Castafiore sends `id`, `albumId` and
|
|
76
|
+
// `artistId` all set to the same value, and starring from it did nothing at
|
|
77
|
+
// all. One id, one entry, whichever parameter carried it.
|
|
78
|
+
for (const name of ['id', 'albumId', 'artistId']) {
|
|
79
|
+
for (const raw of query.getAll(name)) {
|
|
80
|
+
if (raw === '')
|
|
81
|
+
continue;
|
|
82
|
+
const subject = subjectOf(db, raw);
|
|
83
|
+
if (subject === null)
|
|
84
|
+
throw new ApiError(ERROR.notFound, `No such id: ${raw}`);
|
|
85
|
+
named.set(`${subject.kind}:${subject.id}`, [subject.kind, subject.id]);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// A call that named nothing is refused rather than answered `ok`: a client
|
|
89
|
+
// that lost its id on the way has not marked anything, and telling it that it
|
|
90
|
+
// did leaves it showing a star nobody set.
|
|
91
|
+
if (named.size === 0) {
|
|
92
|
+
throw new ApiError(ERROR.missingParameter, 'Required parameter is missing: id');
|
|
93
|
+
}
|
|
94
|
+
withTransaction(db, () => {
|
|
95
|
+
for (const [kind, id] of named.values()) {
|
|
96
|
+
if (setting)
|
|
97
|
+
annotations.star(db, kind, id);
|
|
98
|
+
else
|
|
99
|
+
annotations.unstar(db, kind, id);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
export function star(db, query) {
|
|
104
|
+
mark(db, query, true);
|
|
105
|
+
return {};
|
|
106
|
+
}
|
|
107
|
+
export function unstar(db, query) {
|
|
108
|
+
mark(db, query, false);
|
|
109
|
+
return {};
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* A rating from one to five — and nought, which is the protocol's way of saying
|
|
113
|
+
* there is none.
|
|
114
|
+
*
|
|
115
|
+
* Refused outside that range rather than clamped: a client that asked for seven
|
|
116
|
+
* has a bug, and storing five would tell it the number it sent was taken. The
|
|
117
|
+
* parameter is required, so a call that names no rating is refused by name
|
|
118
|
+
* rather than treated as zero — "set this to nothing" and "I forgot to say" are
|
|
119
|
+
* different requests, and only one of them should clear a rating.
|
|
120
|
+
*
|
|
121
|
+
* The subject is whatever `id` names, the same three kinds `star` takes: the
|
|
122
|
+
* documentation of this method says "file (song) or folder (album/artist)", and
|
|
123
|
+
* a rating a client can set but never read back would be a control that does
|
|
124
|
+
* nothing.
|
|
125
|
+
*/
|
|
126
|
+
export function setRating(db, query) {
|
|
127
|
+
const raw = query.get('rating');
|
|
128
|
+
if (raw === null || raw === '') {
|
|
129
|
+
throw new ApiError(ERROR.missingParameter, 'Required parameter is missing: rating');
|
|
130
|
+
}
|
|
131
|
+
const rating = Number(raw);
|
|
132
|
+
if (!Number.isInteger(rating) || rating < 0 || rating > 5) {
|
|
133
|
+
throw new ApiError(ERROR.generic, `rating is not a number from 0 to 5: ${raw}`);
|
|
134
|
+
}
|
|
135
|
+
const subject = subjectOf(db, query.get('id') ?? '');
|
|
136
|
+
if (subject === null) {
|
|
137
|
+
throw new ApiError(ERROR.notFound, `No such id: ${query.get('id') ?? ''}`);
|
|
138
|
+
}
|
|
139
|
+
annotations.setRating(db, subject.kind, subject.id, rating);
|
|
140
|
+
return {};
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* The starred songs, records and artists, from one music folder if a client
|
|
144
|
+
* asked for one.
|
|
145
|
+
*
|
|
146
|
+
* The folder is the protocol's own filter, and it is honoured the way every
|
|
147
|
+
* other listing in this API honours it — through `confinedTo`, which refuses a
|
|
148
|
+
* folder this server never handed out. A starred listing that accepted the
|
|
149
|
+
* parameter and answered from every folder would tell a client it was looking
|
|
150
|
+
* at one library while showing it another.
|
|
151
|
+
*
|
|
152
|
+
* Records are filtered by where they are, and songs by the root their file
|
|
153
|
+
* lives in. An artist is nobody's to place — one artist's records can sit in
|
|
154
|
+
* several folders — so they are kept when the folder asked about holds a record
|
|
155
|
+
* of theirs, which is the only sense in which an artist is in a folder.
|
|
156
|
+
*/
|
|
157
|
+
function starred(db, query, withSongs, origin) {
|
|
158
|
+
const rootId = confinedTo(db, query);
|
|
159
|
+
const songs = songsByIds(db, annotations.starredIds(db, 'track')).filter((row) => rootId === undefined || row.root_id === rootId);
|
|
160
|
+
const records = albumsById(db, annotations.starredIds(db, 'album')).filter((row) => rootId === undefined || row.root_id === rootId);
|
|
161
|
+
const performerIds = annotations.starredIds(db, 'artist');
|
|
162
|
+
const inRoot = rootId === undefined ? null : artistIdsInRoot(db, performerIds, rootId);
|
|
163
|
+
const performers = artistsById(db, inRoot === null ? performerIds : performerIds.filter((id) => inRoot.has(id)));
|
|
164
|
+
const child = (row) => childOf(row);
|
|
165
|
+
return withSongs
|
|
166
|
+
? {
|
|
167
|
+
starred2: {
|
|
168
|
+
artist: performers.map((row) => artistId3(row, origin)),
|
|
169
|
+
album: records.map(albumId3),
|
|
170
|
+
song: songs.map(child),
|
|
171
|
+
},
|
|
172
|
+
}
|
|
173
|
+
: {
|
|
174
|
+
starred: {
|
|
175
|
+
// The v1 shapes: an `Artist` is a name and an id, and a record is a
|
|
176
|
+
// `Child`. The same three readings, told the older way.
|
|
177
|
+
artist: performers.map((row) => ({
|
|
178
|
+
id: ID.artist(row.id),
|
|
179
|
+
name: row.name,
|
|
180
|
+
...(row.starred_at === null ? {} : { starred: row.starred_at }),
|
|
181
|
+
...(row.rating === null ? {} : { userRating: row.rating }),
|
|
182
|
+
})),
|
|
183
|
+
album: records.map((row) => albumChild(row, ID.top)),
|
|
184
|
+
song: songs.map(child),
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
export function getStarred(db, query, origin) {
|
|
189
|
+
return starred(db, query, false, origin);
|
|
190
|
+
}
|
|
191
|
+
export function getStarred2(db, query, origin) {
|
|
192
|
+
return starred(db, query, true, origin);
|
|
193
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { biographyOf } from "../artist/nfo.js";
|
|
4
|
+
import { decodeText } from "../text/encoding.js";
|
|
5
|
+
import { ID, artistId3, artistImageUrlOf, confinedTo, parseId, required } from "./browse.js";
|
|
6
|
+
import { ApiError, ERROR } from "./envelope.js";
|
|
7
|
+
import { artist, artistByKey, artistNfoIn, artistOwnFolders } from "./meta.js";
|
|
8
|
+
import { virtualEntries, virtualNodes } from "./virtual.js";
|
|
9
|
+
/**
|
|
10
|
+
* What a client is told about an artist, beyond the records they made.
|
|
11
|
+
*
|
|
12
|
+
* The protocol asks this in `getArtistInfo2`, whose usual sources are last.fm
|
|
13
|
+
* and MusicBrainz — services this server does not talk to, by design. What it
|
|
14
|
+
* answers with instead is what the collection already holds: the note the
|
|
15
|
+
* operator's library keeps in the folder named for the artist, the picture
|
|
16
|
+
* `getCoverArt` already serves, and the other artists that folder gathers.
|
|
17
|
+
*
|
|
18
|
+
* `lastFmUrl` is left out because there is nothing to put there: no file in
|
|
19
|
+
* this collection carries one, and a fabricated URL is a lie a client would
|
|
20
|
+
* render as a link.
|
|
21
|
+
*
|
|
22
|
+
* `musicBrainzId` is a different case, and this comment said otherwise until a
|
|
23
|
+
* review measured it. The id **is** on disk — in the same `artist.nfo` this
|
|
24
|
+
* file already reads for the biography, under `<musicbrainzartistid>`, e.g.
|
|
25
|
+
* `69ee3720-a7cb-4402-b48d-a02c366f2bcf` for The Cure. It is not returned
|
|
26
|
+
* because nobody has built the reader, which is a gap to fill rather than an
|
|
27
|
+
* impossibility to explain away. `elementText` in `src/artist/nfo.ts` reads it
|
|
28
|
+
* with the same pass that reads the biography.
|
|
29
|
+
*/
|
|
30
|
+
/** The route a picture is asked for by, which is where the URLs below point. */
|
|
31
|
+
const COVER_ROUTE = '/rest/getCoverArt';
|
|
32
|
+
/** How many related artists the protocol offers when a client does not say. */
|
|
33
|
+
const DEFAULT_COUNT = 20;
|
|
34
|
+
export function getArtistInfo2(db, query, origin) {
|
|
35
|
+
const raw = required(query, 'id');
|
|
36
|
+
const parsed = parseId(raw);
|
|
37
|
+
if (parsed === undefined || parsed.kind !== 'ar') {
|
|
38
|
+
throw new ApiError(ERROR.notFound, `No such id: ${raw}`);
|
|
39
|
+
}
|
|
40
|
+
const row = artist(db, parsed.n);
|
|
41
|
+
if (row === undefined)
|
|
42
|
+
throw new ApiError(ERROR.notFound, `No such id: ${raw}`);
|
|
43
|
+
// The picture is the one `getCoverArt` already serves for this artist, named
|
|
44
|
+
// as a URL because a URL is what the protocol asks for. One picture offered at
|
|
45
|
+
// three sizes: this collection keeps one, and a server that answered with
|
|
46
|
+
// three would be promising thumbnails it has never made. It is the same URL
|
|
47
|
+
// `artistImageUrl` carries — `artistImageUrlOf` is where it is built once,
|
|
48
|
+
// since two builders of one address is how two answers to one question start.
|
|
49
|
+
const picture = artistImageUrlOf(row.id, origin);
|
|
50
|
+
const biography = biographyOfArtist(db, row.id);
|
|
51
|
+
const related = relatedArtists(db, row, confinedTo(db, query), origin).slice(0, countOf(query));
|
|
52
|
+
return {
|
|
53
|
+
artistInfo2: {
|
|
54
|
+
...(biography === null ? {} : { biography }),
|
|
55
|
+
smallImageUrl: picture,
|
|
56
|
+
mediumImageUrl: picture,
|
|
57
|
+
largeImageUrl: picture,
|
|
58
|
+
...(related.length === 0 ? {} : { similarArtist: related }),
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/** How many related artists to offer — the client's number, or the protocol's. */
|
|
63
|
+
function countOf(query) {
|
|
64
|
+
const asked = Number(query.get('count'));
|
|
65
|
+
return Number.isFinite(asked) && asked > 0 ? Math.floor(asked) : DEFAULT_COUNT;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The note the collection keeps about an artist, or nothing.
|
|
69
|
+
*
|
|
70
|
+
* It lives in the folder named for them, so the folders are asked in the order
|
|
71
|
+
* `artistOwnFolders` gives: the one spelled exactly like the artist first, the
|
|
72
|
+
* shelves behind it only if it holds nothing.
|
|
73
|
+
*
|
|
74
|
+
* A note that cannot be read is passed over rather than thrown out of. A row
|
|
75
|
+
* can outlive its file — a folder moved between scans would leave one — and an
|
|
76
|
+
* artist with no readable note is an artist with no note, not a failed request.
|
|
77
|
+
* An earlier version of this comment named a folder in this collection as such
|
|
78
|
+
* a case; a review checked and all three `artist.nfo` rows have their file.
|
|
79
|
+
*
|
|
80
|
+
* `biographyOf` carries the same promise and used not to keep it: it threw on a
|
|
81
|
+
* numeric entity naming no XML character, and the throw left this function
|
|
82
|
+
* because only the read was guarded. Both halves are guarded now.
|
|
83
|
+
*/
|
|
84
|
+
function biographyOfArtist(db, artistId) {
|
|
85
|
+
for (const where of artistOwnFolders(db, artistId)) {
|
|
86
|
+
const file = artistNfoIn(db, where);
|
|
87
|
+
if (file === null)
|
|
88
|
+
continue;
|
|
89
|
+
let text;
|
|
90
|
+
try {
|
|
91
|
+
text = decodeText(readFileSync(join(file.rootPath, file.relPath))).text;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const biography = biographyOf(text);
|
|
97
|
+
if (biography !== null)
|
|
98
|
+
return biography;
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The other artists this artist's folder gathers.
|
|
104
|
+
*
|
|
105
|
+
* Exactly the list that folder shows as drawers, and for the same reason: where
|
|
106
|
+
* a node credits more than one name, each of those names is somebody the
|
|
107
|
+
* collection knows, and the folder is where they are reachable from. That is
|
|
108
|
+
* what makes them the answer to "who else is here" — the question a client
|
|
109
|
+
* asking for similar artists is really asking, and the only version of it this
|
|
110
|
+
* library can answer from what it holds.
|
|
111
|
+
*
|
|
112
|
+
* An artist who owns no folder has no gathering and no related artists. That is
|
|
113
|
+
* not a claim that nobody resembles them; it is that the collection has nothing
|
|
114
|
+
* to say, and an empty list says it.
|
|
115
|
+
*
|
|
116
|
+
* The artist itself is left out. The count beside each of the others is what this
|
|
117
|
+
* drawer holds, which is not always what opening that artist would give. The two
|
|
118
|
+
* agree on this collection — every name a drawer gathers has all of its records
|
|
119
|
+
* in that drawer — and would part for an artist filed in two places. Counting
|
|
120
|
+
* each of them the way their own page does would be a query per name, on a route
|
|
121
|
+
* a client calls for every artist it draws; task:2838 records the difference.
|
|
122
|
+
*/
|
|
123
|
+
function relatedArtists(db, row, rootId, origin) {
|
|
124
|
+
const node = virtualNodes(db, rootId).find((one) => one.artistKey === row.name_key);
|
|
125
|
+
if (node === undefined)
|
|
126
|
+
return [];
|
|
127
|
+
const related = [];
|
|
128
|
+
const seen = new Set([row.name_key]);
|
|
129
|
+
for (const entry of virtualEntries(db, node)) {
|
|
130
|
+
const credit = entry.credit;
|
|
131
|
+
if (credit === null || seen.has(credit.artistKey))
|
|
132
|
+
continue;
|
|
133
|
+
seen.add(credit.artistKey);
|
|
134
|
+
const artistRow = artistByKey(db, credit.artistKey);
|
|
135
|
+
if (artistRow === undefined)
|
|
136
|
+
continue;
|
|
137
|
+
related.push(artistId3({ ...artistRow, album_count: credit.records.length }, origin));
|
|
138
|
+
}
|
|
139
|
+
return related;
|
|
140
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { appendFileSync } from 'node:fs';
|
|
2
|
+
/** Where a deployment's audit lines go when nobody says otherwise. */
|
|
3
|
+
export function auditPath(dbPath) {
|
|
4
|
+
return `${dbPath}.audit.jsonl`;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* A writer for one audit file.
|
|
8
|
+
*
|
|
9
|
+
* **Appended synchronously**, which is the unusual choice and the deliberate
|
|
10
|
+
* one: these lines are rare (a person operating a server, not a client polling
|
|
11
|
+
* it), so the cost is nothing, and what it buys is that the line exists before
|
|
12
|
+
* the answer does. A buffered writer loses exactly the lines written just before
|
|
13
|
+
* a crash, which are the ones somebody will want.
|
|
14
|
+
*
|
|
15
|
+
* A file that cannot be written is said on stderr and reported in the answer
|
|
16
|
+
* rather than thrown: the mutation has already happened by the time this runs,
|
|
17
|
+
* and failing the request would describe it as not having happened. What the
|
|
18
|
+
* caller is owed is the truth about both halves — it was done, and it was not
|
|
19
|
+
* recorded.
|
|
20
|
+
*/
|
|
21
|
+
export function auditLog(path) {
|
|
22
|
+
return (entry) => {
|
|
23
|
+
try {
|
|
24
|
+
appendFileSync(path, `${JSON.stringify(entry)}\n`);
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
process.stderr.write(`funoteka admin: could not write ${path} (${err.message})\n`);
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
}
|
package/dist/api/auth.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { ERROR } from "./envelope.js";
|
|
3
|
+
export function authenticate(query, config, signedRoute = false, registered = () => false) {
|
|
4
|
+
const user = query.get('u');
|
|
5
|
+
const token = query.get('t');
|
|
6
|
+
const salt = query.get('s');
|
|
7
|
+
const password = query.get('p');
|
|
8
|
+
const apiKey = query.get('apiKey');
|
|
9
|
+
// **A signed link is a credential of its own**, and it is checked first
|
|
10
|
+
// because such a request carries no user at all — see `coverSignature`. Only
|
|
11
|
+
// the routes that hand links out accept one, and the caller says which.
|
|
12
|
+
//
|
|
13
|
+
// A signature that is *there* and does not hold is a refusal, not an absence:
|
|
14
|
+
// a client that sent one meant to be let in, and telling it that `u` is
|
|
15
|
+
// missing describes a request it did not make.
|
|
16
|
+
if (signedRoute && signatureOffered(query)) {
|
|
17
|
+
return signatureHolds(query, config) ? { ok: true } : refused();
|
|
18
|
+
}
|
|
19
|
+
// **A key is the whole credential, and it comes alone.** The extension's own
|
|
20
|
+
// words: "When an API key is provided, the client **must not** provide a `u`
|
|
21
|
+
// parameter; passing in `u` **must** be treated as an error 43" — and the same
|
|
22
|
+
// for the other mechanisms, which is what a conflicting set of parameters is.
|
|
23
|
+
//
|
|
24
|
+
// This server had it exactly the other way round: `u` was demanded before
|
|
25
|
+
// anything else, so the *only* form the specification allows was the one form
|
|
26
|
+
// that could not work, and the form it forbids was the one that did. Found by
|
|
27
|
+
// the operator bringing Symfonium up, which sent `apiKey` alone and was told
|
|
28
|
+
// that `u` was missing — and reported it as a server whose version it could
|
|
29
|
+
// not determine, since a refusal and an answer were one HTTP status here.
|
|
30
|
+
// That last part is no longer true of every route: `getTranscodeStream`
|
|
31
|
+
// answers a refusal with a status of its own, because its own page asks for
|
|
32
|
+
// one (`STATUS_REFUSALS` in `server.ts`, task:2913).
|
|
33
|
+
if (apiKey !== null) {
|
|
34
|
+
const alongside = [token !== null || salt !== null, password !== null].filter((yes) => yes).length;
|
|
35
|
+
if (user !== null || alongside > 0) {
|
|
36
|
+
return conflicting('apiKey arrives on its own — not with u, p, t or s');
|
|
37
|
+
}
|
|
38
|
+
// Two places a key may live, and both are checked. The environment's is the
|
|
39
|
+
// bootstrap credential and is checked first — it is the way in that survives
|
|
40
|
+
// a database; the registered ones are what a person can take back while the
|
|
41
|
+
// server runs (`keys.ts`). Neither is a fallback for the other.
|
|
42
|
+
const fromEnvironment = config.apiKey !== '' && sameSecret(apiKey, config.apiKey);
|
|
43
|
+
return fromEnvironment || registered(apiKey) ? { ok: true } : refused();
|
|
44
|
+
}
|
|
45
|
+
if (user === null)
|
|
46
|
+
return missing('u');
|
|
47
|
+
// A token is two parameters and counts as one way in, so that a client which
|
|
48
|
+
// sent `t`, `s` and `p` is seen as having offered two rather than three. The
|
|
49
|
+
// key is not counted here: it never reaches this far.
|
|
50
|
+
const offered = [token !== null || salt !== null, password !== null].filter((yes) => yes).length;
|
|
51
|
+
if (offered > 1)
|
|
52
|
+
return conflicting('Send one of p, or t with s — not both');
|
|
53
|
+
if (offered === 0)
|
|
54
|
+
return missing('p, t with s, or apiKey');
|
|
55
|
+
// The user is checked before the secret, and both refusals are the same one:
|
|
56
|
+
// a caller who guessed the user right learns nothing from the answer.
|
|
57
|
+
if (!sameSecret(user, config.user))
|
|
58
|
+
return refused();
|
|
59
|
+
if (password !== null) {
|
|
60
|
+
const offered = password.startsWith('enc:') ? decodeHex(password.slice(4)) : password;
|
|
61
|
+
if (offered === null)
|
|
62
|
+
return refused();
|
|
63
|
+
return config.password !== '' && sameSecret(offered, config.password) ? { ok: true } : refused();
|
|
64
|
+
}
|
|
65
|
+
// The token path. Half of it is not a partial credential, it is a request that
|
|
66
|
+
// did not finish arriving, which is a different failure from a wrong one.
|
|
67
|
+
if (token === null)
|
|
68
|
+
return missing('t');
|
|
69
|
+
if (salt === null)
|
|
70
|
+
return missing('s');
|
|
71
|
+
const expected = createHash('md5').update(`${config.password}${salt}`).digest('hex');
|
|
72
|
+
return config.password !== '' && sameSecret(token.toLowerCase(), expected) ? { ok: true } : refused();
|
|
73
|
+
}
|
|
74
|
+
function missing(name) {
|
|
75
|
+
return { ok: false, code: ERROR.missingParameter, message: `Required parameter is missing: ${name}` };
|
|
76
|
+
}
|
|
77
|
+
/** Two ways in at once, which the protocol gives a code of its own. */
|
|
78
|
+
function conflicting(message) {
|
|
79
|
+
return { ok: false, code: ERROR.conflictingAuthMechanisms, message };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The secret every picture link is signed with, and what revocation means here.
|
|
83
|
+
*
|
|
84
|
+
* The server's own key, or its password where no key is configured — one of the
|
|
85
|
+
* two is always set, because a server with neither cannot be built at all.
|
|
86
|
+
* **Changing it revokes every link ever handed out**, which is the whole of what
|
|
87
|
+
* revoking a link needs to be: nothing is stored, so there is nothing else to
|
|
88
|
+
* take back.
|
|
89
|
+
*/
|
|
90
|
+
function signingSecret(config) {
|
|
91
|
+
return config.apiKey === '' ? config.password : config.apiKey;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The signature a client may present instead of credentials on `getCoverArt`.
|
|
95
|
+
*
|
|
96
|
+
* **The reason is measured, not assumed.** An image loader does not
|
|
97
|
+
* authenticate: the operator's Symfonium asks for an artist's picture with no
|
|
98
|
+
* `u`, `t` or `s` at all, so a server that guards that route the way it guards
|
|
99
|
+
* every other answers a refusal — and the client can only draw a placeholder
|
|
100
|
+
* over it. Navidrome arrived at the same answer for the same symptom and hands
|
|
101
|
+
* out a signed URL for artist art.
|
|
102
|
+
*
|
|
103
|
+
* It is signed over the id **and the route it is for**, so a link handed out for
|
|
104
|
+
* a picture cannot be replayed against another door: the id alone would let a
|
|
105
|
+
* signed cover link ask `getArtist` for the same id without credentials.
|
|
106
|
+
*/
|
|
107
|
+
export function coverSignature(id, config) {
|
|
108
|
+
return createHmac('sha256', signingSecret(config)).update(`cover:${id}`).digest('base64url');
|
|
109
|
+
}
|
|
110
|
+
/** Whether the request brought a signature at all, which is not whether it fits. */
|
|
111
|
+
function signatureOffered(query) {
|
|
112
|
+
const given = query.get('sig');
|
|
113
|
+
return given !== null && given !== '';
|
|
114
|
+
}
|
|
115
|
+
/** Whether a request carries a signature this server issued for its own id. */
|
|
116
|
+
function signatureHolds(query, config) {
|
|
117
|
+
const id = query.get('id');
|
|
118
|
+
const given = query.get('sig');
|
|
119
|
+
if (id === null || given === null || given === '')
|
|
120
|
+
return false;
|
|
121
|
+
return sameSecret(given, coverSignature(id, config));
|
|
122
|
+
}
|
|
123
|
+
function refused() {
|
|
124
|
+
return { ok: false, code: ERROR.wrongCredentials, message: 'Wrong username or password' };
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Whether two secrets are the same, in a time that does not say how nearly.
|
|
128
|
+
*
|
|
129
|
+
* Both sides are hashed first so that the comparison is between two buffers of
|
|
130
|
+
* one length whatever was sent: `timingSafeEqual` refuses different lengths, and
|
|
131
|
+
* an early return on a length a stranger controls is the comparison leaking by
|
|
132
|
+
* another name. The hashing also means a password is never the thing compared.
|
|
133
|
+
*/
|
|
134
|
+
export function sameSecret(given, expected) {
|
|
135
|
+
return timingSafeEqual(hash(given), hash(expected));
|
|
136
|
+
}
|
|
137
|
+
function hash(value) {
|
|
138
|
+
return createHash('sha256').update(value).digest();
|
|
139
|
+
}
|
|
140
|
+
/** The password `enc:` carries, or null when what follows is not hex at all. */
|
|
141
|
+
function decodeHex(value) {
|
|
142
|
+
if (value.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(value))
|
|
143
|
+
return null;
|
|
144
|
+
return Buffer.from(value, 'hex').toString('utf8');
|
|
145
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { bookmark, bookmarks, unbookmark } from "../bookmark/store.js";
|
|
2
|
+
import { childOf, required, trackId } from "./browse.js";
|
|
3
|
+
import { ApiError, ERROR } from "./envelope.js";
|
|
4
|
+
import { song, songsByIds } from "./meta.js";
|
|
5
|
+
/**
|
|
6
|
+
* Where the listener stopped, in the protocol's shape.
|
|
7
|
+
*
|
|
8
|
+
* The smallest of the listener's own routes and the only one with a single
|
|
9
|
+
* number in it: a bookmark is a song and a position inside it, and everything
|
|
10
|
+
* else in the answer is either the song itself or when the mark was made.
|
|
11
|
+
*
|
|
12
|
+
* One place per song, which is the protocol's rule and the schema's — see
|
|
13
|
+
* `bookmark/store.ts` for what that costs and why it is worth it.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* The mark, as the protocol spells it.
|
|
17
|
+
*
|
|
18
|
+
* The entry carries the position a second time, as `bookmarkPosition`. That is
|
|
19
|
+
* not redundancy to trim: the inner field is on the `Child`, and the `Child` is
|
|
20
|
+
* what a client draws a resume mark from in a list of songs — a client reading
|
|
21
|
+
* only `position` would have to know that the song beside it is the one it
|
|
22
|
+
* belongs to.
|
|
23
|
+
*/
|
|
24
|
+
function markOf(row, entry, config) {
|
|
25
|
+
return {
|
|
26
|
+
entry: { ...entry, bookmarkPosition: row.positionMs },
|
|
27
|
+
position: row.positionMs,
|
|
28
|
+
username: config.user,
|
|
29
|
+
// Absent rather than empty when the listener wrote none: a comment of `""`
|
|
30
|
+
// is a comment whose text is nothing, and a client that draws one draws an
|
|
31
|
+
// empty line under every bookmark nobody annotated.
|
|
32
|
+
...(row.comment === null ? {} : { comment: row.comment }),
|
|
33
|
+
created: row.createdAt,
|
|
34
|
+
changed: row.changedAt,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export function getBookmarks(db, config) {
|
|
38
|
+
const rows = bookmarks(db);
|
|
39
|
+
const songs = songsByIds(db, rows.map((row) => row.trackId));
|
|
40
|
+
const byId = new Map(songs.map((row) => [row.id, row]));
|
|
41
|
+
const list = rows.flatMap((row) => {
|
|
42
|
+
const found = byId.get(row.trackId);
|
|
43
|
+
return found === undefined ? [] : [markOf(row, childOf(found), config)];
|
|
44
|
+
});
|
|
45
|
+
return { bookmarks: { bookmark: list } };
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The position, in milliseconds, which the protocol requires.
|
|
49
|
+
*
|
|
50
|
+
* Refused when absent rather than read as zero: nought is the beginning of the
|
|
51
|
+
* song, which is a bookmark nobody means, and a client that forgot the
|
|
52
|
+
* parameter should be told it forgot rather than handed a mark at the start.
|
|
53
|
+
*/
|
|
54
|
+
function positionIn(query) {
|
|
55
|
+
const raw = required(query, 'position');
|
|
56
|
+
const position = Number(raw);
|
|
57
|
+
if (!Number.isInteger(position) || position < 0) {
|
|
58
|
+
throw new ApiError(ERROR.generic, `position is not a position: ${raw}`);
|
|
59
|
+
}
|
|
60
|
+
return position;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Put a mark on a song, or move the one that is there.
|
|
64
|
+
*
|
|
65
|
+
* A song this server does not hold is refused by name — `song` answers nothing
|
|
66
|
+
* for an id that names no row — because a bookmark on a song that is not here is
|
|
67
|
+
* a mark a client would never be shown again.
|
|
68
|
+
*/
|
|
69
|
+
export function createBookmark(db, query) {
|
|
70
|
+
// The budget this is held to is the hundred milliseconds a person notices, and
|
|
71
|
+
// measured on the live database it is **64–70 ms** — which is the *commit*,
|
|
72
|
+
// not the row: `star` and `unstar` pay 23–101 ms for one annotation written
|
|
73
|
+
// the same way, so this is what the live file costs, not what this route adds.
|
|
74
|
+
// On one thread that is every other client waiting out the write.
|
|
75
|
+
//
|
|
76
|
+
const id = trackId(query);
|
|
77
|
+
const row = song(db, id);
|
|
78
|
+
if (row === undefined)
|
|
79
|
+
throw new ApiError(ERROR.notFound, `No such song: ${id}`);
|
|
80
|
+
const comment = query.get('comment');
|
|
81
|
+
bookmark(db, row.id, positionIn(query), comment === null || comment === '' ? null : comment, new Date().toISOString());
|
|
82
|
+
return {};
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Take the mark off a song.
|
|
86
|
+
*
|
|
87
|
+
* An id that names no row is refused, and one that names a song nobody
|
|
88
|
+
* bookmarked is not: the first is a client asking about a song this server does
|
|
89
|
+
* not have, and the second is a client tidying up — the state it asked for is
|
|
90
|
+
* the state it gets, and refusing would tell it off for succeeding. The
|
|
91
|
+
* asymmetry is deliberate and it is between a *song* and a *mark*: a song this
|
|
92
|
+
* server does not have is a question it cannot answer, and a mark that is not
|
|
93
|
+
* there is the answer already.
|
|
94
|
+
*
|
|
95
|
+
* **Which of the two paths a measurement lands on is worth saying**, because
|
|
96
|
+
* they are twenty times apart: the delete that removes nothing is **3–6 ms**
|
|
97
|
+
* (no write happens) and the one that removes a mark is **84–87 ms** (a commit
|
|
98
|
+
* does). A stand that probed the no-op would report this route as far cheaper
|
|
99
|
+
* than a client experiences it.
|
|
100
|
+
*/
|
|
101
|
+
export function deleteBookmark(db, query) {
|
|
102
|
+
const id = trackId(query);
|
|
103
|
+
const row = song(db, id);
|
|
104
|
+
if (row === undefined)
|
|
105
|
+
throw new ApiError(ERROR.notFound, `No such song: ${id}`);
|
|
106
|
+
unbookmark(db, row.id);
|
|
107
|
+
return {};
|
|
108
|
+
}
|