feat: library scan — probe seam + scan_library (have + monitored + followed)

Adds the AudioProbe seam (probe.py, _probe.py MutagenProbe with a lazy
mutagen import, FakeAudioProbe) and scan_library, which walks /music,
resolves each album against MusicBrainz (falling back to embedded tags
when the folder name doesn't resolve), and idempotently records matched
albums as have (LibraryItem), monitored (MonitoredRelease, fulfilled),
and followed (WatchedArtist, autoMonitorFuture=false).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan
2026-07-11 17:00:33 +02:00
parent af67d01aa2
commit 4907d0d5a4
7 changed files with 279 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
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
+98
View File
@@ -0,0 +1,98 @@
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}