searchReleaseGroup used a strict exact-phrase releasegroup:"<album>", so
Last.fm album names with per-release edition/version qualifiers ("The Stranger
(Legacy Edition)", "Nevermind - Remastered") returned 0 hits → 404 → "No
MusicBrainz match", even though MB knows the artist and the base title.
Add stripEditionQualifiers (removes a trailing edition parenthetical/bracket or
" - " suffix only when it names an edition — deluxe/remaster/legacy/edition/…,
never a plain title parenthetical) and search in decreasing precision: exact
phrase on the given title → exact phrase on the stripped title → a loose
unquoted fallback. Clean titles still resolve in one request; albumPreferenceRank
ranking unchanged. Fixes both callers (/api/mb/release-group + /api/wanted).
Verified live against real MB: "The Stranger (Legacy Edition)"→The Stranger,
"Nevermind - Remastered"→Nevermind, Continuum unchanged. web 141 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.
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.