- TypeScript 74.1%
- Rust 22.8%
- CSS 2.2%
- Dockerfile 0.4%
- JavaScript 0.4%
- Other 0.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| packages | ||
| .dockerignore | ||
| .gitignore | ||
| docker-compose.yaml | ||
| Dockerfile | ||
| Dockerfile.indexer | ||
| eslint.config.js | ||
| package.json | ||
| pnpm-lock.yaml | ||
| pnpm-workspace.yaml | ||
| README.md | ||
| tsconfig.json | ||
| uno.config.ts | ||
frozy
indexes a music folder and lets you grep across all of its metadata - not just artist/album/title, but every tag your tagger ever wrote into the file.
packages/indexer- rust service. walks the library withloftyfor tags, keeps everything in atantivyindex, watches the folder for changes, and writes tags back on requestpackages/backend- thin node fasade (hono + trpc v11 over websocket) that the frontend talks topackages/frontend- solid + unocss, deck-of-panels ui borrowed from kozeki
query syntax
the search box takes lucene-ish queries. any word that isn't a document field is
treated as a tag name, so tags you've never heard of work the same as artist:
aphex # free text across every tag and the path
artist:aphex album:"85-92" # field filters
replaygain_track_gain:* # tags most players don't even show
-genre:ambient # negation
duration_ms:[300000 TO *] # ranges on numeric fields
regex grep is /.../, matched against key=value terms (lowercased), which is
what makes "search everything" actually mean everything:
/.*remaster.*/ # any tag whose key or value contains it
title:/.*\(.*mix.*\)/ # field-scoped grep
-comment:/.*ripped.*/ # negated grep
document fields (as opposed to tags): path, file_name, library, format,
file_size, mtime, duration_ms, bitrate, sample_rate, channels,
bit_depth.
sortable: artist, album, title, path and every numeric field above.
tag editing
the track panel writes changes straight into the file via lofty. vorbis-based formats (flac, ogg, opus, speex) accept arbitrary keys; for other formats only keys lofty can map onto that container work - mp3 TXXX and mp4 freeform atoms are not wired up yet
auth
without an [oidc] block frozy is completely unauthenticated - fine on localhost
or behind a vpn, not fine on a public port, since updateTags writes to your
files. with oidc configured, every trpc procedure requires a valid session:
secret = "some-random-string"
[oidc]
issuer = "https://accounts.example.com"
client_id = "frozy"
client_secret = "..."
redirect_uri = "https://frozy.example.com/api/auth/callback"
scopes = [
"openid",
"profile"
]
pkce = true
# how the client authenticates at the token endpoint. a wrong value here shows up
# as `invalid_client` on the callback, not on discovery
client_auth = "post" # post | basic | none (none for public clients)
# empty = anyone the provider lets through
allowed_subjects = []
the session is a signed jwt cookie (7 days) pointing at a row in the user table.
the first person to log in becomes admin, everyone after starts as viewer:
| role | can |
|---|---|
viewer |
search, open tracks, read edit history |
editor |
+ write tags, trigger a rescan |
admin |
+ manage roles, disable and delete users |
admins get a Users dialog from the avatar menu. allowed_subjects can additionally
restrict who the provider is even allowed to create an account for.
for testing the flow locally, docker compose --profile oauth up -d mock-oidc
and point the issuer at http://localhost:8092/.well-known/openid-configuration.
the indexer itself has no auth - it binds to localhost by default and its port is not published in compose, so only the backend can reach it.
database
postgres holds users and the tag edit log - everything about the library itself lives in the tantivy index, not here. every successful tag write records who changed which keys, with before/after values, shown in the track panel.
migrations run automatically on startup (--no-migrate to skip, --only-migrate
to run and exit). to add a table: edit src/db/*.ts, then pnpm db:generate.
setup for development
pnpm install
# start the indexer against your library (it holds the index in .runtime/index)
cargo run --manifest-path packages/indexer/Cargo.toml -- --library ~/Music
# postgres for users + edit history
docker compose up -d postgres
# in another shell: backend + frontend
pnpm dev
the indexer takes its config from flags or env:
| flag | env | default |
|---|---|---|
--library (repeatable, :-separated) |
FROZY_LIBRARY |
- (required) |
--index-path |
FROZY_INDEX_PATH |
.runtime/index |
--listen |
FROZY_LISTEN |
127.0.0.1:3100 |
--writer-heap-mb |
FROZY_WRITER_HEAP_MB |
128 |
--no-watch |
FROZY_NO_WATCH |
false |
--reindex |
FROZY_REINDEX |
false |
log level comes from FROZY_LOG (tracing syntax); the default is
info,tantivy=warn,lofty=error - tantivy narrates every commit and gc pass at info,
and lofty warns about every mp3 whose duration it had to estimate.
tags are parsed in lofty's relaxed mode, so a single malformed frame costs that field rather than the whole file. files that still fail are logged once and then skipped until they change on disk; an explicit rescan retries all of them.
the backend reads frozy.toml (see packages/backend/frozy.example.toml), and
every value can be overridden with FROZY_-prefixed env vars using __ as the
nesting separator (FROZY_INDEXER__URL=...)
deployment
the node image is composed with nixery - the package list
is the image tag. the indexer builds on rust:slim and ships as distroless/cc.
# put your library at ./music, or edit the volume in docker-compose.override.yaml
docker compose up -d --build
note the two dockerfiles: Dockerfile is the node app, Dockerfile.indexer is the
rust one. when pushing to a registry by hand, pass -f explicitly or you will tag
the node image as the indexer:
docker buildx build --platform linux/amd64 -f Dockerfile.indexer -t <registry>/frozy-indexer:latest --push .
docker buildx build --platform linux/amd64 -f Dockerfile -t <registry>/frozy:latest --push .
the library is mounted read-only by default; drop the :ro if you want tag
editing to work