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
package/dist/cli.js
ADDED
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createServer } from "./api/server.js";
|
|
3
|
+
import { createAdminServer } from "./api/admin.js";
|
|
4
|
+
import { DEFAULT_HOST, DEFAULT_PORT, loadAdminConfig, loadConfig, loadScanConfig, unexpanded, } from "./api/config.js";
|
|
5
|
+
import { configFilePath, DEFAULT_CONFIG_FILE, readConfigFile, } from "./api/config-file.js";
|
|
6
|
+
import { auditLog, auditPath } from "./api/audit.js";
|
|
7
|
+
import { logToFile } from "./api/log-file.js";
|
|
8
|
+
import { adminClient } from "./mcp/client.js";
|
|
9
|
+
import { serveStdio } from "./mcp/server.js";
|
|
10
|
+
import { rescan } from "./api/rescan.js";
|
|
11
|
+
import { listRoots } from "./api/roots.js";
|
|
12
|
+
import { scanner } from "./api/scanner.js";
|
|
13
|
+
import { resolveCommand } from "./cli/args.js";
|
|
14
|
+
import { claim, logPath, logSize, release, running, start, stop } from "./cli/daemon.js";
|
|
15
|
+
import { entryPoint } from "./cli/entry.js";
|
|
16
|
+
import { addKey, addedKey, reportKeys, revokedKey, revokeKey } from "./cli/keys.js";
|
|
17
|
+
import { openDb } from "./db/index.js";
|
|
18
|
+
import { inventory } from "./inventory/inventory.js";
|
|
19
|
+
import { hidden, mark, resolvePath } from "./junk/marks.js";
|
|
20
|
+
import { runStages } from "./run.js";
|
|
21
|
+
const USAGE = `funoteka — smart music library
|
|
22
|
+
|
|
23
|
+
Usage:
|
|
24
|
+
funoteka scan <root...> [--full] [--db <path>]
|
|
25
|
+
funoteka inventory [--db <path>]
|
|
26
|
+
funoteka junk [list] [--db <path>]
|
|
27
|
+
funoteka junk block|allow <path> [--note <text>] [--db <path>]
|
|
28
|
+
funoteka keys [list]
|
|
29
|
+
funoteka keys add|revoke <label|#id> [--db <path>]
|
|
30
|
+
funoteka serve [--daemon] [--db <path>] [--host <address>] [--port <number>]
|
|
31
|
+
funoteka stop [--db <path>]
|
|
32
|
+
funoteka status [--db <path>]
|
|
33
|
+
funoteka mcp [--url <http://host:port>]
|
|
34
|
+
|
|
35
|
+
Commands:
|
|
36
|
+
scan Read the given roots into the meta layer
|
|
37
|
+
inventory Print the classified collection as a readable dump
|
|
38
|
+
junk What the junk filter is keeping out, and the edit that overrides it
|
|
39
|
+
keys The API keys this server accepts, and taking one back
|
|
40
|
+
serve Answer the Subsonic API from the meta layer
|
|
41
|
+
stop Stop the server running on that meta layer
|
|
42
|
+
status Say whether one is running, and where
|
|
43
|
+
mcp Speak MCP on stdin/stdout, driving this server's admin API
|
|
44
|
+
|
|
45
|
+
Options:
|
|
46
|
+
--db <path> SQLite meta layer (default: FUNOTEKA_DB, then the config file,
|
|
47
|
+
then funoteka.db beside the working directory)
|
|
48
|
+
--full Read every file again, whether or not it has changed. The
|
|
49
|
+
ordinary scan trusts size and mtime, which is what makes a
|
|
50
|
+
rescan of an unchanged library cost seconds; this is the answer
|
|
51
|
+
for when that trust is the wrong question.
|
|
52
|
+
--note <text> Why a folder was blocked or allowed. Kept with the mark, for the
|
|
53
|
+
person who finds it in a dump six months later. junk only.
|
|
54
|
+
--host <addr> Address to listen on (default: ${DEFAULT_HOST})
|
|
55
|
+
--port <n> Port to listen on (default: ${DEFAULT_PORT}, 0 to let the kernel choose)
|
|
56
|
+
--daemon Run in the background, outliving this shell. The server writes
|
|
57
|
+
\`<db>.pid\` beside the meta layer and logs to \`<db>.log\`;
|
|
58
|
+
stop and status find it through that file.
|
|
59
|
+
-h, --help Show this help
|
|
60
|
+
|
|
61
|
+
Environment:
|
|
62
|
+
FUNOTEKA_DB, FUNOTEKA_HOST, FUNOTEKA_PORT, FUNOTEKA_USER, FUNOTEKA_PASSWORD,
|
|
63
|
+
FUNOTEKA_APIKEY configure a deployed server; a flag on the command line wins.
|
|
64
|
+
Credentials are environment-only: an argument is readable from the process
|
|
65
|
+
list, which is not where a password belongs.
|
|
66
|
+
|
|
67
|
+
FUNOTEKA_FFMPEG names the binary that cuts a cue track out of an MP4
|
|
68
|
+
container. Only those tracks need it: without it the server still serves
|
|
69
|
+
whole files, FLAC and mp3 segments, browsing and search, and refuses the
|
|
70
|
+
MP4 ones with a reason.
|
|
71
|
+
|
|
72
|
+
FUNOTEKA_LOG_FILE appends this process's own output to a file, for the
|
|
73
|
+
deployments whose supervisor collects nothing. Unset means the log is
|
|
74
|
+
whatever started the server — Docker, systemd, a service wrapper, a shell.
|
|
75
|
+
|
|
76
|
+
FUNOTEKA_ADMIN_TOKEN turns on the admin surface: a second port
|
|
77
|
+
(FUNOTEKA_ADMIN_PORT, default 4534) that takes POST /restart with the token
|
|
78
|
+
as \`Authorization: Bearer …\`. With no token there is no admin listener at
|
|
79
|
+
all. FUNOTEKA_SUPERVISED=1 says something will start this process again,
|
|
80
|
+
which is what makes a restart a restart rather than a stop.
|
|
81
|
+
|
|
82
|
+
Config file:
|
|
83
|
+
FUNOTEKA_CONFIG names a JSON file of the same settings (default
|
|
84
|
+
${DEFAULT_CONFIG_FILE}, in the directory the server runs in). The four layers
|
|
85
|
+
are read in order — defaults, file, environment, flags — so the environment
|
|
86
|
+
still wins over the file. \`funoteka.json.example\` lists every key. A file
|
|
87
|
+
that cannot be read, or that names a key the server does not know, stops the
|
|
88
|
+
command with a sentence naming it: a server that came up on its defaults
|
|
89
|
+
beside a file it could not read looks exactly like one that was configured.
|
|
90
|
+
`;
|
|
91
|
+
/**
|
|
92
|
+
* What the junk filter is keeping out, one folder to a line.
|
|
93
|
+
*
|
|
94
|
+
* The reason and the source both, because a count cannot tell a rule that
|
|
95
|
+
* overreached from a person who marked something. An empty answer says so in
|
|
96
|
+
* words rather than printing nothing: "nothing is hidden" and "this is not the
|
|
97
|
+
* database you meant" look the same on a blank terminal.
|
|
98
|
+
*/
|
|
99
|
+
function reportJunk(rows) {
|
|
100
|
+
if (rows.length === 0)
|
|
101
|
+
return 'nothing is hidden.\n';
|
|
102
|
+
const lines = rows.map((row) => ` ${row.source === 'hand' ? '*' : ' '} ${row.rootPath}/${row.relPath === '' ? '' : row.relPath}` +
|
|
103
|
+
` — ${row.title ?? '(untitled)'} (${row.junkReason})`);
|
|
104
|
+
return [
|
|
105
|
+
`${rows.length} hidden (${rows.filter((row) => row.source === 'hand').length} by hand, marked *):`,
|
|
106
|
+
...lines,
|
|
107
|
+
'',
|
|
108
|
+
].join('\n');
|
|
109
|
+
}
|
|
110
|
+
/** Parenthesised detail, or nothing at all when there is no detail to give. */
|
|
111
|
+
function aside(parts) {
|
|
112
|
+
const kept = parts.filter((part) => part !== false && part !== '');
|
|
113
|
+
return kept.length === 0 ? '' : ` (${kept.join(', ')})`;
|
|
114
|
+
}
|
|
115
|
+
/** Human-readable summary of a scan. The machine-readable form is the database. */
|
|
116
|
+
function report(summary, dbPath) {
|
|
117
|
+
const { scan: counters, classify: classified, tags, cues, playlists, artists, search } = summary;
|
|
118
|
+
// The roots as recorded, not as typed: two spellings of one directory are one
|
|
119
|
+
// root, and a report that said otherwise would be the bug it is reporting on.
|
|
120
|
+
const roots = counters.rootPaths;
|
|
121
|
+
const breakdown = Object.entries(counters.byKind)
|
|
122
|
+
.filter(([, n]) => n > 0)
|
|
123
|
+
.map(([kind, n]) => `${kind} ${n}`)
|
|
124
|
+
.join(', ');
|
|
125
|
+
const shapes = Object.entries(cues.byShape)
|
|
126
|
+
.filter(([, n]) => n > 0)
|
|
127
|
+
.map(([shape, n]) => `${shape} ${n}`)
|
|
128
|
+
.join(', ');
|
|
129
|
+
process.stdout.write([
|
|
130
|
+
`Scanned ${roots.length} root${roots.length === 1 ? '' : 's'} into ${dbPath} (run ${counters.scanRunId})`,
|
|
131
|
+
// What each root gave, on the root's own line. A root that contributed
|
|
132
|
+
// nothing is the reason: an empty directory and a path misspelled print
|
|
133
|
+
// the same otherwise, and the run still exits 0 (task:2756).
|
|
134
|
+
...counters.perRoot.map((root) => ` root ${root.path}${aside([
|
|
135
|
+
`${root.folders} folders`,
|
|
136
|
+
`${root.files} files`,
|
|
137
|
+
root.files === 0 && 'nothing here — an empty directory and a mistyped path read alike',
|
|
138
|
+
])}`),
|
|
139
|
+
// The ignored directories are the folders that are deliberately *not*
|
|
140
|
+
// counted, which is why they belong beside the count they are missing
|
|
141
|
+
// from rather than in a line of their own.
|
|
142
|
+
` folders ${counters.folders}${aside([
|
|
143
|
+
counters.ignored > 0 &&
|
|
144
|
+
`ignored ${counters.ignored} dir(s), ${counters.ignoredAudio} audio file(s)`,
|
|
145
|
+
])}`,
|
|
146
|
+
` files ${counters.files}${aside([
|
|
147
|
+
breakdown,
|
|
148
|
+
counters.unchanged > 0 && `unchanged ${counters.unchanged}`,
|
|
149
|
+
])}`,
|
|
150
|
+
` albums ${classified.albums}${aside([classified.releases > 0 && `releases ${classified.releases}`])}`,
|
|
151
|
+
` tags ${tags.tags}${aside([
|
|
152
|
+
`${tags.files} files read`,
|
|
153
|
+
tags.sidecars > 0 && `sidecars ${tags.sidecars}`,
|
|
154
|
+
tags.durations > 0 && `durations ${tags.durations}`,
|
|
155
|
+
tags.encodings > 0 && `${tags.encodings} encodings guessed`,
|
|
156
|
+
])}`,
|
|
157
|
+
` tracks ${cues.tracks}${aside([
|
|
158
|
+
shapes,
|
|
159
|
+
cues.probesReused > 0 && `probes reused ${cues.probesReused}`,
|
|
160
|
+
])}`,
|
|
161
|
+
// The playlist files the collection carries, said in the two numbers that
|
|
162
|
+
// matter: how many were lists of their own and how many were the folder
|
|
163
|
+
// they sit in. A run that imports nothing is the ordinary run — this
|
|
164
|
+
// collection has 27 playlist files and every one of them is its album.
|
|
165
|
+
` lists ${playlists.imported} imported${aside([
|
|
166
|
+
`${playlists.files} file(s) taken up`,
|
|
167
|
+
playlists.filesRead > 0 && `${playlists.filesRead} read`,
|
|
168
|
+
playlists.redundant > 0 && `${playlists.redundant} redundant`,
|
|
169
|
+
playlists.entriesMissing > 0 && `${playlists.entriesMissing} entries unmatched`,
|
|
170
|
+
])}`,
|
|
171
|
+
// The ambiguity counts belong to the artists, not to the albums they are
|
|
172
|
+
// reported beside — and they are said apart, because a name merged from
|
|
173
|
+
// two spellings and a name split across two folders are different
|
|
174
|
+
// findings that want different answers from whoever reads the report.
|
|
175
|
+
` artists ${artists.artists}${aside([
|
|
176
|
+
artists.linked > 0 && `albums linked ${artists.linked}`,
|
|
177
|
+
artists.ambiguous > 0 && `${artists.ambiguous} ambiguous`,
|
|
178
|
+
artists.homonyms > 0 && `${artists.homonyms} split by folder`,
|
|
179
|
+
])}`,
|
|
180
|
+
// The index is a copy of what the stages above settled, so its size is the
|
|
181
|
+
// one number that says the search a client is about to make will answer
|
|
182
|
+
// about this collection and not an older one.
|
|
183
|
+
` search ${search.rows} row${search.rows === 1 ? '' : 's'}`,
|
|
184
|
+
` issues ${counters.issues + tags.issues + cues.issues + playlists.issues + artists.issues}`,
|
|
185
|
+
'',
|
|
186
|
+
].join('\n'));
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Open the meta layer, run one command against it, and close it whatever
|
|
190
|
+
* happens. Both commands want the same guarantee, and differ only in the verb
|
|
191
|
+
* they name when it goes wrong.
|
|
192
|
+
*/
|
|
193
|
+
function withDb(dbPath, command, work) {
|
|
194
|
+
const db = openDb(dbPath);
|
|
195
|
+
try {
|
|
196
|
+
work(db);
|
|
197
|
+
}
|
|
198
|
+
catch (err) {
|
|
199
|
+
process.stderr.write(`${command} failed: ${err.message}\n`);
|
|
200
|
+
process.exitCode = 1;
|
|
201
|
+
}
|
|
202
|
+
finally {
|
|
203
|
+
db.close();
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* What this process exits with when it has been asked to restart.
|
|
208
|
+
*
|
|
209
|
+
* `EX_TEMPFAIL` from BSD's `sysexits.h`: "a temporary failure… the user is
|
|
210
|
+
* invited to retry". It is the one code that means what a restart means, and it
|
|
211
|
+
* exists because a Windows service wrapper restarts on failure and would read a
|
|
212
|
+
* clean 0 as somebody having stopped the service on purpose.
|
|
213
|
+
*/
|
|
214
|
+
const RESTART_EXIT = 75;
|
|
215
|
+
/**
|
|
216
|
+
* The config file, read once and before anything is decided.
|
|
217
|
+
*
|
|
218
|
+
* Once, because every command below resolves its settings through it and two
|
|
219
|
+
* reads of one file are two chances to disagree about it. Before anything,
|
|
220
|
+
* because **a file that cannot be read stops the command**. A scan that ran on
|
|
221
|
+
* defaults beside a config file it could not parse would write a second database
|
|
222
|
+
* next to the one the file named, and nothing about that looks like a failure
|
|
223
|
+
* until somebody goes looking for their library.
|
|
224
|
+
*
|
|
225
|
+
* Read here rather than inside `loadConfig`, so that the parser stays a pure
|
|
226
|
+
* function of a path and a string — and so that this is the one place that has
|
|
227
|
+
* to decide what to do about a file.
|
|
228
|
+
*/
|
|
229
|
+
const settingsPath = configFilePath(process.env);
|
|
230
|
+
let settingsFile;
|
|
231
|
+
try {
|
|
232
|
+
settingsFile = readConfigFile(settingsPath);
|
|
233
|
+
}
|
|
234
|
+
catch (err) {
|
|
235
|
+
process.stderr.write(`${err.message}\n`);
|
|
236
|
+
process.exit(2);
|
|
237
|
+
}
|
|
238
|
+
const invocation = resolveCommand(process.argv.slice(2));
|
|
239
|
+
switch (invocation.kind) {
|
|
240
|
+
case 'help':
|
|
241
|
+
process.stdout.write(USAGE);
|
|
242
|
+
break;
|
|
243
|
+
case 'error':
|
|
244
|
+
process.stderr.write(`${invocation.message}\n\n${USAGE}`);
|
|
245
|
+
process.exitCode = invocation.code;
|
|
246
|
+
break;
|
|
247
|
+
case 'inventory':
|
|
248
|
+
withDb(dbPathOf(invocation.dbPath), 'inventory', (db) => {
|
|
249
|
+
// **The resolved path, not the flag.** `resolveCommand` stopped defaulting
|
|
250
|
+
// the database path (task:2934), so passing what the flag said prints a
|
|
251
|
+
// heading with no path on every ordinary run — the two other callers of
|
|
252
|
+
// `report` pass the resolved one.
|
|
253
|
+
process.stdout.write(inventory(db, { dbPath: dbPathOf(invocation.dbPath) }));
|
|
254
|
+
});
|
|
255
|
+
break;
|
|
256
|
+
case 'keys':
|
|
257
|
+
withDb(dbPathOf(invocation.dbPath), 'keys', (db) => {
|
|
258
|
+
// The environment's key is read from the environment and not from a
|
|
259
|
+
// loaded config: `keys list` is a question about the registry, and it
|
|
260
|
+
// should answer on a machine where the server's other settings are not
|
|
261
|
+
// set at all — the whole point of a listing is to be readable when
|
|
262
|
+
// something is wrong.
|
|
263
|
+
const environmentKey = process.env.FUNOTEKA_APIKEY ?? '';
|
|
264
|
+
if (invocation.action === 'list') {
|
|
265
|
+
process.stdout.write(reportKeys(db, environmentKey));
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (invocation.action === 'add') {
|
|
269
|
+
process.stdout.write(addedKey(addKey(db, invocation.what ?? '')));
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
process.stdout.write(revokedKey(revokeKey(db, invocation.what ?? '')));
|
|
273
|
+
});
|
|
274
|
+
break;
|
|
275
|
+
case 'junk':
|
|
276
|
+
withDb(dbPathOf(invocation.dbPath), 'junk', (db) => {
|
|
277
|
+
if (invocation.action === 'list') {
|
|
278
|
+
process.stdout.write(reportJunk(hidden(db)));
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const where = resolvePath(db, invocation.path ?? '');
|
|
282
|
+
if (where === undefined) {
|
|
283
|
+
throw new Error(`no configured root contains that path: ${invocation.path}`);
|
|
284
|
+
}
|
|
285
|
+
const verdict = invocation.action === 'block' ? 'junk' : 'trust';
|
|
286
|
+
const reason = mark(db, where.rootId, where.relPath, verdict, invocation.note ?? null);
|
|
287
|
+
const named = where.relPath === '' ? '(the root itself)' : where.relPath;
|
|
288
|
+
// Said as what the folder now *is*, not as what was written: the two come
|
|
289
|
+
// from the same rule, and the sentence a person reads back should be the
|
|
290
|
+
// one the listings are keeping it out by.
|
|
291
|
+
process.stdout.write(reason === null
|
|
292
|
+
? `${named}: a record — it will be listed.\n`
|
|
293
|
+
: `${named}: not a record — hidden (${reason}).\n`);
|
|
294
|
+
});
|
|
295
|
+
break;
|
|
296
|
+
case 'scan':
|
|
297
|
+
withDb(dbPathOf(invocation.dbPath), 'scan', (db) => {
|
|
298
|
+
// The scanner's connection is not the daemon's, and the two want opposite
|
|
299
|
+
// things from the same two settings. Both come down to the same fact:
|
|
300
|
+
// **this process answers nobody**. A wait here holds up no client, so the
|
|
301
|
+
// scan may take the time a batch job is allowed to take — and the daemon,
|
|
302
|
+
// which holds the other connection and serves every request on one thread,
|
|
303
|
+
// may not (`db/index.ts` explains its own two numbers).
|
|
304
|
+
//
|
|
305
|
+
// **The wait.** `busy_timeout` is 250 ms by default, chosen so a client
|
|
306
|
+
// waiting on a lock cannot freeze the server. Here it was survivable while
|
|
307
|
+
// a scan was one transaction; it is not survivable now that a scan yields
|
|
308
|
+
// the lock often enough for clients to actually write during one — and
|
|
309
|
+
// every stage's transaction became a chance to lose that race. Measured
|
|
310
|
+
// on the live collection with a client saving through the API every 40 ms:
|
|
311
|
+
// the run died in the **tags** stage with `database is locked`. Ten
|
|
312
|
+
// seconds rides out any burst of saves a person can make; a lock still
|
|
313
|
+
// held after that is not a race, and failing is the right answer to it.
|
|
314
|
+
//
|
|
315
|
+
// **The flush.** `synchronous = NORMAL` gives up a disk flush per commit.
|
|
316
|
+
// Almost everything the scanner writes is derived from the filesystem, so
|
|
317
|
+
// the worst a power cut costs there is the last few commits — which is
|
|
318
|
+
// what the next scan rebuilds. The exceptions are `scan_run` and `issue`,
|
|
319
|
+
// which are records of a run rather than readings of a disk: losing the
|
|
320
|
+
// commit that carries `finish('ok')` leaves the run looking `running`, and
|
|
321
|
+
// `getScanStatus` would report a scan that had already ended. The next run
|
|
322
|
+
// settles it, and that is the whole of what this setting can cost here.
|
|
323
|
+
// The API's rows are the other kind entirely — a playlist is nowhere on
|
|
324
|
+
// disk — which is why the daemon's connection keeps the default. In WAL
|
|
325
|
+
// mode `NORMAL` is safe from corruption; what it gives up is durability
|
|
326
|
+
// against a hard reboot.
|
|
327
|
+
//
|
|
328
|
+
// **Not a footnote to the batching — it is what pays for it.** Releasing
|
|
329
|
+
// the write lock often (`scan.ts`) turned one commit into thirty, and each
|
|
330
|
+
// commit is a disk flush. Measured on the live collection, same run, same
|
|
331
|
+
// content: **2263 ms with the default against 1486 ms with this** on a
|
|
332
|
+
// quiet machine, and **17.4 s against 5.2 s** on a busy one. A flush is
|
|
333
|
+
// cheap until the disk is shared, and then it is the whole cost of the run.
|
|
334
|
+
db.exec('PRAGMA busy_timeout = 10000');
|
|
335
|
+
db.exec('PRAGMA synchronous = NORMAL');
|
|
336
|
+
// The chain itself lives in `run.ts`, together with the rule that settles
|
|
337
|
+
// the run's status: it is the whole run's to settle, not a stage's.
|
|
338
|
+
report(runStages(db, invocation.roots, { full: invocation.full }), dbPathOf(invocation.dbPath));
|
|
339
|
+
});
|
|
340
|
+
break;
|
|
341
|
+
case 'serve': {
|
|
342
|
+
let config;
|
|
343
|
+
let admin;
|
|
344
|
+
let scan;
|
|
345
|
+
try {
|
|
346
|
+
config = loadConfig(process.env, {
|
|
347
|
+
dbPath: invocation.dbPath,
|
|
348
|
+
host: invocation.host,
|
|
349
|
+
port: invocation.port,
|
|
350
|
+
}, settingsFile);
|
|
351
|
+
admin = loadAdminConfig(process.env, settingsFile);
|
|
352
|
+
scan = loadScanConfig(process.env, settingsFile);
|
|
353
|
+
}
|
|
354
|
+
catch (err) {
|
|
355
|
+
// A flag that could not be read is the operator misusing the command, not
|
|
356
|
+
// the server failing, so it exits the way every other usage error does.
|
|
357
|
+
process.stderr.write(`${err.message}\n\n${USAGE}`);
|
|
358
|
+
process.exitCode = 2;
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
if (!invocation.daemon) {
|
|
362
|
+
serve(config, admin, scan);
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
365
|
+
// The child's arguments are built from the resolved config and never from
|
|
366
|
+
// this process's own — which contain `--daemon`, and a child that daemonized
|
|
367
|
+
// itself again would spawn a third, and so on.
|
|
368
|
+
void start(config.dbPath, [
|
|
369
|
+
'--db',
|
|
370
|
+
config.dbPath,
|
|
371
|
+
'--host',
|
|
372
|
+
config.host,
|
|
373
|
+
'--port',
|
|
374
|
+
String(config.port),
|
|
375
|
+
]).then((started) => {
|
|
376
|
+
if (started.ok) {
|
|
377
|
+
process.stdout.write(`funoteka started, pid ${started.record?.pid} on http://${config.host}:${started.record?.port}/rest\n` +
|
|
378
|
+
` log ${logPath(config.dbPath)}\n` +
|
|
379
|
+
` stop funoteka stop --db ${config.dbPath}\n`);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
process.stderr.write(`serve --daemon: ${started.message}\n`);
|
|
383
|
+
process.exitCode = 2;
|
|
384
|
+
});
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
case 'mcp': {
|
|
388
|
+
// The token comes from the same two places the server reads it from — the
|
|
389
|
+
// environment, then the config file — so an agent started on the machine
|
|
390
|
+
// that serves the music needs no arguments at all beyond the address.
|
|
391
|
+
const admin = loadAdminConfig(process.env, settingsFile);
|
|
392
|
+
if (admin.token === '') {
|
|
393
|
+
// **Two ways to have no token, and the second one is worth naming.** A
|
|
394
|
+
// `${...}` that nothing expanded is not an empty setting — it is a
|
|
395
|
+
// setting somebody made, and the difference matters to whoever is reading
|
|
396
|
+
// this: sending it would be a wrong-token attempt against a surface that
|
|
397
|
+
// locks the address out after ten of them.
|
|
398
|
+
const raw = String(process.env.FUNOTEKA_ADMIN_TOKEN ?? settingsFile.adminToken ?? '');
|
|
399
|
+
process.stderr.write(unexpanded(raw)
|
|
400
|
+
? 'mcp: FUNOTEKA_ADMIN_TOKEN is still a ${...} placeholder — nothing expanded it. ' +
|
|
401
|
+
'Set the variable, or send the token itself: a reference is not a token, and the ' +
|
|
402
|
+
'admin surface counts a wrong one toward a lockout.\n'
|
|
403
|
+
: 'mcp: no admin token — set FUNOTEKA_ADMIN_TOKEN, or put adminToken in the config file\n');
|
|
404
|
+
process.exitCode = 2;
|
|
405
|
+
break;
|
|
406
|
+
}
|
|
407
|
+
// `127.0.0.1` and not the admin host: the host is what the *server* bound
|
|
408
|
+
// (0.0.0.0 on a deployment), and that is not an address a client can call.
|
|
409
|
+
const url = invocation.url ?? process.env.FUNOTEKA_ADMIN_URL ?? `http://127.0.0.1:${admin.port}`;
|
|
410
|
+
// Never resolves: this process lives as long as the agent keeps its end of
|
|
411
|
+
// the pipe open, which is what the transport is.
|
|
412
|
+
void serveStdio(adminClient(url, admin.token));
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
case 'stop': {
|
|
416
|
+
void stop(dbPathOf(invocation.dbPath)).then((stopped) => {
|
|
417
|
+
process.stdout.write(`funoteka: ${stopped.message}\n`);
|
|
418
|
+
if (!stopped.ok)
|
|
419
|
+
process.exitCode = 1;
|
|
420
|
+
});
|
|
421
|
+
break;
|
|
422
|
+
}
|
|
423
|
+
case 'status': {
|
|
424
|
+
const dbPath = dbPathOf(invocation.dbPath);
|
|
425
|
+
const record = running(dbPath);
|
|
426
|
+
if (record === null) {
|
|
427
|
+
process.stdout.write(`funoteka: not running (${dbPath})\n`);
|
|
428
|
+
process.exitCode = 1;
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
431
|
+
process.stdout.write(`funoteka: pid ${record.pid} on http://${record.host}:${record.port}/rest\n` +
|
|
432
|
+
// Only when there is one: a line saying "admin —" would read as a
|
|
433
|
+
// surface that is broken rather than one that was never turned on.
|
|
434
|
+
(record.adminPort === undefined
|
|
435
|
+
? ''
|
|
436
|
+
: ` admin http://${record.host}:${record.adminPort}\n`) +
|
|
437
|
+
` database ${record.dbPath}\n` +
|
|
438
|
+
` since ${record.startedAt}\n` +
|
|
439
|
+
` log ${logPath(dbPath)} (${logSize(dbPath)} bytes)\n`);
|
|
440
|
+
break;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Which meta layer this invocation is about.
|
|
445
|
+
*
|
|
446
|
+
* Resolved through the same config a server is — all four layers of it — so that
|
|
447
|
+
* a daemon started with `FUNOTEKA_DB` set, or with a database named in its
|
|
448
|
+
* config file, is the one `stop` finds. The alternative is a stop command that
|
|
449
|
+
* only works when the operator remembers to repeat what the file already says.
|
|
450
|
+
*/
|
|
451
|
+
function dbPathOf(fromFlag) {
|
|
452
|
+
return loadConfig(process.env, { dbPath: fromFlag }, settingsFile).dbPath;
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Open the meta layer and answer on it until the process is stopped.
|
|
456
|
+
*
|
|
457
|
+
* The database stays open for the life of the server rather than being opened
|
|
458
|
+
* per request: the same connection is what the WAL setting on it is for, and it
|
|
459
|
+
* is what lets a scan write while a client is being answered.
|
|
460
|
+
*
|
|
461
|
+
* The address reported is the one the socket took, not the one that was asked
|
|
462
|
+
* for — they differ when the port was left to the kernel, and a report that
|
|
463
|
+
* echoed the request would name a port nothing is listening on.
|
|
464
|
+
*/
|
|
465
|
+
function serve(config, admin, scan) {
|
|
466
|
+
// Before the meta layer is opened, so that a database that cannot be opened is
|
|
467
|
+
// a line in the file rather than a complaint on a terminal that is gone.
|
|
468
|
+
const stopLog = config.logFile === '' ? () => { } : logToFile(config.logFile);
|
|
469
|
+
const db = openDb(config.dbPath);
|
|
470
|
+
try {
|
|
471
|
+
listen(db, config, admin, scan, stopLog);
|
|
472
|
+
}
|
|
473
|
+
catch (err) {
|
|
474
|
+
// A server that could not be built must not leave its database open behind
|
|
475
|
+
// it, and the reason has to reach the operator as a sentence rather than as
|
|
476
|
+
// a stack trace from somewhere inside the constructor.
|
|
477
|
+
db.close();
|
|
478
|
+
stopLog();
|
|
479
|
+
process.stderr.write(`serve failed: ${err.message}\n`);
|
|
480
|
+
process.exitCode = 1;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
function listen(db, config, admin, scan, stopLog) {
|
|
484
|
+
const api = createServer(db, config);
|
|
485
|
+
// One scanner, two callers: the admin API's `POST /scan` and the timer below.
|
|
486
|
+
// The same object, so `GET /scan` reports the scan the engine started and the
|
|
487
|
+
// engine sees the one an operator started — two scanners would be two answers
|
|
488
|
+
// to "is a scan running", and `start` would refuse across them for ever.
|
|
489
|
+
const scans = scanner({
|
|
490
|
+
db,
|
|
491
|
+
roots: () => listRoots(db).map((one) => one.path),
|
|
492
|
+
command: (mode) => ({
|
|
493
|
+
file: process.execPath,
|
|
494
|
+
args: [
|
|
495
|
+
// The entry point as *this* build spells it — `.ts` beside the sources,
|
|
496
|
+
// `.js` in the compiled build the npm package ships (see `cli/entry.ts`).
|
|
497
|
+
entryPoint('./cli'),
|
|
498
|
+
'scan',
|
|
499
|
+
...listRoots(db).map((one) => one.path),
|
|
500
|
+
'--db',
|
|
501
|
+
config.dbPath,
|
|
502
|
+
...(mode === 'full' ? ['--full'] : []),
|
|
503
|
+
],
|
|
504
|
+
}),
|
|
505
|
+
});
|
|
506
|
+
const control = createAdminServer({
|
|
507
|
+
db,
|
|
508
|
+
config: admin,
|
|
509
|
+
dbPath: config.dbPath,
|
|
510
|
+
configFile: settingsPath,
|
|
511
|
+
env: process.env,
|
|
512
|
+
audit: auditLog(auditPath(config.dbPath)),
|
|
513
|
+
logFile: config.logFile,
|
|
514
|
+
scanner: scans,
|
|
515
|
+
onRestart: () => shutdown('restart'),
|
|
516
|
+
});
|
|
517
|
+
if (control === null) {
|
|
518
|
+
// Said plainly, because the port being closed is the whole of what keeps the
|
|
519
|
+
// admin surface off — and an operator who set no token should not have to
|
|
520
|
+
// discover that by having a connection refused.
|
|
521
|
+
process.stderr.write('funoteka: admin surface off — no FUNOTEKA_ADMIN_TOKEN\n');
|
|
522
|
+
}
|
|
523
|
+
// The server reading the disk by itself. Started after the listeners are
|
|
524
|
+
// built and stopped with them: a scan it starts outlives this process (it is a
|
|
525
|
+
// process of its own), and what stopping means here is that no *new* one is
|
|
526
|
+
// asked for.
|
|
527
|
+
const engine = rescan({
|
|
528
|
+
db,
|
|
529
|
+
scanner: scans,
|
|
530
|
+
roots: () => listRoots(db).map((one) => one.path),
|
|
531
|
+
schedule: scan,
|
|
532
|
+
watch: scan.watch,
|
|
533
|
+
log: (line) => process.stdout.write(`${line}\n`),
|
|
534
|
+
});
|
|
535
|
+
const listeners = control === null ? [api] : [api, control];
|
|
536
|
+
// Two listeners, one shutdown between them, because a process left
|
|
537
|
+
// half-listening is neither stopped nor serving. `POST /restart` calls this
|
|
538
|
+
// same function — having first answered the caller, which is the whole reason
|
|
539
|
+
// it is reachable from here at all.
|
|
540
|
+
let stopping = false;
|
|
541
|
+
const shutdown = (reason) => {
|
|
542
|
+
if (stopping) {
|
|
543
|
+
process.exit(0);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
stopping = true;
|
|
547
|
+
process.stdout.write(`funoteka stopping (${reason})\n`);
|
|
548
|
+
for (const listener of listeners) {
|
|
549
|
+
// A connection still open would hold the process here for as long as the
|
|
550
|
+
// client keeps it, and a server that will not stop is worse than one that
|
|
551
|
+
// stops mid-answer.
|
|
552
|
+
listener.close();
|
|
553
|
+
listener.closeAllConnections?.();
|
|
554
|
+
}
|
|
555
|
+
engine.stop();
|
|
556
|
+
release(config.dbPath);
|
|
557
|
+
stopLog();
|
|
558
|
+
db.close();
|
|
559
|
+
// **The exit code is how a restart reaches a supervisor.** Docker's
|
|
560
|
+
// `restart: unless-stopped` and systemd's `Restart=always` bring the process
|
|
561
|
+
// back whatever it exited with, but a Windows service wrapper restarts on
|
|
562
|
+
// *failure* — so a restart that exited 0 would be read as a deliberate stop
|
|
563
|
+
// and the service would simply be gone. `EX_TEMPFAIL` is the conventional
|
|
564
|
+
// code for "this ended and wants to be run again", and a supervisor that
|
|
565
|
+
// only restarts on failure now has the same behaviour as the other two.
|
|
566
|
+
//
|
|
567
|
+
// Set as `exitCode` rather than exited with, because the ordinary path here
|
|
568
|
+
// is that the process ends by itself the moment the loop drains — an
|
|
569
|
+
// explicit `process.exit` would be a race against that.
|
|
570
|
+
process.exitCode = reason === 'restart' ? RESTART_EXIT : reason === 'failed' ? 1 : 0;
|
|
571
|
+
// The backstop for the path that is not ordinary: a socket in a state that
|
|
572
|
+
// will not let go, where a supervisor waiting for this process to exit would
|
|
573
|
+
// otherwise wait for ever.
|
|
574
|
+
setTimeout(() => process.exit(process.exitCode ?? 0), 3_000).unref();
|
|
575
|
+
};
|
|
576
|
+
api.on('error', (err) => {
|
|
577
|
+
process.stderr.write(`serve failed: ${err.message}\n`);
|
|
578
|
+
shutdown('failed');
|
|
579
|
+
});
|
|
580
|
+
if (control !== null) {
|
|
581
|
+
// The admin port is refused rather than worked around: it is how this server
|
|
582
|
+
// is operated, and one that came up without it would be a server whose
|
|
583
|
+
// controls silently did not exist. It is one setting to change, and the
|
|
584
|
+
// message names it.
|
|
585
|
+
control.on('error', (err) => {
|
|
586
|
+
process.stderr.write(`funoteka: the admin port ${admin.port} could not be opened (${err.message}) — ` +
|
|
587
|
+
'FUNOTEKA_ADMIN_PORT names it, and FUNOTEKA_ADMIN_TOKEN being empty turns it off\n');
|
|
588
|
+
shutdown('failed');
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
// Both, before the claim: the pid file is a promise that this server is
|
|
592
|
+
// reachable, and half a server is not what `stop` and `status` are told about.
|
|
593
|
+
// **The same callback goes to both listeners**, and that is the whole of the
|
|
594
|
+
// mechanism — a counter, because the report below is about a server rather
|
|
595
|
+
// than about a socket. A listener handed no callback is a listener whose
|
|
596
|
+
// arrival nobody counts, and the failure that produces is silent: both ports
|
|
597
|
+
// answer, and the claim the two commands find the server by is never written.
|
|
598
|
+
let waiting = listeners.length;
|
|
599
|
+
const bothUp = () => {
|
|
600
|
+
waiting -= 1;
|
|
601
|
+
if (waiting > 0)
|
|
602
|
+
return;
|
|
603
|
+
{
|
|
604
|
+
const address = api.address();
|
|
605
|
+
const where = typeof address === 'object' && address !== null ? address.port : config.port;
|
|
606
|
+
const controlAddress = control?.address();
|
|
607
|
+
const controlPort = typeof controlAddress === 'object' && controlAddress !== null ? controlAddress.port : undefined;
|
|
608
|
+
// The claim is written here and not before, because this is the first
|
|
609
|
+
// moment at which it is true: a pid file written by a process that failed
|
|
610
|
+
// to bind names a server nobody can reach, and would refuse the next start.
|
|
611
|
+
claim(config.dbPath, {
|
|
612
|
+
pid: process.pid,
|
|
613
|
+
port: where,
|
|
614
|
+
host: config.host,
|
|
615
|
+
dbPath: config.dbPath,
|
|
616
|
+
startedAt: new Date().toISOString(),
|
|
617
|
+
...(controlPort === undefined ? {} : { adminPort: controlPort }),
|
|
618
|
+
});
|
|
619
|
+
process.stdout.write(`funoteka serving ${config.dbPath} on http://${config.host}:${where}/rest\n` +
|
|
620
|
+
(controlPort === undefined ? '' : ` admin http://${admin.host}:${controlPort}\n`) +
|
|
621
|
+
(config.logFile === '' ? '' : ` log ${config.logFile}\n`));
|
|
622
|
+
}
|
|
623
|
+
};
|
|
624
|
+
api.listen(config.port, config.host, bothUp);
|
|
625
|
+
control?.listen(admin.port, admin.host, bothUp);
|
|
626
|
+
// A signal stops the server rather than killing it: the sockets are closed, the
|
|
627
|
+
// meta layer closed with it, and the claim removed, so a `stop` on a machine
|
|
628
|
+
// whose signals are delivered is a clean shutdown rather than a process that
|
|
629
|
+
// disappeared. A second signal does not wait — whoever sent it twice means it.
|
|
630
|
+
//
|
|
631
|
+
// Said plainly because it is not universal: on Windows a signal has no handler
|
|
632
|
+
// to reach, so `stop` there ends the process outright. That is safe rather
|
|
633
|
+
// than lucky — the meta layer is SQLite in WAL mode, which is built to survive
|
|
634
|
+
// a writer that stops mid-write — and the claim is checked rather than
|
|
635
|
+
// believed, so a stopped server is never mistaken for a running one.
|
|
636
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
637
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
638
|
+
}
|