funoteka 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/.env.example +90 -0
  2. package/CHANGELOG.md +104 -0
  3. package/DEPLOY.md +375 -0
  4. package/LICENSE +21 -0
  5. package/README.md +138 -0
  6. package/deploy/answers.schema.json +151 -0
  7. package/deploy/build.mjs +88 -0
  8. package/deploy/check-publishable.mjs +246 -0
  9. package/deploy/smoke.mjs +681 -0
  10. package/deploy/systemd/funoteka.env.example +41 -0
  11. package/deploy/systemd/funoteka.service +77 -0
  12. package/deploy/windows/funoteka-service.xml +58 -0
  13. package/deploy/windows/install-service.ps1 +158 -0
  14. package/dist/annotation/store.js +67 -0
  15. package/dist/api/admin-export.js +227 -0
  16. package/dist/api/admin-guard.js +181 -0
  17. package/dist/api/admin-library.js +113 -0
  18. package/dist/api/admin.js +1162 -0
  19. package/dist/api/annotation.js +193 -0
  20. package/dist/api/artistinfo.js +140 -0
  21. package/dist/api/audit.js +32 -0
  22. package/dist/api/auth.js +145 -0
  23. package/dist/api/bookmark.js +108 -0
  24. package/dist/api/browse.js +1241 -0
  25. package/dist/api/config-file.js +202 -0
  26. package/dist/api/config.js +285 -0
  27. package/dist/api/cover.js +304 -0
  28. package/dist/api/download.js +112 -0
  29. package/dist/api/envelope.js +162 -0
  30. package/dist/api/extensions.js +43 -0
  31. package/dist/api/genre.js +81 -0
  32. package/dist/api/health.js +35 -0
  33. package/dist/api/history.js +374 -0
  34. package/dist/api/idempotency.js +66 -0
  35. package/dist/api/keys.js +90 -0
  36. package/dist/api/log-file.js +65 -0
  37. package/dist/api/meta.js +1410 -0
  38. package/dist/api/playlist.js +311 -0
  39. package/dist/api/rescan.js +196 -0
  40. package/dist/api/roots.js +161 -0
  41. package/dist/api/router.js +133 -0
  42. package/dist/api/scanner.js +163 -0
  43. package/dist/api/search.js +82 -0
  44. package/dist/api/server.js +643 -0
  45. package/dist/api/settings.js +67 -0
  46. package/dist/api/stream.js +442 -0
  47. package/dist/api/stubs.js +183 -0
  48. package/dist/api/transcode.js +678 -0
  49. package/dist/api/user.js +73 -0
  50. package/dist/api/virtual.js +362 -0
  51. package/dist/api/visibility.js +21 -0
  52. package/dist/artist/apply.js +752 -0
  53. package/dist/artist/credit.js +95 -0
  54. package/dist/artist/folder.js +65 -0
  55. package/dist/artist/name.js +205 -0
  56. package/dist/artist/nfo.js +101 -0
  57. package/dist/artist/translit.js +79 -0
  58. package/dist/bookmark/store.js +54 -0
  59. package/dist/classify/classify.js +405 -0
  60. package/dist/classify/collision-name.js +109 -0
  61. package/dist/classify/folder-name.js +466 -0
  62. package/dist/classify/roles.js +277 -0
  63. package/dist/classify/shelf-name.js +178 -0
  64. package/dist/classify/tree.js +43 -0
  65. package/dist/cli/args.js +169 -0
  66. package/dist/cli/daemon.js +192 -0
  67. package/dist/cli/entry.js +25 -0
  68. package/dist/cli/keys.js +52 -0
  69. package/dist/cli.js +638 -0
  70. package/dist/cover/pick.js +58 -0
  71. package/dist/cover/picture.js +111 -0
  72. package/dist/cue/engine.js +759 -0
  73. package/dist/cue/match.js +142 -0
  74. package/dist/cue/parse.js +182 -0
  75. package/dist/cue/plan.js +286 -0
  76. package/dist/cue/track-name.js +120 -0
  77. package/dist/db/index.js +163 -0
  78. package/dist/db/issue.js +73 -0
  79. package/dist/db/ledger.js +84 -0
  80. package/dist/db/migrations/001_init.sql +196 -0
  81. package/dist/db/migrations/002_cue_track_flexible.sql +35 -0
  82. package/dist/db/migrations/003_cue_metadata.sql +14 -0
  83. package/dist/db/migrations/004_cue_performer.sql +12 -0
  84. package/dist/db/migrations/005_incremental.sql +23 -0
  85. package/dist/db/migrations/006_tags.sql +57 -0
  86. package/dist/db/migrations/007_artist_credit.sql +35 -0
  87. package/dist/db/migrations/008_issue_stage.sql +39 -0
  88. package/dist/db/migrations/009_cue_title.sql +20 -0
  89. package/dist/db/migrations/010_duration_source.sql +31 -0
  90. package/dist/db/migrations/011_track_title_source.sql +28 -0
  91. package/dist/db/migrations/012_file_container.sql +25 -0
  92. package/dist/db/migrations/013_cover_art.sql +28 -0
  93. package/dist/db/migrations/014_album_year.sql +17 -0
  94. package/dist/db/migrations/015_probe_method.sql +14 -0
  95. package/dist/db/migrations/016_tags_method.sql +20 -0
  96. package/dist/db/migrations/017_cover_indirect.sql +23 -0
  97. package/dist/db/migrations/018_release_year.sql +14 -0
  98. package/dist/db/migrations/019_file_tag_name_file_value.sql +20 -0
  99. package/dist/db/migrations/020_analyze_after_index.sql +16 -0
  100. package/dist/db/migrations/021_cue_index_files.sql +15 -0
  101. package/dist/db/migrations/022_sidecar_text.sql +19 -0
  102. package/dist/db/migrations/023_track_credit.sql +23 -0
  103. package/dist/db/migrations/024_playlists.sql +63 -0
  104. package/dist/db/migrations/025_playlist_sequence.sql +27 -0
  105. package/dist/db/migrations/026_playlist_source_file.sql +19 -0
  106. package/dist/db/migrations/027_lookup_indexes.sql +30 -0
  107. package/dist/db/migrations/028_annotations.sql +47 -0
  108. package/dist/db/migrations/029_scan_state_root_run.sql +28 -0
  109. package/dist/db/migrations/030_history.sql +108 -0
  110. package/dist/db/migrations/031_queue_position_and_plays.sql +46 -0
  111. package/dist/db/migrations/032_bookmarks.sql +27 -0
  112. package/dist/db/migrations/033_bookmark_order.sql +14 -0
  113. package/dist/db/migrations/034_junk.sql +33 -0
  114. package/dist/db/migrations/035_api_key.sql +38 -0
  115. package/dist/db/migrations/036_file_tag_first.sql +88 -0
  116. package/dist/db/migrations/037_admin_idempotency.sql +31 -0
  117. package/dist/db/migrations/038_cue_audio_index.sql +24 -0
  118. package/dist/db/sweep.js +109 -0
  119. package/dist/history/store.js +151 -0
  120. package/dist/inventory/inventory.js +294 -0
  121. package/dist/junk/marks.js +143 -0
  122. package/dist/junk/rule.js +93 -0
  123. package/dist/mcp/client.js +48 -0
  124. package/dist/mcp/server.js +175 -0
  125. package/dist/mcp/tools.js +348 -0
  126. package/dist/playlist/files.js +116 -0
  127. package/dist/playlist/import.js +260 -0
  128. package/dist/playlist/store.js +240 -0
  129. package/dist/probe/ffprobe.js +179 -0
  130. package/dist/run.js +120 -0
  131. package/dist/scan/kinds.js +49 -0
  132. package/dist/scan/scan.js +503 -0
  133. package/dist/scan/schedule.js +132 -0
  134. package/dist/scan/settle.js +172 -0
  135. package/dist/scan/walk.js +132 -0
  136. package/dist/search/index.js +41 -0
  137. package/dist/search/query.js +28 -0
  138. package/dist/stream/flac.js +541 -0
  139. package/dist/stream/mpeg.js +179 -0
  140. package/dist/stream/recode.js +393 -0
  141. package/dist/stream/rewrite.js +163 -0
  142. package/dist/stream/segment.js +624 -0
  143. package/dist/tags/apply.js +703 -0
  144. package/dist/tags/encode.js +164 -0
  145. package/dist/tags/first.js +130 -0
  146. package/dist/tags/flac.js +196 -0
  147. package/dist/tags/genres.js +123 -0
  148. package/dist/tags/id3v1.js +209 -0
  149. package/dist/tags/id3v2.js +754 -0
  150. package/dist/tags/mp4.js +534 -0
  151. package/dist/tags/mpeg.js +355 -0
  152. package/dist/tags/ogg.js +463 -0
  153. package/dist/tags/picture.js +71 -0
  154. package/dist/tags/read.js +269 -0
  155. package/dist/tags/types.js +51 -0
  156. package/dist/tags/vorbis-comment.js +155 -0
  157. package/dist/text/encoding.js +384 -0
  158. package/dist/text/site-name.js +37 -0
  159. package/dist/util/child.js +21 -0
  160. package/dist/util/names.js +97 -0
  161. package/funoteka.json.example +27 -0
  162. package/package.json +55 -0
@@ -0,0 +1,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
+ }
@@ -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
+ }