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/.env.example ADDED
@@ -0,0 +1,90 @@
1
+ # What a deployment has to say for itself.
2
+ #
3
+ # cp .env.example .env
4
+ #
5
+ # Every value below is optional except the two credentials and the token; the
6
+ # defaults are the ones in the Dockerfile and `src/api/config.ts`. This file is
7
+ # read by `docker compose`, and the same names are what a systemd unit's
8
+ # `EnvironmentFile` and the Windows service wrapper's config carry — one
9
+ # vocabulary for all three deployments.
10
+
11
+ # ---- where the collection is -------------------------------------------------
12
+ # Read by the compose file, not by the server: it is the host path that gets
13
+ # mounted at /music. Set it to the directory that holds the music.
14
+ MUSIC=/srv/music
15
+
16
+ # Where the meta layer, the re-encode cache, the log and the config live. Also a
17
+ # compose variable — the host side of the volume mounted at /data.
18
+ DATA=./data
19
+
20
+ # ---- who may listen ----------------------------------------------------------
21
+ # The Subsonic credentials. Without any, the server refuses to start: a music
22
+ # library on the open network is not "it came up", it is a misconfiguration.
23
+ FUNOTEKA_USER=demo
24
+ FUNOTEKA_PASSWORD=change-me
25
+
26
+ # Or a key instead of a password, for a client that would rather not hold one
27
+ # (OpenSubsonic's `apiKeyAuthentication`). Leave the password out if you use it.
28
+ # FUNOTEKA_APIKEY=
29
+
30
+ # ---- who may operate ---------------------------------------------------------
31
+ # **The admin surface does not exist without this.** Generate one —
32
+ # node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
33
+ # — and keep it out of the collection's reach. It guards the whole admin surface
34
+ # (status, config, roots, scan, junk, export, user, restart), and with no token
35
+ # that surface does not exist at all.
36
+ FUNOTEKA_ADMIN_TOKEN=
37
+
38
+ # ---- the locks that are not the token ---------------------------------------
39
+ # The admin port is guarded by its token. These three are the second lock, and
40
+ # each is off until it is asked for — a value set by accident is worse than one
41
+ # not set at all.
42
+ #
43
+ # Who may reach the port. Comma-separated addresses and CIDR blocks; empty means
44
+ # every address, which is the default and is what the token is then carrying on
45
+ # its own. `0.0.0.0/0` is every IPv4 address and `::/0` every IPv6 one.
46
+ # FUNOTEKA_ADMIN_ALLOW=10.0.0.0/8,192.0.2.0/24
47
+ #
48
+ # Believe `X-Forwarded-For` when deciding the above. Off unless a proxy is the
49
+ # only way in — the header is written by the caller, so on a directly reachable
50
+ # port anyone can claim any address and walk through the allowlist.
51
+ # FUNOTEKA_ADMIN_TRUST_PROXY=1
52
+ #
53
+ # Serve the admin port over TLS. Both or neither; a path that cannot be read
54
+ # stops the server at startup rather than on the first connection. The other
55
+ # way — terminating TLS at a proxy and keeping this port on the loopback — is
56
+ # just as good, and DEPLOY.md describes both.
57
+ # FUNOTEKA_ADMIN_TLS_CERT=/etc/funoteka/admin.crt
58
+ # FUNOTEKA_ADMIN_TLS_KEY=/etc/funoteka/admin.key
59
+
60
+ # ---- who reads the disk, and when -------------------------------------------
61
+ # The server scans on its own every FUNOTEKA_SCAN_INTERVAL minutes (360 = six
62
+ # hours; 0 turns the timer off, for a deployment with a cron job of its own), and
63
+ # never starts one between the quiet hours — a scan reads every file, and at
64
+ # 03:40 that is audible in a house where somebody is asleep.
65
+ # FUNOTEKA_SCAN_INTERVAL=360
66
+ # FUNOTEKA_SCAN_QUIET_FROM=3
67
+ # FUNOTEKA_SCAN_QUIET_TO=6
68
+ #
69
+ # Watch the roots for changes instead of waiting for the interval. Off by
70
+ # default: on a network share it cannot work at all, and the server says so in
71
+ # the log and falls back to the interval rather than pretending.
72
+ # FUNOTEKA_SCAN_WATCH=1
73
+
74
+ # ---- the ports on the HOST ---------------------------------------------------
75
+ # What the compose file publishes. Inside the container the server is on
76
+ # 4533/4534 whatever these say — they are the host side of the mapping, and the
77
+ # difference matters: setting FUNOTEKA_PORT below moves the server inside the
78
+ # container and leaves the mapping pointing where nothing listens.
79
+ PORT=4533
80
+ ADMIN_PORT=4534
81
+
82
+ # ---- the rest, when the defaults do not fit ---------------------------------
83
+ # The server's own ports, which is a different question from the two above. The
84
+ # Dockerfile sets them to 4533/4534 and a container usually leaves them alone.
85
+ # FUNOTEKA_PORT=4533
86
+ # FUNOTEKA_ADMIN_PORT=4534
87
+ # FUNOTEKA_LOG_FILE=/data/funoteka.log
88
+ # FUNOTEKA_FFMPEG=/usr/bin/ffmpeg
89
+ # FUNOTEKA_LOG_REQUESTS=1 # one line per request, which is how a phone
90
+ # # that will not sync gets diagnosed
package/CHANGELOG.md ADDED
@@ -0,0 +1,104 @@
1
+ # Changelog
2
+
3
+ All notable changes to funoteka. Format follows [Keep a Changelog](https://keepachangelog.com/);
4
+ the project uses [Semantic Versioning](https://semver.org/).
5
+
6
+ The version lives in exactly one place — `package.json`.
7
+
8
+ ## [Unreleased]
9
+
10
+ _(nothing yet)_
11
+
12
+ ## [0.1.0] — 2026-09-17
13
+
14
+ First public release. A Subsonic-compatible server that reads whatever is on
15
+ disk and decides for itself what an album, an artist and a track are — how the
16
+ folders happen to be laid out is not a question a client ever has to care about.
17
+
18
+ ### Added
19
+
20
+ **Scanning and classification**
21
+
22
+ - A nine-stage scan — `scan → classify → tags → cues → playlists → artists →
23
+ shelves → collisions → search` — where every derived row is rebuilt from the
24
+ current state, so a scan is idempotent and a repeat scan is not a re-read: a
25
+ file whose size and modification time are unchanged is left alone.
26
+ - Several library roots at once. The same relative path under two roots stays
27
+ two different albums.
28
+ - Classification without an LLM and without a naming convention: the folder
29
+ decides what a record is, and a record that was cut from one image, or that
30
+ names its disc after a number, is recognised as such.
31
+ - Cue sheets: songs inside a lossless image are split and handed out as songs —
32
+ only the m4a/ALAC case goes through ffmpeg, everything else is cut in place.
33
+ - Playlists imported from the `.m3u` files lying in the collection, matched by
34
+ where the entries point rather than by what the file is called.
35
+ - Full-text search over an SQLite FTS5 index, built last, because it is a copy
36
+ of what the previous stages decided.
37
+ - `GET /issues`: what the scanner did **not** understand, as an output rather
38
+ than a line in a log.
39
+ - A half-written file is not read and not recorded: the scanner waits for the
40
+ disk to go quiet, and a file that is still moving is named as such in
41
+ `GET /issues`.
42
+ - The server scans on its own: on an interval, in quiet hours, on changes seen
43
+ by a watcher with a settle window, and when a file was read by an older
44
+ method than the one the current build has.
45
+
46
+ **HTTP API**
47
+
48
+ - Subsonic/OpenSubsonic surface under `/rest/…`: ping, browse (artists →
49
+ albums → tracks), search, stream with byte ranges, cover art (embedded and
50
+ from the folder), playlists, stars and ratings, bookmarks, play queue,
51
+ scrobble/now-playing history, transcoding, and a `download` that copies the
52
+ frame range of a cue image instead of the whole image.
53
+ - `GET /health` without credentials — for a supervisor that cannot have any.
54
+ - A refusal is written down rather than swallowed: a value that is only a
55
+ `${…}` placeholder is read as "no token", and a server with no way in does
56
+ not start at all — a test pins that.
57
+
58
+ **Admin surface (second port, second token)**
59
+
60
+ - The admin port does not exist until `FUNOTEKA_ADMIN_TOKEN` is set, and it
61
+ has four locks: the token, an address/CIDR allowlist, a failure limit
62
+ (10 failures from one address → 15 minutes), and optional TLS.
63
+ - Roots (`GET`/`POST`/`DELETE`), asynchronous scans (`POST /scan`,
64
+ `GET /scan`, `/scan/cancel`, `/scan/history`), `stats`, `issues`, `logs`,
65
+ `inventory`, the three junk verbs (`junk`, `trust`, and taking the mark off),
66
+ `playlists/import`, `export`/`restore`, and a credentials endpoint that says
67
+ what is set and never what it is.
68
+ - Every mutation and every refusal is appended to `<db>.audit.jsonl` — outside
69
+ the database it describes.
70
+ - Mutations are idempotent by `Idempotency-Key`, and the record lives in the
71
+ database rather than in memory, so it survives the restart it may cause.
72
+
73
+ **MCP**
74
+
75
+ - 22 `funoteka_*` tools over stdio (`funoteka mcp`) and over HTTP `/mcp`.
76
+ Each tool is one admin route rather than a second implementation — the same
77
+ gate, the same audit, the same refusals.
78
+
79
+ **Deployment**
80
+
81
+ - `Dockerfile` + `docker-compose.yml`, a systemd unit and a Windows service
82
+ wrapper (WinSW, fetched and hash-checked), all of them telling the server
83
+ `FUNOTEKA_SUPERVISED=1` so that `POST /restart` is a restart and not a stop.
84
+ - A configuration file in four layers — defaults → file → environment →
85
+ flag — where an unknown key or an unparsable file stops the command instead
86
+ of starting a server that only looks configured.
87
+ - **An npm package**: `npx -y funoteka mcp` starts the MCP server against a running
88
+ funoteka, and the same package carries the CLI (`scan`, `serve`, `stop`, `status`).
89
+ Node refuses to strip types from anything under `node_modules`, so the package ships
90
+ the sources compiled once at release — the repository itself still needs no build.
91
+ - Daemon mode with `status` and `stop`, a pid file that is checked against the
92
+ OS rather than believed, and `DEPLOY.md`.
93
+
94
+ ### Known limitations
95
+
96
+ - **No published container image and no cross-build.** The `Dockerfile` is
97
+ built by the machine that runs it and comes out for that machine's
98
+ architecture.
99
+ - **ffmpeg is needed for one case only:** cue segments inside m4a/ALAC. Without
100
+ it those tracks answer with error code 70 and everything else keeps working.
101
+ - **Some Subsonic endpoints are stubs** and say so instead of inventing an
102
+ answer.
103
+ - Single-threaded by design — a Node process serves every client on one
104
+ thread, so an expensive request stops the server for as long as it runs.
package/DEPLOY.md ADDED
@@ -0,0 +1,375 @@
1
+ # Deploying funoteka
2
+
3
+ A self-hosted music server. It reads a directory of music, classifies it, and
4
+ serves it over the Subsonic API — the protocol every music client speaks. It has
5
+ no web interface: what a person operates it with is the admin API on its own
6
+ port, and what an agent operates it with is the same API over MCP.
7
+
8
+ This document is written to be followed **by an agent with no other context**.
9
+ It asks the questions whose answers change the steps, gives a default for each,
10
+ and then walks one path to a server that plays a track. Every command is meant to
11
+ be copied.
12
+
13
+ If something goes wrong, [Troubleshooting](#troubleshooting) lists the failures
14
+ that actually happen, with the sentences they produce.
15
+
16
+ ---
17
+
18
+ ## 1. What you need before starting
19
+
20
+ | | |
21
+ |---|---|
22
+ | **A machine** | Anything that runs Docker, or Linux/Windows with Node 24+. A NAS, a small VDS, or the desktop in the corner all work. |
23
+ | **A music directory** | The collection to serve. It is read, never written — nothing here ever modifies or moves your files. |
24
+ | **Two ports** | `4533` for music clients, `4534` for the admin surface. Both configurable. |
25
+ | **Docker ≥ 20** *(path A)* | Or Node ≥ 24 and ffmpeg *(path B)*. |
26
+
27
+ ffmpeg is needed for one thing: cutting cue tracks out of an `.m4a`/MP4 image.
28
+ Everything else — whole files, FLAC and mp3 segments, browsing, search, covers —
29
+ works without it, and the tracks that need it are refused with a reason. The
30
+ Docker image contains ffmpeg; a native install has to provide it.
31
+
32
+ ---
33
+
34
+ ## 2. Questions to answer first
35
+
36
+ Ask these in order. Each has a default, and the defaults are a working server.
37
+
38
+ | # | Question | Default | Why it matters |
39
+ |---|---|---|---|
40
+ | 1 | Which machine, and is Docker available? | Docker where it is | Docker is the main path: one command, and ffmpeg comes with it. |
41
+ | 2 | Where is the music? | — | Mounted **read-only**. |
42
+ | 3 | Where should the server keep its own files? | `./data` beside the compose file | The meta layer, the log, the cache, the config. This is the whole of what a backup takes. |
43
+ | 4 | What port for music clients? | `4533` | Clients guess this one. |
44
+ | 5 | What port for the admin surface? | `4534` | Reachable from outside by design; guarded by its own token. |
45
+ | 6 | A user name and password for listeners | — | Without both, the server refuses to start. |
46
+ | 7 | An admin token | generated below | **Without it there is no admin surface at all.** |
47
+ | 8 | Should the admin surface terminate TLS itself, or sit behind a proxy? | whatever is simpler | Either is fine; both are described in §7. |
48
+
49
+ There is a machine-readable form of these at `deploy/answers.schema.json` — an
50
+ agent that has been handed answers rather than asked for them should read that
51
+ file for the shape.
52
+
53
+ Generate the admin token (32 random bytes, URL-safe):
54
+
55
+ ```sh
56
+ node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
57
+ ```
58
+
59
+ ---
60
+
61
+ ## 3. Path A — Docker (main path)
62
+
63
+ Works the same on a laptop, a NAS and a VDS. The image is built on the machine
64
+ that runs it (`build: .`), so it comes out `amd64` or `arm64` to match that
65
+ machine — there is no published image and no cross-build yet.
66
+
67
+ ```sh
68
+ git clone <this repository> funoteka
69
+ cd funoteka
70
+ cp .env.example .env
71
+ ```
72
+
73
+ Edit `.env` — four values, and the rest can stay as they are:
74
+
75
+ ```sh
76
+ MUSIC=/absolute/path/to/your/music # read-only into the container
77
+ DATA=./data # everything the server owns
78
+ FUNOTEKA_USER=you
79
+ FUNOTEKA_PASSWORD=change-me
80
+ FUNOTEKA_ADMIN_TOKEN=<the token you generated>
81
+ ```
82
+
83
+ Start it:
84
+
85
+ ```sh
86
+ docker compose up -d
87
+ docker compose logs -f # Ctrl-C leaves it running
88
+ ```
89
+
90
+ `/health` answers as soon as the process is up, and it is meant to be reachable
91
+ without credentials:
92
+
93
+ ```sh
94
+ curl http://127.0.0.1:4533/health
95
+ # {"status":"ok","server":"funoteka","version":"…","schema":…,"uptime":0.4}
96
+ ```
97
+
98
+ ### Tell it where the music is, and read it
99
+
100
+ The admin surface is a second port with its own token. Nothing below works
101
+ without one, and that is the point: with no token the port is not open at all.
102
+
103
+ ```sh
104
+ TOKEN=<the token from .env>
105
+
106
+ curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:4534/status
107
+ curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:4534/stats
108
+ ```
109
+
110
+ Add the collection and read it. The path is the one **inside the container**
111
+ (`/music`, as the compose file mounts it):
112
+
113
+ ```sh
114
+ curl -X POST -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
115
+ -d '{"path": "/music"}' http://127.0.0.1:4534/roots
116
+
117
+ curl -X POST -H "Authorization: Bearer $TOKEN" http://127.0.0.1:4534/scan
118
+ # {"started":{"pid":…,"mode":"incremental","roots":["/music"]}, …}
119
+ ```
120
+
121
+ A scan is a process of its own and the answer above comes back immediately —
122
+ that is what the **202** means. Watch it:
123
+
124
+ ```sh
125
+ curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:4534/scan
126
+ curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:4534/scan/history
127
+ ```
128
+
129
+ The server also scans by itself every six hours, never starting in the quiet
130
+ hours (03:00–06:00 by default), and it can watch the roots for changes instead
131
+ (`FUNOTEKA_SCAN_WATCH=1`) — see §8.
132
+
133
+ ### Is the library there?
134
+
135
+ ```sh
136
+ curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:4534/stats
137
+ # {"roots":1,"folders":…,"songs":…,"albums":…,"artists":…,"issues":…}
138
+ ```
139
+
140
+ `songs` is what a person counts as songs. If it is `0`, the scan found nothing —
141
+ read `issues` for why, which is where every unreadable file, unmatched cue and
142
+ refused tag lands:
143
+
144
+ ```sh
145
+ curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:4534/issues
146
+ ```
147
+
148
+ ### Play a track
149
+
150
+ Any Subsonic client works. To prove it without one:
151
+
152
+ ```sh
153
+ curl -s "http://127.0.0.1:4533/rest/search3?u=$FUNOTEKA_USER&p=$FUNOTEKA_PASSWORD&f=json&query=&songCount=1"
154
+ # {"subsonic-response":{…,"searchResult3":{"song":[{"id":"tr-1",…}]}}}
155
+
156
+ curl -s -o /tmp/track "http://127.0.0.1:4533/rest/stream?u=$FUNOTEKA_USER&p=$FUNOTEKA_PASSWORD&id=tr-1"
157
+ file /tmp/track # audio, and not an error document
158
+ ```
159
+
160
+ That is the whole acceptance: a server answering, a library read, and bytes that
161
+ are audio. Point a client at `http://<host>:4533` with the same credentials and
162
+ it will list the collection.
163
+
164
+ ### The packaging check, if you want one
165
+
166
+ ```sh
167
+ node deploy/smoke.mjs http://127.0.0.1:4533 "$FUNOTEKA_USER" "$FUNOTEKA_PASSWORD"
168
+ ```
169
+
170
+ Walks the acceptance checklist over HTTP — browsing, playlists, stars, ranges,
171
+ cue segments, covers, search, a refusal on wrong credentials — and prints one
172
+ line per step. `skip` means this collection has nothing to check that with,
173
+ which is a fact about the collection and not a failure.
174
+
175
+ ---
176
+
177
+ ## 4. Path B — native Linux (systemd)
178
+
179
+ For a machine where Docker is not wanted. Node 24 and ffmpeg must be installed.
180
+
181
+ ```sh
182
+ sudo useradd --system --home /var/lib/funoteka funoteka
183
+ sudo install -Dm644 deploy/systemd/funoteka.service /etc/systemd/system/funoteka.service
184
+ sudo install -Dm600 deploy/systemd/funoteka.env.example /etc/funoteka/env
185
+ sudo editor /etc/funoteka/env # credentials, admin token, FUNOTEKA_DB
186
+ sudo systemctl daemon-reload
187
+ sudo systemctl enable --now funoteka
188
+ ```
189
+
190
+ The unit keeps the service in `/var/lib/funoteka` and nothing else; the env file
191
+ is where the secrets live, because a unit file is world-readable. Then the same
192
+ admin calls as §3 — add the root with the path **as this machine sees it**
193
+ (`/srv/music`), and scan.
194
+
195
+ ## 5. Path C — native Windows (service)
196
+
197
+ ```powershell
198
+ # elevated PowerShell, from the checkout
199
+ .\deploy\windows\install-service.ps1 -Data 'C:\ProgramData\funoteka'
200
+ ```
201
+
202
+ The script downloads WinSW (pinned, hash-checked) to wrap `node src/cli.ts
203
+ serve` as a service, writes a config file from the example, and restricts its
204
+ ACL. **Edit that config file before starting the service** — it needs a password
205
+ and an admin token. The script's own help says it has not been run end to end by
206
+ whoever wrote it, and that is worth heeding: the first run is the verification.
207
+
208
+ ## 6. Verify, then keep it running
209
+
210
+ | What | How | Expected |
211
+ |---|---|---|
212
+ | the process is up | `curl http://127.0.0.1:4533/health` | `{"status":"ok",…}` |
213
+ | the library was read | admin `GET /stats` | `songs` > 0 |
214
+ | a client can play | the `stream` call in §3 | audio bytes |
215
+ | the admin surface is guarded | `curl http://127.0.0.1:4534/status` with no token | `401` |
216
+ | the admin surface is off when unconfigured | unset `FUNOTEKA_ADMIN_TOKEN`, restart | connection refused, not 401 |
217
+ | the config survives a restart | `POST /config`, then `POST /restart` | the setting is still there |
218
+ | mutations are recorded | `ls <data>/funoteka.db.audit.jsonl` | one JSON line per mutation |
219
+
220
+ A restart is `POST /restart` with the admin token — it answers first and then
221
+ exits, and whatever supervises the process starts it again. Where nothing does,
222
+ it **refuses** (`409`) rather than stopping the server, and says which setting
223
+ would make it true (`FUNOTEKA_SUPERVISED=1`). The compose file and the unit set
224
+ it; a bare `serve --daemon` has nobody and should not claim otherwise.
225
+
226
+ ## 7. TLS, and who may reach the admin port
227
+
228
+ The admin port is meant to be reachable from outside — that is what it is for —
229
+ and it is guarded by its token. Four locks, each a setting, each off by default
230
+ except the token:
231
+
232
+ 1. **`FUNOTEKA_ADMIN_TOKEN`** — the gate. No token, no listener.
233
+ 2. **`FUNOTEKA_ADMIN_ALLOW`** — comma-separated addresses and CIDR blocks
234
+ (`10.0.0.0/8,192.0.2.0/24`). Empty means every address.
235
+ 3. **A failure limit** — ten wrong tokens from one address locks that address out
236
+ for fifteen minutes. Not configurable; the numbers are in `admin-guard.ts`.
237
+ 4. **TLS** — either terminate it here:
238
+
239
+ ```sh
240
+ FUNOTEKA_ADMIN_TLS_CERT=/etc/funoteka/admin.crt
241
+ FUNOTEKA_ADMIN_TLS_KEY=/etc/funoteka/admin.key
242
+ ```
243
+
244
+ (both or neither; a path that cannot be read stops the server at startup
245
+ rather than on the first connection), or terminate it at a proxy and keep the
246
+ admin port on the loopback. In the proxy case, narrow `FUNOTEKA_ADMIN_ALLOW`
247
+ to the proxy's address and set `FUNOTEKA_ADMIN_TRUST_PROXY=1` so
248
+ `X-Forwarded-For` is believed. That header is written by the caller: believing
249
+ it on a port that can be reached directly means anyone can claim any address.
250
+
251
+ ## 8. What it does on its own
252
+
253
+ | Setting | Default | What it does |
254
+ |---|---|---|
255
+ | `FUNOTEKA_SCAN_INTERVAL` | `360` | Minutes between scans. `0` turns the timer off, for a deployment with a cron job of its own. |
256
+ | `FUNOTEKA_SCAN_QUIET_FROM` / `_TO` | `3` / `6` | The hours a scan may not *start*. A scan that comes due inside the window waits for the end of it. A scan is audible: it reads every file. |
257
+ | `FUNOTEKA_SCAN_WATCH` | off | Watch the roots for changes instead of waiting. Waits for five seconds of silence first, so a file being copied in is never read halfway. On a network share it cannot work — the server says so in the log and falls back to the interval. |
258
+
259
+ **A file being written is not read, and does not enter the library.** A scan holds
260
+ back any file the disk has not been quiet about for five seconds — the same window
261
+ the watcher waits out — and says so in `GET /issues` as `still being written`. Held
262
+ means both halves: the run does not write the file's row, and it does not read the
263
+ file either, because the stages that follow select their work by the stamp the scan
264
+ puts on what it saw and a held file does not carry it. The rows a previous run gave
265
+ that file stay where they are, so an album does not lose a track and get it back. A
266
+ file that was *finished* before the scan began is not held: the scan waits out the
267
+ remainder of the window and looks again, so an album copied and then scanned arrives
268
+ whole. A scan that lands *while* a copy is running records everything that has
269
+ finished and leaves the file in flight to the next one.
270
+
271
+ The one case this cannot catch is a copy that pauses for longer than the window and
272
+ then resumes — over a network, where the pause is the point, and in an archive
273
+ unpacked with the mtimes it was packed with. Such a file reads as quiet and is
274
+ recorded; the next scan meets a file that has stopped and records it again. Five
275
+ seconds of quiet is a rule about waiting, not a promise about a writer.
276
+
277
+ A deployment that has just been upgraded scans when it comes up rather than after
278
+ another interval, because files read by an older reader are re-read by the
279
+ stages, and the server knows the next scan is not an ordinary one.
280
+
281
+ ## 9. Backups
282
+
283
+ **The files on disk are the backup of the library.** Albums, tracks, tags, the
284
+ tree and the search index are all readings of them: a rescan rebuilds every one.
285
+ What cannot be rebuilt is what a person said, and that is what `GET /export`
286
+ writes:
287
+
288
+ ```sh
289
+ curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:4534/export > funoteka-export.json
290
+ ```
291
+
292
+ Playlists, stars, ratings, bookmarks, hand edits to the junk filter, and the api
293
+ keys — keyed by the *files* a track lives in, not by row ids, so the document
294
+ survives a rescan and a move. `POST /restore` with that document merges it back
295
+ and lists anything it could not place.
296
+
297
+ Also worth copying, and small: the config file, and the admin token.
298
+
299
+ The password and the bootstrap key are **not** in the export — a backup that
300
+ anybody can read should not be a credential. They live in the config file or the
301
+ environment.
302
+
303
+ ## 10. Upgrading and rolling back
304
+
305
+ ```sh
306
+ git pull
307
+ docker compose up -d --build # or: sudo systemctl restart funoteka
308
+ ```
309
+
310
+ Migrations run when the database is opened, and they only add. An older build
311
+ opening a database a newer one has migrated is **not** refused: `migrate()`
312
+ skips every migration at or below the version already stamped, and the newer
313
+ schema is a superset of the one the older build expects. So rolling the code
314
+ back is usually the whole of the rollback, and the copy of the database is for
315
+ the case where the rollback needs the old shape.
316
+
317
+ What a rollback does not undo is what a stage derived from the files. Rows carry
318
+ the method that wrote them, and a build that does not recognise a method leaves
319
+ those files alone rather than reading them again — so after rolling back, and
320
+ again after returning, run a scan before pointing clients at it.
321
+
322
+ ## 11. Talking to it from an agent
323
+
324
+ The MCP server speaks on stdin and stdout and calls the admin API. It is the same
325
+ program as the CLI, and how it is started depends on how funoteka was installed:
326
+ `package.json` declares a `funoteka` bin, and the image installs no such binary,
327
+ so inside a container the script is named instead.
328
+
329
+ ```sh
330
+ # a native install, with the bin on PATH (`npm link`, or `npm install -g .`)
331
+ FUNOTEKA_ADMIN_TOKEN=$TOKEN funoteka mcp
332
+
333
+ # the container — the token arrives through .env, so it need not be repeated
334
+ docker compose exec -T funoteka node src/cli.ts mcp
335
+ ```
336
+
337
+ The tools are `funoteka_*` — status, stats, issues, logs, config get/set, roots
338
+ list/add/remove, scan start/status/cancel/history, junk list/mark/unmark,
339
+ playlists import, export, restore, user get/set, restart — and `tools/list` is
340
+ how a client finds them. The token comes from the environment or the config file
341
+ and never from an argument: an argument is readable from the process list.
342
+
343
+ The admin port answers `/mcp` for a client that would rather use HTTP. Same
344
+ token, same audit: a mutation asked for through MCP appears in the audit file
345
+ exactly as `curl` does.
346
+
347
+ ## 12. Troubleshooting
348
+
349
+ Every one of these is a sentence this server actually says.
350
+
351
+ | It says | It means |
352
+ |---|---|
353
+ | `no credentials: set FUNOTEKA_USER with FUNOTEKA_PASSWORD or FUNOTEKA_APIKEY` | The server refuses to start without a way in. Set them in `.env` or the unit's env file. |
354
+ | `funoteka: admin surface off — no FUNOTEKA_ADMIN_TOKEN` | No token, so no admin port. This is the intended state, not a fault. |
355
+ | `the admin port 4534 could not be opened (EADDRINUSE)` | Something else has the port. `FUNOTEKA_ADMIN_PORT` names it. |
356
+ | `the admin TLS certificate or key could not be read: …` | The path in the sentence. Both or neither. |
357
+ | `no roots are configured — POST /roots adds one` | A scan was asked for before the collection was added. |
358
+ | `not a directory on this machine: /music` | The path the *server* sees — inside the container, not on the host. |
359
+ | a root scans as empty, and the path in the report is `/app/C:/Program Files/Git/music` | Git Bash on Windows rewrote `/music` into a Windows path before Docker saw it. Prefix the command with `MSYS_NO_PATHCONV=1`. |
360
+ | `no such file (or segment) in this library` | A restore named a song this library does not have. The file moved, or the root is different. |
361
+ | `that is not an export document` | `POST /restore` wants the object `GET /export` answered with. |
362
+ | `funoteka.json is not JSON: …` | The config file is broken and the command stopped rather than coming up on defaults beside it. |
363
+ | `unknown key "prot" — did you mean "port"?` | A typo in the config file. It is refused, not ignored. |
364
+ | `no log file at … — carrying on without one` | The log path has no directory. The server keeps running. |
365
+ | `not watching /mnt/nas — a network path …; the interval still scans` | The watcher cannot work there, and this is the honest fallback. |
366
+ | `nothing to scan — no roots are configured` | Said once, and again if roots come and go. |
367
+
368
+ **A scan that ends with `failed`.** Read `GET /issues` and `GET /logs`. The
369
+ stages record what they could not understand rather than losing it, and the run's
370
+ own status is in `GET /scan/history`.
371
+
372
+ **A client cannot play anything.** Check the credentials first (`/rest/ping`
373
+ with them), then whether the song exists (`/rest/search3`), then the log with
374
+ `FUNOTEKA_LOG_REQUESTS=1`, which prints one line per request with the method and
375
+ the query — masked, because a client spells its password there.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Victor Kuznetsov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.