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