Jonathan 8c8a34e320 feat(library): replace/upgrade an owned album on demand
Add Request.force (migration add_request_force): the pipeline's intake now
skips the "already in library" dedupe for a forced job, so an owned album is
re-downloaded. The import step still keeps the new copy only when it's higher
quality, so a forced upgrade can never downgrade what's on disk.

- Worker: _is_force_job → dedupe bypassed when Request.force.
- POST /api/library/[id]/upgrade finds the matching MonitoredRelease and
  enqueues a force request (guards against stacking on an in-flight job).
- Library route exposes monitoredReleaseId; the album modal shows a
  "Replace / upgrade" button when a release exists.

Worker + web tests added (pipeline force re-acquire; upgrade route).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 21:28:54 +02:00

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

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_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 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):

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.
S
Description
Self-hosted multi-source music acquisition tool replacing Lidarr (Qobuz/Soulseek/YouTube/Spotify)
Readme 2.5 MiB
Languages
Python 50.7%
TypeScript 45.4%
CSS 3.6%
Dockerfile 0.2%
Shell 0.1%