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,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
+ }