From 4907d0d5a448999440ff80b66ce6604b58b7950e Mon Sep 17 00:00:00 2001 From: Jonathan Date: Sat, 11 Jul 2026 17:00:33 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20library=20scan=20=E2=80=94=20probe=20se?= =?UTF-8?q?am=20+=20scan=5Flibrary=20(have=20+=20monitored=20+=20followed)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- worker/lyra_worker/_probe.py | 32 +++++++++ worker/lyra_worker/adapters/fakes.py | 9 +++ worker/lyra_worker/probe.py | 16 +++++ worker/lyra_worker/registry.py | 7 ++ worker/lyra_worker/scan.py | 101 +++++++++++++++++++++++++++ worker/tests/test_probe_live.py | 16 +++++ worker/tests/test_scan.py | 98 ++++++++++++++++++++++++++ 7 files changed, 279 insertions(+) create mode 100644 worker/lyra_worker/_probe.py create mode 100644 worker/lyra_worker/probe.py create mode 100644 worker/lyra_worker/scan.py create mode 100644 worker/tests/test_probe_live.py create mode 100644 worker/tests/test_scan.py diff --git a/worker/lyra_worker/_probe.py b/worker/lyra_worker/_probe.py new file mode 100644 index 0000000..ecf3531 --- /dev/null +++ b/worker/lyra_worker/_probe.py @@ -0,0 +1,32 @@ +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) diff --git a/worker/lyra_worker/adapters/fakes.py b/worker/lyra_worker/adapters/fakes.py index feab4b0..83ab6fe 100644 --- a/worker/lyra_worker/adapters/fakes.py +++ b/worker/lyra_worker/adapters/fakes.py @@ -1,6 +1,7 @@ from typing import Callable from lyra_worker.browser import ArtistHit, ReleaseGroupInfo +from lyra_worker.probe import ProbeResult from lyra_worker.types import Candidate, DownloadResult, MBTarget, Quality _QUALITIES = { @@ -81,3 +82,11 @@ class FakeMbBrowser: def browse_release_groups(self, artist_mbid: str) -> list[ReleaseGroupInfo]: return list(self._releases.get(artist_mbid, [])) + + +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 diff --git a/worker/lyra_worker/probe.py b/worker/lyra_worker/probe.py new file mode 100644 index 0000000..baef895 --- /dev/null +++ b/worker/lyra_worker/probe.py @@ -0,0 +1,16 @@ +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.""" + ... diff --git a/worker/lyra_worker/registry.py b/worker/lyra_worker/registry.py index adead8b..6eed9b5 100644 --- a/worker/lyra_worker/registry.py +++ b/worker/lyra_worker/registry.py @@ -1,6 +1,7 @@ from lyra_worker._mbbrowser import MusicBrainzBrowser from lyra_worker._musicbrainz import MusicBrainzResolver from lyra_worker._mutagen import MutagenTagger +from lyra_worker._probe import MutagenProbe from lyra_worker.adapters._slskd import SlskdClient from lyra_worker.adapters._streamrip import StreamripClient from lyra_worker.adapters._ytdlp import YtDlpClient @@ -9,6 +10,7 @@ from lyra_worker.adapters.qobuz import QobuzAdapter from lyra_worker.adapters.soulseek import SoulseekAdapter from lyra_worker.adapters.youtube import YouTubeAdapter from lyra_worker.browser import MbBrowser +from lyra_worker.probe import AudioProbe from lyra_worker.resolver import MbResolver from lyra_worker.tagger import Tagger @@ -40,3 +42,8 @@ def build_tagger() -> Tagger: def build_browser() -> MbBrowser: """The MusicBrainz browser used by the background monitor.""" return MusicBrainzBrowser() + + +def build_probe() -> AudioProbe: + """The audio probe used by the library scan.""" + return MutagenProbe() diff --git a/worker/lyra_worker/scan.py b/worker/lyra_worker/scan.py new file mode 100644 index 0000000..692394a --- /dev/null +++ b/worker/lyra_worker/scan.py @@ -0,0 +1,101 @@ +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) diff --git a/worker/tests/test_probe_live.py b/worker/tests/test_probe_live.py new file mode 100644 index 0000000..931221e --- /dev/null +++ b/worker/tests/test_probe_live.py @@ -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 diff --git a/worker/tests/test_scan.py b/worker/tests/test_scan.py new file mode 100644 index 0000000..e28716f --- /dev/null +++ b/worker/tests/test_scan.py @@ -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}