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,172 @@
1
+ import { statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ /**
4
+ * The half-write gate: a file that is still being written is not read.
5
+ *
6
+ * A copy in progress is a file that is longer a second from now, and a scan that
7
+ * meets one records a size that is already wrong and hands the stages half a
8
+ * song. The contract's acceptance is that a half-written file does **not** enter
9
+ * the library (§5), and until this existed only the watcher had anything against
10
+ * it — the path that is off by default, while the path the clock starts waited
11
+ * for nothing.
12
+ *
13
+ * **The rule is the watcher's: five seconds of quiet.** `rescan.ts` waits for
14
+ * silence before it acts on a change; this waits for it before it records a
15
+ * file. One rule, one number, both paths.
16
+ *
17
+ * ## Two designs died before this one, and both died by measurement
18
+ *
19
+ * **Looking twice in a row does not work.** The first version compared the
20
+ * walk's observation against a `stat` taken straight after it and held a file
21
+ * when the two disagreed — the idea being that a second look costs nothing on a
22
+ * settled collection. What it got wrong is the size of the gap: for one file it
23
+ * is the walk returning and a `statSync`, tens of microseconds, and a
24
+ * `writeFileSync` of a few kilobytes takes about the same. So while a writer ran
25
+ * the two looks agreed roughly as often as not, and the live check caught it: a
26
+ * file being rewritten in a tight loop was read as settled twice in a row, while
27
+ * the same check called from a script held it — because three `console.log`s
28
+ * between the looks had widened the gap. **A gate whose window is two adjacent
29
+ * syscalls is not a gate.**
30
+ *
31
+ * **Holding everything recent is worse than the disease.** The second version
32
+ * held any file whose mtime was inside the window, no second look. It satisfies
33
+ * the acceptance and breaks the ordinary case: a scan run after a copy holds
34
+ * every file the copy has just finished, so the album arrives without its last
35
+ * track and waits a whole interval for the rest. Measured the blunt way — it
36
+ * turned twenty-one tests red, every one of them a fixture written and then
37
+ * scanned immediately, which is what an operator does. (That design is gone, so
38
+ * the run is not reproducible from this tree; what is reproducible is its shape,
39
+ * and it is why `RunOptions.settleMs` exists: most of the suite's scans are a
40
+ * fixture it has just written, and every one of them is a test about something
41
+ * else.)
42
+ *
43
+ * ## What is here, and the wait it costs
44
+ *
45
+ * A file inside the window is **waited out** — the remainder of the window from
46
+ * its own mtime — and then looked at again; one that moved is still being
47
+ * written and is not recorded. That is what separates "new and finished" from
48
+ * "new and still arriving", and it is the only thing that can: the difference is
49
+ * not visible in one observation at any instant.
50
+ *
51
+ * The cost is bounded and paid only when it buys something. A settled collection
52
+ * has nothing inside the window, so it waits not at all; a scan that follows a
53
+ * copy waits at most the window and then records the whole album. What is left
54
+ * is a scan that lands *while* a copy runs: the file in flight is held, and it
55
+ * arrives on the next scan — which is the acceptance and not a defect.
56
+ *
57
+ * **What it cannot catch is the watcher's blind spot too.** A copy that pauses
58
+ * for longer than the window and then resumes — over a network, where the pause
59
+ * is the point — reads as quiet. The answer is the next scan, which meets a file
60
+ * that has stopped; the price is that the half-written version was in the library
61
+ * for one interval.
62
+ *
63
+ * ## The clock the window is measured against is the walk's
64
+ *
65
+ * `stillMoving` is given the instant the **walk started**, not the reading taken
66
+ * when it is called: a walk of a real collection takes seconds, and a file met at
67
+ * the beginning of one has not been quiet for those seconds — it has been
68
+ * *unlooked at* for them. Passing the later reading would make exactly the oldest
69
+ * observation in the run read as the most settled, and the file a copy was
70
+ * halfway through when the walk passed it would be recorded with the size the
71
+ * copy had reached by then. A file written *during* a walk reads as fresh, which
72
+ * is what it is; the cost is bounded because the wait is.
73
+ *
74
+ * ## The window is the caller's
75
+ *
76
+ * `scan` takes it rather than assuming it, and its default is **no gate**, which
77
+ * is the unusual direction to leave a safety off and is deliberate: only the
78
+ * caller knows whether it can afford to wait. `runStages` is the one path that
79
+ * scans in production and it asks for the window; the suite calls `scan` directly
80
+ * hundreds of times to test things that have nothing to do with time, and a
81
+ * blanket five-second wait would put minutes on it to prove nothing.
82
+ *
83
+ * Zero means no gate in the strongest sense — `stillMoving` returns before it
84
+ * looks at anything — so the default costs a settled suite nothing at all, and
85
+ * the shim `RunOptions.settleMs` exists for the tests that scan a fixture they
86
+ * have just written and are not asking about time either.
87
+ */
88
+ export const SETTLE_MS = 5_000;
89
+ /**
90
+ * An observation, in the one spelling both looks use.
91
+ *
92
+ * `walk.ts` reports the files it met through this, and the second look takes its
93
+ * reading the same way — so what the gate compares is one rule spelled twice,
94
+ * not two rules that agree today. The fraction is dropped because the walk
95
+ * cannot keep it: a second look that kept the fraction would disagree with the
96
+ * walk about a file nobody had touched. The first version of this did exactly
97
+ * that, and the integration test caught it: every file in the fixture was held,
98
+ * because a fractional millisecond made a settled collection look as though it
99
+ * were being written to.
100
+ */
101
+ export function observed(stat) {
102
+ return { size: stat.size, mtimeMs: Math.trunc(stat.mtimeMs) };
103
+ }
104
+ /**
105
+ * Block this thread for a moment, synchronously.
106
+ *
107
+ * `Atomics.wait` rather than a spin: the scan process is a process of its own
108
+ * and holds no lock while it walks, so sleeping costs the server nothing — but a
109
+ * busy loop would burn a core of the machine the operator is listening to music
110
+ * on, which is the thing this whole gate exists to be careful about.
111
+ */
112
+ function sleep(ms) {
113
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
114
+ }
115
+ /**
116
+ * The files under one root that were still being written.
117
+ *
118
+ * Absolute paths come from the root rather than from the walk, because the walk
119
+ * reports where a file sits *in the collection* and this needs where it sits on
120
+ * the disk.
121
+ */
122
+ export function stillMoving(rootPath, files, nowMs, window) {
123
+ // **No gate, and saying so here rather than at the callers.** `0` is what
124
+ // `scan` defaults to, and the difference between "no window" and "a window of
125
+ // zero milliseconds" is not worth leaving to arithmetic: the filter below
126
+ // would take any file whose mtime is not in the past — which under a clock
127
+ // that disagrees is every file — and then wait for it.
128
+ if (window <= 0)
129
+ return [];
130
+ const pending = files.filter((file) => nowMs - file.mtimeMs <= window);
131
+ if (pending.length === 0)
132
+ return [];
133
+ // Out the remainder of the window as measured from the newest of them: waiting
134
+ // a fixed five seconds would make a scan that met a file written one
135
+ // millisecond ago — the most settled file there can be that is still new — pay
136
+ // the full price for nothing.
137
+ //
138
+ // **And never more than the window, which is the one part of this that is a
139
+ // safeguard rather than a rule.** `nowMs` is this process's clock; an mtime is
140
+ // whatever wrote the file. So the subtraction can come out longer than the
141
+ // window — a share whose clock runs ahead, an archive unpacked with the mtimes
142
+ // it was packed with — and waiting that out means sleeping for the skew, with
143
+ // the run stuck in `running` and nothing able to start another (measured:
144
+ // +24.1 s end to end at a skew of 20 s). A file whose clock is ahead of ours
145
+ // is not a file being written; it is a clock disagreeing, and the second look
146
+ // below is what decides it either way.
147
+ //
148
+ // Folded rather than spread: `Math.max(...pending)` is a call whose arguments
149
+ // are the array, and Node throws `RangeError` somewhere around 125 000 of them
150
+ // — a whole collection inside one window, which is exactly the clock-skew case
151
+ // above. A crash there would fail the run rather than hold a file.
152
+ const newest = pending.reduce((most, file) => (file.mtimeMs > most ? file.mtimeMs : most), -Infinity);
153
+ const wait = Math.max(0, Math.min(window, window - (nowMs - newest)));
154
+ if (wait > 0)
155
+ sleep(wait);
156
+ const second = new Map();
157
+ for (const file of pending) {
158
+ try {
159
+ second.set(file.relPath, observed(statSync(join(rootPath, file.relPath))));
160
+ }
161
+ catch {
162
+ // Left out of the map, which reads as moved: a file that cannot be looked
163
+ // at twice has not been seen to settle.
164
+ }
165
+ }
166
+ return pending
167
+ .filter((file) => {
168
+ const again = second.get(file.relPath);
169
+ return again === undefined || again.size !== file.size || again.mtimeMs !== file.mtimeMs;
170
+ })
171
+ .map((file) => file.relPath);
172
+ }
@@ -0,0 +1,132 @@
1
+ import { readdirSync, statSync } from 'node:fs';
2
+ import { extname, join } from 'node:path';
3
+ import { classifyFile } from "./kinds.js";
4
+ import { observed } from "./settle.js";
5
+ /**
6
+ * Directories that hold filesystem bookkeeping rather than music. Walking a
7
+ * Synology `@eaDir` tree would invent a phantom album per track.
8
+ */
9
+ const IGNORED_DIRS = new Set([
10
+ '@eaDir',
11
+ '#recycle',
12
+ '.@__thumb',
13
+ '$RECYCLE.BIN',
14
+ 'System Volume Information',
15
+ '.Trash-1000',
16
+ '.Trashes',
17
+ ]);
18
+ /**
19
+ * What an ignored directory holds, read to the end and counted.
20
+ *
21
+ * The name is what says "bookkeeping, not music", and it is a guess. Undoing
22
+ * the guess costs a traversal of the directory that was being avoided — which
23
+ * is why nothing here is `stat`-ed: `readdir` already answers whether an entry
24
+ * is a file, and the per-file stat is the expensive half of a walk. A directory
25
+ * that cannot be read contributes nothing to either count; it is still reported
26
+ * as ignored, which is the part a reader needs.
27
+ */
28
+ function countIgnored(rootPath, relDir) {
29
+ let audio = 0;
30
+ let other = 0;
31
+ const queue = [relDir];
32
+ while (queue.length > 0) {
33
+ const dir = queue.pop();
34
+ let entries;
35
+ try {
36
+ entries = readdirSync(join(rootPath, dir), { withFileTypes: true });
37
+ }
38
+ catch {
39
+ continue;
40
+ }
41
+ for (const entry of entries) {
42
+ if (entry.isDirectory()) {
43
+ queue.push(`${dir}/${entry.name}`);
44
+ continue;
45
+ }
46
+ // Symlinks and special files are counted as neither: the walk records
47
+ // none of them either, so counting them would overstate what was lost.
48
+ if (!entry.isFile())
49
+ continue;
50
+ if (classifyFile(entry.name) === 'audio')
51
+ audio += 1;
52
+ else
53
+ other += 1;
54
+ }
55
+ }
56
+ return { audio, other };
57
+ }
58
+ /**
59
+ * Walk one configured root and describe everything under it.
60
+ *
61
+ * The result is held in memory. A 50k-file collection is a few tens of MB of
62
+ * plain objects, which is fine for v1; if that stops being true, this is the
63
+ * function to turn into a stream.
64
+ *
65
+ * Symlinked entries are reported as skipped rather than followed — a cycle in
66
+ * the collection would otherwise not terminate.
67
+ */
68
+ export function walkRoot(rootPath) {
69
+ const files = [];
70
+ const folders = [];
71
+ const skipped = [];
72
+ const ignored = [];
73
+ const walk = (relDir) => {
74
+ const absDir = relDir === '' ? rootPath : join(rootPath, relDir);
75
+ let entries;
76
+ try {
77
+ entries = readdirSync(absDir, { withFileTypes: true });
78
+ }
79
+ catch (err) {
80
+ skipped.push({ relPath: relDir, reason: `unreadable directory: ${err.message}` });
81
+ return;
82
+ }
83
+ for (const entry of entries) {
84
+ const relPath = relDir === '' ? entry.name : `${relDir}/${entry.name}`;
85
+ // A symlink reports false for both isDirectory() and isFile(), so it is
86
+ // routed to `skipped` below rather than followed or recorded as a file.
87
+ if (entry.isDirectory()) {
88
+ if (IGNORED_DIRS.has(entry.name)) {
89
+ ignored.push({ relPath, ...countIgnored(rootPath, relPath) });
90
+ continue;
91
+ }
92
+ folders.push(relPath);
93
+ walk(relPath);
94
+ continue;
95
+ }
96
+ if (entry.isSymbolicLink()) {
97
+ skipped.push({ relPath, reason: 'symlink (not followed)' });
98
+ continue;
99
+ }
100
+ if (!entry.isFile()) {
101
+ skipped.push({ relPath, reason: 'not a regular file' });
102
+ continue;
103
+ }
104
+ let stat;
105
+ try {
106
+ stat = statSync(join(rootPath, relPath));
107
+ }
108
+ catch (err) {
109
+ skipped.push({ relPath, reason: `stat failed: ${err.message}` });
110
+ continue;
111
+ }
112
+ files.push({
113
+ relPath,
114
+ folderRelPath: relDir,
115
+ name: entry.name,
116
+ kind: classifyFile(entry.name),
117
+ ext: extname(entry.name).toLowerCase().slice(1),
118
+ // Through the gate's own spelling of an observation, because the gate
119
+ // compares a fresh reading against this one: two `Math.trunc` calls in
120
+ // two files is one rule written down twice, and the day they disagree
121
+ // every file in a settled collection reads as moving. See `settle.ts`.
122
+ ...observed(stat),
123
+ });
124
+ }
125
+ };
126
+ walk('');
127
+ // Deterministic order keeps scans comparable run to run.
128
+ files.sort((a, b) => (a.relPath < b.relPath ? -1 : a.relPath > b.relPath ? 1 : 0));
129
+ folders.sort();
130
+ ignored.sort((a, b) => (a.relPath < b.relPath ? -1 : a.relPath > b.relPath ? 1 : 0));
131
+ return { files, folders, skipped, ignored };
132
+ }
@@ -0,0 +1,41 @@
1
+ export function rebuildSearchIndex(db) {
2
+ try {
3
+ // `IMMEDIATE`, and here the word is load-bearing rather than prophylactic.
4
+ //
5
+ // The `DELETE` below goes through FTS5, which reads before it writes — so a
6
+ // deferred `BEGIN` takes its read snapshot first, and the write that follows
7
+ // is an upgrade: meeting another writer, it is refused at once without ever
8
+ // consulting the busy handler. Measured against a held lock, this stage
9
+ // gives up in **0 ms** where the other three wait. `db/index.ts` has the
10
+ // rule (task:2871).
11
+ db.exec('BEGIN IMMEDIATE');
12
+ db.exec('DELETE FROM track_fts');
13
+ db.prepare(
14
+ // The *record's* name, which for a box is its release folder's. A disc's
15
+ // own title is `CD1 ● Альбом`, and indexing that would make a box findable
16
+ // by a name no client is ever shown — while the name it is shown, the
17
+ // record's, would find nothing.
18
+ `INSERT INTO track_fts (rowid, title, artist, album)
19
+ SELECT t.id,
20
+ COALESCE(t.title, ''),
21
+ COALESCE(ar.name, ''),
22
+ COALESCE(rel.title, al.title, '')
23
+ FROM track t
24
+ LEFT JOIN album al ON al.id = t.album_id
25
+ LEFT JOIN release rel ON rel.id = al.release_id
26
+ LEFT JOIN artist ar ON ar.id = COALESCE(al.artist_id, rel.artist_id)`).run();
27
+ db.exec(`INSERT INTO track_fts (track_fts) VALUES ('optimize')`);
28
+ const counted = db.prepare('SELECT COUNT(*) AS n FROM track_fts').get();
29
+ db.exec('COMMIT');
30
+ return { rows: counted.n };
31
+ }
32
+ catch (err) {
33
+ try {
34
+ db.exec('ROLLBACK');
35
+ }
36
+ catch {
37
+ // Already unwound; the original error is what matters.
38
+ }
39
+ throw err;
40
+ }
41
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * What a user typed, said in the index's own language.
3
+ *
4
+ * The one rule here is that a query is *words*. FTS5 has a query language of its
5
+ * own — `OR`, `NEAR`, parentheses, quotes, `*`, `-` — and handing it a user's
6
+ * text unread means the user is writing that language by accident: a title with
7
+ * a hyphen in it becomes a NOT, an unclosed quote is a syntax error, and a
8
+ * search for "Pictures or You" asks a question about the search engine. So the
9
+ * text is reduced to its words and rebuilt as a phrase list, where nothing the
10
+ * user can type means anything but "this word".
11
+ *
12
+ * Every word has to be there, which is what makes a second word narrow a search
13
+ * rather than widen it. The last word is a prefix, because a search is typed a
14
+ * letter at a time and `Disint` should already find `Disintegration`.
15
+ *
16
+ * Null means "no words at all", which is not the same query as any word: FTS5
17
+ * has no expression for "everything", and the caller answers an empty search
18
+ * from the tables instead. That distinction is the whole reason this returns a
19
+ * nullable string rather than an empty one.
20
+ */
21
+ export function matchExpression(query) {
22
+ const words = query.match(/[\p{L}\p{N}]+/gu);
23
+ if (words === null || words.length === 0)
24
+ return null;
25
+ return words
26
+ .map((word, at) => (at === words.length - 1 ? `"${word}"*` : `"${word}"`))
27
+ .join(' ');
28
+ }