- Client filters the row's album thumbnails to full albums only (hides singles/EPs left over from pre-albums-only sweeps). - Worker: re-deriving an artist deletes its pending album suggestions that are no longer picks (stale singles / now-owned), keeping wanted/dismissed rows, so the suggestion set self-cleans instead of accumulating. worker 246, web 189 tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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:
- 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. - 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.
- Discovery — ✅ done. Recommendation-driven finding of new music via
ListenBrainz similar-artists and Last.fm
artist.getSimilar(both MBID-native), behind a pluggableSimilaritySourceinterface — 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/.
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/ for the full design.
Running it
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).
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):
# 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_PASSWORDin.envto require a single shared password for the entire UI and API. A request without a valid session cookie is redirected to/login(or gets401for/api/*). The cookie is httpOnly and carries an HMAC of the password keyed byLYRA_SECRET_KEY, so it can't be forged. IfLYRA_PASSWORDis 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
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 — soDATABASE_URLchanges from@db:5432to@localhost:5432. - Published ports move to the
gluetuncontainer (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):
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_ipv6sysctl 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.