funoteka 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +90 -0
- package/CHANGELOG.md +104 -0
- package/DEPLOY.md +375 -0
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/deploy/answers.schema.json +151 -0
- package/deploy/build.mjs +88 -0
- package/deploy/check-publishable.mjs +246 -0
- package/deploy/smoke.mjs +681 -0
- package/deploy/systemd/funoteka.env.example +41 -0
- package/deploy/systemd/funoteka.service +77 -0
- package/deploy/windows/funoteka-service.xml +58 -0
- package/deploy/windows/install-service.ps1 +158 -0
- package/dist/annotation/store.js +67 -0
- package/dist/api/admin-export.js +227 -0
- package/dist/api/admin-guard.js +181 -0
- package/dist/api/admin-library.js +113 -0
- package/dist/api/admin.js +1162 -0
- package/dist/api/annotation.js +193 -0
- package/dist/api/artistinfo.js +140 -0
- package/dist/api/audit.js +32 -0
- package/dist/api/auth.js +145 -0
- package/dist/api/bookmark.js +108 -0
- package/dist/api/browse.js +1241 -0
- package/dist/api/config-file.js +202 -0
- package/dist/api/config.js +285 -0
- package/dist/api/cover.js +304 -0
- package/dist/api/download.js +112 -0
- package/dist/api/envelope.js +162 -0
- package/dist/api/extensions.js +43 -0
- package/dist/api/genre.js +81 -0
- package/dist/api/health.js +35 -0
- package/dist/api/history.js +374 -0
- package/dist/api/idempotency.js +66 -0
- package/dist/api/keys.js +90 -0
- package/dist/api/log-file.js +65 -0
- package/dist/api/meta.js +1410 -0
- package/dist/api/playlist.js +311 -0
- package/dist/api/rescan.js +196 -0
- package/dist/api/roots.js +161 -0
- package/dist/api/router.js +133 -0
- package/dist/api/scanner.js +163 -0
- package/dist/api/search.js +82 -0
- package/dist/api/server.js +643 -0
- package/dist/api/settings.js +67 -0
- package/dist/api/stream.js +442 -0
- package/dist/api/stubs.js +183 -0
- package/dist/api/transcode.js +678 -0
- package/dist/api/user.js +73 -0
- package/dist/api/virtual.js +362 -0
- package/dist/api/visibility.js +21 -0
- package/dist/artist/apply.js +752 -0
- package/dist/artist/credit.js +95 -0
- package/dist/artist/folder.js +65 -0
- package/dist/artist/name.js +205 -0
- package/dist/artist/nfo.js +101 -0
- package/dist/artist/translit.js +79 -0
- package/dist/bookmark/store.js +54 -0
- package/dist/classify/classify.js +405 -0
- package/dist/classify/collision-name.js +109 -0
- package/dist/classify/folder-name.js +466 -0
- package/dist/classify/roles.js +277 -0
- package/dist/classify/shelf-name.js +178 -0
- package/dist/classify/tree.js +43 -0
- package/dist/cli/args.js +169 -0
- package/dist/cli/daemon.js +192 -0
- package/dist/cli/entry.js +25 -0
- package/dist/cli/keys.js +52 -0
- package/dist/cli.js +638 -0
- package/dist/cover/pick.js +58 -0
- package/dist/cover/picture.js +111 -0
- package/dist/cue/engine.js +759 -0
- package/dist/cue/match.js +142 -0
- package/dist/cue/parse.js +182 -0
- package/dist/cue/plan.js +286 -0
- package/dist/cue/track-name.js +120 -0
- package/dist/db/index.js +163 -0
- package/dist/db/issue.js +73 -0
- package/dist/db/ledger.js +84 -0
- package/dist/db/migrations/001_init.sql +196 -0
- package/dist/db/migrations/002_cue_track_flexible.sql +35 -0
- package/dist/db/migrations/003_cue_metadata.sql +14 -0
- package/dist/db/migrations/004_cue_performer.sql +12 -0
- package/dist/db/migrations/005_incremental.sql +23 -0
- package/dist/db/migrations/006_tags.sql +57 -0
- package/dist/db/migrations/007_artist_credit.sql +35 -0
- package/dist/db/migrations/008_issue_stage.sql +39 -0
- package/dist/db/migrations/009_cue_title.sql +20 -0
- package/dist/db/migrations/010_duration_source.sql +31 -0
- package/dist/db/migrations/011_track_title_source.sql +28 -0
- package/dist/db/migrations/012_file_container.sql +25 -0
- package/dist/db/migrations/013_cover_art.sql +28 -0
- package/dist/db/migrations/014_album_year.sql +17 -0
- package/dist/db/migrations/015_probe_method.sql +14 -0
- package/dist/db/migrations/016_tags_method.sql +20 -0
- package/dist/db/migrations/017_cover_indirect.sql +23 -0
- package/dist/db/migrations/018_release_year.sql +14 -0
- package/dist/db/migrations/019_file_tag_name_file_value.sql +20 -0
- package/dist/db/migrations/020_analyze_after_index.sql +16 -0
- package/dist/db/migrations/021_cue_index_files.sql +15 -0
- package/dist/db/migrations/022_sidecar_text.sql +19 -0
- package/dist/db/migrations/023_track_credit.sql +23 -0
- package/dist/db/migrations/024_playlists.sql +63 -0
- package/dist/db/migrations/025_playlist_sequence.sql +27 -0
- package/dist/db/migrations/026_playlist_source_file.sql +19 -0
- package/dist/db/migrations/027_lookup_indexes.sql +30 -0
- package/dist/db/migrations/028_annotations.sql +47 -0
- package/dist/db/migrations/029_scan_state_root_run.sql +28 -0
- package/dist/db/migrations/030_history.sql +108 -0
- package/dist/db/migrations/031_queue_position_and_plays.sql +46 -0
- package/dist/db/migrations/032_bookmarks.sql +27 -0
- package/dist/db/migrations/033_bookmark_order.sql +14 -0
- package/dist/db/migrations/034_junk.sql +33 -0
- package/dist/db/migrations/035_api_key.sql +38 -0
- package/dist/db/migrations/036_file_tag_first.sql +88 -0
- package/dist/db/migrations/037_admin_idempotency.sql +31 -0
- package/dist/db/migrations/038_cue_audio_index.sql +24 -0
- package/dist/db/sweep.js +109 -0
- package/dist/history/store.js +151 -0
- package/dist/inventory/inventory.js +294 -0
- package/dist/junk/marks.js +143 -0
- package/dist/junk/rule.js +93 -0
- package/dist/mcp/client.js +48 -0
- package/dist/mcp/server.js +175 -0
- package/dist/mcp/tools.js +348 -0
- package/dist/playlist/files.js +116 -0
- package/dist/playlist/import.js +260 -0
- package/dist/playlist/store.js +240 -0
- package/dist/probe/ffprobe.js +179 -0
- package/dist/run.js +120 -0
- package/dist/scan/kinds.js +49 -0
- package/dist/scan/scan.js +503 -0
- package/dist/scan/schedule.js +132 -0
- package/dist/scan/settle.js +172 -0
- package/dist/scan/walk.js +132 -0
- package/dist/search/index.js +41 -0
- package/dist/search/query.js +28 -0
- package/dist/stream/flac.js +541 -0
- package/dist/stream/mpeg.js +179 -0
- package/dist/stream/recode.js +393 -0
- package/dist/stream/rewrite.js +163 -0
- package/dist/stream/segment.js +624 -0
- package/dist/tags/apply.js +703 -0
- package/dist/tags/encode.js +164 -0
- package/dist/tags/first.js +130 -0
- package/dist/tags/flac.js +196 -0
- package/dist/tags/genres.js +123 -0
- package/dist/tags/id3v1.js +209 -0
- package/dist/tags/id3v2.js +754 -0
- package/dist/tags/mp4.js +534 -0
- package/dist/tags/mpeg.js +355 -0
- package/dist/tags/ogg.js +463 -0
- package/dist/tags/picture.js +71 -0
- package/dist/tags/read.js +269 -0
- package/dist/tags/types.js +51 -0
- package/dist/tags/vorbis-comment.js +155 -0
- package/dist/text/encoding.js +384 -0
- package/dist/text/site-name.js +37 -0
- package/dist/util/child.js +21 -0
- package/dist/util/names.js +97 -0
- package/funoteka.json.example +27 -0
- package/package.json +55 -0
package/README.md
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# funoteka
|
|
2
|
+
|
|
3
|
+
**The Subsonic server a real collection deserves.**
|
|
4
|
+
|
|
5
|
+
**A folder is an album** — not a guess, not a heuristic: the rule. funoteka keys every record on
|
|
6
|
+
its folder path, so three pressings of the same album stay three albums, a box stays a box and its
|
|
7
|
+
discs, and an image file with a cue sheet becomes real, playable tracks. Nothing merged, nothing lost.
|
|
8
|
+
|
|
9
|
+
- **An image file and its cue sheet become real tracks** — cut exactly where the cue's `INDEX`
|
|
10
|
+
says, at the frame it names.
|
|
11
|
+
- **A folder is an album.** Three pressings stay three; a box set stays a box set *and* its
|
|
12
|
+
disc-albums. Tags are metadata, never the identity.
|
|
13
|
+
- **Every format your collection actually has** — FLAC, ALAC, MP3, Ogg Vorbis, Opus — plus
|
|
14
|
+
**transcoding**, so a client that cannot play one still can.
|
|
15
|
+
- **Your files are never modified.** Classification, cue boundaries, encodings, dedup — all of it
|
|
16
|
+
lives in a meta layer beside the server. Point it at your disk; your disk stays as it is.
|
|
17
|
+
- **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`.
|
|
20
|
+
|
|
21
|
+
A complete **Subsonic / OpenSubsonic** surface, filled from your own files wherever it can be and
|
|
22
|
+
answered honestly and empty wherever it cannot — so the client you already like just works.
|
|
23
|
+
|
|
24
|
+
## Why the folder wins
|
|
25
|
+
|
|
26
|
+
Three CD pressings of one album are three different records, and a box set is not one CD. Because
|
|
27
|
+
funoteka keys the album on its **path**, that is exactly what you get:
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
Cure, The/Pictures Of You [1989 UK CD]/ ← one album
|
|
31
|
+
Cure, The/Pictures Of You [1990 US CD]/ ← another album
|
|
32
|
+
Cure, The/Disintegration (Box, 12 CD)/ ← the box, and each disc inside it
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Three albums, a box set of twelve discs. No merging, no guessing, nothing lost.
|
|
36
|
+
|
|
37
|
+
## What it does
|
|
38
|
+
|
|
39
|
+
| | |
|
|
40
|
+
|---|---|
|
|
41
|
+
| **Identity** | the absolute folder path — not the tag |
|
|
42
|
+
| **Cue sheets** | image + cue → real tracks; several `.cue` in one folder, `INDEX 00`, `mm:ss:ff`, per-track performer |
|
|
43
|
+
| **Box sets** | a release *and* its disc-albums |
|
|
44
|
+
| **Encodings** | CP1251, UTF-16, ID3v1/v2, Vorbis, MP4 — normalised in the meta layer, files untouched |
|
|
45
|
+
| **Rescan** | incremental by mtime/size; a timer, an optional filesystem watcher, or your own scheduler |
|
|
46
|
+
| **Junk** | releaser debris and fake folders are marked and hidden from the default view — never deleted |
|
|
47
|
+
| **Library** | playlists (server-side and `.m3u`), stars and ratings, play history, play queue, bookmarks, offline download |
|
|
48
|
+
| **Clients** | the Subsonic API + OpenSubsonic extensions; everything else answers with honest, well-formed empty responses — never a 404 |
|
|
49
|
+
| **Management** | an admin API on its own port, and an **MCP server** — an agent can run the whole thing |
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
### Docker — the main path
|
|
54
|
+
|
|
55
|
+
The image carries everything, **ffmpeg included** — the only external binary the server ever wants,
|
|
56
|
+
and only for cue tracks inside an m4a/MP4 container.
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
git clone https://github.com/kzntsv-dev/funoteka && cd funoteka
|
|
60
|
+
cp .env.example .env # your music folder, a password, an admin token
|
|
61
|
+
docker compose up -d
|
|
62
|
+
docker compose exec funoteka node src/cli.ts scan /music # fill the library, once
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
> **On Windows with Git Bash**, prefix that last line with `MSYS_NO_PATHCONV=1`. The shell
|
|
66
|
+
> rewrites `/music` into a Windows path before Docker ever sees it, and the scan then answers
|
|
67
|
+
> honestly about an empty root — `/app/C:/Program Files/Git/music` — which reads like a broken
|
|
68
|
+
> image and is really the shell.
|
|
69
|
+
|
|
70
|
+
### Or let your agent do it
|
|
71
|
+
|
|
72
|
+
> **Give `DEPLOY.md` to your agent.** It asks the questions that matter — where the music is, where
|
|
73
|
+
> to keep the database, which ports, TLS or a reverse proxy, a password — and does the rest. Local
|
|
74
|
+
> machine, NAS, VDS: the guide covers all three.
|
|
75
|
+
|
|
76
|
+
### Native, without Docker
|
|
77
|
+
|
|
78
|
+
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.
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
npm install -g funoteka
|
|
87
|
+
funoteka scan /path/to/music # build the library
|
|
88
|
+
funoteka serve --daemon # serve it
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Quick start
|
|
92
|
+
|
|
93
|
+
1. Start the server. It listens on **4533**.
|
|
94
|
+
2. Install any Subsonic client — Symfonium, Feishin, Substreamer, or whatever you already use.
|
|
95
|
+
3. Point it at `http://your-host:4533`, with the login and password you set.
|
|
96
|
+
4. Browse and play.
|
|
97
|
+
|
|
98
|
+
You see the disk as it should have looked all along: artists, albums, box sets, and cue tracks that
|
|
99
|
+
are tracks.
|
|
100
|
+
|
|
101
|
+
## How it works
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
your folders ─► scanner ─► classifier ─► meta layer (SQLite) ─► Subsonic API ─► any client
|
|
105
|
+
│ │ ▲
|
|
106
|
+
cue engine folder identity admin API · MCP
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The scanner **reads**. Everything it learns — what is an album, where a cue track starts, what an
|
|
110
|
+
encoding was — goes into a SQLite database it owns, beside the server. Change your mind about a
|
|
111
|
+
classification and you edit that layer, not your music.
|
|
112
|
+
|
|
113
|
+
## Compatibility
|
|
114
|
+
|
|
115
|
+
- **Subsonic 1.16.1** — the standard surface.
|
|
116
|
+
- **OpenSubsonic** — `getArtistInfo2`, genres, `getAlbumList2` by year and genre, embedded cover
|
|
117
|
+
art, Ogg and ID3 readers, transcoding, and the extension manifest (`getOpenSubsonicExtensions`
|
|
118
|
+
declares exactly what works — nothing more).
|
|
119
|
+
- What the server does not source from your own files — lyrics, similar artists, top songs, bios —
|
|
120
|
+
is answered **honestly and empty**, so no client breaks on it.
|
|
121
|
+
|
|
122
|
+
## What it is not
|
|
123
|
+
|
|
124
|
+
- **Not a web player.** It is headless: no UI, no dashboard. You drive it with the admin API or MCP,
|
|
125
|
+
and the music reaches you through the Subsonic client you already like.
|
|
126
|
+
- **Not tag-driven.** It will not merge your pressings, rename your artists to match an online
|
|
127
|
+
database, or call home. Nothing about your collection leaves the machine.
|
|
128
|
+
- **Not overclaiming.** Where a thing does not exist yet — a published cross-build, say — the docs
|
|
129
|
+
say so instead of pretending.
|
|
130
|
+
|
|
131
|
+
## Documentation
|
|
132
|
+
|
|
133
|
+
- `DEPLOY.md` — install, configure and manage, written to be read by an agent
|
|
134
|
+
- The admin API and the MCP server — `DEPLOY.md` §11
|
|
135
|
+
|
|
136
|
+
## License
|
|
137
|
+
|
|
138
|
+
MIT © Victor Kuznetsov. Use it, fork it, ship it.
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "https://funoteka/deploy/answers.schema.json",
|
|
4
|
+
"title": "funoteka deployment answers",
|
|
5
|
+
"description": "The questions DEPLOY.md asks, in a form a program can hold. An agent that has been handed answers instead of asking for them reads this for the shape; an agent that has to ask reads the descriptions, which say why each answer matters and what happens when it is left out. Every property except the music directory and the listener credentials has a default, and the defaults are a working server.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": ["music", "user", "password"],
|
|
9
|
+
"properties": {
|
|
10
|
+
"target": {
|
|
11
|
+
"description": "Which path of DEPLOY.md to follow. Docker is the main path: one command, and ffmpeg comes inside the image. The native paths are for a machine where Docker is not wanted, and need Node 24 and ffmpeg installed.",
|
|
12
|
+
"enum": ["docker", "linux-service", "windows-service"],
|
|
13
|
+
"default": "docker"
|
|
14
|
+
},
|
|
15
|
+
"host": {
|
|
16
|
+
"description": "The machine this is being deployed to, as the person answering would name it. Used in the steps that have to be run there rather than here; nothing in the deployment depends on it.",
|
|
17
|
+
"type": "string"
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
"music": {
|
|
21
|
+
"description": "Absolute path to the collection, as the SERVER sees it. For the docker target that is the path inside the container (the compose file mounts the host's directory at /music), and for the native targets it is the path on the machine. It is mounted read-only: nothing in this server ever writes to, moves or renames a file of yours.",
|
|
22
|
+
"type": "string"
|
|
23
|
+
},
|
|
24
|
+
"data": {
|
|
25
|
+
"description": "Where the server keeps what is its own: the meta layer (a SQLite file), its re-encode cache, its log, its audit file and the config file. This directory is the whole of what a backup takes, apart from the music itself.",
|
|
26
|
+
"type": "string",
|
|
27
|
+
"default": "./data"
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
"musicPort": {
|
|
31
|
+
"description": "The port music clients connect to. 4533 is Subsonic's own, so a client that guesses one guesses right.",
|
|
32
|
+
"type": "integer",
|
|
33
|
+
"minimum": 1,
|
|
34
|
+
"maximum": 65535,
|
|
35
|
+
"default": 4533
|
|
36
|
+
},
|
|
37
|
+
"adminPort": {
|
|
38
|
+
"description": "The port the admin surface listens on. It is meant to be reachable from outside — that is what it is for — and it is guarded by its own token rather than by not being reachable. One more than the music port, so it is one number to remember.",
|
|
39
|
+
"type": "integer",
|
|
40
|
+
"minimum": 1,
|
|
41
|
+
"maximum": 65535,
|
|
42
|
+
"default": 4534
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
"user": {
|
|
46
|
+
"description": "The listener's name. Subsonic clients send it with every request.",
|
|
47
|
+
"type": "string",
|
|
48
|
+
"minLength": 1
|
|
49
|
+
},
|
|
50
|
+
"password": {
|
|
51
|
+
"description": "The listener's password. The server REFUSES TO START without a password or an api key: a music library on the open network is not 'it came up', it is a misconfiguration.",
|
|
52
|
+
"type": "string",
|
|
53
|
+
"minLength": 1
|
|
54
|
+
},
|
|
55
|
+
"apiKey": {
|
|
56
|
+
"description": "An alternative to the password, for a client that would rather not hold one (OpenSubsonic's apiKeyAuthentication). Either one is enough; both may be set.",
|
|
57
|
+
"type": "string"
|
|
58
|
+
},
|
|
59
|
+
"adminToken": {
|
|
60
|
+
"description": "The admin surface's whole gate, and the admin surface does not exist without it: with no token the port is not open at all. Generate one with `node -e \"console.log(require('crypto').randomBytes(32).toString('base64url'))\"`. It is a credential: keep it out of the music directory and out of git.",
|
|
61
|
+
"type": "string"
|
|
62
|
+
},
|
|
63
|
+
"adminAllow": {
|
|
64
|
+
"description": "Who may reach the admin port at all, as comma-separated addresses and CIDR blocks. Empty means every address, which is what the token is then carrying on its own. A second lock, not the gate.",
|
|
65
|
+
"type": "string",
|
|
66
|
+
"default": ""
|
|
67
|
+
},
|
|
68
|
+
"adminTrustProxy": {
|
|
69
|
+
"description": "Whether X-Forwarded-For may be believed when deciding adminAllow. False unless a proxy is the only way in: the header is written by the caller, so believing it on a directly reachable port means anyone can claim any address and walk through the allowlist.",
|
|
70
|
+
"type": "boolean",
|
|
71
|
+
"default": false
|
|
72
|
+
},
|
|
73
|
+
"tls": {
|
|
74
|
+
"description": "How the admin port is encrypted. 'self' terminates TLS here and needs tlsCert and tlsKey (both or neither; a path that cannot be read stops the server at startup). 'proxy' leaves the port plain and expects a reverse proxy in front of it — not a weaker arrangement, and the one to narrow adminAllow to the proxy's address for. 'none' is for a deployment on a private network.",
|
|
75
|
+
"enum": ["none", "self", "proxy"],
|
|
76
|
+
"default": "none"
|
|
77
|
+
},
|
|
78
|
+
"tlsCert": {
|
|
79
|
+
"description": "Path to the certificate, when tls is 'self'.",
|
|
80
|
+
"type": "string"
|
|
81
|
+
},
|
|
82
|
+
"tlsKey": {
|
|
83
|
+
"description": "Path to its private key, when tls is 'self'.",
|
|
84
|
+
"type": "string"
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
"supervised": {
|
|
88
|
+
"description": "Whether something will start this process again after it exits — a restart policy, a systemd unit, a service wrapper. It is what makes POST /restart a restart rather than a stop, and where it is false the route refuses with a 409 instead of taking the music down. The compose file and the unit set it; a bare `serve --daemon` has nobody and should say so.",
|
|
89
|
+
"type": "boolean",
|
|
90
|
+
"default": true
|
|
91
|
+
},
|
|
92
|
+
"scanInterval": {
|
|
93
|
+
"description": "Minutes between the scans the server starts by itself. Zero turns the timer off, for a deployment with a cron job of its own.",
|
|
94
|
+
"type": "integer",
|
|
95
|
+
"minimum": 0,
|
|
96
|
+
"default": 360
|
|
97
|
+
},
|
|
98
|
+
"scanQuietFrom": {
|
|
99
|
+
"description": "The first hour (0-23, local) in which a scan may not START. A scan is audible — it reads every file — and these are the hours a house is asleep in. A scan that comes due inside the window waits for the end of it rather than being skipped.",
|
|
100
|
+
"type": "integer",
|
|
101
|
+
"minimum": 0,
|
|
102
|
+
"maximum": 23,
|
|
103
|
+
"default": 3
|
|
104
|
+
},
|
|
105
|
+
"scanQuietTo": {
|
|
106
|
+
"description": "The hour the quiet window ends. Equal to scanQuietFrom means no quiet hours at all. The window may cross midnight (23 to 6).",
|
|
107
|
+
"type": "integer",
|
|
108
|
+
"minimum": 0,
|
|
109
|
+
"maximum": 23,
|
|
110
|
+
"default": 6
|
|
111
|
+
},
|
|
112
|
+
"scanWatch": {
|
|
113
|
+
"description": "Watch the roots for changes instead of waiting for the interval. It waits for five seconds of silence first, so a file being copied in is never read halfway. It cannot work on a network share — the server says so in the log and falls back to the interval rather than pretending.",
|
|
114
|
+
"type": "boolean",
|
|
115
|
+
"default": false
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
"logFile": {
|
|
119
|
+
"description": "A file to append this process's own output to, for a deployment whose supervisor collects nothing. Leave it out where Docker, systemd or the service wrapper already collects it.",
|
|
120
|
+
"type": "string",
|
|
121
|
+
"default": ""
|
|
122
|
+
},
|
|
123
|
+
"ffmpeg": {
|
|
124
|
+
"description": "The ffmpeg binary, needed only to cut cue tracks out of an .m4a/MP4 image. The Docker image has one; a native install has to provide it. Without it those tracks are refused with a reason and everything else works.",
|
|
125
|
+
"type": "string",
|
|
126
|
+
"default": "ffmpeg"
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
"examples": [
|
|
130
|
+
{
|
|
131
|
+
"target": "docker",
|
|
132
|
+
"music": "/music",
|
|
133
|
+
"data": "./data",
|
|
134
|
+
"user": "you",
|
|
135
|
+
"password": "change-me",
|
|
136
|
+
"adminToken": "…32 random bytes…",
|
|
137
|
+
"supervised": true
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
"target": "linux-service",
|
|
141
|
+
"music": "/srv/music",
|
|
142
|
+
"data": "/var/lib/funoteka",
|
|
143
|
+
"user": "you",
|
|
144
|
+
"password": "change-me",
|
|
145
|
+
"adminToken": "…32 random bytes…",
|
|
146
|
+
"adminAllow": "10.0.0.0/8",
|
|
147
|
+
"tls": "proxy",
|
|
148
|
+
"adminTrustProxy": true
|
|
149
|
+
}
|
|
150
|
+
]
|
|
151
|
+
}
|
package/deploy/build.mjs
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The build, for the npm package and for nothing else.
|
|
4
|
+
*
|
|
5
|
+
* node deploy/build.mjs
|
|
6
|
+
*
|
|
7
|
+
* **The repository runs its own TypeScript and needs no build.** `node
|
|
8
|
+
* src/cli.ts` is the program, `npm test` is the suite, and TypeScript is a
|
|
9
|
+
* dev-time tool — that is a property of this project worth keeping, and this
|
|
10
|
+
* script does not take it away.
|
|
11
|
+
*
|
|
12
|
+
* The *package* is the one place it cannot hold. Node refuses to strip types
|
|
13
|
+
* from any file under `node_modules` (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`),
|
|
14
|
+
* and there is no flag that lifts it — checked on v24.19.0: the default, both
|
|
15
|
+
* `--experimental-strip-types` and `--experimental-transform-types`, and the two
|
|
16
|
+
* together all refuse. So a package whose `bin` names a `.ts` file installs and
|
|
17
|
+
* then cannot start, which is what `npx -y funoteka mcp` would have done. The
|
|
18
|
+
* published artifact therefore carries what `tsc` emits, and this is how it is
|
|
19
|
+
* made.
|
|
20
|
+
*
|
|
21
|
+
* Two steps, because `tsc` emits TypeScript and the program also reads files
|
|
22
|
+
* that are not: the schema migrations live beside the code that applies them
|
|
23
|
+
* (`src/db/migrations`, found through `import.meta.dirname`). Every such file is
|
|
24
|
+
* copied to the same place under `dist` — every one, not the migrations
|
|
25
|
+
* specifically, so that a `.json` added tomorrow travels without anyone
|
|
26
|
+
* remembering to extend this script — and then the copy is *checked* rather than
|
|
27
|
+
* assumed. A build that compiled the code and left an asset behind would produce
|
|
28
|
+
* a package that starts and then fails somewhere deeper, which is the kind of
|
|
29
|
+
* failure that reads as a bug in the program.
|
|
30
|
+
*
|
|
31
|
+
* Exit code 0 on a complete build, 1 otherwise.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs';
|
|
35
|
+
import { spawnSync } from 'node:child_process';
|
|
36
|
+
import { dirname, join, relative } from 'node:path';
|
|
37
|
+
|
|
38
|
+
/** Every file under `src` that is not TypeScript, as a path relative to `src`. */
|
|
39
|
+
function assets(dir = 'src') {
|
|
40
|
+
const found = [];
|
|
41
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
42
|
+
const from = join(dir, entry.name);
|
|
43
|
+
if (entry.isDirectory()) {
|
|
44
|
+
found.push(...assets(from));
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (entry.name.endsWith('.ts')) continue;
|
|
48
|
+
found.push(relative('src', from));
|
|
49
|
+
}
|
|
50
|
+
return found.sort();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const tsc = join('node_modules', 'typescript', 'bin', 'tsc');
|
|
54
|
+
if (!existsSync(tsc)) {
|
|
55
|
+
console.error('no TypeScript in node_modules — run `npm ci` first');
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
rmSync('dist', { recursive: true, force: true });
|
|
60
|
+
|
|
61
|
+
const compiled = spawnSync(process.execPath, [tsc, '-p', 'tsconfig.build.json'], { stdio: 'inherit' });
|
|
62
|
+
if (compiled.status !== 0) {
|
|
63
|
+
console.error(`tsc exited with ${compiled.status}`);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const carried = assets();
|
|
68
|
+
for (const one of carried) {
|
|
69
|
+
const to = join('dist', one);
|
|
70
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
71
|
+
cpSync(join('src', one), to);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const entry = join('dist', 'cli.js');
|
|
75
|
+
const entrySize = existsSync(entry) ? statSync(entry).size : 0;
|
|
76
|
+
const missing = carried.filter((one) => !existsSync(join('dist', one)));
|
|
77
|
+
const schema = carried.filter((one) => one.endsWith('.sql'));
|
|
78
|
+
|
|
79
|
+
console.log(`dist/cli.js ${entrySize} bytes, ${carried.length - missing.length}/${carried.length} asset(s) beside it`);
|
|
80
|
+
if (entrySize === 0 || missing.length > 0) {
|
|
81
|
+
for (const one of missing) console.error(` not in dist: ${one}`);
|
|
82
|
+
console.error('the build is not complete: the entry point or a file the program reads is missing');
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
if (schema.length === 0) {
|
|
86
|
+
console.error('no schema migrations under src — a package without them could not open a database');
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The publishability gate: what must not be in a copy that leaves this machine.
|
|
4
|
+
*
|
|
5
|
+
* node deploy/check-publishable.mjs [dir] [--shapes-only] # default: the current directory
|
|
6
|
+
*
|
|
7
|
+
* **Run it on the curated copy, not on the development tree.** The development
|
|
8
|
+
* tree keeps its meta deliberately — `.mappa/`, `AGENTS.md`, `.pi/` are tracked
|
|
9
|
+
* there, and the private host lives in `.mappa/config.yaml` on purpose. A gate
|
|
10
|
+
* run against the tree is red every day, and a gate that is red every day is a
|
|
11
|
+
* gate nobody reads. The copy is the artefact; the copy is what is checked.
|
|
12
|
+
*
|
|
13
|
+
* Two questions, and both are about the copy:
|
|
14
|
+
*
|
|
15
|
+
* 1. **private VALUES — zero hits.** By value, not by name: `.mappa` in a
|
|
16
|
+
* `.gitignore` line is not a leak and must not be reported as one, while a
|
|
17
|
+
* hostname, a machine name or the collection's own path is a leak wherever
|
|
18
|
+
* it appears.
|
|
19
|
+
* 2. **meta files in the tree — none**, at any depth, not only at the top.
|
|
20
|
+
*
|
|
21
|
+
* **Which values, and why the list is split in two.** Shapes are named here and
|
|
22
|
+
* are safe to publish: a Windows drive letter, a workstation prefix, the name of
|
|
23
|
+
* a registry product. The *identifiers* of this estate — the private git host,
|
|
24
|
+
* its registry, the leading characters of an administrative token, the operator,
|
|
25
|
+
* the LAN the live server sits on, the NAS marker — are **not** in this file:
|
|
26
|
+
* this file travels to the public repository, and a gate that publishes what it
|
|
27
|
+
* forbids is worse than no gate. They arrive by environment instead:
|
|
28
|
+
*
|
|
29
|
+
* FUNOTEKA_PRIVATE_VALUES='git.example.site,some-handle' node deploy/check-publishable.mjs .
|
|
30
|
+
*
|
|
31
|
+
* CI supplies them from a repository secret, so the public workflow carries the
|
|
32
|
+
* check and not the values.
|
|
33
|
+
*
|
|
34
|
+
* **The identifiers are required, not optional.** Without them the gate knows
|
|
35
|
+
* three shapes and nothing else, and a green verdict would mean far less than it
|
|
36
|
+
* looks like it means — so it exits 2 and says so. `--shapes-only` is the
|
|
37
|
+
* explicit way to ask for the reduced check, and it is a way to say "I know this
|
|
38
|
+
* is weaker", not a default.
|
|
39
|
+
*
|
|
40
|
+
* **One file is exempt from the shape scan and not from the rest:** this one,
|
|
41
|
+
* wherever a copy of this repository keeps it (`deploy/check-publishable.mjs`)
|
|
42
|
+
* and whichever copy is the one running. It names the shapes it forbids, so it
|
|
43
|
+
* matches itself, and a gate that reports itself as a leak is a gate that gets
|
|
44
|
+
* switched off. The exemption is by that path and by this file's own resolved
|
|
45
|
+
* path, so it holds whoever runs it and from wherever — and it covers the built-in
|
|
46
|
+
* shapes only: anything arriving through `FUNOTEKA_PRIVATE_VALUES` is checked here
|
|
47
|
+
* too, which is what keeps this file honest about the only values that would
|
|
48
|
+
* matter if they appeared in it.
|
|
49
|
+
*
|
|
50
|
+
* Exit code: 0 clean, 1 findings, 2 misused (bad directory, or identifiers
|
|
51
|
+
* missing without `--shapes-only`). A gate that cannot fail would be a report,
|
|
52
|
+
* and a green report is not a check.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
import { readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
|
|
56
|
+
import { basename, join, relative, resolve, sep } from 'node:path';
|
|
57
|
+
|
|
58
|
+
const argv = process.argv.slice(2);
|
|
59
|
+
const shapesOnly = argv.includes('--shapes-only');
|
|
60
|
+
const target = resolve(argv.find((argument) => !argument.startsWith('--')) ?? '.');
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
if (!statSync(target).isDirectory()) throw new Error('not a directory');
|
|
64
|
+
} catch (error) {
|
|
65
|
+
console.error(`cannot read ${target}: ${error.message}`);
|
|
66
|
+
process.exit(2);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Shapes: generic markers, safe to name in public, and each one a leak wherever
|
|
71
|
+
* it appears.
|
|
72
|
+
*
|
|
73
|
+
* **A private IPv4 range is deliberately NOT one of them.** `10.0.0.0/8` is
|
|
74
|
+
* what every page about an address allowlist says, including this project's own
|
|
75
|
+
* documentation and its tests — a rule against private ranges flags the
|
|
76
|
+
* documentation for documenting the feature, and 57 such lines is how a gate
|
|
77
|
+
* gets switched off. `music/` is absent for the same reason: `/music` is the
|
|
78
|
+
* container's mount point and is public by design.
|
|
79
|
+
*
|
|
80
|
+
* So what stays here is what is generic, and what names *this* estate goes to
|
|
81
|
+
* `FUNOTEKA_PRIVATE_VALUES` — including the LAN the live server sits on, which
|
|
82
|
+
* is an identifier of this estate and not a shape.
|
|
83
|
+
*/
|
|
84
|
+
const SHAPES = [
|
|
85
|
+
{ pattern: /\bZ:\\/, why: 'the collection drive' },
|
|
86
|
+
{ pattern: /DESKTOP-/, why: 'a workstation name' },
|
|
87
|
+
{ pattern: /verdaccio/, why: 'a private registry' },
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Identifiers of this estate, supplied from outside (see the header). Each is
|
|
92
|
+
* matched literally after escaping, **unanchored and case-sensitive**: an
|
|
93
|
+
* unanchored substring is the strict reading — a value inside a longer word is
|
|
94
|
+
* still the value — and the price of that strictness is that the values must be
|
|
95
|
+
* specific. A one- or two-character value would match half the tree, so short
|
|
96
|
+
* ones are reported as a warning rather than quietly trusted.
|
|
97
|
+
*/
|
|
98
|
+
const supplied = (() => {
|
|
99
|
+
const raw = process.env.FUNOTEKA_PRIVATE_VALUES ?? '';
|
|
100
|
+
return raw
|
|
101
|
+
.split(',')
|
|
102
|
+
.map((value) => value.trim())
|
|
103
|
+
.filter((value) => value !== '')
|
|
104
|
+
.map((value) => ({ pattern: new RegExp(value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), why: 'supplied as private' }));
|
|
105
|
+
})();
|
|
106
|
+
|
|
107
|
+
if (supplied.length === 0 && !shapesOnly) {
|
|
108
|
+
console.error('the estate identifiers were not supplied — set FUNOTEKA_PRIVATE_VALUES, or pass --shapes-only');
|
|
109
|
+
console.error(' (--shapes-only checks three generic shapes and no hostname, handle or token, and says so in its verdict)');
|
|
110
|
+
process.exit(2);
|
|
111
|
+
}
|
|
112
|
+
for (const { pattern } of supplied) {
|
|
113
|
+
const value = pattern.source.replace(/\\(.)/g, '$1');
|
|
114
|
+
if (value.length < 4) console.warn(` warning: supplied value ${JSON.stringify(value)} is very short — expect false positives`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Meta that belongs to the development tree, by name or by directory anywhere in
|
|
119
|
+
* the tree. `README.ru.md` is here because the public README is English and the
|
|
120
|
+
* Russian one is a development note that is not in the allowlist. `.env` and
|
|
121
|
+
* `funoteka.json` are not meta, they are somebody's machine — a copy made from a
|
|
122
|
+
* working checkout would otherwise carry credentials or a port — so the report
|
|
123
|
+
* heading says "meta and machine-local files" rather than calling them meta.
|
|
124
|
+
*/
|
|
125
|
+
const META_FILES = ['AGENTS.md', 'CLAUDE.md', '.mappa-manifest.json', 'README.ru.md', '.env', 'funoteka.json'];
|
|
126
|
+
const META_DIRS = ['.mappa', '.pi', '.wiki', '.tasks'];
|
|
127
|
+
|
|
128
|
+
/** A file this large is read in one piece; past the cap it is named, not read. */
|
|
129
|
+
const MAX_BYTES = 16 * 1024 * 1024;
|
|
130
|
+
|
|
131
|
+
const selfPath = realpathSync(import.meta.filename);
|
|
132
|
+
const selfRelative = 'deploy/check-publishable.mjs';
|
|
133
|
+
|
|
134
|
+
const rules = [
|
|
135
|
+
...SHAPES.map(({ pattern, why }) => ({ pattern, why, selfExempt: true })),
|
|
136
|
+
...supplied.map(({ pattern, why }) => ({ pattern, why, selfExempt: false })),
|
|
137
|
+
];
|
|
138
|
+
const hits = rules.map(() => []);
|
|
139
|
+
const skipped = { large: 0, other: 0 };
|
|
140
|
+
const meta = new Set();
|
|
141
|
+
|
|
142
|
+
function* walk(dir) {
|
|
143
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
144
|
+
const path = join(dir, entry.name);
|
|
145
|
+
if (entry.isDirectory()) {
|
|
146
|
+
yield path;
|
|
147
|
+
if (entry.name === '.git' || entry.name === 'node_modules' || META_DIRS.includes(entry.name)) continue;
|
|
148
|
+
yield* walk(path);
|
|
149
|
+
} else if (entry.isFile()) {
|
|
150
|
+
yield path;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Both readings of a file, because a private value does not care how the bytes
|
|
157
|
+
* were encoded: UTF-8 lossy (which is what a PNG's text chunk or a PDF reads as)
|
|
158
|
+
* and, when there are NUL bytes, UTF-16 as well — a `.env`-shaped file saved by
|
|
159
|
+
* a Windows editor is UTF-16 and would otherwise be skipped as "binary".
|
|
160
|
+
*/
|
|
161
|
+
function textsOf(path) {
|
|
162
|
+
const bytes = readFileSync(path);
|
|
163
|
+
const texts = [bytes.toString('utf8')];
|
|
164
|
+
if (bytes.includes(0)) texts.push(bytes.toString('utf16le'));
|
|
165
|
+
return texts;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const targetPrefix = target.endsWith(sep) ? target : target + sep;
|
|
169
|
+
let files = 0;
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
for (const path of walk(target)) {
|
|
173
|
+
const relativePath = relative(target, path).split(sep).join('/');
|
|
174
|
+
const parts = relativePath.split('/');
|
|
175
|
+
const metaDir = parts.findIndex((part) => META_DIRS.includes(part));
|
|
176
|
+
if (metaDir >= 0) meta.add(parts.slice(0, metaDir + 1).join('/'));
|
|
177
|
+
else if (META_FILES.includes(basename(relativePath))) meta.add(relativePath);
|
|
178
|
+
|
|
179
|
+
if (metaDir >= 0 || !statSync(path).isFile()) continue;
|
|
180
|
+
|
|
181
|
+
const stats = statSync(path);
|
|
182
|
+
if (stats.size > MAX_BYTES) {
|
|
183
|
+
skipped.large += 1;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (realpathSync(path) === selfPath || relativePath === selfRelative) {
|
|
187
|
+
// Scanned only for the supplied identifiers; see the header.
|
|
188
|
+
const text = readFileSync(path, 'utf8');
|
|
189
|
+
text.split('\n').forEach((line, index) => {
|
|
190
|
+
rules.forEach((rule, position) => {
|
|
191
|
+
if (rule.selfExempt) return;
|
|
192
|
+
const match = rule.pattern.exec(line);
|
|
193
|
+
if (match) hits[position].push(`${relativePath}:${index + 1}: ${line.slice(Math.max(0, match.index - 40), match.index + 60)}`);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
files += 1;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
files += 1;
|
|
201
|
+
for (const text of textsOf(path)) {
|
|
202
|
+
text.split('\n').forEach((line, index) => {
|
|
203
|
+
rules.forEach((rule, position) => {
|
|
204
|
+
const match = rule.pattern.exec(line);
|
|
205
|
+
if (match) hits[position].push(`${relativePath}:${index + 1}: ${line.slice(Math.max(0, match.index - 40), match.index + 60)}`);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
} catch (error) {
|
|
211
|
+
console.error(`the walk stopped: ${error.message}`);
|
|
212
|
+
process.exit(2);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const shown = relative(process.cwd(), target) || basename(target);
|
|
216
|
+
console.log(`funoteka publishability gate — ${shown === '' ? basename(target) : shown}`);
|
|
217
|
+
console.log(` ${files} text file(s) read`);
|
|
218
|
+
if (skipped.large > 0) console.log(` ${skipped.large} file(s) skipped: larger than ${MAX_BYTES} bytes`);
|
|
219
|
+
console.log(
|
|
220
|
+
supplied.length === 0
|
|
221
|
+
? ' identifiers: SHAPES ONLY — requested with --shapes-only, not a full check'
|
|
222
|
+
: ` identifiers: ${supplied.length} supplied via FUNOTEKA_PRIVATE_VALUES`,
|
|
223
|
+
);
|
|
224
|
+
console.log();
|
|
225
|
+
|
|
226
|
+
let findings = 0;
|
|
227
|
+
console.log('private values (by value, not by meta name):');
|
|
228
|
+
for (const [position, rule] of rules.entries()) {
|
|
229
|
+
const found = hits[position];
|
|
230
|
+
if (found.length === 0) continue;
|
|
231
|
+
findings += 1;
|
|
232
|
+
console.log(` ✗ ${rule.pattern} — ${rule.why}: ${found.length} line(s)`);
|
|
233
|
+
for (const line of found.slice(0, 5)) console.log(` ${line}`);
|
|
234
|
+
if (found.length > 5) console.log(` … and ${found.length - 5} more`);
|
|
235
|
+
}
|
|
236
|
+
if (findings === 0) console.log(' ✓ zero hits');
|
|
237
|
+
|
|
238
|
+
console.log('\nmeta and machine-local files in the tree:');
|
|
239
|
+
if (meta.size === 0) console.log(' ✓ none');
|
|
240
|
+
else {
|
|
241
|
+
findings += meta.size;
|
|
242
|
+
for (const name of [...meta].sort()) console.log(` ✗ ${name}`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
console.log(`\nverdict: ${findings === 0 ? 'publishable' : `NOT publishable — ${findings} finding(s)`}`);
|
|
246
|
+
process.exit(findings === 0 ? 0 : 1);
|