From aafd8b15d5558a9fecf79abfea42ee68082c8ed1 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Sat, 11 Jul 2026 17:49:26 +0200 Subject: [PATCH] fix: scan populates each followed artist's full discography (unmonitored) A followed artist previously showed only the one owned album ("of 1") because scan_library recorded just the resolved release. Now scan browses each artist's full MusicBrainz release-group list once per run and stores it as unmonitored MonitoredRelease rows, matching web-follow behavior. The owned album keeps its monitored/fulfilled state via ON CONFLICT DO NOTHING. --- worker/lyra_worker/main.py | 6 ++-- worker/lyra_worker/scan.py | 60 ++++++++++++++++++++++--------- worker/tests/test_scan.py | 41 ++++++++++++++++----- worker/tests/test_scan_trigger.py | 3 +- 4 files changed, 81 insertions(+), 29 deletions(-) diff --git a/worker/lyra_worker/main.py b/worker/lyra_worker/main.py index ec7137c..71e13c3 100644 --- a/worker/lyra_worker/main.py +++ b/worker/lyra_worker/main.py @@ -25,8 +25,8 @@ def _set_config(conn, key: str, value: str) -> None: conn.commit() -def _run_scan(conn, resolver, probe, dest_root: str = DEST_ROOT) -> None: - result = scan_library(conn, resolver, probe, dest_root) +def _run_scan(conn, resolver, probe, browser, dest_root: str = DEST_ROOT) -> None: + result = scan_library(conn, resolver, probe, browser, dest_root) _set_config(conn, "scan.result", f"imported {result.imported}, skipped {result.skipped}") _set_config(conn, "scan.requested", "false") print(f"worker: library scan done — {result.imported} imported, {result.skipped} skipped", flush=True) @@ -58,7 +58,7 @@ def run_forever() -> None: if str(config.get("scan.requested", "")).strip().lower() in _TRUE: try: - _run_scan(conn, resolver, probe) + _run_scan(conn, resolver, probe, browser) except Exception as e: # a scan error must never kill the worker print(f"worker: library scan failed: {e}", flush=True) conn.rollback() diff --git a/worker/lyra_worker/scan.py b/worker/lyra_worker/scan.py index 6cb849d..d270eea 100644 --- a/worker/lyra_worker/scan.py +++ b/worker/lyra_worker/scan.py @@ -28,21 +28,41 @@ def _first_audio(album_path: str) -> str | None: return None -def _upsert(conn, target, quality_class: int, fmt: str, path: str) -> bool: - """Record have + monitored + followed for a resolved album. Returns True if newly imported.""" +def _ensure_artist(conn, target) -> str | None: + """Upsert the WatchedArtist (by mbid) and return its id, or None if the target has no artist mbid.""" + if not target.artist_mbid: + return None 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 + 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() + conn.commit() + return row[0] if row else None + +def _populate_discography(conn, browser, watched_id, artist_mbid: str, artist_name: str) -> None: + """Store the artist's full release-group list as unmonitored rows (like a web follow).""" + for rg in browser.browse_release_groups(artist_mbid): + with conn.cursor() as cur: + cur.execute( + 'INSERT INTO "MonitoredRelease" (id, "watchedArtistId", "artistMbid", "artistName", ' + '"rgMbid", album, "primaryType", "secondaryTypes", "firstReleaseDate", monitored, state, "createdAt") ' + "VALUES (gen_random_uuid()::text, %s, %s, %s, %s, %s, %s, %s, %s, false, 'wanted', now()) " + 'ON CONFLICT ("rgMbid") DO NOTHING', + (watched_id, artist_mbid, artist_name, rg.rg_mbid, rg.title, rg.primary_type or None, + list(rg.secondary_types), rg.first_release_date or None), + ) + conn.commit() + + +def _record_owned(conn, target, quality_class: int, fmt: str, path: str, watched_id) -> bool: + """Mark the owned release monitored+fulfilled and record the LibraryItem. Returns True if newly imported.""" + with conn.cursor() as cur: if target.rg_mbid: cur.execute( 'INSERT INTO "MonitoredRelease" (id, "watchedArtistId", "artistMbid", "artistName", ' @@ -55,7 +75,6 @@ def _upsert(conn, target, quality_class: int, fmt: str, path: str) -> bool: ' "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") ' @@ -68,11 +87,14 @@ def _upsert(conn, target, quality_class: int, fmt: str, path: str) -> bool: return newly -def scan_library(conn: psycopg.Connection, resolver, probe: AudioProbe, dest_root: str = "/music") -> ScanResult: +def scan_library(conn: psycopg.Connection, resolver, probe: AudioProbe, browser, 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.""" + matched albums as have + monitored + followed. Also browses each followed artist's full + discography (once per scan run) and stores it as unmonitored releases, matching web-follow + behavior. Unmatched or audio-less folders are skipped.""" imported = 0 skipped = 0 + browsed: set[str] = set() # artists whose discography we've populated this run if not os.path.isdir(dest_root): return ScanResult(0, 0) for artist_name in sorted(os.listdir(dest_root)): @@ -95,6 +117,10 @@ def scan_library(conn: psycopg.Connection, resolver, probe: AudioProbe, dest_roo if target is None: skipped += 1 continue - if _upsert(conn, target, pr.quality_class, pr.fmt, album_path): + watched_id = _ensure_artist(conn, target) + if watched_id is not None and target.artist_mbid not in browsed: + _populate_discography(conn, browser, watched_id, target.artist_mbid, target.artist) + browsed.add(target.artist_mbid) + if _record_owned(conn, target, pr.quality_class, pr.fmt, album_path, watched_id): imported += 1 return ScanResult(imported, skipped) diff --git a/worker/tests/test_scan.py b/worker/tests/test_scan.py index 67eb877..810c474 100644 --- a/worker/tests/test_scan.py +++ b/worker/tests/test_scan.py @@ -1,3 +1,5 @@ +from lyra_worker.adapters.fakes import FakeMbBrowser +from lyra_worker.browser import ReleaseGroupInfo from lyra_worker.probe import ProbeResult from lyra_worker.scan import scan_library from lyra_worker.types import MBTarget @@ -42,7 +44,7 @@ def test_scan_records_have_monitored_and_follows_artist(conn, tmp_path): ("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)) + result = scan_library(conn, resolver, FakeProbe(quality_class=3), FakeMbBrowser(), dest_root=str(tmp_path)) assert (result.imported, result.skipped) == (1, 0) assert _counts(conn) == {"LibraryItem": 1, "MonitoredRelease": 1, "WatchedArtist": 1} @@ -59,7 +61,7 @@ def test_scan_records_have_monitored_and_follows_artist(conn, tmp_path): 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)) + result = scan_library(conn, FakeResolver({}), FakeProbe(), FakeMbBrowser(), dest_root=str(tmp_path)) assert (result.imported, result.skipped) == (0, 1) assert _counts(conn) == {"LibraryItem": 0, "MonitoredRelease": 0, "WatchedArtist": 0} @@ -71,7 +73,7 @@ def test_scan_falls_back_to_tags_when_folder_does_not_resolve(conn, tmp_path): 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)) + result = scan_library(conn, resolver, probe, FakeMbBrowser(), 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",)) @@ -82,7 +84,7 @@ 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)) + result = scan_library(conn, FakeResolver({}), FakeProbe(), FakeMbBrowser(), dest_root=str(tmp_path)) assert (result.imported, result.skipped) == (0, 0) @@ -92,8 +94,9 @@ def test_scan_is_idempotent(conn, tmp_path): ("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)) + browser = FakeMbBrowser() + scan_library(conn, resolver, FakeProbe(quality_class=3), browser, dest_root=str(tmp_path)) + second = scan_library(conn, resolver, FakeProbe(quality_class=3), browser, dest_root=str(tmp_path)) assert second.imported == 0 # already recorded assert _counts(conn) == {"LibraryItem": 1, "MonitoredRelease": 1, "WatchedArtist": 1} @@ -104,7 +107,7 @@ def test_scan_records_the_probed_format(conn, tmp_path): ("John Mayer", "Sob Rock"): MBTarget( artist="John Mayer", album="Sob Rock", year=2021, rg_mbid="rg2", artist_mbid="a1"), }) - scan_library(conn, resolver, FakeProbe(fmt="MP3", quality_class=1), dest_root=str(tmp_path)) + scan_library(conn, resolver, FakeProbe(fmt="MP3", quality_class=1), FakeMbBrowser(), dest_root=str(tmp_path)) with conn.cursor() as cur: cur.execute('SELECT format, "qualityClass" FROM "LibraryItem" WHERE album=%s', ("Sob Rock",)) assert cur.fetchone() == ("MP3", 1) # the probed format is recorded, not a hardcoded FLAC @@ -114,6 +117,28 @@ def test_scan_skips_hidden_album_dirs(conn, tmp_path): hidden = tmp_path / "John Mayer" / ".sync" hidden.mkdir(parents=True) (hidden / "01 Track.flac").write_bytes(b"") - result = scan_library(conn, FakeResolver({}), FakeProbe(), dest_root=str(tmp_path)) + result = scan_library(conn, FakeResolver({}), FakeProbe(), FakeMbBrowser(), dest_root=str(tmp_path)) assert (result.imported, result.skipped) == (0, 0) # hidden album dir not scanned assert _counts(conn) == {"LibraryItem": 0, "MonitoredRelease": 0, "WatchedArtist": 0} + + +def test_scan_populates_full_discography_unmonitored(conn, tmp_path): + from lyra_worker.adapters.fakes import FakeMbBrowser + from lyra_worker.browser import ReleaseGroupInfo + _album(tmp_path, "50 Cent", "Get Rich or Die Tryin' (2003)") + resolver = FakeResolver({ + ("50 Cent", "Get Rich or Die Tryin'"): MBTarget( + artist="50 Cent", album="Get Rich or Die Tryin'", year=2003, rg_mbid="rg-owned", artist_mbid="a50"), + }) + browser = FakeMbBrowser(releases={"a50": [ + ReleaseGroupInfo("rg-owned", "Get Rich or Die Tryin'", "Album", (), "2003-02-06"), + ReleaseGroupInfo("rg-other", "The Massacre", "Album", (), "2005-03-03"), + ]}) + scan_library(conn, resolver, FakeProbe(quality_class=2), browser, dest_root=str(tmp_path)) + with conn.cursor() as cur: + cur.execute('SELECT count(*) FROM "MonitoredRelease" WHERE "artistName"=%s', ("50 Cent",)) + assert cur.fetchone()[0] == 2 # full discography populated, not just the owned album + cur.execute('SELECT monitored, state FROM "MonitoredRelease" WHERE "rgMbid"=%s', ("rg-owned",)) + assert cur.fetchone() == (True, "fulfilled") # owned album stays monitored+fulfilled + cur.execute('SELECT monitored, state FROM "MonitoredRelease" WHERE "rgMbid"=%s', ("rg-other",)) + assert cur.fetchone() == (False, "wanted") # the rest are unmonitored diff --git a/worker/tests/test_scan_trigger.py b/worker/tests/test_scan_trigger.py index 0e55eb0..88f9639 100644 --- a/worker/tests/test_scan_trigger.py +++ b/worker/tests/test_scan_trigger.py @@ -1,3 +1,4 @@ +from lyra_worker.adapters.fakes import FakeMbBrowser from lyra_worker.main import _run_scan from lyra_worker.types import MBTarget @@ -35,7 +36,7 @@ def test_run_scan_writes_result_and_clears_flag(conn, tmp_path): (tmp_path / "John Mayer" / "Continuum (2006)" / "01.flac").write_bytes(b"") _set(conn, "scan.requested", "true") - _run_scan(conn, FakeResolver(), FakeProbe(), dest_root=str(tmp_path)) + _run_scan(conn, FakeResolver(), FakeProbe(), FakeMbBrowser(), dest_root=str(tmp_path)) assert _get(conn, "scan.requested") == "false" # flag cleared assert "imported" in (_get(conn, "scan.result") or "") # summary written