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:
@@ -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}
|
||||
Reference in New Issue
Block a user