No description
  • TypeScript 74.1%
  • Rust 22.8%
  • CSS 2.2%
  • Dockerfile 0.4%
  • JavaScript 0.4%
  • Other 0.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-08-14 18:11:16 +03:00
packages feat: redesign settings 2026-08-14 18:11:16 +03:00
.dockerignore feat: initial 2026-08-14 13:47:55 +03:00
.gitignore feat: initial 2026-08-14 13:47:55 +03:00
docker-compose.yaml chore: mrrp mrrp 2026-08-14 14:15:48 +03:00
Dockerfile feat: initial 2026-08-14 13:47:55 +03:00
Dockerfile.indexer feat: initial 2026-08-14 13:47:55 +03:00
eslint.config.js feat: initial 2026-08-14 13:47:55 +03:00
package.json feat: initial 2026-08-14 13:47:55 +03:00
pnpm-lock.yaml feat: initial 2026-08-14 13:47:55 +03:00
pnpm-workspace.yaml feat: initial 2026-08-14 13:47:55 +03:00
README.md feat: better logs yk? 2026-08-14 14:19:50 +03:00
tsconfig.json feat: initial 2026-08-14 13:47:55 +03:00
uno.config.ts feat: initial 2026-08-14 13:47:55 +03:00

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 with lofty for tags, keeps everything in a tantivy index, watches the folder for changes, and writes tags back on request
  • packages/backend - thin node fasade (hono + trpc v11 over websocket) that the frontend talks to
  • packages/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