Documentation
Documentation
Installing Muzikk, pointing it at your services, and getting the paths right — which is where almost every problem actually comes from.
How it is put together #
Everything runs in a single container. FastAPI serves both the API and the compiled React interface, and an internal asyncio worker drains a job queue stored in SQLite. There is no Redis and no Postgres to provision.
Muzikk never holds a password of its own: authentication is delegated to Jellyfin, and the accounts are imported from it. Secrets you enter — API keys, the qBittorrent password — are encrypted at rest with a Fernet key generated in /config, and are returned masked by the API.
Requirements #
| Service | Role | Required |
|---|---|---|
| Jellyfin | Authentication, user list, music library, rescan | Yes |
| MusicBrainz | Catalogue: search, releases, tracklists | Recommended — the public server is used as a fallback |
| slskd | Soulseek downloading | At least one provider |
| Prowlarr + qBittorrent | Torrent search and downloading | At least one provider |
A shared external Docker network — named mediastack in the compose file that ships with the project — lets Muzikk reach those containers by name.
Installation #
docker network create mediastack # if it does not exist yet
git clone <your-repository> muzikk
cd muzikk
cp .env.example .env
$EDITOR .env # PUID/PGID, MUSIC_LIBRARY, DOWNLOADS_ROOT
docker compose up -d --buildThe interface is then available on http://your-host:8383.
Volumes
| Volume | Contents |
|---|---|
/config | SQLite database, encryption key, JWT key, cover cache, logs |
MUSIC_LIBRARY_CONTAINER (/music) | The Jellyfin library, and where imports are filed |
DOWNLOADS_CONTAINER (/downloads) | The download root shared with slskd and qBittorrent |
The host paths come from MUSIC_LIBRARY and DOWNLOADS_ROOT, the paths inside the container from MUSIC_LIBRARY_CONTAINER and DOWNLOADS_CONTAINER. Those last two exist because the other containers do not necessarily see the disks in the same place: give Muzikk the path Jellyfin uses for the library, and the one slskd and qBittorrent use for downloads. Mounting the same host disk twice on two different paths is perfectly fine — hardlinks keep working, since it is still one filesystem.
The compose file
services:
muzikk:
build:
context: .
dockerfile: Dockerfile
image: muzikk:latest
container_name: muzikk
restart: unless-stopped
environment:
PUID: ${PUID:-1000}
PGID: ${PGID:-1000}
UMASK: ${UMASK:-002}
TZ: ${TZ:-Europe/Paris}
MUZIKK_PORT: 8383
MUZIKK_LOG_LEVEL: ${MUZIKK_LOG_LEVEL:-INFO}
ports:
- "${MUZIKK_PORT:-8383}:8383"
volumes:
- ${MUZIKK_CONFIG:-./config}:/config
- ${MUSIC_LIBRARY:?set MUSIC_LIBRARY in .env}:${MUSIC_LIBRARY_CONTAINER:-/music}
- ${DOWNLOADS_ROOT:?set DOWNLOADS_ROOT in .env}:${DOWNLOADS_CONTAINER:-/downloads}
extra_hosts:
- "host.docker.internal:host-gateway"
networks:
- mediastack
networks:
mediastack:
external: trueFirst start #
Because Muzikk authenticates through Jellyfin, nobody can sign in until Jellyfin is configured. A setup wizard is therefore open on the very first launch, and closed for good afterwards.
- Jellyfin — the URL, for instance
http://jellyfin:8096, and an API key created in Jellyfin under Dashboard → Advanced → API keys. - Library — tick the music libraries to watch, and give the destination folder for imports,
/musicby default. - Jellyfin users are imported. Sign in with a Jellyfin administrator account: it becomes a Muzikk administrator.
Indexing the library starts in the background. Depending on its size, expect a few minutes before the "already owned" badges show up.
Configuring the services #
Everything is set in Administration, one section at a time. Each service has a Test connection button that uses the values currently on screen, including the ones you have not saved yet. Secrets come back masked: leave a masked field alone to keep its current value.
Jellyfin
| Setting | Detail |
|---|---|
| URL / API key | As in the wizard above |
| Watched libraries | Restricts indexing to the music libraries you pick |
| Trigger a scan after import | Calls POST /Library/Refresh once an album is filed |
| Allow every Jellyfin user | Turn it off and only the listed user ids may sign in |
Jellyfin administrators — Policy.IsAdministrator — are Muzikk administrators, and that status is refreshed at each sign-in and each user sync.
MusicBrainz
Point the URL at your local instance, for example http://musicbrainz:5000. Full-text search needs Solr; without it only identifier lookups work, and Muzikk falls back to musicbrainz.org if the fallback is enabled. Rate limits are honoured separately for the local instance (10 requests per second by default) and for the public server (1 per second, as MusicBrainz requires).
Label pages deserve a word: MusicBrainz attaches labels to releases rather than to albums, so Muzikk walks a label's releases and folds them back into albums. A record pressed five times shows up once, and the five pressings still help recognise what you already own. The counter on the label page is therefore a number of releases, larger than the number of tiles.
Cover art
Artwork comes from the Cover Art Archive and is cached in /config/cache. You choose the size, whether it is embedded in the files, and whether a cover.jpg and a folder.jpg are written next to the tracks. Both names exist because players disagree: Jellyfin and Kodi read either, Plex only looks at cover.jpg, others only at folder.jpg. Writing both costs a few kilobytes and settles the question.
For a catalogue album, Muzikk tries the release artwork then the release group. For an album you already own, it tries in turn the Jellyfin image, a cover.jpg, folder.jpg or front.jpg in the album folder, the artwork embedded in the tags of the first track, and finally the Cover Art Archive. Anything found is cached and resized.
The Clear the cover cache button in the System section forces a fresh lookup, which is handy after adding missing artwork to your library. The cache is purged automatically when you change the service URL.
slskd
| Setting | Detail |
|---|---|
| URL | http://slskd:5030 |
| API key | From slskd.yml, under web.authentication.api_keys |
| URL prefix | Only needed when slskd runs behind a subpath |
| Download folder | The path as Muzikk sees it, /downloads/slskd by default |
| Search duration | In milliseconds — slskd reads this value as milliseconds despite its own documentation |
| Minimum peer speed / maximum queue | Filters out peers that are too slow or too busy |
Make sure the slskd API key allows the Muzikk container address in its cidr.
Prowlarr
| Setting | Detail |
|---|---|
| URL | http://prowlarr:9696 |
| API key | Settings → General → API Key |
| Categories | 3000 (Audio), 3010 (MP3), 3040 (Lossless) by default |
| Dedicated music search | Uses type=music where the indexer supports it |
Check the .torrent before adding it | Leave this on — it is what avoids most false positives |
Once saved, go to Indexers and run the sync. Each indexer can then be enabled, prioritised, classified public or private, and given a seeder threshold of its own.
qBittorrent
| Setting | Detail |
|---|---|
| URL | http://qbittorrent:8080, adjust if WEBUI_PORT differs |
| Username / password | Leave empty when authentication is disabled for the local network |
| Category | muzikk, created automatically |
| Download folder | The same path in both containers |
| Keep seeding after import | Recommended for private trackers |
Authentication changed with qBittorrent 5.2: a successful login returns an empty 204 instead of a 200 containing Ok., a wrong password returns 401 instead of a 200 containing Fails., and the session cookie was renamed. Muzikk handles both generations. To read a failure:
- HTTP 401 — credentials refused on qBittorrent 5.2 and later. On earlier versions this code usually means the
Hostheader was rejected, which is common when reaching qBittorrent by container name: untick Enable Host header validation in Tools → Options → Web UI. Fails.— credentials refused on qBittorrent 5.1 and earlier.- HTTP 403 — after a few failures qBittorrent temporarily bans the address. Restart the container to lift it.
The alternative that avoids all of this is to tick Bypass authentication for clients in whitelisted IP subnets with your Docker subnet, and leave the username empty in Muzikk.
Quality
Accepted lossless formats from best to worst, an optional compressed fallback, size thresholds per track, a seeder count, a tolerance on the track count and a minimum score for acceptance — 78 by default. Lower it if too many albums fail, raise it if bad ones get through.
Require the artist in the candidate path rejects a release whose path names no artist resembling the one requested. The artist is only worth 20 points out of 100, so without this rule a namesake's album — same title, same track count, same format — clears the threshold and can be imported instead of the right one. The price is that a folder named after the album alone, without its artist, is refused too: untick the rule if your sources are organised that way.
Provider order
Reorder the slskd, public trackers and private trackers groups. The first one to offer a candidate above the threshold wins, and the others are never queried.
Metadata
This section drives the metadata workshop, which administrators reach from the Metadata entry in the menu. The analysis walks the library folder, groups files per album and reports seven anomalies: no MusicBrainz tag, a match that is only probable, missing artwork, incomplete tags, doubled tags, a duplicate, and a folder Jellyfin cannot see.
| Setting | Effect |
|---|---|
| Nightly analysis and its hour | Re-runs the analysis every night at the given hour |
| Minimum files per album | Below it, a folder counts as loose tracks rather than an album |
| Confidence score | The score above which a proposal is presented as reliable |
Embedded artwork / cover.jpg / folder.jpg | What gets written when you fix an album |
| AcoustID | Audio fingerprinting; needs a free API key |
Nothing is written without confirmation: each album is simulated first, field by field, before and after. Once the tags are fixed, the Reindex Jellyfin button makes the server rediscover the folders it had ignored.
Audio fingerprinting relies on fpcalc, provided by the image's libchromaprint-tools package. If your image was built before that feature existed, rebuild it.
Player
By default Muzikk reads the file straight from the library folder, resolving the path even when Jellyfin sees it under a different mount point. It is faster and depends on no Jellyfin playback policy. Untick Read files from the music folder if the library is only visible to Jellyfin: the stream is then relayed by the API, and the browser never receives a token. Exotic formats — APE, DSF, WavPack — always go through Jellyfin, which transcodes them.
The maximum bitrate, in bits per second, applies to that relay; 0 streams the original file. Plays can be reported to Jellyfin under the listener's account, which assumes they have signed in to Muzikk since playback was enabled, so that their token has been stored.
Users and permissions
Accounts come from Jellyfin. For each one you grant, independently: Account active, Request albums, Upgrade to lossless and Import a folder. Automatic approval is a three-way choice — follow the global setting, always, or never — and the weekly quota is a number, zero meaning unlimited. Administrators bypass permissions and quotas.
Naming template #
The default template reproduces the usual Picard script:
{albumartist}/{album} ({year})/{disc_prefix}{track:02} {artist_prefix}{title}which gives Daft Punk/Discovery (2001)/03 Digital Love.flac.
| Variable | Value |
|---|---|
{albumartist} {artist} | Album artist / track artist |
{album} {title} | Album title / track title |
{year} {date} | Year, full release date |
{track} {disc} {totaldiscs} | Numbers; {track:02} pads to two digits |
{disc_prefix} | Empty on a single disc, 1- otherwise, 01- beyond nine discs |
{artist_prefix} | Empty, except on a multi-artist album where it becomes Artist - |
{ext} | File extension, appended automatically when absent |
The preview updates as you type, on three representative examples: a plain album, a multi-disc box set and a compilation.
How acquisition works #
request → (approval) → search → candidate chosen → download
→ verification → tagging → import → Jellyfin rescanBefore searching anything, Muzikk inspects the slskd download folder: if the album already sits there complete, it is imported directly, without going back to the network. That candidate folder is scored exactly like a remote one — format, track count, titles, size per track — so a partial download or a different album cannot be picked up by mistake. This is what avoids re-downloading an album whose import failed for a configuration reason.
Then, for each provider, in the configured order:
- Search from the artist, the normalised title, the year and the track count. Up to four wordings are tried, each dropping something the peer may not have written: the release type first — Soulseek only answers when every word appears in the path, so searching "Pharaoh EP" never finds a folder named "Eekoz - Pharaoh" — then edition mentions. The bare title, without the artist, goes last: it is the only wording that returns hundreds of unrelated folders.
- Score each candidate: artist and title similarity with
rapidfuzz, after stripping accents, punctuation and mentions like "deluxe" or "remaster"; track count match; track title coverage; detected format; consistency of the size per track; seeders or peer speed. - For torrents the
.torrentis fetched and its file list read before it is handed to qBittorrent. For slskd, results are grouped per remote folder, and a folder holding a single audio file is discarded — an isolated track named after the album is not the album. Unless MusicBrainz says the release has one track, in which case one file is enough. - The best candidate above the threshold is queued; otherwise Muzikk moves to the next provider. If they all fail, the request is marked failed and retried later on its own.
Each request keeps every candidate it evaluated, with its score and the reason it was rejected, readable from the Requests page. Bulk actions at the top of that page clear imported requests, clear failed ones, retry every failure at once, or cancel the active ones — each acting on the whole matching list, not only the rows on screen.
Approving an upgrade before the old version is deleted
An upgrade almost always lands in the very folder it improves, since the naming scheme yields the same artist, album and year. The old MP3s and the new FLACs end up side by side in one folder, which is exactly what makes deleting that folder unsafe.
So at the end of an upgrade import, Muzikk records both versions file by file — format, resolution, bitrate, duration, size — and puts the request in the To approve state without deleting anything. The requester or an administrator opens it, compares the two columns, plays either side, then chooses:
- Approve deletes the old audio files only, keeping the folder artwork and the tracks that were just written. If the new folder is elsewhere, the old one goes entirely.
- Reject does the opposite: the freshly imported files are removed and the old version stays. Only the folder artwork, rewritten on import, cannot be restored.
The setting Ask for a validation before deleting the old copy, in the Naming section, turns this step off and makes the deletion immediate again.
Following an artist without downloading anything
Following an artist triggers no download. The periodic check compares their MusicBrainz discography to the library and records what is missing; the Watchlist tab shows it, and every album waits for a click. Each followed artist has its own scope: New releases shows only the last 400 days, Discography shows everything missing. Albums, EPs and singles count in both cases; compilations, live albums and remixes are left out.
Environment variables #
Only infrastructure and paths go through the environment; everything else is configured in the interface.
| Variable | Default | Role |
|---|---|---|
PUID / PGID | 1000 | Owner of the files Muzikk writes; must match the library |
UMASK | 002 | Mask applied to imported files |
TZ | Europe/Paris | Container time zone |
MUZIKK_PORT | 8383 | HTTP port |
MUZIKK_CONFIG_DIR | /config | Database, keys, cache, logs |
MUZIKK_STATIC_DIR | /app/static | The compiled interface |
MUZIKK_LOG_LEVEL | INFO | DEBUG, INFO, WARNING |
MUZIKK_WORKER_CONCURRENCY | 2 | Jobs handled in parallel, 1 to 8 |
MUZIKK_SESSION_HOURS | 336 | How long a session stays valid |
In the compose file, MUSIC_LIBRARY and DOWNLOADS_ROOT are the host paths, MUSIC_LIBRARY_CONTAINER and DOWNLOADS_CONTAINER the matching paths inside the container.
Development #
Backend
python -m venv .venv
.venv/bin/pip install -r backend/requirements.txt
MUZIKK_CONFIG_DIR=./config .venv/bin/uvicorn muzikk.main:app \
--reload --app-dir backend --port 8383Frontend
cd frontend
npm install
npm run dev # http://localhost:5173, /api is proxied to port 8383Quick checks, without Node or Docker
.venv/bin/python -m ruff check backend # lint
.venv/bin/python backend/smoke_test.py # startup, routes and auth guards
.venv/bin/python backend/pipeline_test.py # naming, matching, file/track pairing
.venv/bin/python backend/artwork_test.py # artwork resolution
.venv/bin/python backend/metadata_test.py # library analysis and tag reading
.venv/bin/python backend/local_import_test.py # local folder import
.venv/bin/python backend/playback_test.py # file lookup and Range requests
.venv/bin/python frontend/check_frontend.py # imports and translation keysMigrations
cd backend && alembic revision --autogenerate -m "description"Migrations are applied automatically at startup. On an empty database the schema is created and then stamped at the latest revision.
Troubleshooting #
Search returns nothing
No candidate is ever accepted
The same album is downloaded over and over
Files are copied instead of hardlinked
/downloads and /music paths must be on the same filesystem and mounted at the same location in every container. A separate network mount forces a copy.Jellyfin does not see the new albums
PUID, PGID and UMASK: Jellyfin has to be able to read the files. The automatic rescan can also be turned off in the Jellyfin settings. If a folder stays invisible despite a rescan, it appears in Metadata under "Missing from Jellyfin" — almost always an album without an album or albumartist tag, which the media server cannot classify. Fix the tags from that page, then reindex.Playback does not start
I cannot sign in
Detailed logs live in /config/logs/muzikk.log and in docker compose logs -f muzikk. Both receive the same thing.