funoteka 0.1.2 → 0.1.4

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/CHANGELOG.md CHANGED
@@ -9,6 +9,47 @@ The version lives in exactly one place — `package.json`.
9
9
 
10
10
  _(nothing yet)_
11
11
 
12
+ ## [0.1.4] — 2026-09-17
13
+
14
+ ### Fixed
15
+
16
+ - **The scan started from the server starts again.** `src/cli.ts` called
17
+ `entryPoint('./cli')` and the helper's default base was `import.meta.url`
18
+ evaluated *inside the helper* — so `./cli` resolved against `src/cli/`, not the
19
+ caller, and the scan child died with `Cannot find module '.../src/cli/cli.ts'`.
20
+ That killed every server-side entry to a scan: `POST /scan`, the MCP
21
+ `funoteka_scan_start` tool, the interval timer and the reader-change trigger.
22
+ The CLI scan kept working, which is why a broken published build read as a
23
+ deployment problem. The base is now a **required** argument to `entryPoint`
24
+ (forgetting it is a `TS2554`, and a path that does not exist throws), both call
25
+ sites name it, and the seam has tests over the exact pair the call sites use.
26
+
27
+ ## [0.1.3] — 2026-09-17
28
+
29
+ ### Changed
30
+
31
+ - **The public README, which is a release artefact and ships in this package.**
32
+ Three things in it are new to a reader: **what it costs**, measured rather than
33
+ asserted (about 42 MB of RAM idle, 30 MB of SQLite for 3 432 songs, a 421 MB
34
+ image, and a collection that is only ever read); the **first scan is a step in
35
+ the quick start** (it was missing, so the documented install produced a server
36
+ with an empty library); and the **agent pitch opens the file**. The clients named
37
+ are the ones that have actually connected (`player`: Feishin, Symfonium,
38
+ Substreamer, Amcfy Music, Castafiore), not a compatibility wish-list.
39
+ - **A checkout now has a working `funoteka`.** `bin` names `dist/cli.js` and a
40
+ clone had no `dist/` — so `npm install && npm link` produced a binary that
41
+ failed — until `prepare` was added to build it on install.
42
+ - `DEPLOY.md` gained the two journeys that were guesses: the Windows service
43
+ wrapper versus the daemon that is in daily use, and the two things a NAS shows
44
+ that the command does not (the container's uid, and the arm64 half being built
45
+ under emulation).
46
+
47
+ ### Fixed
48
+
49
+ - The engine of this patch is unchanged: no server code, no schema. What changed
50
+ is what a reader is told, and one line of `package.json` that makes the clone
51
+ behave.
52
+
12
53
  ## [0.1.2] — 2026-09-17
13
54
 
14
55
  ### Fixed
package/DEPLOY.md CHANGED
@@ -61,7 +61,7 @@ node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
61
61
  ## 3. Path A — Docker (main path)
62
62
 
63
63
  Works the same on a laptop, a NAS and a VDS. There is a **published image** —
64
- `ghcr.io/kzntsv-dev/funoteka:0.1.2`, `linux/amd64` and `linux/arm64` in one
64
+ `ghcr.io/kzntsv-dev/funoteka:0.1.4`, `linux/amd64` and `linux/arm64` in one
65
65
  manifest — and `build: .` still builds on the machine that runs it (that is the
66
66
  path where a published image is not wanted or not reachable).
67
67
 
@@ -72,16 +72,32 @@ installation (verified by running exactly this against the published image):
72
72
  MUSIC=/absolute/path/to/your/music
73
73
  docker run -d --name funoteka -p 4533:4533 -p 4534:4534 \
74
74
  -e FUNOTEKA_USER=you -e FUNOTEKA_PASSWORD=change-me \
75
- -e FUNOTEKA_ADMIN_TOKEN=<the token you generated> \
75
+ -e FUNOTEKA_ADMIN_TOKEN=PASTE_THE_TOKEN_YOU_GENERATED \
76
76
  -e FUNOTEKA_SUPERVISED=1 -e FUNOTEKA_LOG_FILE=/data/funoteka.log \
77
77
  -v "$MUSIC":/music:ro -v funoteka-data:/data \
78
- --restart unless-stopped ghcr.io/kzntsv-dev/funoteka:0.1.2
78
+ --restart unless-stopped ghcr.io/kzntsv-dev/funoteka:0.1.4
79
79
  ```
80
80
 
81
+ (The token above is a placeholder with no angle brackets in it, on purpose: a
82
+ copy-paste that misses the substitution should fail to authenticate loudly rather
83
+ than carry a string that looks like a token.)
84
+
81
85
  `FUNOTEKA_SUPERVISED=1` is what makes `POST /restart` a restart rather than a
82
86
  stop, and the restart policy beside it is what supervises the process. The first
83
87
  scan is still a command, not a startup side effect — see the end of this section.
84
88
 
89
+ Two things that bite on a NAS, and neither is visible from the command above:
90
+
91
+ - **The container runs as `node`, uid 1000.** A collection on a share that this
92
+ uid cannot read scans as an empty root — the server says so rather than failing
93
+ (`nothing here — an empty directory and a mistyped path read alike`), and the
94
+ fix is on the host: `chown` it, grant the group, or run the container with
95
+ `--user` set to the owner.
96
+ - **The `arm64` half of the published image is built under emulation** on an
97
+ `amd64` machine. It runs correctly and it is not as fast as a build made on
98
+ that box would be — for a small board this is the difference worth measuring,
99
+ and `build: .` in a checkout on that box is the other path.
100
+
85
101
  From a checkout, the same installation is the compose file:
86
102
 
87
103
  ```sh
@@ -97,7 +113,7 @@ MUSIC=/absolute/path/to/your/music # read-only into the container
97
113
  DATA=./data # everything the server owns
98
114
  FUNOTEKA_USER=you
99
115
  FUNOTEKA_PASSWORD=change-me
100
- FUNOTEKA_ADMIN_TOKEN=<the token you generated>
116
+ FUNOTEKA_ADMIN_TOKEN=PASTE_THE_TOKEN_YOU_GENERATED
101
117
  ```
102
118
 
103
119
  Start it:
@@ -214,16 +230,21 @@ admin calls as §3 — add the root with the path **as this machine sees it**
214
230
 
215
231
  ## 5. Path C — native Windows (service)
216
232
 
233
+ **The daemon works on Windows and is in daily use** — it is how the author's own
234
+ server runs (started by a `start.cmd` that carries the `FUNOTEKA_*` variables
235
+ from §3). What has not been run end to end is the *service wrapper* below, and
236
+ its own help says so.
237
+
217
238
  ```powershell
218
239
  # elevated PowerShell, from the checkout
219
240
  .\deploy\windows\install-service.ps1 -Data 'C:\ProgramData\funoteka'
220
241
  ```
221
242
 
222
243
  The script downloads WinSW (pinned, hash-checked) to wrap `node src/cli.ts
223
- serve` as a service, writes a config file from the example, and restricts its
224
- ACL. **Edit that config file before starting the service** it needs a password
225
- and an admin token. The script's own help says it has not been run end to end by
226
- whoever wrote it, and that is worth heeding: the first run is the verification.
244
+ serve` as a service, writes the config file to `<Data>\funoteka.json` and the log
245
+ to `<Data>\funoteka.log`, and restricts the directory's ACL. **Edit that config
246
+ file before starting the service** it needs a password and an admin token, and
247
+ the service reads them from there rather than from a shell.
227
248
 
228
249
  ## 6. Verify, then keep it running
229
250
 
@@ -350,6 +371,9 @@ so inside a container the script is named instead.
350
371
  # a native install, with the bin on PATH (`npm link`, or `npm install -g .`)
351
372
  FUNOTEKA_ADMIN_TOKEN=$TOKEN funoteka mcp
352
373
 
374
+ # or without installing anything: the published package, the same command
375
+ FUNOTEKA_ADMIN_TOKEN=$TOKEN npx -y funoteka mcp
376
+
353
377
  # the container — the token arrives through .env, so it need not be repeated
354
378
  docker compose exec -T funoteka node src/cli.ts mcp
355
379
  ```
@@ -393,3 +417,26 @@ own status is in `GET /scan/history`.
393
417
  with them), then whether the song exists (`/rest/search3`), then the log with
394
418
  `FUNOTEKA_LOG_REQUESTS=1`, which prints one line per request with the method and
395
419
  the query — masked, because a client spells its password there.
420
+
421
+ ## 13. Why the published package is built, and the repository is not
422
+
423
+ The repository runs its own TypeScript: `node src/cli.ts` is the program, `npm
424
+ test` is the suite, and there is no build step to run before either of them.
425
+
426
+ **The npm package cannot do that**, and the reason is a rule rather than a
427
+ preference: Node refuses to strip types from any file under `node_modules`
428
+ (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`), and no flag lifts it — checked on
429
+ v24.19.0 with the default, both `--experimental-strip-types` and
430
+ `--experimental-transform-types`, and the two together. A `bin` that named a `.ts`
431
+ file would install a package that cannot start.
432
+
433
+ So `npm run build` compiles `src` into `dist` for the package only
434
+ (`tsconfig.build.json`, `deploy/build.mjs`), copying the schema migrations
435
+ beside the code that reads them, and `bin` names `dist/cli.js`. Two consequences
436
+ worth knowing:
437
+
438
+ - **A fresh checkout has no `dist/`** and therefore no `funoteka` bin until the
439
+ build runs. `npm install` in a checkout now runs it (`prepare`), so
440
+ `npm link` from a clone works; `git clone` without an install does not.
441
+ - **The image still copies `src/`** and runs `node src/cli.ts serve` — the
442
+ container is a checkout, and it never needs the build.
package/README.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  **The Subsonic server a real collection deserves.**
4
4
 
5
+ > ## Give `DEPLOY.md` to your agent.
6
+ > Installing a music server means answering fiddly questions — where the music lives, which ports,
7
+ > TLS or a reverse proxy, where the database goes, who may log in. Hand them to an agent: it reads
8
+ > `DEPLOY.md`, asks you only what matters, installs funoteka (**Docker or native** — your machine,
9
+ > your NAS, or a VDS), configures it, and then **keeps running it** — rescanning the library,
10
+ > filtering junk, managing roots — over the **admin API and MCP**.
11
+
5
12
  **A folder is an album** — not a guess, not a heuristic: the rule. funoteka keys every record on
6
13
  its folder path, so three pressings of the same album stay three albums, a box stays a box and its
7
14
  discs, and an image file with a cue sheet becomes real, playable tracks. Nothing merged, nothing lost.
@@ -15,8 +22,12 @@ discs, and an image file with a cue sheet becomes real, playable tracks. Nothing
15
22
  - **Your files are never modified.** Classification, cue boundaries, encodings, dedup — all of it
16
23
  lives in a meta layer beside the server. Point it at your disk; your disk stays as it is.
17
24
  - **No LLM, no Discogs, no Last.fm, no external service.** The core reads what is in your files and
18
- folders. Enrichment is possible later, and it is optional.
19
- - **An agent installs, configures and runs it.** Hand your agent `DEPLOY.md`.
25
+ folders, and what it writes stays on your disk: the meta layer, the log, and an audit file beside
26
+ the database that records what was changed through the admin surface. Enrichment is possible
27
+ later, and it is optional.
28
+ - **Any client you already like just works.** Symfonium, Feishin, Substreamer, Amcfy Music and
29
+ Castafiore have all reported themselves to this server, and none of them needed anything special;
30
+ the API is the point, the client is yours.
20
31
 
21
32
  A complete **Subsonic / OpenSubsonic** surface, filled from your own files wherever it can be and
22
33
  answered honestly and empty wherever it cannot — so the client you already like just works.
@@ -50,6 +61,18 @@ Three albums, a box set of twelve discs. No merging, no guessing, nothing lost.
50
61
 
51
62
  ## Install
52
63
 
64
+ ### What it costs
65
+
66
+ Measured on the machine this was built on, and worth knowing before you point it at 200 GB of FLAC:
67
+
68
+ | | |
69
+ |---|---|
70
+ | **Idle** | about **42 MB** of RAM, no measurable CPU — the container, with an empty library |
71
+ | **The meta layer** | **30 MB** of SQLite for 3 432 songs and 472 albums (the author's own library); it grows with the catalogue, not with the audio |
72
+ | **The re-encode cache** | grows with what you ask for: 300 MB after a year of cue segments from m4a/MP4 images, and it is inside the one directory you mount |
73
+ | **The collection** | read-only, never written to, never copied anywhere. The server reads the bytes it serves and does not keep the library in memory |
74
+ | **The image** | 421 MB, ffmpeg included |
75
+
53
76
  ### Docker — the main path
54
77
 
55
78
  The image carries everything, **ffmpeg included** — the only external binary the server ever wants,
@@ -76,11 +99,10 @@ docker compose exec funoteka node src/cli.ts scan /music # fill the library, o
76
99
  ### Native, without Docker
77
100
 
78
101
  Needs **Node 24+**. There are **no runtime dependencies** — Node carries SQLite and FTS5, and the
79
- repository runs its own TypeScript, with no build step at all. The published *package* is the one
80
- place that cannot hold: Node refuses to strip types from anything under `node_modules`
81
- (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`, and no flag lifts it), so what npm gets is the same
82
- sources compiled once at release (`npm run build`) a build that exists for the package and not for
83
- the repository. `ffmpeg` on the PATH is needed only for cue tracks inside an m4a/MP4 container.
102
+ repository runs its own TypeScript with no build step at all. (The published *package* is the one
103
+ place that cannot hold, because Node refuses to strip types from anything under `node_modules`; what
104
+ npm gets is the same sources compiled once at release. Why, and what that changes `DEPLOY.md` §13.)
105
+ `ffmpeg` on the PATH is needed only for cue tracks inside an m4a/MP4 container.
84
106
 
85
107
  ```sh
86
108
  npm install -g funoteka
@@ -132,6 +154,9 @@ classification and you edit that layer, not your music.
132
154
 
133
155
  - `DEPLOY.md` — install, configure and manage, written to be read by an agent
134
156
  - The admin API and the MCP server — `DEPLOY.md` §11
157
+ - Something broken, or missing: [an issue](https://github.com/kzntsv-dev/funoteka/issues) — say
158
+ what you did, what you expected and what the server said; the log and `GET /issues` usually
159
+ already contain the answer, and the troubleshooting table in `DEPLOY.md` has the rest
135
160
 
136
161
  ## License
137
162
 
@@ -30,7 +30,7 @@ export const API_VERSION = '1.16.1';
30
30
  * module every route depends on.
31
31
  */
32
32
  export const SERVER_TYPE = 'funoteka';
33
- export const SERVER_VERSION = '0.1.2';
33
+ export const SERVER_VERSION = '0.1.4';
34
34
  /**
35
35
  * The fields every answer carries, whatever the answer is.
36
36
  *
@@ -101,8 +101,10 @@ export async function start(dbPath, argv, timeoutMs = 15_000) {
101
101
  const log = openSync(logPath(dbPath), 'a');
102
102
  const child = spawn(process.execPath,
103
103
  // The entry point as *this* build spells it — `.ts` beside the sources, `.js`
104
- // in the compiled build the npm package ships (see `cli/entry.ts`).
105
- [entryPoint('../cli'), 'serve', ...argv], {
104
+ // in the compiled build the npm package ships (see `cli/entry.ts`). The base
105
+ // is this module's own URL, because the relative name is counted from the
106
+ // file that spawns (`issue:91`).
107
+ [entryPoint('../cli', import.meta.url), 'serve', ...argv], {
106
108
  detached: true,
107
109
  // Nothing on stdin: a daemon that could read a terminal would be waiting
108
110
  // on a prompt nobody is there to answer.
package/dist/cli/entry.js CHANGED
@@ -13,13 +13,34 @@ import { fileURLToPath } from 'node:url';
13
13
  *
14
14
  * Asking the filesystem instead of inferring the answer from the caller's own
15
15
  * extension makes it checkable rather than assumed: the emitted file is preferred
16
- * because a tree that has one is a built tree, the source is the fallback, and
17
- * neither existing names the source — so the failure reads as "the build did not
18
- * run" rather than as a `.js` that was never going to be there.
16
+ * because a tree that has one is a built tree, the source is the fallback.
17
+ *
18
+ * ## The base is the caller's, and it is a parameter rather than a default
19
+ *
20
+ * `from` has **no default on purpose.** It had one — `import.meta.url`, which
21
+ * reads as "here" and is evaluated *in this file*: so a caller writing
22
+ * `entryPoint('./cli')` from `src/cli.ts` was asking for a sibling of
23
+ * `src/cli/entry.ts`, and got `src/cli/cli.ts`, which does not exist. The server
24
+ * answered `202` to `POST /scan`, the child died on `MODULE_NOT_FOUND`, and the
25
+ * only trace was in the child's stderr — `issue:91`, and it reached a released
26
+ * image because nothing in the suite ran the command the scanner builds. A
27
+ * required parameter turns that from a silent wrong path into a compile error at
28
+ * the next call site, which is the version of this that cannot happen again.
29
+ *
30
+ * ## And a path that is not there is a throw, not a guess
31
+ *
32
+ * The first version returned the `.ts` spelling whether or not it existed, so a
33
+ * wrong base travelled out of here as a plausible-looking string and failed
34
+ * somewhere else entirely. Now the search either finds a file or says so.
19
35
  */
20
- export function entryPoint(relative, from = import.meta.url) {
36
+ export function entryPoint(relative, from) {
21
37
  const emitted = fileURLToPath(new URL(`${relative}.js`, from));
22
38
  if (existsSync(emitted))
23
39
  return emitted;
24
- return fileURLToPath(new URL(`${relative}.ts`, from));
40
+ const source = fileURLToPath(new URL(`${relative}.ts`, from));
41
+ if (existsSync(source))
42
+ return source;
43
+ throw new Error(`no entry point beside ${fileURLToPath(from)}: neither ${emitted} nor ${source} exists. ` +
44
+ `A relative name is resolved against the base the caller passes, so a base that is not ` +
45
+ `the calling module's own URL looks in the wrong directory.`);
25
46
  }
package/dist/cli.js CHANGED
@@ -494,7 +494,10 @@ function listen(db, config, admin, scan, stopLog) {
494
494
  args: [
495
495
  // The entry point as *this* build spells it — `.ts` beside the sources,
496
496
  // `.js` in the compiled build the npm package ships (see `cli/entry.ts`).
497
- entryPoint('./cli'),
497
+ // `import.meta.url` is this module's, and it has to be passed: the name is
498
+ // relative to the file that *spawns*, not to the helper that resolves it
499
+ // (`issue:91`).
500
+ entryPoint('./cli', import.meta.url),
498
501
  'scan',
499
502
  ...listRoots(db).map((one) => one.path),
500
503
  '--db',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "funoteka",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Subsonic-compatible server: smart music library — scanner-classifier, cue splitting, meta layer, virtual tree",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -41,6 +41,7 @@
41
41
  "scripts": {
42
42
  "build": "node deploy/build.mjs",
43
43
  "prepublishOnly": "npm run build",
44
+ "prepare": "node deploy/build.mjs",
44
45
  "scan": "node src/cli.ts scan",
45
46
  "serve": "node src/cli.ts serve",
46
47
  "smoke": "node deploy/smoke.mjs",