docs: add library-scan design (ingest on-disk albums as have+monitored)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
# 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):
|
||||
|
||||
1. **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.
|
||||
2. **Quality:** read the first audio file via mutagen (`bits_per_sample`, `sample_rate`,
|
||||
lossless-by-extension) → build a `Quality` → `quality_class` (3/2/1). This is the recorded
|
||||
`currentQualityClass`/`LibraryItem.qualityClass`.
|
||||
3. **Resolve** `(artist, album)` via the MusicBrainz resolver → canonical artist/album/year +
|
||||
**`rgMbid`** + **`artistMbid`**. **No match → skip** (increment `skipped`, log).
|
||||
4. **Record (idempotent upserts):**
|
||||
- **`WatchedArtist`** — `INSERT ... ON CONFLICT (mbid) DO NOTHING` when `artistMbid` is
|
||||
present (`autoMonitorFuture=false`, `monitorFrom=now`). If `artistMbid` is 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 `imported` when a new `LibraryItem` (or monitored record) is created.
|
||||
|
||||
Re-scanning is safe: existing rows are updated-or-left, never duplicated.
|
||||
|
||||
## Data-model touch-points
|
||||
|
||||
- **`MBTarget`** gains `rg_mbid: str = ""` and `artist_mbid: str = ""`; the `MusicBrainzResolver`
|
||||
already 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.requestId` becomes nullable** (`String?`). A scanned "have" record has no
|
||||
originating `Request`; making the FK optional lets scanned items exist without a synthetic
|
||||
request (which would otherwise flood the queue view). Downloads still set `requestId`. Additive,
|
||||
backward-compatible migration (`DROP NOT NULL`).
|
||||
- **Config keys:** `scan.requested` (web sets `"true"`, worker clears) and `scan.result` (worker
|
||||
writes a human summary + timestamp; Settings displays it). Plain (non-secret) `Config` rows.
|
||||
|
||||
Everything else reuses existing tables (`WatchedArtist`, `MonitoredRelease`, `LibraryItem`).
|
||||
|
||||
## Web
|
||||
|
||||
- `POST /api/scan` → upsert `Config` `scan.requested="true"` → 202.
|
||||
- `GET /api/scan` → return `Config` `scan.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_library`** unit tests with a temp `/music` tree, a **fake resolver** (canned MBTarget
|
||||
incl. `rg_mbid`/`artist_mbid`, or `None` to 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
|
||||
`source` to `scan`.
|
||||
- **Resolver extension** — the opt-in live resolver test asserts `rg_mbid`/`artist_mbid` are
|
||||
populated; offline construction stays lazy.
|
||||
- **Web** — `POST`/`GET /api/scan` route tests against seeded `lyra_test`.
|
||||
- **Backward-compat** — the nullable-`requestId` migration 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.
|
||||
Reference in New Issue
Block a user