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