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,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
|
+
}
|
package/dist/api/keys.js
ADDED
|
@@ -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
|
+
}
|