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
+32
View File
@@ -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)
+9
View File
@@ -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
+16
View File
@@ -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."""
...
+7
View File
@@ -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()
+101
View File
@@ -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)