Self-hosted music acquisition for Navidrome.
Multi-user from day one — 2FA, encrypted secrets, per-user access tokens. Spotify, Deezer or YouTube as source.
Tonus is the acquisition half of a self-hosted music setup. You search, you queue, Tonus downloads and lands the file in your Navidrome library folder. Navidrome itself stays the player — Tonus does not stream, transcode, or expose music to clients. Think of it as the "buy/grab" workflow that sits next to your existing Navidrome instance.
It's a single-tenant tool you self-host on a NAS (or any Docker host). All credentials and audio stay on your machine — no external service calls beyond the metadata APIs you opt into.
Alpha. The core acquisition pipeline (search → download → tag → file in Navidrome library) and the multi-user auth stack (JWT + Argon2id + TOTP + PATs + brute-force ban) are stable and used in production by the maintainer. Expect breaking changes — env-var renames, DB-schema migrations — until the first v1.0.0 tag.
Stable enough to use daily:
- Core acquisition: Deezer/Spotify search → YouTube download → Navidrome scan trigger → file delivered, tagged, and visible in your library
- Auth: multi-user with TOTP-2FA, Personal Access Tokens, brute-force lifetime ban
- Encrypted-at-rest provider credentials and TOTP secrets (Fernet)
- Persistent app data on a separate volume (auth + queue + settings survive image swaps)
- Multi-arch image (amd64 + arm64), pin to
:0.1for production
Working but rough edges:
- YouTube bot-detection occasionally requires a manual cookie export
- CSV-import resume after browser reload works but UI feedback is sparse
- Dual-VPN source-IP-splitting is NAS-mode only and silently disables itself if the bind addresses are unreachable
Out of scope right now: Subsonic-direct (without Navidrome), Ansible/Helm/non-Docker deployment.
Open question: Soulseek / slskd integration — see Discussions → Ideas if this matters to you.
Most self-hosted music-acquisition setups today look like this: Lidarr orchestrates, with Deemix, Slskd / Soulseek, Tubifarry or Naviseerr layered on top. That works, but it's 2–5 components to deploy, monitor, and update.
Tonus exists because that stack is heavy for a single-household use case, and because none of those components are designed multi-user-first. Here's how the picture compares:
| Lidarr + Deemix | Lidarr + Tubifarry | Naviseerr | MusicSeerr | Tonus | |
|---|---|---|---|---|---|
| Components to deploy | 2 | 1 (plugin in Lidarr) | 4 (Navidrome + Lidarr + slskd + sidecar) | 1+ (Lidarr required) | 1 + Navidrome |
| Requires Lidarr | yes | yes | yes | yes | no |
| YouTube as audio source | no (Deezer only) | yes | no | depends on Lidarr indexer | yes |
| Spotify catalog search | no | yes (playlist import) | no | yes (as request UI) | yes |
| Soulseek / slskd | optional | yes | required | optional | no (open question — see Discussions) |
| Per-user accounts (built-in) | reverse-proxy only | inherits Lidarr's | reverse-proxy only | yes | yes |
| 2FA / TOTP (built-in) | no | no | no | partial | yes |
| Encrypted secrets at rest | no | no | no | no | yes (Fernet) |
| Source-IP-splitting (built-in) | no | no | external VPN container | external | yes (dual-lane) |
| UI for provider config | partial (via Lidarr) | inherits | none | yes | yes (encrypted in DB) |
| Stack complexity (1 = simple, 5 = heavy) | 3 | 3 | 5 | 4 | 2 |
If your acquisition needs are already handled by a Lidarr-based stack and Soulseek is critical to you, Tonus is not a drop-in replacement — yet. If you don't need Soulseek and you're tired of orchestrating four containers, Tonus is the simpler shape.
- Multi-provider search — Deezer (free, no key) or Spotify (catalog) for metadata; audio stream falls back across Deezer ↔ YouTube as needed
- First-run onboarding wizard — admin account, optional 2FA (TOTP), provider connections, all in the browser
- Per-user accounts with admin separation — non-admins see a stripped-down settings panel; admins manage users, tokens, providers, bans
- Personal Access Tokens (PATs) — for the Navidrome plugin and scripts; revocable, scoped to your account
- Brute-force protection — five failed logins from the same IP triggers a lifetime ban (admin can unban from Settings → Brute-force)
- Hot-reloadable defaults — change worker cooldowns, default provider, audio codec from the UI without a container restart
- Dual-VPN source-IP splitting (optional, NAS-mode) — bind alternating download threads to two network interfaces for higher throughput on rate-limited APIs
- Persistent state on a separate volume — auth DB and queue jobs live in
/app/data/; clearing the audio download cache cannot wipe your users - Navidrome plugin — Go binary that triggers per-user discovery + auto-playlist runs in Navidrome via PAT auth. Plugin repo is being prepared for public release — track progress in issue #3.
- Docker and
docker composev2 - A Navidrome instance (or any music library folder Tonus can write to)
- ~1 GB free disk for in-flight downloads (cleared on each completed job)
# Get the code
git clone https://github.com/madmax1301/tonus.git
cd tonus
# Local data folders next to the compose file (matches default volume paths)
mkdir -p tonus-data downloads
# Point the music volume at your Navidrome library
$EDITOR docker-compose.yml # replace /path/to/music with your actual library path
# Configure
cp .env.example .env
$EDITOR .env # at minimum: NAVIDROME_* + a strong TONUS_API_TOKENOn a Synology NAS where absolute paths are nicer, either edit docker-compose.yml directly to point at /volume1/docker/... or drop a docker-compose.override.yml next to it that only overrides the volumes: section.
docker compose up -d
# UI: http://<host>:8088The shipped docker-compose.yml pulls a prebuilt image from GitHub Container Registry. To build from a local checkout instead, see Building from source below.
On first open the setup wizard creates your admin account and walks you through 2FA + provider connections. After that everything is configurable from Settings.
docker-compose.override.yml ships with the repo and is auto-merged by docker compose. It swaps network_mode: host for bridge + port mapping and points volumes at ./test-{music,downloads,data} so a fresh git clone runs on macOS Docker Desktop without changes. On the NAS, do not commit a local override — the base file is what's used in production.
- Setup wizard — empty user table → setup form → admin account
- 2FA wizard — server-rendered SVG QR for any TOTP app (Authy, 1Password, Aegis…); recovery code shown once
- Onboarding step 2/2 — pick the providers you want connected; expandable help blocks explain credential setup (Spotify Dashboard, Navidrome admin, etc.)
- Settings → Verbindungen later on — change provider credentials, swap defaults, configure cooldowns
If TONUS_API_TOKEN is set in .env, the legacy static-token plugin path remains active in parallel until you migrate the plugin to a PAT. See Authentication → Migration below.
┌─────────── Browser ───────────┐ ┌─────── Navidrome plugin ──────┐
│ SvelteKit (adapter-static) │ │ Go binary, PAT auth │
└──────────────┬────────────────┘ └──────────────┬────────────────┘
│ JWT cookies │ Bearer PAT
▼ ▼
┌──────────────────────────────────────────────────────────────────┐
│ FastAPI (uvicorn) │
│ ┌─────────────┐ ┌─────────────┐ ┌──────────────┐ ┌───────────┐ │
│ │ Auth/User │ │ Job-Queue │ │ Workers │ │ Settings │ │
│ │ JWT + Argon2│ │ SQLite WAL │ │ asyncio + │ │ DB-backed │ │
│ │ TOTP + bans │ │ idempotent │ │ source-IP │ │ overrides │ │
│ └─────────────┘ └─────────────┘ └──────────────┘ └───────────┘ │
└──────────────────────────────────────────────────────────────────┘
│
├─── /app/data/jobs.db (auth + queue + settings, persistent)
├─── /app/downloads/ (in-flight audio, ephemeral)
└─── /music/ (Navidrome library, final destination)
Why two volumes? /app/downloads is a working area for in-flight files — clearing it during cleanup must not destroy your users or queue history. /app/data is the durable store. Bind-mount both separately.
The plugin lives in its own repo and will be published once it's ready for external use — track progress in issue #3.
Tonus has three auth paths, each meant for a specific consumer:
| Path | For | Lifetime | Where |
|---|---|---|---|
| JWT | Browser sessions | Refresh-rotated, ~30 d | Login page, cookie-bound |
| PAT | Navidrome plugin, scripts, curl | User-defined (no expiry possible) | Settings → API-Tokens |
| TOTP | Optional 2nd factor on top of password | Per-user toggle | Settings → Sicherheit |
Five failed logins from the same IP within 24 h → lifetime ban. The ban list lives in /app/data/jobs.db (table banned_ips) and is checked before every authenticated request. The host's loopback range is permanently exempt so localhost/Docker-internal calls cannot self-ban. Admins inspect and unban from Settings → Brute-force.
The legacy static-token path stays available but is deprecated:
- With
TONUS_API_TOKENset, the onboarding wizard still opens as long as the user table is empty. Create your admin account through the wizard. - Configure the Navidrome plugin to use a PAT (Settings → API-Tokens) instead of
TONUS_API_TOKEN. Restart the plugin; queue jobs now arrive tagged with your user. - Remove
TONUS_API_TOKENfrom.envand restart Tonus. The legacy path is closed; all calls require JWT or PAT.
Rollback: revoke the PAT, restore TONUS_API_TOKEN, restart.
Tonus reads configuration from two sources, in this priority:
/app/data/jobs.db→app_settingstable (highest) — what you set via Settings → Verbindungen / Defaults in the UI.envfile — bootstrap values, used when the DB has no override
This means once you've configured a provider in the UI, the .env value for it is ignored. You can keep .env for first-boot bootstrap and then forget it.
| Variable | Default | Required | Purpose |
|---|---|---|---|
| Metadata providers | |||
DEFAULT_METADATA_PROVIDER |
deezer |
no | deezer (free, no key) or spotify. Overridable in Settings → Defaults. |
SPOTIFY_CLIENT_ID |
— | only with spotify |
Spotify Web API Client ID. Get one at developer.spotify.com. |
SPOTIFY_CLIENT_SECRET |
— | only with spotify |
Paired secret. Tonus uses Client-Credentials flow — no user OAuth. |
SPOTIFY_REDIRECT_URI |
http://localhost:8000/callback |
no | Reserved for future user-OAuth flow; not used by current builds. |
| Navidrome integration | |||
NAVIDROME_MUSIC_PATH |
/music (in container) |
yes | Final destination for downloaded tracks. Inside the container; bind-mounted from your Navidrome library. |
NAVIDROME_MUSIC_PATHS |
— | no | Comma- or newline-separated list of multiple library paths. Each appears as "Download to" in the UI. |
NAVIDROME_MUSIC_LABELS |
— | no | Display labels parallel to NAVIDROME_MUSIC_PATHS (e.g. Library A,Library B). |
NAVIDROME_API_URL |
http://localhost:4533 |
yes (for scan) | Navidrome HTTP endpoint. Tonus posts a scan trigger after each completed download. |
NAVIDROME_USERNAME |
admin |
yes | Navidrome admin user (needed for scan API). |
NAVIDROME_PASSWORD |
— | yes | Paired password. Stored encrypted at rest once moved into the DB layer via the UI. |
NAVIDROME_SYNC_ENABLED |
true |
no | Periodic library scan: walk NAVIDROME_MUSIC_PATH, mark catalog tracks already present. |
NAVIDROME_SYNC_INTERVAL_HOURS |
4 |
no | Hours between background sync passes. |
NAVIDROME_SYNC_INITIAL_DELAY_SEC |
120 |
no | Wait at boot before the first sync, so the worker doesn't fight cold-start I/O. |
NAVIDROME_SYNC_API_DELAY_SEC |
0.12 |
no | Throttle between Navidrome API calls during sync. |
| Storage paths | |||
JOBS_DB_PATH |
/app/data/jobs.db |
no | Auth DB, job queue, app settings, banned IPs. Override only for non-Docker setups. |
DOWNLOAD_DIR |
./downloads (/app/downloads in container) |
no | In-flight working area for audio files. Separate from JOBS_DB_PATH — clearing this folder must not wipe auth state. |
| Audio output | |||
OUTPUT_FORMAT |
mp3 |
no | Container/codec for the final file. |
AUDIO_QUALITY |
128 |
no | Bitrate in kbps. 128 is a good size/quality balance. |
TEMP_FILE_CLEANUP_DELAY_SEC |
60 |
no | Browser temp-file lifetime; avoids 404s on duplicate GETs. |
| YouTube | |||
YOUTUBE_COOKIES_PATH |
— | no | Netscape-format cookies for yt-dlp when YouTube triggers bot detection. Export with a browser extension. Trades anonymity for fewer 403/captcha hits — leave empty for anonymous mode. |
YOUTUBE_RATELIMIT_BPS |
1500000 |
no | Per-download bandwidth cap in bytes/sec. 1.5 MB/s avoids the burst-pattern that triggers CDN anti-rip detection. Lower = stealthier, slower. |
YOUTUBE_CHUNK_MIN_MB |
8 |
no | Lower bound for the random HTTP chunk size picked per download. Varying chunk sizes prevents fingerprinting Tonus across multiple downloads. |
YOUTUBE_CHUNK_MAX_MB |
16 |
no | Upper bound. Each download picks a fresh random size between min and max. |
YOUTUBE_SLEEP_REQUESTS_S |
1 |
no | Seconds to wait between API calls within a single download (metadata, format lookup). |
YOUTUBE_SLEEP_MIN_S |
5 |
no | Lower bound for yt-dlp's random sleep between fragments/requests. |
YOUTUBE_SLEEP_MAX_S |
15 |
no | Upper bound. yt-dlp randomizes between min and max — natural pauses, not clockwork. |
YOUTUBE_IMPERSONATE |
chrome |
no | TLS-handshake fingerprint to emulate (via curl-cffi). Set to empty string to disable. Other targets: firefox, safari. Massive impact on anti-bot resilience. |
YOUTUBE_PLAYER_CLIENTS |
default,web,android_vr |
no | Comma-separated list of player clients yt-dlp tries in order if one fails. default triggers the bundled po_token plugin automatically. (tv_embedded was unsupported as of yt-dlp 2026 — removed from defaults.) |
| Multi-Source-Resolver (v0.2.0+) | |||
ENABLED_SOURCES |
youtube,soundcloud |
no | Comma-separated source list searched in parallel for each track. Order = priority on score-tie. Set soundcloud,youtube to prefer auth-friction-free sources. (Bandcamp is not in defaults — yt-dlp has no bcsearch prefix; only Bandcamp URLs work as direct downloads.) |
MULTI_SOURCE_TIMEOUT_S |
10 |
no | Per-source pre-search timeout (seconds). Slow source can't block the full resolve. |
MULTI_SOURCE_MIN_SCORE |
0.65 |
no | Minimum match-score (0-1). Candidates below are dropped — prevents wrong tracks from landing in your library. |
MULTI_SOURCE_CANDIDATES_PER_SOURCE |
3 |
no | How many candidates each source returns before ranking. More = better choice, slower. |
| Logging | |||
LOG_LEVEL |
INFO |
no | Tonus' own loggers (resolver, worker, app). DEBUG / INFO / WARNING / ERROR / CRITICAL. |
UVICORN_ACCESS_LOG |
false |
no | HTTP access logs (GET /api/queue 200 OK). Default off because the UI polls every second — spam would drown out real messages. |
YT_DLP_QUIET |
false |
no | yt-dlp's own logging. true = errors only, false = full [youtube] / [soundcloud] chatter (good for debug). |
| API server | |||
API_HOST |
0.0.0.0 |
no | Bind address for uvicorn. |
API_PORT |
8000 (8088 in Docker) |
no | TCP port. The shipped docker-compose.yml forces 8088. |
CORS_ORIGINS |
http://localhost:3000,http://127.0.0.1:3000 |
no | Comma-separated list of allowed origins for the dev frontend. |
| Authentication (legacy) | |||
TONUS_API_TOKEN |
— | no | Deprecated. Static plugin token; set only during PAT migration. Remove once the plugin uses a PAT. |
| Network / VPN | |||
VPN_SPLIT_ENABLED |
true |
no | Disable for hosts without two bindable interfaces. |
VPN_SOURCE_A |
192.168.1.200 |
yes if VPN_SPLIT_ENABLED=true |
Source-IP for download lane A. Must be locally bindable, otherwise boot aborts. |
VPN_SOURCE_B |
192.168.1.201 |
yes if VPN_SPLIT_ENABLED=true |
Source-IP for download lane B. |
Once you save provider credentials through Settings → Verbindungen, Tonus stores them in app_settings encrypted with Fernet (AES-128-CBC + HMAC-SHA256). The encryption key is derived from a per-installation seed in /app/data/jobs.db. You can clear-text-edit .env but everything that goes through the UI is encrypted.
User TOTP secrets are stored encrypted with the same Fernet key. Losing /app/data/jobs.db means losing 2FA enrollments — back this volume up if you care about recovering accounts.
# Backend
cd backend
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
uvicorn app:app --reload --port 8088
# Frontend (separate shell — Vite dev server proxies API to :8088)
cd frontend
npm install
npm run dev# Python
cd backend && python -m pytest
# Svelte (typecheck + svelte-check)
cd frontend && npm run checkThe shipped docker-compose.yml only references the GHCR image (no build: directive). To build a local image — useful when you've forked the repo and want to test changes before pushing:
# Build and tag with the same name docker-compose expects
docker build -t ghcr.io/madmax1301/tonus:latest .
# Now `docker compose up -d` uses the local image instead of pulling
docker compose up -dThe Dockerfile is multi-stage: SvelteKit is built in stage 1 (node:20-alpine) and copied into the runtime image (python:3.11-slim). No separate frontend deploy needed.
cd /opt/GitHub/tonus
git pull # only needed if compose file or .env changed
docker compose pull # fetch the new image from GHCR
docker compose up -d # restart with the new image/app/data/jobs.db lives on a host bind-mount (the ./tonus-data folder by default) and survives image swaps. Do not skip the mkdir tonus-data downloads step on first install — without those bind-mount targets, the DB dies on every container recreation.
Images are built and published to GHCR by .github/workflows/build.yml on every push to main and on every vX.Y.Z git tag. Multi-arch: linux/amd64 + linux/arm64.
Tonus uses two release channels — bleeding-edge :dev for testing, and immutable semver tags (:X.Y.Z, :X.Y, :latest) for production.
| Tag | Channel | Updates when | Points to |
|---|---|---|---|
:dev |
dev (bleeding-edge) | Every push to dev |
Latest commit on dev |
:sha-abc1234 |
dev (pinned) | Never (immutable) | A specific commit |
:0.1.0 |
stable (pinned) | Never (immutable) | A specific tagged release |
:0.1 |
stable (rolling within minor) | New patch tags v0.1.x |
Latest patch in 0.1.x |
:latest |
stable (rolling) | New semver tag vX.Y.Z |
Most recent tagged release |
For production, pin to a fixed minor:
services:
tonus:
image: ghcr.io/madmax1301/tonus:0.1You'll get every patch release automatically but stay locked out of 0.2.x until you opt in. The :dev tag exists for testing parallel dev/staging deployments — it tracks dev continuously, no tag cut needed.
Is this a Lidarr replacement? For households that don't depend on Soulseek/slskd, yes. The acquisition flow (search a catalog → download from YouTube → tag → land in Navidrome) covers what most people use Lidarr+Deemix for, with one container instead of two and built-in multi-user auth. If Soulseek is your primary source, Tonus is not there yet — see Discussions → Ideas for the open thread.
Does Tonus need Navidrome to work? The acquisition pipeline (search, download, tag) runs without Navidrome — files just land in whatever folder you configure. The optional bits — library-scan trigger, "already downloaded" deduplication, the upcoming Subsonic playlist generation — need a Navidrome instance Tonus can reach via API. Subsonic-direct (without Navidrome) is not on the roadmap.
Can I use Tonus without Spotify credentials? Yes. Deezer is the default catalog, has no API key, and covers most western music. Spotify is opt-in for a richer catalog and album art. YouTube is the audio source either way.
Is Soulseek integration planned? Open question. There's a Discussions thread collecting demand. If enough Tonus users actually need it as primary source (not just "would be nice"), it goes on the roadmap.
Does this work with Jellyfin? Audio files land in a folder — any tool that scans that folder will pick them up, including Jellyfin. The Navidrome-specific integrations (scan trigger, library sync, plugin) won't fire, but the acquisition itself is library-agnostic.
Tonus is intended for personal, non-commercial use against music you have a legal right to access. You are responsible for complying with the terms of service of any provider you point it at — including Deezer, Spotify, and YouTube — and with the copyright and digital-content laws of your jurisdiction. The maintainer ships the tool; how you use it is on you.
Released under the MIT License — © 2026 madmax1301.



