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,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)
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
...
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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