funoteka 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/.env.example +90 -0
  2. package/CHANGELOG.md +104 -0
  3. package/DEPLOY.md +375 -0
  4. package/LICENSE +21 -0
  5. package/README.md +138 -0
  6. package/deploy/answers.schema.json +151 -0
  7. package/deploy/build.mjs +88 -0
  8. package/deploy/check-publishable.mjs +246 -0
  9. package/deploy/smoke.mjs +681 -0
  10. package/deploy/systemd/funoteka.env.example +41 -0
  11. package/deploy/systemd/funoteka.service +77 -0
  12. package/deploy/windows/funoteka-service.xml +58 -0
  13. package/deploy/windows/install-service.ps1 +158 -0
  14. package/dist/annotation/store.js +67 -0
  15. package/dist/api/admin-export.js +227 -0
  16. package/dist/api/admin-guard.js +181 -0
  17. package/dist/api/admin-library.js +113 -0
  18. package/dist/api/admin.js +1162 -0
  19. package/dist/api/annotation.js +193 -0
  20. package/dist/api/artistinfo.js +140 -0
  21. package/dist/api/audit.js +32 -0
  22. package/dist/api/auth.js +145 -0
  23. package/dist/api/bookmark.js +108 -0
  24. package/dist/api/browse.js +1241 -0
  25. package/dist/api/config-file.js +202 -0
  26. package/dist/api/config.js +285 -0
  27. package/dist/api/cover.js +304 -0
  28. package/dist/api/download.js +112 -0
  29. package/dist/api/envelope.js +162 -0
  30. package/dist/api/extensions.js +43 -0
  31. package/dist/api/genre.js +81 -0
  32. package/dist/api/health.js +35 -0
  33. package/dist/api/history.js +374 -0
  34. package/dist/api/idempotency.js +66 -0
  35. package/dist/api/keys.js +90 -0
  36. package/dist/api/log-file.js +65 -0
  37. package/dist/api/meta.js +1410 -0
  38. package/dist/api/playlist.js +311 -0
  39. package/dist/api/rescan.js +196 -0
  40. package/dist/api/roots.js +161 -0
  41. package/dist/api/router.js +133 -0
  42. package/dist/api/scanner.js +163 -0
  43. package/dist/api/search.js +82 -0
  44. package/dist/api/server.js +643 -0
  45. package/dist/api/settings.js +67 -0
  46. package/dist/api/stream.js +442 -0
  47. package/dist/api/stubs.js +183 -0
  48. package/dist/api/transcode.js +678 -0
  49. package/dist/api/user.js +73 -0
  50. package/dist/api/virtual.js +362 -0
  51. package/dist/api/visibility.js +21 -0
  52. package/dist/artist/apply.js +752 -0
  53. package/dist/artist/credit.js +95 -0
  54. package/dist/artist/folder.js +65 -0
  55. package/dist/artist/name.js +205 -0
  56. package/dist/artist/nfo.js +101 -0
  57. package/dist/artist/translit.js +79 -0
  58. package/dist/bookmark/store.js +54 -0
  59. package/dist/classify/classify.js +405 -0
  60. package/dist/classify/collision-name.js +109 -0
  61. package/dist/classify/folder-name.js +466 -0
  62. package/dist/classify/roles.js +277 -0
  63. package/dist/classify/shelf-name.js +178 -0
  64. package/dist/classify/tree.js +43 -0
  65. package/dist/cli/args.js +169 -0
  66. package/dist/cli/daemon.js +192 -0
  67. package/dist/cli/entry.js +25 -0
  68. package/dist/cli/keys.js +52 -0
  69. package/dist/cli.js +638 -0
  70. package/dist/cover/pick.js +58 -0
  71. package/dist/cover/picture.js +111 -0
  72. package/dist/cue/engine.js +759 -0
  73. package/dist/cue/match.js +142 -0
  74. package/dist/cue/parse.js +182 -0
  75. package/dist/cue/plan.js +286 -0
  76. package/dist/cue/track-name.js +120 -0
  77. package/dist/db/index.js +163 -0
  78. package/dist/db/issue.js +73 -0
  79. package/dist/db/ledger.js +84 -0
  80. package/dist/db/migrations/001_init.sql +196 -0
  81. package/dist/db/migrations/002_cue_track_flexible.sql +35 -0
  82. package/dist/db/migrations/003_cue_metadata.sql +14 -0
  83. package/dist/db/migrations/004_cue_performer.sql +12 -0
  84. package/dist/db/migrations/005_incremental.sql +23 -0
  85. package/dist/db/migrations/006_tags.sql +57 -0
  86. package/dist/db/migrations/007_artist_credit.sql +35 -0
  87. package/dist/db/migrations/008_issue_stage.sql +39 -0
  88. package/dist/db/migrations/009_cue_title.sql +20 -0
  89. package/dist/db/migrations/010_duration_source.sql +31 -0
  90. package/dist/db/migrations/011_track_title_source.sql +28 -0
  91. package/dist/db/migrations/012_file_container.sql +25 -0
  92. package/dist/db/migrations/013_cover_art.sql +28 -0
  93. package/dist/db/migrations/014_album_year.sql +17 -0
  94. package/dist/db/migrations/015_probe_method.sql +14 -0
  95. package/dist/db/migrations/016_tags_method.sql +20 -0
  96. package/dist/db/migrations/017_cover_indirect.sql +23 -0
  97. package/dist/db/migrations/018_release_year.sql +14 -0
  98. package/dist/db/migrations/019_file_tag_name_file_value.sql +20 -0
  99. package/dist/db/migrations/020_analyze_after_index.sql +16 -0
  100. package/dist/db/migrations/021_cue_index_files.sql +15 -0
  101. package/dist/db/migrations/022_sidecar_text.sql +19 -0
  102. package/dist/db/migrations/023_track_credit.sql +23 -0
  103. package/dist/db/migrations/024_playlists.sql +63 -0
  104. package/dist/db/migrations/025_playlist_sequence.sql +27 -0
  105. package/dist/db/migrations/026_playlist_source_file.sql +19 -0
  106. package/dist/db/migrations/027_lookup_indexes.sql +30 -0
  107. package/dist/db/migrations/028_annotations.sql +47 -0
  108. package/dist/db/migrations/029_scan_state_root_run.sql +28 -0
  109. package/dist/db/migrations/030_history.sql +108 -0
  110. package/dist/db/migrations/031_queue_position_and_plays.sql +46 -0
  111. package/dist/db/migrations/032_bookmarks.sql +27 -0
  112. package/dist/db/migrations/033_bookmark_order.sql +14 -0
  113. package/dist/db/migrations/034_junk.sql +33 -0
  114. package/dist/db/migrations/035_api_key.sql +38 -0
  115. package/dist/db/migrations/036_file_tag_first.sql +88 -0
  116. package/dist/db/migrations/037_admin_idempotency.sql +31 -0
  117. package/dist/db/migrations/038_cue_audio_index.sql +24 -0
  118. package/dist/db/sweep.js +109 -0
  119. package/dist/history/store.js +151 -0
  120. package/dist/inventory/inventory.js +294 -0
  121. package/dist/junk/marks.js +143 -0
  122. package/dist/junk/rule.js +93 -0
  123. package/dist/mcp/client.js +48 -0
  124. package/dist/mcp/server.js +175 -0
  125. package/dist/mcp/tools.js +348 -0
  126. package/dist/playlist/files.js +116 -0
  127. package/dist/playlist/import.js +260 -0
  128. package/dist/playlist/store.js +240 -0
  129. package/dist/probe/ffprobe.js +179 -0
  130. package/dist/run.js +120 -0
  131. package/dist/scan/kinds.js +49 -0
  132. package/dist/scan/scan.js +503 -0
  133. package/dist/scan/schedule.js +132 -0
  134. package/dist/scan/settle.js +172 -0
  135. package/dist/scan/walk.js +132 -0
  136. package/dist/search/index.js +41 -0
  137. package/dist/search/query.js +28 -0
  138. package/dist/stream/flac.js +541 -0
  139. package/dist/stream/mpeg.js +179 -0
  140. package/dist/stream/recode.js +393 -0
  141. package/dist/stream/rewrite.js +163 -0
  142. package/dist/stream/segment.js +624 -0
  143. package/dist/tags/apply.js +703 -0
  144. package/dist/tags/encode.js +164 -0
  145. package/dist/tags/first.js +130 -0
  146. package/dist/tags/flac.js +196 -0
  147. package/dist/tags/genres.js +123 -0
  148. package/dist/tags/id3v1.js +209 -0
  149. package/dist/tags/id3v2.js +754 -0
  150. package/dist/tags/mp4.js +534 -0
  151. package/dist/tags/mpeg.js +355 -0
  152. package/dist/tags/ogg.js +463 -0
  153. package/dist/tags/picture.js +71 -0
  154. package/dist/tags/read.js +269 -0
  155. package/dist/tags/types.js +51 -0
  156. package/dist/tags/vorbis-comment.js +155 -0
  157. package/dist/text/encoding.js +384 -0
  158. package/dist/text/site-name.js +37 -0
  159. package/dist/util/child.js +21 -0
  160. package/dist/util/names.js +97 -0
  161. package/funoteka.json.example +27 -0
  162. package/package.json +55 -0
@@ -0,0 +1,202 @@
1
+ import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { PORT_SETTINGS, SETTINGS, isPort } from "./settings.js";
3
+ /**
4
+ * The config file: a deployment's settings, written down where a person can
5
+ * read them.
6
+ *
7
+ * The environment is how a service manager hands a server its settings, and it
8
+ * stays the interface a deployed server is configured through. A file is for the
9
+ * two things the environment is bad at: settings a person has to *edit* (a
10
+ * twenty-variable `EnvironmentFile` is a file with extra steps), and settings
11
+ * that outlive one shell — a NAS where nobody remembers which session exported
12
+ * what. So the file is a layer between the defaults and the environment, and the
13
+ * environment keeps the last word. The order is stated in `config.ts`; this
14
+ * module is only the reading and the writing of it.
15
+ *
16
+ * A JSON object and not YAML, because this project has no dependencies and Node
17
+ * parses JSON natively. The vocabulary is the config's own field names
18
+ * (`settings.ts`), so the file and the admin API's `config` route speak one
19
+ * language.
20
+ *
21
+ * **Nothing here is silently forgiven.** A file that cannot be parsed, a key
22
+ * that does not exist, or a value of the wrong shape stops the command with a
23
+ * sentence naming the file and the key. The alternative is the failure this
24
+ * exists to prevent: a server that came up on its defaults beside a file it
25
+ * could not read, looking exactly like a server that was configured.
26
+ */
27
+ /** Where the file is when nobody says otherwise: the directory the server runs in. */
28
+ export const DEFAULT_CONFIG_FILE = 'funoteka.json';
29
+ /**
30
+ * The two names a JSON object can carry that are *known* to mean nothing.
31
+ *
32
+ * JSON has no comments, and this is a file a person edits — `funoteka.json.example`
33
+ * is copy-safe because of these two lines, and an example that explained itself
34
+ * with a key the reader refused would fail on the first run of whoever copied
35
+ * it. They are not the unknown-key rule relaxed: an unknown key is still a
36
+ * refusal, and these are not unknown.
37
+ */
38
+ const COMMENTS = new Set(['//', '#']);
39
+ /** Which file this process reads, which is the one thing env alone decides. */
40
+ export function configFilePath(env) {
41
+ return env.FUNOTEKA_CONFIG ?? DEFAULT_CONFIG_FILE;
42
+ }
43
+ /**
44
+ * Read a config file, or answer with nothing if there is no file there.
45
+ *
46
+ * Absent is not an error: most deployments name their settings in the
47
+ * environment and have no file at all, and refusing to start over a file nobody
48
+ * wrote would make the file mandatory by accident.
49
+ */
50
+ export function readConfigFile(path) {
51
+ const parsed = parse(readOf(path), path);
52
+ const values = {};
53
+ for (const [key, value] of Object.entries(parsed)) {
54
+ if (COMMENTS.has(key))
55
+ continue;
56
+ values[key] = validated(key, value, path);
57
+ }
58
+ return values;
59
+ }
60
+ /**
61
+ * Write settings into the file, and answer with what it now says.
62
+ *
63
+ * **The file is read as it is and written back with only these keys changed**,
64
+ * rather than being rebuilt from the config. That is what keeps a `//` comment
65
+ * where somebody put it, keeps the keys in the order they were written, and
66
+ * leaves alone any key this server does not know about — a route that rewrote
67
+ * the whole file from its own idea of the config would silently drop the note
68
+ * that explains the deployment to the next person.
69
+ *
70
+ * A value of `null` removes the key, which is how a setting written here is
71
+ * handed back to the environment and the defaults.
72
+ *
73
+ * Written through a temporary file and a rename: a process that dies mid-write
74
+ * leaves the old config or the new one, and never half of either — the file that
75
+ * a restart reads is the file that governs whether there *is* a restart.
76
+ */
77
+ export function writeConfigFile(path, changes) {
78
+ const current = parse(readOf(path), path);
79
+ for (const [key, value] of Object.entries(changes)) {
80
+ if (value === null) {
81
+ delete current[key];
82
+ continue;
83
+ }
84
+ current[key] = validated(key, value, path);
85
+ }
86
+ const temp = `${path}.tmp`;
87
+ try {
88
+ writeFileSync(temp, `${JSON.stringify(current, null, 2)}\n`, existsSync(path) ? {} : { mode: 0o600 });
89
+ renameSync(temp, path);
90
+ }
91
+ catch (err) {
92
+ throw new Error(`${path} could not be written: ${err.message}`);
93
+ }
94
+ return readConfigFile(path);
95
+ }
96
+ /** The file's text, or an empty object's worth of nothing when there is no file. */
97
+ function readOf(path) {
98
+ try {
99
+ return readFileSync(path, 'utf8');
100
+ }
101
+ catch (err) {
102
+ if (err.code === 'ENOENT')
103
+ return '{}';
104
+ throw new Error(`${path} could not be read: ${err.message}`);
105
+ }
106
+ }
107
+ function parse(text, path) {
108
+ let parsed;
109
+ try {
110
+ parsed = JSON.parse(text);
111
+ }
112
+ catch (err) {
113
+ // The parser's own sentence, quoted. It names the line and the character,
114
+ // which is the whole of what the person editing the file needs.
115
+ throw new Error(`${path} is not JSON: ${err.message}`);
116
+ }
117
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
118
+ throw new Error(`${path} must be an object of settings — this is ${described(parsed)}`);
119
+ }
120
+ return parsed;
121
+ }
122
+ /** One key's value, or a refusal naming the file, the key and what it should be. */
123
+ function validated(key, value, path) {
124
+ const kind = SETTINGS[key];
125
+ if (kind === undefined) {
126
+ throw new Error(`${path}: unknown key "${key}"${didYouMean(key)}`);
127
+ }
128
+ if (kind === 'number') {
129
+ if (typeof value !== 'number') {
130
+ throw new Error(`${path}: "${key}" must be a number, not ${described(value)}`);
131
+ }
132
+ // Ports are held to the range a socket accepts, and named as sockets in the
133
+ // refusal. Every other number here — an interval in minutes, an hour of the
134
+ // day — is the config's own to bound, and is checked where it is read.
135
+ if (PORT_SETTINGS.has(key) && !isPort(value)) {
136
+ throw new Error(`${path}: "${key}" is not a port number: ${value}`);
137
+ }
138
+ return value;
139
+ }
140
+ if (kind === 'string' && typeof value === 'string')
141
+ return value;
142
+ if (kind === 'boolean' && typeof value === 'boolean')
143
+ return value;
144
+ const wanted = kind === 'boolean' ? 'true or false' : 'a string';
145
+ throw new Error(`${path}: "${key}" must be ${wanted}, not ${described(value)}`);
146
+ }
147
+ /** What a JSON value is, in words, for a refusal that has to name it. */
148
+ function described(value) {
149
+ if (value === null)
150
+ return 'null';
151
+ if (Array.isArray(value))
152
+ return 'a list';
153
+ const kind = typeof value;
154
+ if (kind === 'string')
155
+ return `the string ${JSON.stringify(value)}`;
156
+ if (kind === 'number' || kind === 'boolean')
157
+ return `the ${kind} ${String(value)}`;
158
+ if (kind === 'object')
159
+ return 'an object';
160
+ return kind;
161
+ }
162
+ /**
163
+ * The key they probably meant, when they meant one at all.
164
+ *
165
+ * A refusal that names the key is the whole point (see the top of this file),
166
+ * and the person reading it has usually made a typo rather than invented a
167
+ * setting: `prot` for `port`, `showjunk` for `showJunk`. One guessed neighbour
168
+ * turns "unknown key" into "unknown key, did you mean port" — and it is a guess,
169
+ * so it is offered as a question and never acted on.
170
+ */
171
+ function didYouMean(key) {
172
+ let nearest;
173
+ let best = 3;
174
+ for (const known of Object.keys(SETTINGS)) {
175
+ const distance = editDistance(key.toLowerCase(), known.toLowerCase());
176
+ if (distance < best) {
177
+ best = distance;
178
+ nearest = known;
179
+ }
180
+ }
181
+ return nearest === undefined ? '' : ` — did you mean "${nearest}"?`;
182
+ }
183
+ /**
184
+ * How many single-character edits apart two words are — the Wagner–Fischer
185
+ * table, one row at a time.
186
+ *
187
+ * The `?? 0` on every read is for the compiler's `noUncheckedIndexedAccess` and
188
+ * never for the arithmetic: each index is inside its own row by construction,
189
+ * and a neighbour that were somehow absent could only make the distance
190
+ * larger — a worse guess at the key, never a wrong refusal.
191
+ */
192
+ function editDistance(a, b) {
193
+ let previous = Array.from({ length: b.length + 1 }, (_, at) => at);
194
+ for (let i = 1; i <= a.length; i += 1) {
195
+ const row = [i];
196
+ for (let j = 1; j <= b.length; j += 1) {
197
+ row[j] = Math.min((previous[j] ?? 0) + 1, (row[j - 1] ?? 0) + 1, (previous[j - 1] ?? 0) + (a[i - 1] === b[j - 1] ? 0 : 1));
198
+ }
199
+ previous = row;
200
+ }
201
+ return previous[b.length] ?? 0;
202
+ }
@@ -0,0 +1,285 @@
1
+ import { dirname, join } from 'node:path';
2
+ import { DEFAULT_DB } from "../db/index.js";
3
+ import { SECRET_SETTINGS, SETTINGS, isPort } from "./settings.js";
4
+ /**
5
+ * The scan schedule, layered exactly as everything else is.
6
+ *
7
+ * `scanInterval` is in **minutes** and defaults to six hours. Zero — or anything
8
+ * below it — turns the timer off: a deployment that would rather scan from a
9
+ * cron job of its own says so with a number, and needs no second setting to say
10
+ * it with.
11
+ */
12
+ export function loadScanConfig(env, file = {}) {
13
+ return scanConfig(reader(file, env));
14
+ }
15
+ function scanConfig(r) {
16
+ const interval = r.pick('scanInterval', 'FUNOTEKA_SCAN_INTERVAL');
17
+ const from = r.pick('scanQuietFrom', 'FUNOTEKA_SCAN_QUIET_FROM');
18
+ const to = r.pick('scanQuietTo', 'FUNOTEKA_SCAN_QUIET_TO');
19
+ return {
20
+ intervalMinutes: interval === undefined ? DEFAULT_SCAN_INTERVAL_MINUTES : wholeNumber(interval),
21
+ quietFrom: from === undefined ? DEFAULT_QUIET_FROM : hour(from),
22
+ quietTo: to === undefined ? DEFAULT_QUIET_TO : hour(to),
23
+ watch: offUnlessOn(r.pick('scanWatch', 'FUNOTEKA_SCAN_WATCH')?.value, false),
24
+ };
25
+ }
26
+ /** A number from whichever layer named it, or a refusal naming that layer. */
27
+ function wholeNumber(picked) {
28
+ const value = Number(picked.value);
29
+ if (!Number.isInteger(value)) {
30
+ throw new Error(`${picked.source} is not a whole number: ${String(picked.value)}`);
31
+ }
32
+ return value;
33
+ }
34
+ /** An hour of the day, 0–23, or a refusal saying which setting was wrong. */
35
+ function hour(picked) {
36
+ const value = wholeNumber(picked);
37
+ if (value < 0 || value > 23) {
38
+ throw new Error(`${picked.source} is not an hour of the day (0-23): ${value}`);
39
+ }
40
+ return value;
41
+ }
42
+ /**
43
+ * Subsonic's own port, so a client that guesses one guesses right, and above
44
+ * 1024 so the server does not need privileges it has no other use for.
45
+ */
46
+ export const DEFAULT_PORT = 4533;
47
+ /**
48
+ * The admin port: the Subsonic port plus one, because two ports that differ by
49
+ * one are one number to remember instead of two, and 4534 is not a port anything
50
+ * else claims.
51
+ */
52
+ export const DEFAULT_ADMIN_PORT = 4534;
53
+ /** Reachable from the phone on the sofa, which is the whole point of the server. */
54
+ export const DEFAULT_HOST = '0.0.0.0';
55
+ /** Six hours: often enough that a change is noticed the same day, rarely enough to be quiet. */
56
+ export const DEFAULT_SCAN_INTERVAL_MINUTES = 360;
57
+ /**
58
+ * The quiet hours: three in the morning to six.
59
+ *
60
+ * The hours a house is asleep in, and the reason they exist is that a scan is
61
+ * audible — it reads every file, and on a machine that is also somebody's
62
+ * desktop it is the difference between a library that keeps up and a library
63
+ * that stutters while they are working. `from === to` is no quiet hours at all.
64
+ */
65
+ export const DEFAULT_QUIET_FROM = 3;
66
+ export const DEFAULT_QUIET_TO = 6;
67
+ /** Where to look for ffmpeg, which only the m4a and MP4 segments ever need. */
68
+ export const DEFAULT_FFMPEG = 'ffmpeg';
69
+ function reader(file, env) {
70
+ const sources = {};
71
+ return {
72
+ sources,
73
+ pick(key, envName, flag, flagName) {
74
+ if (flag !== undefined) {
75
+ sources[key] = 'flag';
76
+ return { value: flag, source: flagName ?? key };
77
+ }
78
+ const fromEnv = env[envName];
79
+ if (fromEnv !== undefined) {
80
+ sources[key] = 'environment';
81
+ return { value: fromEnv, source: envName };
82
+ }
83
+ const fromFile = file[key];
84
+ if (fromFile !== undefined) {
85
+ sources[key] = 'file';
86
+ return { value: fromFile, source: key };
87
+ }
88
+ return undefined;
89
+ },
90
+ };
91
+ }
92
+ function serverConfig(r, overrides) {
93
+ const dbPath = String(r.pick('dbPath', 'FUNOTEKA_DB', overrides.dbPath, '--db')?.value ?? DEFAULT_DB);
94
+ // Named together so the refusal below can say which of the three was wrong:
95
+ // the operator who mistyped a flag, the one who mistyped a unit file, and the
96
+ // one who mistyped the config file are looking in different places.
97
+ const port = r.pick('port', 'FUNOTEKA_PORT', overrides.port, '--port');
98
+ return {
99
+ dbPath,
100
+ host: String(r.pick('host', 'FUNOTEKA_HOST', overrides.host, '--host')?.value ?? DEFAULT_HOST),
101
+ port: port === undefined || port.value === ''
102
+ ? DEFAULT_PORT
103
+ : parsePort(String(port.value), port.source),
104
+ user: String(r.pick('user', 'FUNOTEKA_USER', overrides.user, '--user')?.value ?? ''),
105
+ password: String(r.pick('password', 'FUNOTEKA_PASSWORD', overrides.password, '--password')?.value ?? ''),
106
+ apiKey: String(r.pick('apiKey', 'FUNOTEKA_APIKEY', overrides.apiKey, '--apikey')?.value ?? ''),
107
+ ffmpeg: String(r.pick('ffmpeg', 'FUNOTEKA_FFMPEG', overrides.ffmpeg, '--ffmpeg')?.value ?? DEFAULT_FFMPEG),
108
+ cacheDir: String(r.pick('cacheDir', 'FUNOTEKA_CACHE', overrides.cacheDir, '--cache')?.value ??
109
+ join(dirname(dbPath), 'cache')),
110
+ logFile: String(r.pick('logFile', 'FUNOTEKA_LOG_FILE')?.value ?? ''),
111
+ logRequests: offUnlessOn(r.pick('logRequests', 'FUNOTEKA_LOG_REQUESTS')?.value, false),
112
+ cors: onUnlessOff(r.pick('cors', 'FUNOTEKA_CORS')?.value, true),
113
+ showJunk: offUnlessOn(r.pick('showJunk', 'FUNOTEKA_SHOW_JUNK')?.value, false),
114
+ };
115
+ }
116
+ function adminConfig(r) {
117
+ const port = r.pick('adminPort', 'FUNOTEKA_ADMIN_PORT');
118
+ const cert = String(r.pick('adminTlsCert', 'FUNOTEKA_ADMIN_TLS_CERT')?.value ?? '');
119
+ const key = String(r.pick('adminTlsKey', 'FUNOTEKA_ADMIN_TLS_KEY')?.value ?? '');
120
+ const token = String(r.pick('adminToken', 'FUNOTEKA_ADMIN_TOKEN')?.value ?? '');
121
+ // Both or neither. Half a TLS is not a weaker TLS — it is a server that would
122
+ // have to decide whether to answer in plaintext somebody who asked for a
123
+ // certificate, and there is no answer to that which is not a surprise.
124
+ if ((cert === '') !== (key === '')) {
125
+ const missing = cert === '' ? 'FUNOTEKA_ADMIN_TLS_CERT' : 'FUNOTEKA_ADMIN_TLS_KEY';
126
+ throw new Error(`${missing} is not set, and TLS needs both the certificate and its key`);
127
+ }
128
+ return {
129
+ port: port === undefined || port.value === ''
130
+ ? DEFAULT_ADMIN_PORT
131
+ : parsePort(String(port.value), port.source),
132
+ host: String(r.pick('adminHost', 'FUNOTEKA_ADMIN_HOST')?.value ?? DEFAULT_HOST),
133
+ // A template is not a secret — see `unexpanded`, and read the empty one the
134
+ // same way, because the contract's answer to "no token" is a surface that is
135
+ // off rather than a surface that is open.
136
+ token: unexpanded(token) ? '' : token,
137
+ allow: String(r.pick('adminAllow', 'FUNOTEKA_ADMIN_ALLOW')?.value ?? ''),
138
+ trustProxy: offUnlessOn(r.pick('adminTrustProxy', 'FUNOTEKA_ADMIN_TRUST_PROXY')?.value, false),
139
+ tls: cert === '' ? null : { cert, key },
140
+ supervised: onUnlessOff(r.pick('supervised', 'FUNOTEKA_SUPERVISED')?.value, false),
141
+ };
142
+ }
143
+ /**
144
+ * Every setting, in force and where from.
145
+ *
146
+ * This is what makes `config set` honest. The route reads the file, writes it,
147
+ * and then answers with this: what the file now says, what is actually in force,
148
+ * and which of the two the operator is looking at. A value written to the file
149
+ * that an environment variable overrides is a change that will not survive a
150
+ * restart either, and saying so is the whole difference between a control
151
+ * surface that works and one that only appears to.
152
+ */
153
+ export function configReport(env, overrides = {}, file = {}) {
154
+ const r = reader(file, env);
155
+ const server = serverConfig(r, overrides);
156
+ const admin = adminConfig(r);
157
+ const scan = scanConfig(r);
158
+ // Spelled out rather than spread from the two objects: the field names of a
159
+ // config are not the names of its settings (`admin.token` is `adminToken`),
160
+ // and a list a reader can check against `settings.ts` at a glance is worth
161
+ // more here than the lines it saves. A setting missing from it is caught by
162
+ // the test that walks the vocabulary.
163
+ const values = {
164
+ dbPath: server.dbPath,
165
+ host: server.host,
166
+ port: server.port,
167
+ user: server.user,
168
+ password: server.password,
169
+ apiKey: server.apiKey,
170
+ ffmpeg: server.ffmpeg,
171
+ cacheDir: server.cacheDir,
172
+ logFile: server.logFile,
173
+ logRequests: server.logRequests,
174
+ cors: server.cors,
175
+ showJunk: server.showJunk,
176
+ adminPort: admin.port,
177
+ adminHost: admin.host,
178
+ adminToken: admin.token,
179
+ adminAllow: admin.allow,
180
+ adminTrustProxy: admin.trustProxy,
181
+ adminTlsCert: admin.tls?.cert ?? '',
182
+ adminTlsKey: admin.tls?.key ?? '',
183
+ supervised: admin.supervised,
184
+ scanInterval: scan.intervalMinutes,
185
+ scanQuietFrom: scan.quietFrom,
186
+ scanQuietTo: scan.quietTo,
187
+ scanWatch: scan.watch,
188
+ };
189
+ return Object.keys(SETTINGS).map((key) => ({
190
+ key,
191
+ value: SECRET_SETTINGS.has(key) ? null : (values[key] ?? null),
192
+ source: r.sources[key] ?? 'default',
193
+ secret: SECRET_SETTINGS.has(key),
194
+ }));
195
+ }
196
+ /** Whether a setting that is on unless it was turned off was turned off. */
197
+ function isOff(value) {
198
+ return value !== undefined && ['0', 'false', 'no', 'off'].includes(value.toLowerCase());
199
+ }
200
+ /**
201
+ * Whether a setting was turned on.
202
+ *
203
+ * Absent is off, and so is anything that reads as a no — an operator who writes
204
+ * `FUNOTEKA_LOG_REQUESTS=0` means off, and one who writes `=1` means on. Any
205
+ * other value is taken as on, because a variable that was set at all was set on
206
+ * purpose and refusing to understand it would turn a working server into a
207
+ * silent one.
208
+ */
209
+ function isOn(value) {
210
+ if (value === undefined || value === '')
211
+ return false;
212
+ return !['0', 'false', 'no', 'off'].includes(value.toLowerCase());
213
+ }
214
+ /**
215
+ * A setting that is off unless it was turned on, from whichever layer set it.
216
+ *
217
+ * The file gives a real boolean and the environment gives a word, and both are
218
+ * read here — a file is JSON, so `"logRequests": true` is a boolean and there is
219
+ * no reason to make its author write `"1"`.
220
+ */
221
+ function offUnlessOn(value, fallback) {
222
+ if (value === undefined)
223
+ return fallback;
224
+ if (typeof value === 'boolean')
225
+ return value;
226
+ return isOn(String(value));
227
+ }
228
+ /** The same, for a setting that is on unless it was turned off. */
229
+ function onUnlessOff(value, fallback) {
230
+ if (value === undefined)
231
+ return fallback;
232
+ if (typeof value === 'boolean')
233
+ return value;
234
+ return !isOff(String(value));
235
+ }
236
+ /**
237
+ * A value that is still a `${...}` placeholder was never expanded.
238
+ *
239
+ * **Nothing in this file's layering expands anything**, which is what makes the
240
+ * question worth asking: expansion belongs to whatever wraps the process, and
241
+ * the wrappers differ. Claude Code expands `${VAR}` in an `.mcp.json` — and,
242
+ * when the variable is *not* set, passes the text through untouched, so the
243
+ * literal `${FUNOTEKA_ADMIN_TOKEN}` arrives as the token. A template is not a
244
+ * value somebody chose; it is a value somebody meant to substitute, and both
245
+ * readings of it as a secret are bad. As the server's token it is a *published*
246
+ * string that anyone could present; as a client's it is a wrong-token attempt,
247
+ * and ten of those lock the address out for fifteen minutes. Reading it as no
248
+ * token at all is the only safe one, and it is the reading the contract already
249
+ * has for a token nobody set.
250
+ *
251
+ * A `$` and a `{` that are not adjacent are ordinary characters in a secret, so
252
+ * only `$` immediately followed by `{` counts.
253
+ */
254
+ export function unexpanded(value) {
255
+ return value.includes('${');
256
+ }
257
+ /**
258
+ * The whole config, for a server about to listen.
259
+ *
260
+ * The two functions below are the same reading with different questions asked of
261
+ * it — `configReport` wants to know where each value came from as well as what
262
+ * it is — so the layering itself lives once, in `reader`, and each caller is a
263
+ * line.
264
+ */
265
+ export function loadConfig(env, overrides = {}, file = {}) {
266
+ return serverConfig(reader(file, env), overrides);
267
+ }
268
+ /** What the admin listener is configured by, layered exactly as above. */
269
+ export function loadAdminConfig(env, file = {}) {
270
+ return adminConfig(reader(file, env));
271
+ }
272
+ /**
273
+ * A port number, or a throw naming what could not be read as one.
274
+ *
275
+ * The range is the one a socket accepts, and 0 is inside it: it asks the kernel
276
+ * to pick a free port, which is what a test wants and what `serve` reports back
277
+ * as the port it actually bound.
278
+ */
279
+ export function parsePort(value, source) {
280
+ const port = Number(value);
281
+ if (!isPort(port)) {
282
+ throw new Error(`${source} is not a port number: ${value}`);
283
+ }
284
+ return port;
285
+ }