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,81 @@
1
+ import { childOf, required } from "./browse.js";
2
+ import { ApiError, ERROR } from "./envelope.js";
3
+ import { genres, songsByGenre } from "./meta.js";
4
+ /**
5
+ * The genres, which are the one thing a Subsonic client asks for that this
6
+ * server had nothing to answer with.
7
+ *
8
+ * The meta layer has held them since the first scan — every file's `genre` tag
9
+ * is a row in `file_tag`, and there are 2338 of them over 159 values — but no
10
+ * route ever read one. A client asking `getGenres` was told the method did not
11
+ * exist and drew an empty list, which is what "the collection has no genres"
12
+ * looks like from the outside. It had them all along.
13
+ *
14
+ * There is no genre *table* here and this does not add one. A genre is what a
15
+ * file states, the same way a title is, and a table would be a second place for
16
+ * it to be true — with the first one still being the file. What is derived is
17
+ * the two counts a client wants beside each name, and they are derived on the
18
+ * request rather than stored.
19
+ */
20
+ /** The protocol's own ceiling on a page, matching the album and song listings. */
21
+ const MAX_PAGE = 500;
22
+ /**
23
+ * How many songs `getSongsByGenre` returns when the client does not say.
24
+ *
25
+ * **The protocol's own number and its own parameter name**, which is `count` and
26
+ * not `size` — the sibling listing route uses `size`, and this method does not.
27
+ * The first version of this read `size` and defaulted to a hundred, which meant
28
+ * a client asking for `count=50` was quietly given whatever this server felt
29
+ * like: a caller who names an argument and is not obeyed has been lied to, and
30
+ * the lie is invisible from either side.
31
+ */
32
+ const DEFAULT_COUNT = 10;
33
+ function numeric(query, name, fallback) {
34
+ const raw = query.get(name);
35
+ if (raw === null || raw === '')
36
+ return fallback;
37
+ const value = Number(raw);
38
+ if (!Number.isInteger(value)) {
39
+ throw new ApiError(ERROR.generic, `${name} is not a whole number: ${raw}`);
40
+ }
41
+ return value;
42
+ }
43
+ /**
44
+ * Every genre the collection states, with how many songs and records state it.
45
+ *
46
+ * No paging, and no `size`/`offset` honoured: the protocol defines neither for
47
+ * this method, and a client shows the whole list in a sidebar. 159 entries is
48
+ * what this collection has; a list an order of magnitude larger would be a
49
+ * collection with a problem rather than this route's.
50
+ */
51
+ export function getGenres(db, visibility = 'records') {
52
+ return {
53
+ genres: {
54
+ genre: genres(db, visibility).map((row) => ({
55
+ value: row.value,
56
+ songCount: row.song_count,
57
+ albumCount: row.album_count,
58
+ })),
59
+ },
60
+ };
61
+ }
62
+ /**
63
+ * The songs one genre names.
64
+ *
65
+ * The genre is taken as the file states it, trimmed the way the list trims it —
66
+ * so a client can hand back a value it was given and get the songs that value
67
+ * came from, which is the whole contract between these two methods.
68
+ */
69
+ export function getSongsByGenre(db, query, visibility = 'records') {
70
+ const genre = required(query, 'genre');
71
+ // `count`, which is this method's own name for the argument — the sibling
72
+ // listing route calls it `size`, and a server that answered the wrong one
73
+ // would look to a client exactly like a server that ignored it.
74
+ const count = Math.min(Math.max(numeric(query, 'count', DEFAULT_COUNT), 1), MAX_PAGE);
75
+ const offset = Math.max(numeric(query, 'offset', 0), 0);
76
+ return {
77
+ songsByGenre: {
78
+ song: songsByGenre(db, genre, count, offset, visibility).map((row) => childOf(row)),
79
+ },
80
+ };
81
+ }
@@ -0,0 +1,35 @@
1
+ import { SERVER_VERSION } from "./envelope.js";
2
+ /** Seconds this process has been up, which is health's own business to report. */
3
+ export const startedAgo = () => process.uptime();
4
+ /**
5
+ * Read the meta layer and answer.
6
+ *
7
+ * A read that throws is the failure this route exists to catch, and it is
8
+ * answered rather than propagated: a database that has been replaced by a
9
+ * directory, a disk that has gone read-only, a file another process has locked
10
+ * for longer than a transaction should — each of them is a server that is *up*
11
+ * and cannot serve, which is a different state from "not running" and the one a
12
+ * supervisor must be told about.
13
+ *
14
+ * The schema version is read from the database rather than from the build's own
15
+ * constant: what a reader wants to know is what the *file* is at, and a build
16
+ * that expected a migration the file never got is exactly the mismatch this
17
+ * number makes visible.
18
+ */
19
+ export function health(db, uptimeSeconds = startedAgo()) {
20
+ const base = {
21
+ server: 'funoteka',
22
+ version: SERVER_VERSION,
23
+ uptime: Math.round(uptimeSeconds * 10) / 10,
24
+ };
25
+ try {
26
+ const read = db.prepare('PRAGMA user_version').get();
27
+ return { status: 200, body: JSON.stringify({ status: 'ok', ...base, schema: read.user_version }) };
28
+ }
29
+ catch (err) {
30
+ return {
31
+ status: 503,
32
+ body: JSON.stringify({ status: 'failed', ...base, error: err.message }),
33
+ };
34
+ }
35
+ }
@@ -0,0 +1,374 @@
1
+ import { markPlaying, nowPlaying, player, queue, recordPlay, saveQueue } from "../history/store.js";
2
+ import { withTransaction } from "../db/index.js";
3
+ import { childOf, ID, parseId, required } from "./browse.js";
4
+ import { ApiError, ERROR } from "./envelope.js";
5
+ import { knownTrackIds, song, songsByIds } from "./meta.js";
6
+ /**
7
+ * What the listener played, what is playing, and the queue they left.
8
+ *
9
+ * The third set of routes that write — playlists first, then stars — and the
10
+ * same kind of thing: a statement about the music that no file states and no
11
+ * scan can rebuild. These go further than the other two in one way only: they
12
+ * are about *time*, so a row here says the collection was used rather than what
13
+ * somebody thought of it.
14
+ *
15
+ * The shapes are the protocol's, and several fields have no input parameter at
16
+ * all — `playerId`, `playerName` and `minutesAgo` are the server's to derive.
17
+ * Where that is so, the comment below says what it was derived from and why,
18
+ * because a client cannot be told apart from another except by the name it gave.
19
+ */
20
+ /** How a client names itself. The protocol's `c`, and the only identity there is. */
21
+ function clientOf(query) {
22
+ return query.get('c') ?? '';
23
+ }
24
+ /**
25
+ * The track an id names, or a refusal.
26
+ *
27
+ * `scrobble`, `savePlayQueue` and the rest all take ids that name *songs*, so a
28
+ * `al:` or a `pl:` is not a narrower answer — it is a client asking the wrong
29
+ * question, and saying "no such id" is truer than answering about an album.
30
+ */
31
+ function trackOf(db, raw) {
32
+ const parsed = parseId(raw);
33
+ const found = parsed !== undefined && parsed.kind === 'tr' ? song(db, parsed.n) : undefined;
34
+ if (found === undefined)
35
+ throw new ApiError(ERROR.notFound, `No such id: ${raw}`);
36
+ return found.id;
37
+ }
38
+ /** Every `id` the request repeated, in the order it was given, empty ones dropped. */
39
+ function ids(query) {
40
+ return query.getAll('id').filter((raw) => raw !== '');
41
+ }
42
+ /**
43
+ * The tracks a request named, resolved for the whole call rather than per id.
44
+ *
45
+ * `trackOf` answers with a whole `SongRow` — a `SONG_SELECT`, with the
46
+ * album-groups subquery materialized inside it — and the write paths need
47
+ * nothing from it but the id. Measured on the live daemon: a six-hundred-id
48
+ * `scrobble` paid that select six hundred times, took **1122–1243 ms**, and a
49
+ * `ping` sent during it waited **1133 ms** against 1.8 ms idle. On one thread
50
+ * that is not a slow handler, it is every other client stopped for a second —
51
+ * and the two calls that take a list are the two a client makes with one:
52
+ * finishing an album, uploading a day of offline listening, saving a queue.
53
+ *
54
+ * `knownTrackIds` answers the same question in one statement per five hundred
55
+ * ids — measured at **1.3 ms for six hundred** — which is what `playlist.ts`
56
+ * does for its own ids and for this reason.
57
+ *
58
+ * Order and duplicates are kept. The last id is what a scrobble leaves playing,
59
+ * and a queue may hold the same song at two seats; a set would lose both.
60
+ */
61
+ function tracksOf(db, raw) {
62
+ const parsed = raw.map((one) => {
63
+ const id = parseId(one);
64
+ if (id === undefined || id.kind !== 'tr') {
65
+ throw new ApiError(ERROR.notFound, `No such id: ${one}`);
66
+ }
67
+ return id.n;
68
+ });
69
+ const known = knownTrackIds(db, parsed);
70
+ return parsed.map((id, at) => {
71
+ if (!known.has(id))
72
+ throw new ApiError(ERROR.notFound, `No such id: ${raw[at]}`);
73
+ return id;
74
+ });
75
+ }
76
+ /** How long ago, in whole minutes, as the protocol counts it. */
77
+ function minutesAgo(from, now) {
78
+ // Floored, not rounded: "last update" is time *elapsed*, and a track thirty
79
+ // seconds in is nought minutes old — reporting it as one would say a client
80
+ // had been playing something it had only just started.
81
+ return Math.max(0, Math.floor((now.getTime() - new Date(from).getTime()) / 60_000));
82
+ }
83
+ /**
84
+ * Register a play, or that one is happening.
85
+ *
86
+ * **Both kinds update what is playing**, and only one writes a play. The
87
+ * protocol's own description of this method is what settles it — a scrobble
88
+ * "makes the media files appear in the Now playing page … and appear in the list
89
+ * of songs returned by `getNowPlaying`" — while `submission=false` is the
90
+ * narrower notification a client sends as a track *starts*. So the two differ in
91
+ * whether a play is recorded, and not in whether the client is shown as playing:
92
+ * a client that only ever sends submissions is still shown, which is what the
93
+ * bullet above promises it.
94
+ *
95
+ * An id that names nothing refuses the whole call before anything is written —
96
+ * `trackOf` throws while the list is still being built — so a batch of forty
97
+ * songs written one at a time cannot leave thirty-nine of them behind.
98
+ *
99
+ * **`time` is read, per id.** The protocol pairs it with `id` one for one
100
+ * ("Since 1.8.0 you may specify multiple `id` (and optionally `time`)
101
+ * parameters"), and it is the only thing that dates a play made while the client
102
+ * was offline — which is the case the playback-report extension names. An id
103
+ * whose `time` is absent, unreadable or missing is stamped with the moment the
104
+ * server was told, which is what every play was stamped with before this.
105
+ */
106
+ export function scrobble(db, query) {
107
+ const named = ids(query);
108
+ if (named.length === 0)
109
+ required(query, 'id');
110
+ const trackIds = tracksOf(db, named);
111
+ const submission = query.get('submission') !== 'false';
112
+ const at = new Date();
113
+ const times = query.getAll('time');
114
+ withTransaction(db, () => {
115
+ // Inside the transaction with everything else: `player` is a read and a
116
+ // possible insert, and its twin in `playlist/store.ts` documents why that
117
+ // pair has to be one atomic step.
118
+ const who = player(db, clientOf(query));
119
+ if (submission) {
120
+ recordPlay(db, who, trackIds.map((trackId, index) => ({ trackId, at: playTime(times[index], at) })));
121
+ }
122
+ // The last one named: a client finishing an album is the ordinary batch, and
123
+ // the track it finished on is the one that was playing.
124
+ markPlaying(db, who, trackIds[trackIds.length - 1], at.toISOString());
125
+ });
126
+ return {};
127
+ }
128
+ /** The client's `time` for one play, or the moment the server was told. */
129
+ function playTime(raw, at) {
130
+ if (raw === undefined || raw === '')
131
+ return at.toISOString();
132
+ const ms = Number(raw);
133
+ return Number.isFinite(ms) ? new Date(ms).toISOString() : at.toISOString();
134
+ }
135
+ /**
136
+ * What every client is playing, and how far in.
137
+ *
138
+ * `minutesAgo` is the whole of what tells a reader whether an entry is live:
139
+ * this server does not expire one, because the protocol leaves the judgement to
140
+ * whoever reads the list and any timeout would be a rule about how long a track
141
+ * is that no client agreed to. `now` is taken once for the whole answer so two
142
+ * entries updated in the same second cannot disagree by a minute, and it is a
143
+ * seam because that is the only way a test can ask about an entry that is not
144
+ * brand new.
145
+ */
146
+ export function getNowPlaying(db, config, now = () => new Date()) {
147
+ const rows = nowPlaying(db);
148
+ const songs = songsByIds(db, rows.map((row) => row.trackId));
149
+ const byId = new Map(songs.map((row) => [row.id, row]));
150
+ const at = now();
151
+ const entry = rows.flatMap((row) => {
152
+ const found = byId.get(row.trackId);
153
+ if (found === undefined)
154
+ return [];
155
+ return [
156
+ {
157
+ ...childOf(found),
158
+ username: config.user,
159
+ minutesAgo: minutesAgo(row.updatedAt, at),
160
+ playerId: row.playerId,
161
+ // Absent rather than empty when the client named itself after nothing:
162
+ // a `playerName` of `""` is a player whose name is nothing, and one that
163
+ // is not there is a player nobody named.
164
+ ...(row.playerName === '' ? {} : { playerName: row.playerName }),
165
+ ...(row.state === null ? {} : { state: row.state }),
166
+ ...(row.positionMs === null ? {} : { positionMs: row.positionMs }),
167
+ ...(row.playbackRate === null ? {} : { playbackRate: row.playbackRate }),
168
+ },
169
+ ];
170
+ });
171
+ return { nowPlaying: { entry } };
172
+ }
173
+ /**
174
+ * Where a player is in a track, as the `playbackReport` extension reports it.
175
+ *
176
+ * **This server keeps the state and does not scrobble from it.** The extension
177
+ * asks a server to record a play once the media reaches a `stopped` state, and
178
+ * that is a rule this server has not decided: "was it played" is a policy — half
179
+ * the track, four minutes, whatever a person means by having listened — and the
180
+ * clients here already say it themselves through `scrobble`, which is the method
181
+ * the protocol provides for it. So `state` and `positionMs` are remembered and
182
+ * reported back by `getNowPlaying` to a *different* client, which is what this
183
+ * extension is for, and no play is written here.
184
+ *
185
+ * `ignoreScrobble` is therefore accepted and has nothing to suppress. It is not
186
+ * ignored in the sense of being unimplemented — there is no scrobble on this
187
+ * path for it to prevent — and that is said here rather than left for whoever
188
+ * wonders why the parameter changes nothing.
189
+ */
190
+ export function reportPlayback(db, query) {
191
+ const mediaId = required(query, 'mediaId');
192
+ // `mediaType` is required by the extension, so an absent one is a missing
193
+ // parameter and not a bad value — the two are answered differently on purpose
194
+ // (`ERROR.missingParameter` against `ERROR.generic`), and a client that forgot
195
+ // it should be told it forgot rather than that it sent the wrong thing.
196
+ const mediaType = required(query, 'mediaType');
197
+ if (mediaType !== 'song') {
198
+ throw new ApiError(ERROR.generic, `mediaType is not a song this server holds: ${mediaType} (it has no podcasts)`);
199
+ }
200
+ const rawPosition = required(query, 'positionMs');
201
+ const positionMs = Number(rawPosition);
202
+ if (!Number.isFinite(positionMs) || positionMs < 0) {
203
+ throw new ApiError(ERROR.generic, `positionMs is not a position: ${rawPosition}`);
204
+ }
205
+ const state = required(query, 'state');
206
+ if (!STATES.has(state)) {
207
+ throw new ApiError(ERROR.generic, `state is not one of ${[...STATES].join(', ')}: ${state}`);
208
+ }
209
+ const rawRate = query.get('playbackRate');
210
+ const rate = rawRate === null || rawRate === '' ? 1 : Number(rawRate);
211
+ if (!Number.isFinite(rate) || rate <= 0) {
212
+ throw new ApiError(ERROR.generic, `playbackRate is not a speed: ${rawRate ?? ''}`);
213
+ }
214
+ const trackId = trackOf(db, mediaId);
215
+ withTransaction(db, () => {
216
+ markPlaying(db, player(db, clientOf(query)), trackId, new Date().toISOString(), {
217
+ state,
218
+ positionMs,
219
+ playbackRate: rate,
220
+ });
221
+ });
222
+ return {};
223
+ }
224
+ const STATES = new Set(['starting', 'playing', 'paused', 'stopped']);
225
+ /**
226
+ * The seat the current track occupies, from the id a client can also use.
227
+ *
228
+ * The older endpoint addresses the current track by id and the `indexBasedQueue`
229
+ * extension by seat, and both read the same queue — the extension exists because
230
+ * an id cannot say *which* of two identical entries is playing, not to keep a
231
+ * second list. So a save by id finds the seat here, and a save by seat uses it
232
+ * as given.
233
+ *
234
+ * The *first* matching seat wins, which is the only answer an id can give when
235
+ * the queue holds the same song twice; a client that means the second one says
236
+ * so with the endpoint that can.
237
+ */
238
+ function seatOfTrack(entries, trackId, raw) {
239
+ const found = entries.find((entry) => entry.trackId === trackId);
240
+ if (found === undefined) {
241
+ throw new ApiError(ERROR.generic, `current is not in the queue: ${raw}`);
242
+ }
243
+ return found.position;
244
+ }
245
+ /** The `position` parameter, which the protocol says to read as zero when absent. */
246
+ function positionOf(query) {
247
+ const raw = query.get('position');
248
+ if (raw === null || raw === '')
249
+ return 0;
250
+ const position = Number(raw);
251
+ if (!Number.isFinite(position) || position < 0) {
252
+ throw new ApiError(ERROR.generic, `position is not a position: ${raw}`);
253
+ }
254
+ return position;
255
+ }
256
+ /**
257
+ * The two saves, which differ only in how the current track is named.
258
+ *
259
+ * `id` is optional in both — OpenSubsonic's change — and a call with none is how
260
+ * a client says "forget this queue": the whole queue is cleared and `changedBy`
261
+ * still says who cleared it. `currentIndex` is *not* read on that path, because
262
+ * the specification forbids it: "Send a call without any parameters to clear the
263
+ * currently saved queue. In this case, `currentIndex` **must not** be set." A
264
+ * call that sets it anyway is a client that has confused the two endpoints, and
265
+ * clearing its queue in silence is the one answer that would hide that.
266
+ */
267
+ function save(db, query, byIndex) {
268
+ const named = ids(query);
269
+ const at = new Date().toISOString();
270
+ const changedBy = clientOf(query);
271
+ if (named.length === 0) {
272
+ const stray = query.get('currentIndex');
273
+ if (stray !== null && stray !== '') {
274
+ throw new ApiError(ERROR.missingParameter, `currentIndex names a track in a queue that was not sent: ${stray} (send no id to clear)`);
275
+ }
276
+ saveQueue(db, { trackIds: [], currentPosition: null, positionMs: 0, changedBy }, at);
277
+ return {};
278
+ }
279
+ const trackIds = tracksOf(db, named);
280
+ const positionMs = positionOf(query);
281
+ let currentPosition;
282
+ if (byIndex) {
283
+ const raw = required(query, 'currentIndex');
284
+ // Code 10 by the specification's own words: "if `currentIndex` is not
285
+ // between 0 and length of the queue - 1 (inclusive), the server must respond
286
+ // with error code 10" — which is this protocol's name for a parameter that
287
+ // cannot be used, whatever the reason it cannot be.
288
+ const index = Number(raw);
289
+ if (!Number.isInteger(index) || index < 0 || index >= trackIds.length) {
290
+ throw new ApiError(ERROR.missingParameter, `currentIndex is not a position in the queue: ${raw} (0..${trackIds.length - 1})`);
291
+ }
292
+ currentPosition = index;
293
+ }
294
+ else {
295
+ const raw = required(query, 'current');
296
+ currentPosition = seatOfTrack(trackIds.map((trackId, position) => ({ position, trackId })), trackOf(db, raw), raw);
297
+ }
298
+ saveQueue(db, { trackIds, currentPosition, positionMs, changedBy }, at);
299
+ return {};
300
+ }
301
+ export function savePlayQueue(db, query) {
302
+ return save(db, query, false);
303
+ }
304
+ export function savePlayQueueByIndex(db, query) {
305
+ return save(db, query, true);
306
+ }
307
+ /**
308
+ * Where in the surviving seats the current one is.
309
+ *
310
+ * The stored number is a *seat*, and a sweep that takes a queued track away
311
+ * leaves the seat unused rather than moving the ones after it. So the current
312
+ * track is found by its number and not by counting from the front — and when the
313
+ * seat itself is gone, the queue has moved on, so the answer is the next seat
314
+ * along and the last one when there is no next.
315
+ *
316
+ * The protocol requires a current track to be named whenever the queue is not
317
+ * empty ("OpenSubsonic servers must ensure that `current` exists and is a valid
318
+ * id in the list of songs"), which is why this never answers "none" while there
319
+ * is something to point at.
320
+ */
321
+ function currentSeat(entries, seat) {
322
+ const exact = entries.findIndex((entry) => entry.position === seat);
323
+ if (exact !== -1)
324
+ return exact;
325
+ const next = entries.findIndex((entry) => entry.position > seat);
326
+ return next === -1 ? entries.length - 1 : next;
327
+ }
328
+ /**
329
+ * The queue, answered the two ways its two endpoints ask.
330
+ *
331
+ * `changed` and `changedBy` are the protocol's required fields, and they are
332
+ * absent in exactly one case: no queue has ever been saved. The specification's
333
+ * list of required fields describes a queue, and before the first save there is
334
+ * none to describe — so the answer names the user, carries no entries, and says
335
+ * nothing about who last changed a thing that has never changed. A queue that
336
+ * was saved and then emptied *does* carry them, which is the difference between
337
+ * "nobody has ever queued anything" and "this client cleared the queue", and a
338
+ * client syncing between devices wants to tell those apart.
339
+ */
340
+ function read(db, config, byIndex) {
341
+ const saved = queue(db);
342
+ const rows = saved === null ? [] : songsByIds(db, saved.entries.map((entry) => entry.trackId));
343
+ const byId = new Map(rows.map((row) => [row.id, row]));
344
+ // Every seat has its track: `play_queue_entry` cascades, so a swept track took
345
+ // its seat with it. The order is the seats' own, which is the order they were
346
+ // saved in.
347
+ const entries = (saved?.entries ?? []).map((entry) => childOf(byId.get(entry.trackId)));
348
+ const seat = saved === null || saved.currentPosition === null || entries.length === 0
349
+ ? null
350
+ : currentSeat(saved.entries, saved.currentPosition);
351
+ const current = seat === null ? undefined : ID.track(saved.entries[seat]?.trackId);
352
+ const body = {
353
+ username: config.user,
354
+ ...(saved === null ? {} : { changed: saved.changedAt, changedBy: saved.changedBy }),
355
+ ...(current === undefined ? {} : { current }),
356
+ position: saved?.positionMs ?? 0,
357
+ entry: entries,
358
+ };
359
+ if (!byIndex)
360
+ return { playQueue: body };
361
+ // The extension's own shape: the same queue, addressed by seat, and the id
362
+ // left out — a client that asked ByIndex has said it does not want to be told
363
+ // which id is playing, because an id cannot tell two equal entries apart.
364
+ const { current: _ignored, ...byIndexBody } = body;
365
+ return {
366
+ playQueueByIndex: { ...byIndexBody, ...(seat === null ? {} : { currentIndex: seat }) },
367
+ };
368
+ }
369
+ export function getPlayQueue(db, config) {
370
+ return read(db, config, false);
371
+ }
372
+ export function getPlayQueueByIndex(db, config) {
373
+ return read(db, config, true);
374
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The same request, made twice, answered once.
3
+ *
4
+ * A control surface is called by scripts and agents over a network, and a
5
+ * network offers two failures that look identical from the caller's side: the
6
+ * request that never arrived, and the answer that was lost coming back. A caller
7
+ * that retries cannot tell them apart — and for a mutation it must, because
8
+ * doing the work twice is not the same as doing it once. `Idempotency-Key` is
9
+ * the caller saying "this is the same request"; this is where the first answer
10
+ * waits so that the second one can be given it.
11
+ *
12
+ * **Kept in the database, because `POST /restart` is in this surface.** An
13
+ * in-memory map would be cleared by the very operation a client is most likely
14
+ * to be retrying across — the server restarts, the client's connection drops,
15
+ * the client retries, and the process that knew the key is gone. The record has
16
+ * to outlive the thing it was protecting.
17
+ *
18
+ * What a key is *for* is checked as well as what it answered: the same key on a
19
+ * different method or path is a caller that reused one by mistake, and answering
20
+ * it with another operation's result would be the worst possible behaviour —
21
+ * silently correct-looking and about something else.
22
+ */
23
+ /**
24
+ * How long an answer stays replayable.
25
+ *
26
+ * A day, because the window this exists for is one client's retry after a
27
+ * dropped connection — seconds to minutes — and the reason to bound it is that
28
+ * the table would otherwise grow for ever with keys nobody will ask about again.
29
+ */
30
+ export const KEEP_MS = 24 * 60 * 60 * 1000;
31
+ /**
32
+ * What this key answered before, if anything.
33
+ *
34
+ * An empty key is not a key: a caller that sent none is asking for the ordinary
35
+ * behaviour, and treating `''` as a value would make every such request share
36
+ * one record.
37
+ */
38
+ export function recall(db, key, method, path) {
39
+ if (key === '')
40
+ return { kind: 'fresh' };
41
+ const row = db
42
+ .prepare('SELECT method, path, status, body FROM admin_idempotency WHERE key = ?')
43
+ .get(key);
44
+ if (row === undefined)
45
+ return { kind: 'fresh' };
46
+ const recorded = { status: row.status, body: row.body };
47
+ if (row.method !== method || row.path !== path)
48
+ return { kind: 'conflict', recorded };
49
+ return { kind: 'replay', recorded };
50
+ }
51
+ /**
52
+ * Keep this answer for this key.
53
+ *
54
+ * `INSERT OR REPLACE` rather than a plain insert: two callers racing with one
55
+ * key is not an error to report, it is the case this exists for, and whoever
56
+ * gets there second overwrites an answer that is about to be read back as the
57
+ * same answer anyway. Pruning runs here rather than on a timer, because this is
58
+ * the only moment the table grows.
59
+ */
60
+ export function remember(db, key, method, path, status, body, now = Date.now()) {
61
+ if (key === '')
62
+ return;
63
+ db.prepare('INSERT OR REPLACE INTO admin_idempotency (key, method, path, status, body, created_at) ' +
64
+ 'VALUES (?, ?, ?, ?, ?, ?)').run(key, method, path, status, body, new Date(now).toISOString());
65
+ db.prepare('DELETE FROM admin_idempotency WHERE created_at < ?').run(new Date(now - KEEP_MS).toISOString());
66
+ }
@@ -0,0 +1,90 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { sameSecret } from "./auth.js";
3
+ /**
4
+ * A secret for a new key, from the operating system's random source.
5
+ *
6
+ * 32 bytes, hex: the same shape as the environment key this project already
7
+ * uses, so that anything which accepts one accepts the other, and long enough
8
+ * that guessing it is not a thing anyone tries twice.
9
+ */
10
+ export function newSecret() {
11
+ return randomBytes(32).toString('hex');
12
+ }
13
+ /**
14
+ * Whether a presented key is one of the registered ones.
15
+ *
16
+ * **Every active row is compared, rather than looked up by value.** A `WHERE
17
+ * secret = ?` would be an index probe whose duration says how nearly the guess
18
+ * matched, which is the same leak `sameSecret` exists to close on the password;
19
+ * a registry of keys is small enough that comparing all of them is free. Revoked
20
+ * rows are left out — they are kept for the record, not honoured.
21
+ */
22
+ export function keyHolds(db, given) {
23
+ const rows = db.prepare('SELECT secret FROM api_key WHERE revoked_at IS NULL').all();
24
+ return rows.some((row) => sameSecret(given, row.secret));
25
+ }
26
+ /**
27
+ * Register a key under a label, and answer with the row that was written.
28
+ *
29
+ * A secret may be given rather than generated, because a person moving their
30
+ * setup may want the key they already have — but a secret this table has ever
31
+ * held is refused, revoked or not. Silently accepting one would resurrect a key
32
+ * somebody had deliberately taken back.
33
+ */
34
+ export function addKey(db, label, secret = newSecret(), now = new Date()) {
35
+ const trimmed = label.trim();
36
+ if (trimmed === '')
37
+ throw new Error('a key needs a label — what it is for, or whose it is');
38
+ if (secret.trim() === '')
39
+ throw new Error('a key needs a secret');
40
+ const seen = db.prepare('SELECT revoked_at FROM api_key WHERE secret = ?').get(secret);
41
+ if (seen !== undefined) {
42
+ throw new Error(seen.revoked_at === null
43
+ ? 'that secret is already registered to an active key'
44
+ : `that secret was revoked on ${seen.revoked_at} and is not given out again`);
45
+ }
46
+ const createdAt = now.toISOString();
47
+ db.prepare('INSERT INTO api_key (label, secret, created_at) VALUES (?, ?, ?)').run(trimmed, secret, createdAt);
48
+ const written = db.prepare('SELECT id FROM api_key WHERE secret = ?').get(secret);
49
+ return { id: written.id, label: trimmed, secret, createdAt, revokedAt: null };
50
+ }
51
+ /** Every key the table has ever held, newest first, revoked ones included. */
52
+ export function listKeys(db) {
53
+ const rows = db
54
+ .prepare(`SELECT id, label, secret, created_at AS createdAt, revoked_at AS revokedAt
55
+ FROM api_key ORDER BY created_at DESC, id DESC`)
56
+ .all();
57
+ return rows;
58
+ }
59
+ /**
60
+ * Take a key back, by id or by the label it was given.
61
+ *
62
+ * Refuses rather than answering quietly when it matches nothing or matches
63
+ * more than one: `revoke tablet` with two keys called "tablet" would take back
64
+ * a key the person did not name, and this is the one operation where being
65
+ * wrong is invisible until a device stops working.
66
+ */
67
+ export function revokeKey(db, idOrLabel, now = new Date()) {
68
+ // **`#3` is a form this has to accept, because it is the form this prints.**
69
+ // `keys list` shows `#3 the tablet`, the ambiguity refusal below says "name
70
+ // one by number: #12 (label)", and the usage line offers `<label|#id>` — and
71
+ // `Number('#3')` is `NaN`, so the one spelling the CLI advertised was the one
72
+ // it could not read. Found independently by two axes of the review umbrella,
73
+ // which is what a spelling nobody tested looks like from both sides
74
+ // (task:2924).
75
+ const named = idOrLabel.startsWith('#') ? idOrLabel.slice(1) : idOrLabel;
76
+ const asId = Number(named);
77
+ const rows = db
78
+ .prepare(`SELECT id, label, secret, created_at AS createdAt, revoked_at AS revokedAt
79
+ FROM api_key WHERE revoked_at IS NULL AND (label = ? OR id = ?)`)
80
+ .all(idOrLabel, Number.isInteger(asId) ? asId : -1);
81
+ if (rows.length === 0)
82
+ throw new Error(`no active key called or numbered "${idOrLabel}"`);
83
+ if (rows.length > 1) {
84
+ throw new Error(`${rows.length} active keys match "${idOrLabel}" — name one by number: ` +
85
+ rows.map((row) => `#${row.id} (${row.label})`).join(', '));
86
+ }
87
+ const revokedAt = now.toISOString();
88
+ db.prepare('UPDATE api_key SET revoked_at = ? WHERE id = ?').run(revokedAt, rows[0].id);
89
+ return { ...rows[0], revokedAt };
90
+ }