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 #

ServiceRoleRequired
JellyfinAuthentication, user list, music library, rescanYes
MusicBrainzCatalogue: search, releases, tracklistsRecommended — the public server is used as a fallback
slskdSoulseek downloadingAt least one provider
Prowlarr + qBittorrentTorrent search and downloadingAt 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 #

bash
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 --build

The interface is then available on http://your-host:8383.

Volumes

VolumeContents
/configSQLite 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

yaml
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: true

First 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.

  1. Jellyfin — the URL, for instance http://jellyfin:8096, and an API key created in Jellyfin under Dashboard → Advanced → API keys.
  2. Library — tick the music libraries to watch, and give the destination folder for imports, /music by default.
  3. 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

SettingDetail
URL / API keyAs in the wizard above
Watched librariesRestricts indexing to the music libraries you pick
Trigger a scan after importCalls POST /Library/Refresh once an album is filed
Allow every Jellyfin userTurn 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

SettingDetail
URLhttp://slskd:5030
API keyFrom slskd.yml, under web.authentication.api_keys
URL prefixOnly needed when slskd runs behind a subpath
Download folderThe path as Muzikk sees it, /downloads/slskd by default
Search durationIn milliseconds — slskd reads this value as milliseconds despite its own documentation
Minimum peer speed / maximum queueFilters out peers that are too slow or too busy

Make sure the slskd API key allows the Muzikk container address in its cidr.

Prowlarr

SettingDetail
URLhttp://prowlarr:9696
API keySettings → General → API Key
Categories3000 (Audio), 3010 (MP3), 3040 (Lossless) by default
Dedicated music searchUses type=music where the indexer supports it
Check the .torrent before adding itLeave 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

SettingDetail
URLhttp://qbittorrent:8080, adjust if WEBUI_PORT differs
Username / passwordLeave empty when authentication is disabled for the local network
Categorymuzikk, created automatically
Download folderThe same path in both containers
Keep seeding after importRecommended 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 Host header 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.

SettingEffect
Nightly analysis and its hourRe-runs the analysis every night at the given hour
Minimum files per albumBelow it, a folder counts as loose tracks rather than an album
Confidence scoreThe score above which a proposal is presented as reliable
Embedded artwork / cover.jpg / folder.jpgWhat gets written when you fix an album
AcoustIDAudio 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:

text
{albumartist}/{album} ({year})/{disc_prefix}{track:02} {artist_prefix}{title}

which gives Daft Punk/Discovery (2001)/03 Digital Love.flac.

VariableValue
{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 #

text
request → (approval) → search → candidate chosen → download
        → verification → tagging → import → Jellyfin rescan

Before 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:

  1. 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.
  2. 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.
  3. For torrents the .torrent is 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.
  4. 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.

VariableDefaultRole
PUID / PGID1000Owner of the files Muzikk writes; must match the library
UMASK002Mask applied to imported files
TZEurope/ParisContainer time zone
MUZIKK_PORT8383HTTP port
MUZIKK_CONFIG_DIR/configDatabase, keys, cache, logs
MUZIKK_STATIC_DIR/app/staticThe compiled interface
MUZIKK_LOG_LEVELINFODEBUG, INFO, WARNING
MUZIKK_WORKER_CONCURRENCY2Jobs handled in parallel, 1 to 8
MUZIKK_SESSION_HOURS336How 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

bash
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 8383

Frontend

bash
cd frontend
npm install
npm run dev          # http://localhost:5173, /api is proxied to port 8383

Quick checks, without Node or Docker

bash
.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 keys

Migrations

bash
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
The local MusicBrainz instance most likely has no Solr. The connection test says so explicitly. Enable the public fallback in the meantime.
No candidate is ever accepted
Open the request: every candidate evaluated shows its score and the reason it was rejected. The usual causes are a different track count — the wrong MusicBrainz edition was picked, so force another one from the album page — a score that is just too low, so lower the threshold in Quality, or not enough seeders.
The same album is downloaded over and over
The request log then contains the downloaded files could not be located on disk. slskd did fetch the album, but Muzikk cannot find the files and treats the candidate as a failure. Fix the download folder in the slskd section and retry the request: the files already there are imported without being downloaded again. Muzikk now stops the request immediately in this case rather than trying the next candidates, and schedules no automatic retry until the configuration is corrected.
Files are copied instead of hardlinked
The /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
Check 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
The player shows the exact reason returned by the server next to "Cannot play". The Player section must be enabled and, if direct access is off, Jellyfin has to be reachable from the container. An account that signed in before playback was enabled has no stored token yet, so the play falls back to the server API key and is not credited; signing out and back in is enough.
I cannot sign in
Muzikk stores no password, so the failure comes from Jellyfin. Check that the user is active in Muzikk under Administration → Users, and that the Jellyfin API key is still valid.

Detailed logs live in /config/logs/muzikk.log and in docker compose logs -f muzikk. Both receive the same thing.