# Lyra Self-hosted, multi-source music acquisition and library tool — a replacement for Lidarr + NZBget. Lyra discovers, ranks, downloads, tags, and organizes music across sources of differing quality: **Qobuz** (lossless/hi-res), **Soulseek** (P2P), and **YouTube** (universal lossy fallback), with recommendation-driven discovery via **ListenBrainz** and **Last.fm** (Spotify's recommendation/related-artists APIs were retired for new apps, so discovery is MetaBrainz-native and MBID-clean — no Spotify). ## Status Built in three slices: 1. **Acquisition engine** — ✅ **done**. Resolve a request against MusicBrainz, match/rank across Qobuz/Soulseek/YouTube, download the best source into an isolated staging dir, verify completeness, tag (mutagen), and atomically promote the clean album into `Artist/Album (Year)/## Title.ext`. 2. **Library manager** — ✅ **done**. Watched artists (live MB search + follow), per-release monitor toggles + auto-monitor-future, a wanted list with retry + quality-upgrade window, and a worker "monitor" that discovers new releases and auto-grabs through the acquisition pipeline. Plus a **library scan** that ingests existing on-disk albums as have + monitored + followed, and a per-artist studio-only / show-all release-type filter. 3. **Discovery** — ✅ **done**. Recommendation-driven finding of new music via **ListenBrainz** similar-artists and **Last.fm** `artist.getSimilar` (both MBID-native), behind a pluggable `SimilaritySource` interface — Last.fm registers as a second source automatically once its API key is set, and cross-source scores are summed per candidate. A background sweep (and a "Discover now" button) aggregates artists similar to the ones you follow into a manual review feed of suggested **artists** and **albums** at `/discover`; **Follow** and **Want** reuse the slice-2 follow / wanted flows, **Dismiss** is permanent. Plus a web-side seed-search box. Every suggestion and seed-search result opens a read-only **artist preview** (`/discover/artist/[mbid]`) — discography, per-album tracklists, and "in library" / "monitored" badges — where you can Follow or Want before committing. No Spotify (its recommendation APIs are dead for new apps). Alongside the three slices: - **Last.fm browse** (`/lastfm`) — your Last.fm top artists and albums (by period), with a "hide items already in my library" toggle; each artist opens a modal of your most-played songs and albums, and every album is one click to **Want**. - **Library** (`/library`) — a cover-art grid of everything on disk, filterable, each album opening a tracklist modal. - **The Floor** (home) — a live queue of in-flight acquisitions with three-phase progress (**Searching → Downloading → Finishing**), a live download percentage, a per-job "pressing ticket" (source · format · tracks · elapsed), and a **Retry** button on anything that needs attention. The whole UI runs a hand-rolled "Pressing Plant" design system (warm-paper theme, self-hosted Fraunces serif for prose vs. system mono for machine data, light/dark aware). **Operational notes:** the monitor and the discovery sweep are both **off by default** — set `monitor.enabled=true` (or Settings → Monitor, + optional `monitor.*` tuning) to activate auto-grab/upgrade, and `discover.enabled=true` (or Settings → Discovery, + optional `discover.*` tuning) to activate the scheduled discovery sweep. The `/discover` "Discover now" button and seed-search work with the sweep off. Credentials (Qobuz, Soulseek, Last.fm) are entered in Settings and persist across `docker compose up -d --build` rebuilds (don't use `down -v`; secrets are encrypted at rest with `LYRA_SECRET_KEY`). Tests run against a separate `lyra_test` database (a guard blocks the live DB). Deferred hardening + the full roadmap live in the implementation-plan "Notes" sections under [`docs/superpowers/plans/`](docs/superpowers/plans/). ## Architecture The `docker compose` stack is three services on a home server: - **db** — Postgres, the shared source of truth. - **web** — the Next.js app: UI + API (search, request, queue, progress, settings). - **worker** — the Python worker: all source integration (streamrip, yt-dlp, slskd client, mutagen; MusicBrainz + ListenBrainz + Last.fm for metadata and discovery), running the six-stage acquisition pipeline plus the background monitor and discovery sweeps. Soulseek support talks to an **external slskd** daemon (an off-the-shelf headless Soulseek client) — the worker reaches it via the `slskd.url` + `slskd.api_key` credentials set in Settings, so run slskd separately rather than as part of this stack. A shared `/music` volume is the library, with a separate `/staging` volume for in-progress downloads (point `STAGING_DIR` at a fast local disk when `MUSIC_DIR` is a network share). See [`docs/superpowers/specs/`](docs/superpowers/specs/) for the full design. ## Running it ```sh cp .env.example .env # then edit: set LYRA_SECRET_KEY (openssl rand -base64 32) and MUSIC_DIR docker compose up -d --build # web on http://localhost:8770 ; NEVER `down -v` (wipes the Postgres volume) ``` **Generate your own `LYRA_SECRET_KEY`** (`openssl rand -base64 32`) before first run — it encrypts your stored Qobuz/Soulseek/Last.fm credentials. `.env.example` ships an obvious placeholder, and both web and worker **refuse to start** while the key is missing or still the placeholder. Once set, keep it stable: changing it makes already-stored credentials undecryptable (you'd re-enter them in Settings). Optionally set `LYRA_PASSWORD` too (see [Security](#security)). The web entrypoint runs `prisma migrate deploy` on every rebuild, so schema changes reach the live DB automatically. Enter Qobuz / Soulseek / Last.fm credentials in **Settings** (they persist across rebuilds), then follow an artist or request an album to feed The Floor. ## Managing the library Open an album on the **Library** page and use **Delete album** to remove it: the folder is deleted from disk and the album is dropped and un-monitored (so auto-monitor won't re-grab it — re-Want it from the artist page if you change your mind). This is why the `web` service also bind-mounts `MUSIC_DIR` at `/music` (read-write) — deletion is guarded to only ever remove folders strictly inside the library root. ## Backup & restore **All** durable state — library metadata, monitored/wanted lists, discovery data, config, and the encrypted Qobuz/Soulseek/Last.fm credentials — lives in the single `postgres-data` volume. Losing that volume (disk failure, or a stray `down -v`) means losing everything, recoverable only by a full re-scan and re-entering every credential. The `db-backup` sidecar guards against that. It runs `pg_dump` immediately on start and then every `BACKUP_INTERVAL_SECONDS` (default daily), keeping the last `BACKUP_KEEP` dumps (default 7) as compressed custom-format `.dump` files in `BACKUP_DIR` (default `./backups`). It starts automatically with `docker compose up -d`. For **off-box** safety (a backup on the same disk doesn't survive a disk failure), point `BACKUP_DIR` at a NAS/synced path, or rsync `./backups` to another machine on a cron. **Restore** into a fresh/empty database (stop `web`/`worker` first so nothing writes mid-restore): ```sh # with the stack up and db healthy: docker compose stop web worker docker compose cp ./backups/lyra-YYYYMMDD-HHMMSS.dump db:/tmp/restore.dump docker compose exec db pg_restore --clean --if-exists -U lyra -d lyra /tmp/restore.dump docker compose start web worker ``` `--clean --if-exists` drops and recreates each object, so restoring over an existing DB is safe. To restore onto a brand-new host, bring up just `db` first (`docker compose up -d db`), restore, then `up -d --build` the rest. ## Security Lyra has **no per-user accounts**. Two layers protect it: - **Shared-password gate.** Set `LYRA_PASSWORD` in `.env` to require a single shared password for the entire UI and API. A request without a valid session cookie is redirected to `/login` (or gets `401` for `/api/*`). The cookie is httpOnly and carries an HMAC of the password keyed by `LYRA_SECRET_KEY`, so it can't be forged. **If `LYRA_PASSWORD` is unset, the app is completely open** — anyone who can reach the host can browse the library, trigger downloads, and read/overwrite the stored credentials in Settings. Set it for any deployment that isn't on a fully trusted, isolated network. - **Network.** The shared password is deliberately minimal (single password, bearer cookie, plain HTTP on the LAN). For anything internet-facing, also put Lyra behind a reverse proxy with TLS and/or a VPN or auth gateway — don't rely on the shared password alone. ## Running behind a VPN (Gluetun) Lyra runs fine with its traffic routed through a [Gluetun](https://github.com/qdm12/gluetun) VPN container — useful for hiding the download traffic (Soulseek / Qobuz / YouTube). Nothing in Lyra is VPN-incompatible; the only thing that changes is container networking, because a service using `network_mode: "service:gluetun"` gives up its own network stack and shares Gluetun's. Two consequences: - Containers that share Gluetun's namespace reach each other over **`localhost`**, not by Docker service name — so `DATABASE_URL` changes from `@db:5432` to **`@localhost:5432`**. - **Published ports move to the `gluetun` container** (the individual services can no longer publish their own). The simplest, most consistent setup is to put the **whole stack** behind Gluetun. Sketch (merge into your existing Gluetun compose; adjust the VPN provider block to yours): ```yaml services: gluetun: image: qmcgaw/gluetun cap_add: [NET_ADMIN] environment: # ... your VPN provider / credentials ... FIREWALL_INPUT_PORTS: "3000" # let your browser reach the web UI FIREWALL_OUTBOUND_SUBNETS: "192.168.0.0/16" # your LAN — so the worker can reach slskd ports: - "8770:3000" # Lyra web (published on gluetun, not on `web`) # - "5432:5432" # only if you want DB access from the LAN web: build: ./web network_mode: "service:gluetun" # no `ports:` / `networks:` here environment: DATABASE_URL: postgresql://lyra:lyra@localhost:5432/lyra # `db` -> `localhost` LYRA_SECRET_KEY: ${LYRA_SECRET_KEY} LYRA_PASSWORD: ${LYRA_PASSWORD:-} HOSTNAME: 0.0.0.0 LYRA_LIBRARY_ROOT: /music volumes: - ${MUSIC_DIR:-./music}:/music depends_on: gluetun: { condition: service_healthy } db: { condition: service_healthy } worker: build: ./worker network_mode: "service:gluetun" environment: DATABASE_URL: postgresql://lyra:lyra@localhost:5432/lyra LYRA_SECRET_KEY: ${LYRA_SECRET_KEY} STAGING_ROOT: /staging SLSKD_DOWNLOADS_ROOT: /slskd-downloads volumes: - ${MUSIC_DIR:-./music}:/music - ${STAGING_DIR:-${MUSIC_DIR:-./music}/.staging}:/staging - ${SLSKD_DOWNLOADS_DIR:-./slskd-downloads}:/slskd-downloads depends_on: gluetun: { condition: service_healthy } db: { condition: service_healthy } db: image: postgres:17 network_mode: "service:gluetun" # shares the namespace so `localhost:5432` resolves environment: POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: ${POSTGRES_DB} volumes: - postgres-data:/var/lib/postgresql/data ``` (Give `db-backup` the same `network_mode` and `PGHOST: localhost`.) The `HOSTNAME=0.0.0.0` and the built-in healthchecks keep working, since everything is in one namespace. Caveats worth knowing: - **Qobuz is geo-sensitive** — its catalog/availability varies by country, so pick a VPN exit in a region where your Qobuz account works, or some downloads will fail to match. - The worker's `net.ipv6.conf.all.disable_ipv6` sysctl **can't apply behind Gluetun** (a shared netns can't set sysctls) — harmless, because Gluetun blocks IPv6 anyway, which is exactly what that sysctl worked around (Qobuz's CDN resolving to an unroutable IPv6). - MusicBrainz rate-limits per IP; a shared VPN exit is marginally more likely to hit the 1 req/s ceiling, but the built-in response cache makes that a non-issue in normal use. - Bind mounts (`/music`, `/staging`, `/slskd-downloads`), the Postgres volume, and backups are filesystem, not network — **completely unaffected** by the VPN.