Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
7.1 KiB
Lyra — Library Scan (Design)
Date: 2026-07-11 Status: Approved design, ready for implementation planning Scope: Ingest an existing on-disk library into Lyra's have + monitor state.
Problem / Goal
Lyra only "knows about" albums it downloaded (via LibraryItem/MonitoredRelease rows).
Albums already on disk — from prior downloads whose DB rows were lost, or a pre-existing
collection — are invisible: not shown as owned, not monitored, and re-downloadable. A
library scan walks /music, matches each album to MusicBrainz, and records it as
have (LibraryItem) and on monitor (MonitoredRelease, fulfilled), with the artist
followed (WatchedArtist), so the library manager reflects the real collection.
Decisions (from brainstorming)
- Identification: folder names first (
Artist/Album (Year)/), falling back to embedded tags (mutagen) when the folder doesn't yield an artist+album. - MusicBrainz matching: resolve every album; skip (log) anything MB can't match — only matched albums are recorded (nothing half-recorded).
- Trigger: a manual "Scan library" button (no auto-scan on startup — avoids re-hitting MB for the whole library on every boot).
- Artists: scanned artists become followed (
WatchedArtist,autoMonitorFuture=false) so they appear under/artists.
Non-goals
- No downloading, tagging, or file moves — scan is read-only on
/music. - No auto-scan on startup; no filesystem watching.
- No multi-disc/compilation special handling; each album folder is one album.
- No re-tagging or re-organizing existing files.
Architecture
The scan runs in the worker (it owns the /music mount, the MusicBrainz resolver, and
mutagen). The worker has no HTTP server, so the UI triggers it through the database:
[Settings "Scan library" button] --POST /api/scan--> Config: scan.requested = "true"
│
[worker loop] --sees scan.requested--> scan_library() --> upserts LibraryItem/MonitoredRelease/WatchedArtist
└─> Config: scan.result = "imported X, skipped Y", clears scan.requested
[Settings] --GET /api/scan--> reads Config: scan.result (status line)
This mirrors the existing monitor-tick pattern (the worker loop already reads config and does periodic work). No new transport.
The scan (worker/lyra_worker/scan.py)
scan_library(conn, resolver, dest_root="/music") -> ScanResult(imported, skipped). For each
Artist/Album[ (Year)]/ directory under dest_root (skipping .staging and dot-dirs):
- Identify artist + album + year:
- Parse the folder names: artist = parent dir;
album, year = parse "Name (YYYY)"(year optional). - If the folder parse yields a blank artist or album, read the first audio file's embedded
tags (mutagen:
artist,album) as a fallback. - Skip folders with no audio files.
- Parse the folder names: artist = parent dir;
- Quality: read the first audio file via mutagen (
bits_per_sample,sample_rate, lossless-by-extension) → build aQuality→quality_class(3/2/1). This is the recordedcurrentQualityClass/LibraryItem.qualityClass. - Resolve
(artist, album)via the MusicBrainz resolver → canonical artist/album/year +rgMbid+artistMbid. No match → skip (incrementskipped, log). - Record (idempotent upserts):
WatchedArtist—INSERT ... ON CONFLICT (mbid) DO NOTHINGwhenartistMbidis present (autoMonitorFuture=false,monitorFrom=now). IfartistMbidis blank, the release is monitored standalone (watchedArtistId=null).MonitoredRelease—INSERT ... ON CONFLICT (rgMbid) DO UPDATE SET monitored=true, state='fulfilled', currentQualityClass=<q>, firstGrabbedAt=COALESCE(existing, now), watchedArtistId=<artist>. ("on monitor")LibraryItem—INSERT ... ON CONFLICT (artist, album) DO NOTHING(path=album_dir, source"scan", format, qualityClass). Never overwrites a real download's provenance. ("have")- Count as
importedwhen a newLibraryItem(or monitored record) is created.
Re-scanning is safe: existing rows are updated-or-left, never duplicated.
Data-model touch-points
MBTargetgainsrg_mbid: str = ""andartist_mbid: str = ""; theMusicBrainzResolveralready computes the release-group and artist-credit — it fills these (they are currently discarded). This also lets pipeline jobs link to release-groups later.LibraryItem.requestIdbecomes nullable (String?). A scanned "have" record has no originatingRequest; making the FK optional lets scanned items exist without a synthetic request (which would otherwise flood the queue view). Downloads still setrequestId. Additive, backward-compatible migration (DROP NOT NULL).- Config keys:
scan.requested(web sets"true", worker clears) andscan.result(worker writes a human summary + timestamp; Settings displays it). Plain (non-secret)Configrows.
Everything else reuses existing tables (WatchedArtist, MonitoredRelease, LibraryItem).
Web
POST /api/scan→ upsertConfigscan.requested="true"→ 202.GET /api/scan→ returnConfigscan.result(or "never run").- Settings gains a "Scan library" button + a status line (last result), following the existing settings-form style.
Worker wiring
run_forever()'s loop, alongside the monitor tick: if config["scan.requested"] is truthy, run
scan_library(conn, resolver, DEST_ROOT), write scan.result, and clear scan.requested.
Wrapped in try/except (log, rollback) so a scan error can't kill the worker — consistent with the
monitor/pipeline guards.
Testing
Offline, zero real MB/downloads:
scan_libraryunit tests with a temp/musictree, a fake resolver (canned MBTarget incl.rg_mbid/artist_mbid, orNoneto force a skip), and a seeded DB:- folder-name identification; tag fallback when the folder is blank; skip folders with no audio.
- quality read (a real tiny FLAC fixture, or a fake quality reader seam) → correct
quality_class. - a matched album creates
LibraryItem(have) +MonitoredRelease(monitored, fulfilled) +WatchedArtist(followed); an unmatched album is skipped and records nothing. - idempotent re-scan — running twice doesn't duplicate rows or flip a real download's
sourcetoscan.
- Resolver extension — the opt-in live resolver test asserts
rg_mbid/artist_mbidare populated; offline construction stays lazy. - Web —
POST/GET /api/scanroute tests against seededlyra_test. - Backward-compat — the nullable-
requestIdmigration doesn't break existing pipeline/import tests; the full worker + web suites stay green.
Notes
Reading audio quality via mutagen is the one place the scan touches file internals; the mutagen
import stays lazy (as in the tagger). After this lands: click Scan library, and the existing
/music albums (e.g. Continuum, Sob Rock) show up as have + monitored with their real
quality, and their artists appear under /artists. Deferred: auto-scan/watching, multi-disc
handling, cover/tag repair.