Files
Lyra/docs/superpowers/plans/2026-07-11-lyra-library-scan.md
T
Jonathan f421904c9b docs: add library-scan implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 16:50:57 +02:00

939 lines
35 KiB
Markdown

# Lyra Library Scan Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** A manual "Scan library" action that walks `/music`, matches each album to MusicBrainz, and records it as **have** (`LibraryItem`) + **on monitor** (`MonitoredRelease`, fulfilled) with the artist **followed** (`WatchedArtist`) — so the library manager reflects what's already on disk.
**Architecture:** The scan runs in the worker (owns `/music`, the MB resolver, mutagen), triggered via a `Config` flag the web sets. A new `scan.py` walks the library, probes each album's tags/quality via an injectable `AudioProbe` seam, resolves it against MusicBrainz (extended to return `rgMbid`/`artistMbid`), and idempotently upserts the rows. `LibraryItem.requestId` becomes nullable so scanned "have" records need no originating request. The web adds a Settings button + `POST/GET /api/scan`.
**Tech Stack:** Python 3.12 worker (psycopg, mutagen lazy), Prisma/Postgres, Next.js 15 web. Tests: pytest with a fake `AudioProbe` + fake resolver + temp `/music` tree + seeded `lyra_test`; vitest for the web routes. Zero real MusicBrainz/downloads offline.
## Global Constraints
- **Tests MUST target `lyra_test`** (a guard in conftest/setup refuses non-`*_test` DBs). Worker: `DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra_test .venv/bin/python -m pytest` (or leave `DATABASE_URL` unset — conftest defaults to `lyra_test`). Web: `npm test` (already points at `lyra_test`). Run worker cmds from `/home/jonathan/Projects/lyra/worker` using `.venv/bin/python`; web cmds from `/home/jonathan/Projects/lyra/web`.
- Worker: psycopg raw SQL against Prisma tables (quoted PascalCase, camelCase columns, `gen_random_uuid()::text`, SQL-side `now()`), enum literals as plain strings, `text[]` as Python lists.
- The scan is READ-ONLY on `/music` — no downloads, tagging, moves, or file writes. mutagen is imported lazily inside the real probe (construction stays offline).
- Idempotent: re-scanning updates-or-leaves rows, never duplicates, and never flips a real download's `LibraryItem.source` to `"scan"`.
- Backward compatibility: the nullable-`requestId` migration is additive; every existing worker + web test stays green. `MBTarget` gains defaulted fields (existing constructions unaffected).
- Every task ends with a commit.
## Shared interfaces (defined by tasks below)
```python
# worker/lyra_worker/types.py (Task 1): MBTarget gains rg_mbid: str = "", artist_mbid: str = ""
# worker/lyra_worker/_musicbrainz.py (Task 1): resolver fills rg_mbid + artist_mbid
# worker/lyra_worker/probe.py (Task 3)
@dataclass(frozen=True)
class ProbeResult: artist: str; album: str; fmt: str; quality_class: int
class AudioProbe(Protocol):
def probe(self, path: str) -> ProbeResult: ...
# worker/lyra_worker/scan.py (Task 3)
@dataclass(frozen=True)
class ScanResult: imported: int; skipped: int
def scan_library(conn, resolver, probe: AudioProbe, dest_root: str = "/music") -> ScanResult: ...
# worker/lyra_worker/registry.py (Task 3): build_probe() -> AudioProbe
# worker/lyra_worker/main.py (Task 4): loop runs scan_library when Config "scan.requested" is truthy
# web: POST /api/scan (set scan.requested), GET /api/scan (read scan.result) (Task 5); Settings button (Task 6)
```
---
### Task 1: MBTarget + resolver expose `rgMbid` / `artistMbid`
**Files:**
- Modify: `worker/lyra_worker/types.py`
- Modify: `worker/lyra_worker/_musicbrainz.py`
- Test: `worker/tests/test_types_mbtarget.py`
- Test: `worker/tests/test_musicbrainz_live.py` (extend the opt-in live assertion)
**Interfaces:**
- Produces: `MBTarget.rg_mbid: str = ""`, `MBTarget.artist_mbid: str = ""`; `MusicBrainzResolver.resolve` fills both from the matched release-group and its artist-credit.
- [ ] **Step 1: Write the failing test**
`worker/tests/test_types_mbtarget.py`:
```python
from lyra_worker.types import MBTarget
def test_mbtarget_has_rg_and_artist_mbid_defaults():
t = MBTarget(artist="A", album="B")
assert t.rg_mbid == "" and t.artist_mbid == ""
t2 = MBTarget(artist="A", album="B", rg_mbid="rg1", artist_mbid="a1")
assert t2.rg_mbid == "rg1" and t2.artist_mbid == "a1"
```
- [ ] **Step 2: Run it to verify it fails**
```bash
cd /home/jonathan/Projects/lyra/worker
.venv/bin/python -m pytest tests/test_types_mbtarget.py -v
```
Expected: FAIL — `MBTarget` has no `rg_mbid`.
- [ ] **Step 3: Add the fields**
In `worker/lyra_worker/types.py`, add two fields to `MBTarget` (after `tracklist`):
```python
tracklist: tuple[str, ...] = ()
rg_mbid: str = ""
artist_mbid: str = ""
```
- [ ] **Step 4: Fill them in the resolver**
In `worker/lyra_worker/_musicbrainz.py`, in `resolve`, capture the artist MBID next to the name, and the release-group id, and pass both to `MBTarget(...)`.
Where `canonical_artist` is computed from the credit, also capture the id:
```python
canonical_artist = artist
artist_mbid = ""
credit = rg.get("artist-credit")
if isinstance(credit, list) and credit and isinstance(credit[0], dict):
_artist = credit[0].get("artist") or {}
canonical_artist = _artist.get("name", artist)
artist_mbid = _artist.get("id", "") or ""
```
And in the returned `MBTarget(...)`, add:
```python
return MBTarget(
artist=canonical_artist,
album=canonical_album,
track_count=track_count,
total_duration_s=total_duration_s,
year=year,
tracklist=titles,
rg_mbid=rg.get("id", "") or "",
artist_mbid=artist_mbid,
)
```
- [ ] **Step 5: Extend the opt-in live test**
In `worker/tests/test_musicbrainz_live.py`, in the existing live resolve test (the one gated by `LYRA_LIVE_TESTS`), add after the target is resolved:
```python
assert target.rg_mbid # a real release-group MBID
assert target.artist_mbid # a real artist MBID
```
(If the test's variable is not named `target`, use whatever it binds the resolved `MBTarget` to.)
- [ ] **Step 6: Run the offline tests + full suite**
```bash
cd /home/jonathan/Projects/lyra/worker
.venv/bin/python -m pytest tests/test_types_mbtarget.py -v
.venv/bin/python -m pytest -q
```
Expected: the new test passes; full suite green (live test still skipped); output pristine.
- [ ] **Step 7: Commit**
```bash
cd /home/jonathan/Projects/lyra
git add worker/lyra_worker/types.py worker/lyra_worker/_musicbrainz.py worker/tests/test_types_mbtarget.py worker/tests/test_musicbrainz_live.py
git commit -m "feat: MBTarget exposes rgMbid + artistMbid from the resolver"
```
---
### Task 2: `LibraryItem.requestId` nullable + test cleanup
**Files:**
- Modify: `web/prisma/schema.prisma`
- Create: (generated) migration under `web/prisma/migrations/`
- Modify: `worker/tests/conftest.py` (clean `LibraryItem`)
- Modify: `web/src/test/setup.ts` (clean `libraryItem`)
- Test: `worker/tests/test_library_item_optional_request.py`
**Interfaces:**
- Produces: `LibraryItem.requestId` is optional; a `LibraryItem` can be inserted with `requestId = NULL`. Test fixtures clean `LibraryItem` (scanned rows have no `Request` to cascade from).
- [ ] **Step 1: Write the failing test**
`worker/tests/test_library_item_optional_request.py`:
```python
def test_library_item_can_have_null_request(conn):
with conn.cursor() as cur:
cur.execute(
'INSERT INTO "LibraryItem" (id, "requestId", artist, album, path, source, format, '
'"qualityClass", "importedAt") '
"VALUES (gen_random_uuid()::text, NULL, 'A', 'B', '/m/A/B', 'scan', 'FLAC', 2, now()) "
"RETURNING id"
)
lib_id = cur.fetchone()[0]
conn.commit()
with conn.cursor() as cur:
cur.execute('SELECT "requestId" FROM "LibraryItem" WHERE id = %s', (lib_id,))
assert cur.fetchone()[0] is None
```
- [ ] **Step 2: Run it to verify it fails**
```bash
cd /home/jonathan/Projects/lyra/worker
.venv/bin/python -m pytest tests/test_library_item_optional_request.py -v
```
Expected: FAIL — `null value in column "requestId" violates not-null constraint`.
- [ ] **Step 3: Make the relation optional**
In `web/prisma/schema.prisma`, `model LibraryItem`, change the request field + fk to optional:
```prisma
request Request? @relation(fields: [requestId], references: [id], onDelete: Cascade)
requestId String? @unique
```
- [ ] **Step 4: Generate + apply the migration (into lyra_test)**
```bash
cd /home/jonathan/Projects/lyra/web
DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra_test npx prisma migrate dev --name library_item_optional_request
```
Expected: a migration with `ALTER TABLE "LibraryItem" ALTER COLUMN "requestId" DROP NOT NULL;` is created and applied to `lyra_test`. (The live `lyra` DB gets it via `migrate deploy` on the next `docker compose up --build`.)
- [ ] **Step 5: Clean `LibraryItem` in the test fixtures**
Scanned `LibraryItem`s have `requestId = NULL`, so they no longer cascade-delete when `Request` rows are cleared. Add explicit cleanup.
In `worker/tests/conftest.py`, in BOTH cleanup blocks of the `conn` fixture, add a `LibraryItem` delete after `Request`:
```python
cur.execute('DELETE FROM "Job"')
cur.execute('DELETE FROM "Request"')
cur.execute('DELETE FROM "LibraryItem"')
cur.execute('DELETE FROM "MonitoredRelease"')
cur.execute('DELETE FROM "WatchedArtist"')
cur.execute('DELETE FROM "Config"')
```
In `web/src/test/setup.ts`, add `libraryItem` to the `beforeEach` cleanup (after `request`):
```ts
await prisma.job.deleteMany();
await prisma.request.deleteMany();
await prisma.libraryItem.deleteMany();
await prisma.monitoredRelease.deleteMany();
await prisma.watchedArtist.deleteMany();
await prisma.config.deleteMany();
```
- [ ] **Step 6: Run the test + full worker suite + web suite**
```bash
cd /home/jonathan/Projects/lyra/worker
.venv/bin/python -m pytest tests/test_library_item_optional_request.py -v
.venv/bin/python -m pytest -q
cd /home/jonathan/Projects/lyra/web
npm test --silent 2>&1 | tail -4
```
Expected: new test passes; full worker suite green; web suite green (the `libraryItem` cleanup is harmless to existing tests).
- [ ] **Step 7: Commit**
```bash
cd /home/jonathan/Projects/lyra
git add web/prisma/schema.prisma web/prisma/migrations worker/tests/conftest.py web/src/test/setup.ts worker/tests/test_library_item_optional_request.py
git commit -m "feat: LibraryItem.requestId nullable (scanned haves need no request) + test cleanup"
```
---
### Task 3: The scan — `AudioProbe` seam + `scan_library`
**Files:**
- Create: `worker/lyra_worker/probe.py` (seam)
- Create: `worker/lyra_worker/_probe.py` (real `MutagenProbe`)
- Create: `worker/lyra_worker/scan.py`
- Modify: `worker/lyra_worker/registry.py` (`build_probe`)
- Modify: `worker/lyra_worker/adapters/fakes.py` (add `FakeAudioProbe`)
- Test: `worker/tests/test_scan.py`
- Test: `worker/tests/test_probe_live.py` (opt-in)
**Interfaces:**
- Consumes: `MBTarget` (with `rg_mbid`/`artist_mbid`), an `MbResolver`, `AudioProbe`.
- Produces: `ProbeResult`, `AudioProbe` (Protocol), `MutagenProbe`, `FakeAudioProbe`, `ScanResult`, `scan_library(conn, resolver, probe, dest_root)`, `registry.build_probe()`.
- [ ] **Step 1: Write the failing test**
`worker/tests/test_scan.py`:
```python
from lyra_worker.probe import ProbeResult
from lyra_worker.scan import scan_library
from lyra_worker.types import MBTarget
class FakeProbe:
"""Returns a canned ProbeResult regardless of path (tests control identification via folders)."""
def __init__(self, artist="", album="", fmt="FLAC", quality_class=3):
self._r = ProbeResult(artist=artist, album=album, fmt=fmt, quality_class=quality_class)
def probe(self, path):
return self._r
class FakeResolver:
"""resolve(artist, album) -> MBTarget or None, keyed by (artist, album)."""
def __init__(self, table):
self._t = dict(table)
def resolve(self, artist, album):
return self._t.get((artist, album))
def _album(tmp_path, artist, album_folder, files=("01 Track.flac",)):
d = tmp_path / artist / album_folder
d.mkdir(parents=True)
for f in files:
(d / f).write_bytes(b"")
return d
def _counts(conn):
with conn.cursor() as cur:
out = {}
for t in ("LibraryItem", "MonitoredRelease", "WatchedArtist"):
cur.execute(f'SELECT count(*) FROM "{t}"')
out[t] = cur.fetchone()[0]
return out
def test_scan_records_have_monitored_and_follows_artist(conn, tmp_path):
_album(tmp_path, "John Mayer", "Continuum (2006)")
resolver = FakeResolver({
("John Mayer", "Continuum"): MBTarget(
artist="John Mayer", album="Continuum", year=2006, rg_mbid="rg1", artist_mbid="a1"),
})
result = scan_library(conn, resolver, FakeProbe(quality_class=3), dest_root=str(tmp_path))
assert (result.imported, result.skipped) == (1, 0)
assert _counts(conn) == {"LibraryItem": 1, "MonitoredRelease": 1, "WatchedArtist": 1}
with conn.cursor() as cur:
cur.execute('SELECT source, "qualityClass" FROM "LibraryItem" WHERE artist=%s AND album=%s',
("John Mayer", "Continuum"))
assert cur.fetchone() == ("scan", 3)
cur.execute('SELECT monitored, state, "currentQualityClass" FROM "MonitoredRelease" WHERE "rgMbid"=%s',
("rg1",))
assert cur.fetchone() == (True, "fulfilled", 3)
cur.execute('SELECT "autoMonitorFuture" FROM "WatchedArtist" WHERE mbid=%s', ("a1",))
assert cur.fetchone()[0] is False
def test_scan_skips_unmatched_albums(conn, tmp_path):
_album(tmp_path, "Obscure", "Nope (1999)")
result = scan_library(conn, FakeResolver({}), FakeProbe(), dest_root=str(tmp_path))
assert (result.imported, result.skipped) == (0, 1)
assert _counts(conn) == {"LibraryItem": 0, "MonitoredRelease": 0, "WatchedArtist": 0}
def test_scan_falls_back_to_tags_when_folder_does_not_resolve(conn, tmp_path):
_album(tmp_path, "GARBLE", "CD1") # folder names won't resolve
resolver = FakeResolver({
("Radiohead", "In Rainbows"): MBTarget(
artist="Radiohead", album="In Rainbows", year=2007, rg_mbid="rg9", artist_mbid="a9"),
})
probe = FakeProbe(artist="Radiohead", album="In Rainbows", quality_class=3) # embedded tags
result = scan_library(conn, resolver, probe, dest_root=str(tmp_path))
assert (result.imported, result.skipped) == (1, 0)
with conn.cursor() as cur:
cur.execute('SELECT count(*) FROM "MonitoredRelease" WHERE "rgMbid"=%s', ("rg9",))
assert cur.fetchone()[0] == 1
def test_scan_skips_folders_without_audio(conn, tmp_path):
d = tmp_path / "Artist" / "Album"
d.mkdir(parents=True)
(d / "cover.jpg").write_bytes(b"") # no audio
result = scan_library(conn, FakeResolver({}), FakeProbe(), dest_root=str(tmp_path))
assert (result.imported, result.skipped) == (0, 0)
def test_scan_is_idempotent(conn, tmp_path):
_album(tmp_path, "John Mayer", "Continuum (2006)")
resolver = FakeResolver({
("John Mayer", "Continuum"): MBTarget(
artist="John Mayer", album="Continuum", year=2006, rg_mbid="rg1", artist_mbid="a1"),
})
scan_library(conn, resolver, FakeProbe(quality_class=3), dest_root=str(tmp_path))
second = scan_library(conn, resolver, FakeProbe(quality_class=3), dest_root=str(tmp_path))
assert second.imported == 0 # already recorded
assert _counts(conn) == {"LibraryItem": 1, "MonitoredRelease": 1, "WatchedArtist": 1}
```
- [ ] **Step 2: Run it to verify it fails**
```bash
cd /home/jonathan/Projects/lyra/worker
.venv/bin/python -m pytest tests/test_scan.py -v
```
Expected: FAIL — `No module named 'lyra_worker.scan'`.
- [ ] **Step 3: Add the probe seam**
`worker/lyra_worker/probe.py`:
```python
from dataclasses import dataclass
from typing import Protocol
@dataclass(frozen=True)
class ProbeResult:
artist: str
album: str
fmt: str
quality_class: int
class AudioProbe(Protocol):
def probe(self, path: str) -> ProbeResult:
"""Read an audio file's artist/album tags + format/quality."""
...
```
`worker/lyra_worker/_probe.py`:
```python
import os
from lyra_worker.probe import ProbeResult
from lyra_worker.quality import quality_class
from lyra_worker.types import Quality
_LOSSLESS_EXT = {".flac", ".wav", ".alac", ".aiff", ".aif"}
class MutagenProbe:
"""Real AudioProbe via mutagen (imported lazily; not unit-tested offline)."""
def probe(self, path: str) -> ProbeResult:
import mutagen
ext = os.path.splitext(path)[1].lower()
audio = mutagen.File(path, easy=True)
artist = album = ""
bit_depth = sample_rate = None
if audio is not None:
artist = (audio.get("artist") or [""])[0]
album = (audio.get("album") or [""])[0]
info = getattr(audio, "info", None)
bit_depth = getattr(info, "bits_per_sample", None)
sample_rate = getattr(info, "sample_rate", None)
q = quality_class(Quality(
fmt=ext.lstrip(".").upper(),
lossless=ext in _LOSSLESS_EXT,
bit_depth=bit_depth,
sample_rate=sample_rate,
))
return ProbeResult(artist=artist, album=album, fmt=ext.lstrip(".").upper(), quality_class=q)
```
Append to `worker/lyra_worker/adapters/fakes.py`:
```python
from lyra_worker.probe import ProbeResult
class FakeAudioProbe:
"""In-memory AudioProbe for tests."""
def __init__(self, artist="", album="", fmt="FLAC", quality_class=3):
self._r = ProbeResult(artist=artist, album=album, fmt=fmt, quality_class=quality_class)
def probe(self, path: str) -> ProbeResult:
return self._r
```
- [ ] **Step 4: Implement `scan_library`**
`worker/lyra_worker/scan.py`:
```python
import os
import re
from dataclasses import dataclass
import psycopg
from lyra_worker.probe import AudioProbe
_AUDIO_EXT = {".flac", ".mp3", ".m4a", ".opus", ".ogg", ".aac", ".wav"}
_YEAR = re.compile(r"^(.*) \((\d{4})\)$")
@dataclass(frozen=True)
class ScanResult:
imported: int
skipped: int
def _parse_album_dir(name: str) -> str:
m = _YEAR.match(name)
return m.group(1) if m else name
def _first_audio(album_path: str) -> str | None:
for name in sorted(os.listdir(album_path)):
if os.path.splitext(name)[1].lower() in _AUDIO_EXT:
return os.path.join(album_path, name)
return None
def _upsert(conn, target, quality_class: int, path: str) -> bool:
"""Record have + monitored + followed for a resolved album. Returns True if newly imported."""
with conn.cursor() as cur:
watched_id = None
if target.artist_mbid:
cur.execute(
'INSERT INTO "WatchedArtist" (id, mbid, name, "autoMonitorFuture", "monitorFrom", "createdAt") '
"VALUES (gen_random_uuid()::text, %s, %s, false, now(), now()) "
'ON CONFLICT (mbid) DO NOTHING',
(target.artist_mbid, target.artist),
)
cur.execute('SELECT id FROM "WatchedArtist" WHERE mbid = %s', (target.artist_mbid,))
row = cur.fetchone()
watched_id = row[0] if row else None
if target.rg_mbid:
cur.execute(
'INSERT INTO "MonitoredRelease" (id, "watchedArtistId", "artistMbid", "artistName", '
'"rgMbid", album, "secondaryTypes", monitored, state, "currentQualityClass", '
'"firstGrabbedAt", "createdAt") '
"VALUES (gen_random_uuid()::text, %s, %s, %s, %s, %s, '{}', true, 'fulfilled', %s, now(), now()) "
'ON CONFLICT ("rgMbid") DO UPDATE SET monitored = true, state = \'fulfilled\', '
' "currentQualityClass" = EXCLUDED."currentQualityClass", '
' "firstGrabbedAt" = COALESCE("MonitoredRelease"."firstGrabbedAt", now()), '
' "watchedArtistId" = COALESCE("MonitoredRelease"."watchedArtistId", EXCLUDED."watchedArtistId")',
(watched_id, target.artist_mbid, target.artist, target.rg_mbid, target.album, quality_class),
)
cur.execute(
'INSERT INTO "LibraryItem" (id, "requestId", artist, album, path, source, format, '
'"qualityClass", "importedAt") '
"VALUES (gen_random_uuid()::text, NULL, %s, %s, %s, 'scan', %s, %s, now()) "
'ON CONFLICT (artist, album) DO NOTHING',
(target.artist, target.album, path, "FLAC", quality_class),
)
newly = cur.rowcount == 1 # a new LibraryItem means this album wasn't recorded before
conn.commit()
return newly
def scan_library(conn: psycopg.Connection, resolver, probe: AudioProbe, dest_root: str = "/music") -> ScanResult:
"""Walk `{dest_root}/{Artist}/{Album}/`, resolve each album against MusicBrainz, and record
matched albums as have + monitored + followed. Unmatched or audio-less folders are skipped."""
imported = 0
skipped = 0
if not os.path.isdir(dest_root):
return ScanResult(0, 0)
for artist_name in sorted(os.listdir(dest_root)):
artist_path = os.path.join(dest_root, artist_name)
if artist_name.startswith(".") or not os.path.isdir(artist_path):
continue
for album_folder in sorted(os.listdir(artist_path)):
album_path = os.path.join(artist_path, album_folder)
if not os.path.isdir(album_path):
continue
audio = _first_audio(album_path)
if audio is None:
continue # no audio → not an album
album_name = _parse_album_dir(album_folder)
target = resolver.resolve(artist_name, album_name) if artist_name and album_name else None
if target is None: # fall back to the file's embedded tags
pr = probe.probe(audio)
if pr.artist and pr.album and (pr.artist, pr.album) != (artist_name, album_name):
target = resolver.resolve(pr.artist, pr.album)
if target is None:
skipped += 1
continue
q = probe.probe(audio).quality_class
if _upsert(conn, target, q, album_path):
imported += 1
return ScanResult(imported, skipped)
```
Add to `worker/lyra_worker/registry.py`:
```python
from lyra_worker._probe import MutagenProbe
from lyra_worker.probe import AudioProbe
def build_probe() -> AudioProbe:
"""The audio probe used by the library scan."""
return MutagenProbe()
```
- [ ] **Step 5: Run the scan tests to verify they pass**
```bash
cd /home/jonathan/Projects/lyra/worker
.venv/bin/python -m pytest tests/test_scan.py -v
```
Expected: PASS — all five tests green.
- [ ] **Step 6: Add the opt-in real-probe test**
`worker/tests/test_probe_live.py`:
```python
import os
import pytest
pytestmark = pytest.mark.skipif(
not (os.environ.get("LYRA_LIVE_TESTS") and os.environ.get("LYRA_SAMPLE_AUDIO")),
reason="reads a real audio file; set LYRA_LIVE_TESTS=1 + LYRA_SAMPLE_AUDIO=/path/to/a.flac to run",
)
def test_probe_reads_a_real_file():
from lyra_worker._probe import MutagenProbe
r = MutagenProbe().probe(os.environ["LYRA_SAMPLE_AUDIO"])
assert r.quality_class in (1, 2, 3)
assert r.fmt
```
- [ ] **Step 7: Verify lazy import + full suite**
```bash
cd /home/jonathan/Projects/lyra/worker
.venv/bin/python -c "import sys; from lyra_worker._probe import MutagenProbe; from lyra_worker.scan import scan_library; print('import ok; mutagen loaded:', 'mutagen' in sys.modules)"
.venv/bin/python -m pytest -q
```
Expected: prints `import ok; mutagen loaded: False`; full suite green, `test_probe_live.py` skipped, output pristine.
- [ ] **Step 8: Commit**
```bash
cd /home/jonathan/Projects/lyra
git add worker/lyra_worker/probe.py worker/lyra_worker/_probe.py worker/lyra_worker/scan.py worker/lyra_worker/registry.py worker/lyra_worker/adapters/fakes.py worker/tests/test_scan.py worker/tests/test_probe_live.py
git commit -m "feat: library scan — probe seam + scan_library (have + monitored + followed)"
```
---
### Task 4: Wire the scan trigger into the worker loop
**Files:**
- Modify: `worker/lyra_worker/main.py`
- Test: `worker/tests/test_scan_trigger.py`
**Interfaces:**
- Consumes: `scan_library`, `build_probe`, the `Config` table.
- Produces: `run_forever()` reads config once per loop; when `Config` `scan.requested` is truthy it runs `scan_library`, writes a `scan.result` summary, and clears `scan.requested`. A `_run_scan(conn, resolver, probe)` helper is unit-tested.
- [ ] **Step 1: Write the failing test**
`worker/tests/test_scan_trigger.py`:
```python
from lyra_worker.main import _run_scan
from lyra_worker.types import MBTarget
class FakeProbe:
def probe(self, path):
from lyra_worker.probe import ProbeResult
return ProbeResult(artist="", album="", fmt="FLAC", quality_class=3)
class FakeResolver:
def resolve(self, artist, album):
return MBTarget(artist=artist, album=album, rg_mbid="rg1", artist_mbid="a1")
def _set(conn, key, value):
with conn.cursor() as cur:
cur.execute(
'INSERT INTO "Config"(key, value, secret, "updatedAt") VALUES (%s, %s, false, now()) '
'ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, "updatedAt" = now()',
(key, value),
)
conn.commit()
def _get(conn, key):
with conn.cursor() as cur:
cur.execute('SELECT value FROM "Config" WHERE key = %s', (key,))
row = cur.fetchone()
return row[0] if row else None
def test_run_scan_writes_result_and_clears_flag(conn, tmp_path):
(tmp_path / "John Mayer" / "Continuum (2006)").mkdir(parents=True)
(tmp_path / "John Mayer" / "Continuum (2006)" / "01.flac").write_bytes(b"")
_set(conn, "scan.requested", "true")
_run_scan(conn, FakeResolver(), FakeProbe(), dest_root=str(tmp_path))
assert _get(conn, "scan.requested") == "false" # flag cleared
assert "imported" in (_get(conn, "scan.result") or "") # summary written
with conn.cursor() as cur:
cur.execute('SELECT count(*) FROM "LibraryItem"')
assert cur.fetchone()[0] == 1
```
- [ ] **Step 2: Run it to verify it fails**
```bash
cd /home/jonathan/Projects/lyra/worker
.venv/bin/python -m pytest tests/test_scan_trigger.py -v
```
Expected: FAIL — `cannot import name '_run_scan'`.
- [ ] **Step 3: Add `_run_scan` + wire the loop**
In `worker/lyra_worker/main.py`:
(a) Update imports:
```python
from lyra_worker.registry import build_adapters, build_browser, build_probe, build_resolver, build_tagger
from lyra_worker.scan import scan_library
```
(b) Add the helper (module level):
```python
_TRUE = {"1", "true", "yes", "on"}
def _set_config(conn, key: str, value: str) -> None:
with conn.cursor() as cur:
cur.execute(
'INSERT INTO "Config"(key, value, secret, "updatedAt") VALUES (%s, %s, false, now()) '
'ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, "updatedAt" = now()',
(key, value),
)
conn.commit()
def _run_scan(conn, resolver, probe, dest_root: str = DEST_ROOT) -> None:
result = scan_library(conn, resolver, probe, dest_root)
_set_config(conn, "scan.result", f"imported {result.imported}, skipped {result.skipped}")
_set_config(conn, "scan.requested", "false")
print(f"worker: library scan done — {result.imported} imported, {result.skipped} skipped", flush=True)
```
(c) Build the probe and check the flag each loop. Replace `mcfg = MonitorConfig.from_config(get_config(conn))` with a single config read, and add the scan check after the monitor tick:
```python
probe = build_probe()
...
while True:
config = get_config(conn)
mcfg = MonitorConfig.from_config(config)
now = time.monotonic()
if mcfg.enabled and now - last_tick >= MONITOR_TICK_SECONDS:
... # unchanged monitor sweep block
last_tick = now
if str(config.get("scan.requested", "")).strip().lower() in _TRUE:
try:
_run_scan(conn, resolver, probe)
except Exception as e: # a scan error must never kill the worker
print(f"worker: library scan failed: {e}", flush=True)
conn.rollback()
_set_config(conn, "scan.requested", "false")
job_id = claim_next(conn)
... # unchanged
```
(`build_probe` is added to the imports from `registry`.)
- [ ] **Step 4: Run the trigger test to verify it passes**
```bash
cd /home/jonathan/Projects/lyra/worker
.venv/bin/python -m pytest tests/test_scan_trigger.py -v
```
Expected: PASS.
- [ ] **Step 5: Verify main imports + full suite**
```bash
cd /home/jonathan/Projects/lyra/worker
.venv/bin/python -c "from lyra_worker import main; print('ok')"
.venv/bin/python -m pytest -q
```
Expected: `ok`; full suite green, output pristine.
- [ ] **Step 6: Commit**
```bash
cd /home/jonathan/Projects/lyra
git add worker/lyra_worker/main.py worker/tests/test_scan_trigger.py
git commit -m "feat: worker runs the library scan when Config scan.requested is set"
```
---
### Task 5: Web `/api/scan` routes
**Files:**
- Create: `web/src/app/api/scan/route.ts`
- Test: `web/src/app/api/scan/route.test.ts`
**Interfaces:**
- Consumes: `prisma`, the `Config` table.
- Produces: `POST /api/scan` upserts `Config` `scan.requested="true"` → 202 `{requested:true}`; `GET /api/scan` returns `{ result, requested }` from `Config` (`result` = `scan.result` value or `null`; `requested` = whether `scan.requested` is `"true"`).
- [ ] **Step 1: Write the failing test**
`web/src/app/api/scan/route.test.ts`:
```ts
import { describe, it, expect } from "vitest";
import { prisma } from "@/lib/db";
import { POST, GET } from "./route";
describe("scan API", () => {
it("POST sets scan.requested and GET reflects it", async () => {
const res = await POST(new Request("http://localhost/api/scan", { method: "POST" }));
expect(res.status).toBe(202);
expect((await res.json()).requested).toBe(true);
const row = await prisma.config.findUnique({ where: { key: "scan.requested" } });
expect(row?.value).toBe("true");
const g = await GET();
const body = await g.json();
expect(body.requested).toBe(true);
expect(body.result).toBeNull(); // no scan.result yet
});
it("GET returns the worker's scan.result when present", async () => {
await prisma.config.create({ data: { key: "scan.result", value: "imported 3, skipped 1" } });
const body = await (await GET()).json();
expect(body.result).toBe("imported 3, skipped 1");
});
});
```
- [ ] **Step 2: Run it to verify it fails**
```bash
cd /home/jonathan/Projects/lyra/web
DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra_test npx vitest run api/scan 2>&1 | tail -5
```
Expected: FAIL — cannot resolve `./route`.
- [ ] **Step 3: Implement the route**
`web/src/app/api/scan/route.ts`:
```ts
import { prisma } from "@/lib/db";
export async function POST() {
await prisma.config.upsert({
where: { key: "scan.requested" },
create: { key: "scan.requested", value: "true", secret: false },
update: { value: "true" },
});
return Response.json({ requested: true }, { status: 202 });
}
export async function GET() {
const [requested, result] = await Promise.all([
prisma.config.findUnique({ where: { key: "scan.requested" } }),
prisma.config.findUnique({ where: { key: "scan.result" } }),
]);
return Response.json({
requested: requested?.value === "true",
result: result?.value ?? null,
});
}
```
- [ ] **Step 4: Run the test to verify it passes**
```bash
cd /home/jonathan/Projects/lyra/web
DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra_test npx vitest run api/scan 2>&1 | tail -5
```
Expected: PASS — both tests green.
- [ ] **Step 5: Run the full web suite**
```bash
cd /home/jonathan/Projects/lyra/web
npm test --silent 2>&1 | tail -4
```
Expected: PASS — every web test green.
- [ ] **Step 6: Commit**
```bash
cd /home/jonathan/Projects/lyra
git add web/src/app/api/scan
git commit -m "feat: /api/scan — request a library scan + read its result"
```
---
### Task 6: Settings "Scan library" button
**Files:**
- Modify: `web/src/app/settings/settings-form.tsx`
**Interfaces:**
- Consumes: `POST /api/scan`, `GET /api/scan`. UI — verified by `npx tsc --noEmit`.
- [ ] **Step 1: Add the scan control to the settings form**
In `web/src/app/settings/settings-form.tsx`, add scan state + handlers inside `SettingsForm` (alongside the existing state), and render a "Scan library" section below the existing `<form>`. Add near the other `useState` hooks:
```tsx
const [scanResult, setScanResult] = useState<string | null>(null);
const [scanRequested, setScanRequested] = useState(false);
useEffect(() => {
fetch("/api/scan")
.then((r) => r.json())
.then((s: { result: string | null; requested: boolean }) => {
setScanResult(s.result);
setScanRequested(s.requested);
});
}, []);
async function requestScan() {
await fetch("/api/scan", { method: "POST" });
setScanRequested(true);
}
```
Then, immediately AFTER the closing `</form>` tag (still inside the component's returned fragment — wrap the form and this new block in a `<>``</>` if needed), add:
```tsx
<section>
<h2>Library</h2>
<p>Scan <code>/music</code> and record what's already on disk as owned + monitored.</p>
<button type="button" onClick={requestScan} disabled={scanRequested}>
{scanRequested ? "Scan requested…" : "Scan library"}
</button>
{scanResult ? <p aria-label="scan result">Last scan: {scanResult}</p> : null}
</section>
```
If the component currently `return`s a single `<form>...</form>`, change it to `return (<>{<form>...</form>}<section>...</section></>);` — i.e. wrap the existing form and the new `<section>` in a fragment. Do not change any existing form fields.
- [ ] **Step 2: Typecheck**
```bash
cd /home/jonathan/Projects/lyra/web
npx tsc --noEmit
```
Expected: no type errors.
- [ ] **Step 3: Run the web suite (no regressions)**
```bash
cd /home/jonathan/Projects/lyra/web
npm test --silent 2>&1 | tail -4
```
Expected: PASS — existing tests unaffected (no component tests for the settings form; route tests still green).
- [ ] **Step 4: Commit**
```bash
cd /home/jonathan/Projects/lyra
git add web/src/app/settings/settings-form.tsx
git commit -m "feat: Settings 'Scan library' button + last-result status"
```
---
## Notes: library scan complete
With this plan, clicking **Scan library** in Settings walks `/music`, resolves each album against
MusicBrainz, and records matched albums as **have** (`LibraryItem`), **on monitor**
(`MonitoredRelease`, fulfilled, with real quality), and **followed** (`WatchedArtist`) — skipping
anything MusicBrainz can't match. Re-scans are idempotent. After it lands: re-`up --build` (applies
the nullable-`requestId` migration to the live DB), click Scan, and confirm *Continuum*/*Sob Rock*
appear as have + monitored under their artist. Deferred: auto-scan/filesystem-watching, multi-disc
handling, and repairing/re-tagging existing files.
```