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}