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,133 @@
|
|
|
1
|
+
import { getStarred, getStarred2, setRating, star, unstar } from "./annotation.js";
|
|
2
|
+
import { getArtistInfo2 } from "./artistinfo.js";
|
|
3
|
+
import { createBookmark, deleteBookmark, getBookmarks } from "./bookmark.js";
|
|
4
|
+
import { getAlbum, getAlbumList2, getArtist, getArtists, getIndexes, getMusicDirectory, getMusicFolders, getRandomSongs, getSong, } from "./browse.js";
|
|
5
|
+
import { download } from "./download.js";
|
|
6
|
+
import { coverArt } from "./cover.js";
|
|
7
|
+
import { openSubsonicExtensions } from "./extensions.js";
|
|
8
|
+
import { getGenres, getSongsByGenre } from "./genre.js";
|
|
9
|
+
import { getNowPlaying, getPlayQueue, getPlayQueueByIndex, reportPlayback, savePlayQueue, savePlayQueueByIndex, scrobble, } from "./history.js";
|
|
10
|
+
import { scanStatus } from "./meta.js";
|
|
11
|
+
import { createPlaylist, deletePlaylist, getPlaylist, getPlaylists, updatePlaylist, } from "./playlist.js";
|
|
12
|
+
import { search3 } from "./search.js";
|
|
13
|
+
import { stream } from "./stream.js";
|
|
14
|
+
import { STUBBED, STUBBED_BYTES, stubPayload, stubRefusal } from "./stubs.js";
|
|
15
|
+
import { getTranscodeDecision, getTranscodeStream } from "./transcode.js";
|
|
16
|
+
import { getUser, getUsers } from "./user.js";
|
|
17
|
+
const ROUTES = new Map([
|
|
18
|
+
['ping', () => ({})],
|
|
19
|
+
// What this server supports, which is the one answer a client may ask for
|
|
20
|
+
// without credentials — see `PUBLIC` in `server.ts`. It sits here rather than
|
|
21
|
+
// near the rest because it is not about the collection at all: it is about
|
|
22
|
+
// the build.
|
|
23
|
+
['getopensubsonicextensions', () => openSubsonicExtensions()],
|
|
24
|
+
// A server that runs on your own hardware has no trial to expire and nothing
|
|
25
|
+
// to sell, so the license is always valid. The method exists because clients
|
|
26
|
+
// call it before anything else and take a refusal as "do not talk to me".
|
|
27
|
+
['getlicense', () => ({ license: { valid: true } })],
|
|
28
|
+
['getscanstatus', ({ db }) => ({ scanStatus: scanStatus(db) })],
|
|
29
|
+
['getindexes', ({ db, query, visibility }) => getIndexes(db, query, visibility)],
|
|
30
|
+
['getartists', ({ db, query, visibility, origin }) => getArtists(db, query, visibility, origin)],
|
|
31
|
+
['getmusicfolders', ({ db }) => getMusicFolders(db)],
|
|
32
|
+
['getartist', ({ db, query, visibility, origin }) => getArtist(db, query, visibility, origin)],
|
|
33
|
+
['getartistinfo2', ({ db, query, origin }) => getArtistInfo2(db, query, origin)],
|
|
34
|
+
['getalbum', ({ db, query }) => getAlbum(db, query)],
|
|
35
|
+
['getalbumlist2', ({ db, query, visibility }) => getAlbumList2(db, query, visibility)],
|
|
36
|
+
['getmusicdirectory', ({ db, query, visibility }) => getMusicDirectory(db, query, visibility)],
|
|
37
|
+
['getsong', ({ db, query }) => getSong(db, query)],
|
|
38
|
+
['getgenres', ({ db, visibility }) => getGenres(db, visibility)],
|
|
39
|
+
['getsongsbygenre', ({ db, query, visibility }) => getSongsByGenre(db, query, visibility)],
|
|
40
|
+
['search3', ({ db, query, visibility, origin }) => search3(db, query, visibility, origin)],
|
|
41
|
+
['getuser', ({ db, config, query }) => getUser(db, config, query.get('username'))],
|
|
42
|
+
// The same record inside a list, which is what the protocol's plural asks for
|
|
43
|
+
// and what the operator asked for when the two answers disagreed (wiki:3640).
|
|
44
|
+
['getusers', ({ db, config }) => getUsers(db, config)],
|
|
45
|
+
// Something to open the app on, and a place to come back to. The first is
|
|
46
|
+
// the one listing here whose answer is not the same twice.
|
|
47
|
+
['getrandomsongs', ({ db, query, visibility }) => getRandomSongs(db, query, visibility)],
|
|
48
|
+
['getbookmarks', ({ db, config }) => getBookmarks(db, config)],
|
|
49
|
+
['createbookmark', ({ db, query }) => createBookmark(db, query)],
|
|
50
|
+
['deletebookmark', ({ db, query }) => deleteBookmark(db, query)],
|
|
51
|
+
// The listener's marks: the rest of the writing surface, and the same kind of
|
|
52
|
+
// thing as a playlist — said about the music rather than read from it. The
|
|
53
|
+
// two listings read what the three above write, and they take the protocol's
|
|
54
|
+
// music-folder filter like every other listing here.
|
|
55
|
+
['star', ({ db, query }) => star(db, query)],
|
|
56
|
+
['unstar', ({ db, query }) => unstar(db, query)],
|
|
57
|
+
['setrating', ({ db, query }) => setRating(db, query)],
|
|
58
|
+
['getstarred', ({ db, query, origin }) => getStarred(db, query, origin)],
|
|
59
|
+
['getstarred2', ({ db, query, origin }) => getStarred2(db, query, origin)],
|
|
60
|
+
// What the listener played, what is on, and the queue they left. The third
|
|
61
|
+
// part of their own layer, and the only one that is about *time*: the rows
|
|
62
|
+
// here say the collection was used, not what somebody thought of it.
|
|
63
|
+
['scrobble', ({ db, query }) => scrobble(db, query)],
|
|
64
|
+
['getnowplaying', ({ db, config }) => getNowPlaying(db, config)],
|
|
65
|
+
['reportplayback', ({ db, query }) => reportPlayback(db, query)],
|
|
66
|
+
['getplayqueue', ({ db, config }) => getPlayQueue(db, config)],
|
|
67
|
+
['saveplayqueue', ({ db, query }) => savePlayQueue(db, query)],
|
|
68
|
+
// The `indexBasedQueue` extension, and the same queue: an id cannot say which
|
|
69
|
+
// of two identical entries is playing, and a position can.
|
|
70
|
+
['getplayqueuebyindex', ({ db, config }) => getPlayQueueByIndex(db, config)],
|
|
71
|
+
['saveplayqueuebyindex', ({ db, query }) => savePlayQueueByIndex(db, query)],
|
|
72
|
+
// The listener's playlists. Everything else here answers about a collection
|
|
73
|
+
// the scanner built; these answer about what the listener said of it, and
|
|
74
|
+
// three of the five write.
|
|
75
|
+
['getplaylists', ({ db, config, query }) => getPlaylists(db, config, query)],
|
|
76
|
+
['getplaylist', ({ db, config, query }) => getPlaylist(db, config, query)],
|
|
77
|
+
['createplaylist', ({ db, config, query }) => createPlaylist(db, config, query)],
|
|
78
|
+
['updateplaylist', ({ db, config, query }) => updatePlaylist(db, config, query)],
|
|
79
|
+
['deleteplaylist', ({ db, query }) => deletePlaylist(db, query)],
|
|
80
|
+
// The `transcoding` extension, first half: what this server would do with a
|
|
81
|
+
// song for a client that has stated what it can play. Its other half answers
|
|
82
|
+
// with the stream itself and is a binary route below, because the bytes are
|
|
83
|
+
// `stream`'s own — see `transcode.ts`.
|
|
84
|
+
['gettranscodedecision', (context) => getTranscodeDecision(context)],
|
|
85
|
+
]);
|
|
86
|
+
/**
|
|
87
|
+
* The route for a method name, if there is one.
|
|
88
|
+
*
|
|
89
|
+
* The lookup lowers the name: the protocol spells its methods in camelCase, and
|
|
90
|
+
* a server that answered only the exact spelling would fail a client over a
|
|
91
|
+
* difference no answer depends on.
|
|
92
|
+
*/
|
|
93
|
+
export function route(method) {
|
|
94
|
+
const name = method.toLowerCase();
|
|
95
|
+
const answered = ROUTES.get(name);
|
|
96
|
+
if (answered !== undefined)
|
|
97
|
+
return answered;
|
|
98
|
+
// What this server answers empty by form rather than refusing. Asked here
|
|
99
|
+
// instead of being listed beside the real routes so that `ROUTES` above stays
|
|
100
|
+
// what it reads as — the surface this server *fills* — and the two cannot be
|
|
101
|
+
// confused for each other by a reader counting entries. See `stubs.ts`.
|
|
102
|
+
if (STUBBED.has(name)) {
|
|
103
|
+
return ({ query }) => stubPayload(name, query) ?? {};
|
|
104
|
+
}
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
const BINARY = new Map([
|
|
108
|
+
['stream', stream],
|
|
109
|
+
// The same bytes as `stream` and a different promise: `stream` answers with
|
|
110
|
+
// something a client can play, `download` with the file itself.
|
|
111
|
+
['download', download],
|
|
112
|
+
['getcoverart', coverArt],
|
|
113
|
+
// The other half of `transcoding`: the same bytes `stream` would send, chosen
|
|
114
|
+
// by the decision a client was handed rather than by parameters of its own.
|
|
115
|
+
['gettranscodestream', getTranscodeStream],
|
|
116
|
+
]);
|
|
117
|
+
export function binaryRoute(method) {
|
|
118
|
+
const name = method.toLowerCase();
|
|
119
|
+
const answered = BINARY.get(name);
|
|
120
|
+
if (answered !== undefined)
|
|
121
|
+
return answered;
|
|
122
|
+
// The byte-answering stubs: three methods the protocol answers with an image,
|
|
123
|
+
// a caption file or a playlist, none of which this server can produce. The
|
|
124
|
+
// refusal is thrown rather than written, so it goes out through the one place
|
|
125
|
+
// that renders refusals — and before any header, which is the rule every
|
|
126
|
+
// binary route keeps.
|
|
127
|
+
const reason = STUBBED_BYTES.get(name);
|
|
128
|
+
if (reason === undefined)
|
|
129
|
+
return undefined;
|
|
130
|
+
return () => {
|
|
131
|
+
throw stubRefusal(reason);
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
export function scanner(deps) {
|
|
3
|
+
let child = null;
|
|
4
|
+
let mode = 'incremental';
|
|
5
|
+
let startedAt = '';
|
|
6
|
+
let cancelling = false;
|
|
7
|
+
const finish = () => {
|
|
8
|
+
child = null;
|
|
9
|
+
cancelling = false;
|
|
10
|
+
};
|
|
11
|
+
return {
|
|
12
|
+
start(next) {
|
|
13
|
+
if (child !== null) {
|
|
14
|
+
return { ok: false, reason: `a scan is already running (pid ${child.pid ?? '?'})` };
|
|
15
|
+
}
|
|
16
|
+
// **A run the database still calls `running` is a run in the way**, whoever
|
|
17
|
+
// started it. Two scans on one file do not corrupt it — SQLite sees to
|
|
18
|
+
// that — but they read the whole collection twice and each reports half of
|
|
19
|
+
// what happened, and the operator asked for one. `cancel` is the way out
|
|
20
|
+
// of a row whose process is gone, and it says when it is settling one.
|
|
21
|
+
const last = newest(deps.db);
|
|
22
|
+
if (last?.status === 'running') {
|
|
23
|
+
return {
|
|
24
|
+
ok: false,
|
|
25
|
+
reason: `run ${last.id} is still recorded as running (started ${last.started_at}). ` +
|
|
26
|
+
'If its process is gone, POST /scan/cancel settles it',
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
// **A scan with nothing to read is refused here rather than spawned.**
|
|
30
|
+
// The child would exit with a usage error — `scan needs at least one root`
|
|
31
|
+
// — and this end would have logged "scanning" about it, which is a line
|
|
32
|
+
// that says the opposite of what happened. Found on a live server whose
|
|
33
|
+
// engine started before any root was configured.
|
|
34
|
+
const roots = deps.roots();
|
|
35
|
+
if (roots.length === 0) {
|
|
36
|
+
return { ok: false, reason: 'no roots are configured — POST /roots adds one' };
|
|
37
|
+
}
|
|
38
|
+
const { file, args } = deps.command(next);
|
|
39
|
+
mode = next;
|
|
40
|
+
startedAt = new Date().toISOString();
|
|
41
|
+
cancelling = false;
|
|
42
|
+
const started = spawn(file, args, {
|
|
43
|
+
// The scanner's own narration goes where this server's goes: the log a
|
|
44
|
+
// deployment already collects. Piped and re-emitted, a child's output
|
|
45
|
+
// arrives in the server's process and can be lost with it; inherited, it
|
|
46
|
+
// is written by the process that produced it.
|
|
47
|
+
stdio: ['ignore', 'inherit', 'inherit'],
|
|
48
|
+
windowsHide: true,
|
|
49
|
+
});
|
|
50
|
+
child = started;
|
|
51
|
+
started.once('exit', () => {
|
|
52
|
+
// The row is left as the run settled it — `ok` or `failed`, written by
|
|
53
|
+
// the scanner itself. Only a cancellation has to be written from here,
|
|
54
|
+
// because the process was killed before it could say so.
|
|
55
|
+
if (cancelling)
|
|
56
|
+
settleCancelled(deps.db, startedAt);
|
|
57
|
+
finish();
|
|
58
|
+
});
|
|
59
|
+
started.once('error', finish);
|
|
60
|
+
return { ok: true, pid: started.pid ?? 0 };
|
|
61
|
+
},
|
|
62
|
+
status() {
|
|
63
|
+
return {
|
|
64
|
+
running: child === null || child.pid === undefined
|
|
65
|
+
? null
|
|
66
|
+
: { pid: child.pid, mode, startedAt, cancelling },
|
|
67
|
+
last: asRow(newest(deps.db)),
|
|
68
|
+
};
|
|
69
|
+
},
|
|
70
|
+
cancel() {
|
|
71
|
+
// A child of this process is killed first, and it is killed whether or not
|
|
72
|
+
// its run has already recorded its end: a process that has finished its
|
|
73
|
+
// work and has not exited yet is still a process, and the handle is ours.
|
|
74
|
+
// (Measured on a live server: a run over 2400 files finished in 353 ms and
|
|
75
|
+
// its child was still there a second later. Killing it was right; the row
|
|
76
|
+
// it had already settled is left alone by `settleCancelled`.)
|
|
77
|
+
if (child !== null) {
|
|
78
|
+
cancelling = true;
|
|
79
|
+
// Killed rather than asked to stop, and on Windows there is no choice: a
|
|
80
|
+
// signal has no handler to reach. Safe here for the same reason it is
|
|
81
|
+
// safe for the server: the meta layer is SQLite in WAL, built to survive
|
|
82
|
+
// a writer that stops mid-write, and the stages rebuild their own rows
|
|
83
|
+
// from the present — which is what makes a half-finished run something
|
|
84
|
+
// the next one repairs rather than something that has to be undone.
|
|
85
|
+
child.kill();
|
|
86
|
+
return { ok: true, settled: null };
|
|
87
|
+
}
|
|
88
|
+
// Nothing of ours is running, so what is left to stop is a *row* — one a
|
|
89
|
+
// process that has since gone left `running`, which is a server restarted
|
|
90
|
+
// mid-scan or a scan killed with the machine. Settling it is the whole of
|
|
91
|
+
// what cancel can mean here, and saying which of the two it did is the
|
|
92
|
+
// difference between "stopped" and "marked as stopped".
|
|
93
|
+
const last = newest(deps.db);
|
|
94
|
+
if (last?.status !== 'running')
|
|
95
|
+
return { ok: false, reason: 'no scan is running' };
|
|
96
|
+
settleCancelled(deps.db, last.started_at);
|
|
97
|
+
return { ok: true, settled: last.id };
|
|
98
|
+
},
|
|
99
|
+
history(limit) {
|
|
100
|
+
const rows = deps.db
|
|
101
|
+
.prepare('SELECT id, started_at, finished_at, status, roots_json FROM scan_run ORDER BY id DESC LIMIT ?')
|
|
102
|
+
.all(limit);
|
|
103
|
+
return rows.map((row) => ({
|
|
104
|
+
id: row.id,
|
|
105
|
+
startedAt: row.started_at,
|
|
106
|
+
finishedAt: row.finished_at,
|
|
107
|
+
status: row.status,
|
|
108
|
+
roots: parseRoots(row.roots_json),
|
|
109
|
+
}));
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* The newest run, which is the one a question about "the scan" is about.
|
|
115
|
+
*
|
|
116
|
+
* Read from the database rather than remembered from this process's own child,
|
|
117
|
+
* because a scan outlives a restart of the server: a server that has just come
|
|
118
|
+
* up has no handle to a walk that is still going, and reporting "nothing is
|
|
119
|
+
* running" beside a row that says otherwise would be two answers to one
|
|
120
|
+
* question.
|
|
121
|
+
*/
|
|
122
|
+
function newest(db) {
|
|
123
|
+
return (db
|
|
124
|
+
.prepare('SELECT id, started_at, finished_at, status, roots_json FROM scan_run ORDER BY id DESC LIMIT 1')
|
|
125
|
+
.get() ?? null);
|
|
126
|
+
}
|
|
127
|
+
/** A run row as the surface reports it, or nothing when there is no row. */
|
|
128
|
+
function asRow(row) {
|
|
129
|
+
if (row === null)
|
|
130
|
+
return null;
|
|
131
|
+
return {
|
|
132
|
+
id: row.id,
|
|
133
|
+
startedAt: row.started_at,
|
|
134
|
+
finishedAt: row.finished_at,
|
|
135
|
+
status: row.status,
|
|
136
|
+
roots: parseRoots(row.roots_json),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Write down that a run was stopped.
|
|
141
|
+
*
|
|
142
|
+
* `cancelled` is a fourth value in a column whose comment names three — the
|
|
143
|
+
* alternative was leaving the row `running` for ever, which is worse in every
|
|
144
|
+
* direction: `getScanStatus` would keep telling every client that a scan is on,
|
|
145
|
+
* and `start` would refuse to start one. Readers that ask `=== 'running'` read
|
|
146
|
+
* it as not running, which is what it is.
|
|
147
|
+
*
|
|
148
|
+
* The row is found by the time the run started rather than by its id, because
|
|
149
|
+
* the id is the scanner's to create and this process never saw it: a run started
|
|
150
|
+
* after this one cannot be the one being settled.
|
|
151
|
+
*/
|
|
152
|
+
function settleCancelled(db, since) {
|
|
153
|
+
db.prepare("UPDATE scan_run SET status = 'cancelled', finished_at = ? WHERE status = 'running' AND started_at >= ?").run(new Date().toISOString(), since);
|
|
154
|
+
}
|
|
155
|
+
export function parseRoots(json) {
|
|
156
|
+
try {
|
|
157
|
+
const parsed = JSON.parse(json);
|
|
158
|
+
return Array.isArray(parsed) ? parsed.filter((one) => typeof one === 'string') : [];
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { matchExpression } from "../search/query.js";
|
|
2
|
+
import { albumsById, artistsById, root, searchAlbumIds, searchArtistIds, searchSongs, } from "./meta.js";
|
|
3
|
+
import { albumId3, artistId3, childOf, confinedTo, parseId } from "./browse.js";
|
|
4
|
+
import { ApiError, ERROR } from "./envelope.js";
|
|
5
|
+
/**
|
|
6
|
+
* The library, searched.
|
|
7
|
+
*
|
|
8
|
+
* Three sections answer one query — artists, albums and songs — and they do not
|
|
9
|
+
* answer it the same way. An artist and an album are *found through* the songs
|
|
10
|
+
* that match, so a query for a title answers with the record it is on and who
|
|
11
|
+
* made it, which is what a client showing a search result actually needs: the
|
|
12
|
+
* user typed a song and is being offered a place to play it from.
|
|
13
|
+
*
|
|
14
|
+
* The counts are small on purpose and the protocol's own default is kept. A
|
|
15
|
+
* client shows a handful of each and asks again with an offset when the user
|
|
16
|
+
* scrolls, so a server that answered with everything would put the library on
|
|
17
|
+
* the wire to have nineteen twentieths of it thrown away.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* The protocol's default page, per section.
|
|
21
|
+
*
|
|
22
|
+
* Its own number and not the listing route's ten: a search is the one call a
|
|
23
|
+
* client makes on every keystroke, and the two methods were given different
|
|
24
|
+
* defaults by the protocol for that reason.
|
|
25
|
+
*/
|
|
26
|
+
const DEFAULT_COUNT = 20;
|
|
27
|
+
/** The ceiling a client cannot raise, so one call cannot ask for the collection. */
|
|
28
|
+
const MAX_COUNT = 500;
|
|
29
|
+
function count(query, name) {
|
|
30
|
+
const raw = query.get(name);
|
|
31
|
+
if (raw === null || raw === '')
|
|
32
|
+
return DEFAULT_COUNT;
|
|
33
|
+
const value = Number(raw);
|
|
34
|
+
if (!Number.isInteger(value)) {
|
|
35
|
+
throw new ApiError(ERROR.generic, `${name} is not a whole number: ${raw}`);
|
|
36
|
+
}
|
|
37
|
+
// Zero is a page of no rows and not a missing page: `songCount=0` is a client
|
|
38
|
+
// asking for artists only, and clamping it up to one — which is what the
|
|
39
|
+
// listing route does with its own page size — answers with a song nobody
|
|
40
|
+
// asked for.
|
|
41
|
+
return Math.min(Math.max(value, 0), MAX_COUNT);
|
|
42
|
+
}
|
|
43
|
+
function offset(query, name) {
|
|
44
|
+
const raw = query.get(name);
|
|
45
|
+
if (raw === null || raw === '')
|
|
46
|
+
return 0;
|
|
47
|
+
const value = Number(raw);
|
|
48
|
+
if (!Number.isInteger(value)) {
|
|
49
|
+
throw new ApiError(ERROR.generic, `${name} is not a whole number: ${raw}`);
|
|
50
|
+
}
|
|
51
|
+
return Math.max(value, 0);
|
|
52
|
+
}
|
|
53
|
+
export function search3(db, query, visibility, origin) {
|
|
54
|
+
const rootId = confinedTo(db, query);
|
|
55
|
+
// The protocol calls `query` required, and the empty one is the exception that
|
|
56
|
+
// matters: a client's first sync asks `search3?query=` and expects the library
|
|
57
|
+
// back. So a missing query and an empty one are the same request, and both
|
|
58
|
+
// mean "everything" — see `SearchPage`.
|
|
59
|
+
const words = matchExpression(query.get('query') ?? '');
|
|
60
|
+
const page = (prefix) => ({
|
|
61
|
+
match: words,
|
|
62
|
+
size: count(query, `${prefix}Count`),
|
|
63
|
+
offset: offset(query, `${prefix}Offset`),
|
|
64
|
+
...(rootId === undefined ? {} : { rootId }),
|
|
65
|
+
visibility,
|
|
66
|
+
});
|
|
67
|
+
return {
|
|
68
|
+
searchResult3: {
|
|
69
|
+
// The sections are always present, empty ones included: a client that
|
|
70
|
+
// reads a missing section and an empty one differently would otherwise
|
|
71
|
+
// have to guess which this server meant.
|
|
72
|
+
// Each section resolves its whole page in one query. Asking per id was a
|
|
73
|
+
// separate execution of the album select — which carries the genre window
|
|
74
|
+
// table — for every row returned, so a page of 250 cost about two seconds.
|
|
75
|
+
// The sections were never slow to *find* their rows; they were slow to
|
|
76
|
+
// look them up one at a time.
|
|
77
|
+
artist: artistsById(db, searchArtistIds(db, page('artist'))).map((row) => artistId3(row, origin)),
|
|
78
|
+
album: albumsById(db, searchAlbumIds(db, page('album'))).map(albumId3),
|
|
79
|
+
song: searchSongs(db, page('song')).map((row) => childOf(row)),
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|