- Go 71.8%
- Vue 20.2%
- JavaScript 4.1%
- CSS 1.8%
- Makefile 1.3%
- Other 0.8%
"does not contain electro" also drops Electronica, and nothing on the page said so. The text typed into a contains rule is a pattern, not a value, and the library is the only thing that can tell you what it selects. Add GET /api/match: it reads the suggestion cache already kept for autocompletion and applies the same comparison Navidrome will, exact for is/isNot and substring for contains/notContains, plus their list forms. The list it returns is capped at 100, the count is not. The editor shows the answer under the rule, and an empty one in red — that is a typo. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3AZBjgLbEZTWKhSSifv2i |
||
|---|---|---|
| cmd/nspkit | ||
| internal | ||
| testdata | ||
| web | ||
| .dockerignore | ||
| .gitignore | ||
| Dockerfile | ||
| go.mod | ||
| Makefile | ||
| README.md | ||
nspkit
Interactive builder for Navidrome smart playlists (.nsp), served at
https://music.cosmao.info/smartplaylist.
One Go binary: the HTTP API plus the Vue SPA embedded with go:embed. It keeps
no state — the .nsp files in the playlists directory are the state.
What the format actually supports
The documentation
is wrong on two points, and silent on a third. All three were checked against
model/criteria/json.go:
- No
notconjunction. Onlyallandany. Negation exists only as an operator's negative twin (isNot,notContains,notInTheLast,notInPlaylist,isMissing), which is why the UI puts negation in the operator dropdown instead of offering a NOT toggle.startsWith,endsWith,inTheRange,beforeandafterhave no twin: "does not start with" is not expressible. - No
gte/lte. nspkit offers≥and≤and writes them as{"any":[{"gt":{f:v}},{"is":{f:v}}]}, folding that back on read. Exact for every type, unlikegt: v-1. - No list operators. A rule holds one value. nspkit offers
is any of,is none of,contains any ofandcontains none ofon the three fields with a completion source (genre, artist, album artist), and writes them as one simple operator per value under a conjunction:{"all":[{"notContains":{"genre":"Electro"}},{"notContains":{"genre":"Techno"}}]}. A group of that shape folds back into a single rule on read, so a hand-written OR of three genres opens as one chip list.
make check-upstream diffs the operator and field tables against Navidrome's
source. Run it before bumping the Navidrome image.
Running it locally
make dev # Go server on :8080, playlists in /tmp/nspkit-playlists
cd web && npm run dev # Vite, proxying /smartplaylist/api to :8080
--dev-user stands in for the Remote-User header Authelia sets in production.
Configuration
| Flag | Env | Default |
|---|---|---|
--addr |
ADDR |
:8080 |
--playlists |
PLAYLISTS_DIR |
/playlists |
--navidrome |
NAVIDROME_URL |
http://navidrome.navidrome.svc.cluster.local:4533 |
--base-path |
BASE_PATH |
/smartplaylist |
--user-header |
USER_HEADER |
Remote-User |
--dev-user |
DEV_USER |
— |
--base-path and Vite's base in web/vite.config.js must agree: there is no
strip-prefix middleware in front, the server owns the prefix.
Authentication
nspkit never authenticates a user itself. Authelia gates the route and sets
Remote-User; nspkit forwards that header to Navidrome, which trusts it because
the pod sits in ND_EXTAUTH_TRUSTEDSOURCES. No credential is stored anywhere.
Triggering a scan requires a Navidrome admin. A non-admin gets a 403 naming the account.
Concurrent edits
GET /api/playlists/{file} returns a version — a hash of the file's contents.
PUT sends it back and is refused with a 409 when it no longer matches, so a
file edited by hand in between is reported rather than clobbered. Not the mtime:
the kernel caches the wall clock at roughly jiffy granularity, so two writes a
few milliseconds apart share a timestamp and the guard would miss the case it
exists for.
Deployment
Chart sd/nspkit in the kube_manifests repo.