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.
This commit is contained in:
@@ -25,8 +25,8 @@ def _set_config(conn, key: str, value: str) -> None:
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
def _run_scan(conn, resolver, probe, dest_root: str = DEST_ROOT) -> None:
|
def _run_scan(conn, resolver, probe, browser, dest_root: str = DEST_ROOT) -> None:
|
||||||
result = scan_library(conn, resolver, probe, dest_root)
|
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.result", f"imported {result.imported}, skipped {result.skipped}")
|
||||||
_set_config(conn, "scan.requested", "false")
|
_set_config(conn, "scan.requested", "false")
|
||||||
print(f"worker: library scan done — {result.imported} imported, {result.skipped} skipped", flush=True)
|
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:
|
if str(config.get("scan.requested", "")).strip().lower() in _TRUE:
|
||||||
try:
|
try:
|
||||||
_run_scan(conn, resolver, probe)
|
_run_scan(conn, resolver, probe, browser)
|
||||||
except Exception as e: # a scan error must never kill the worker
|
except Exception as e: # a scan error must never kill the worker
|
||||||
print(f"worker: library scan failed: {e}", flush=True)
|
print(f"worker: library scan failed: {e}", flush=True)
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
|
|||||||
@@ -28,11 +28,11 @@ def _first_audio(album_path: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _upsert(conn, target, quality_class: int, fmt: str, path: str) -> bool:
|
def _ensure_artist(conn, target) -> str | None:
|
||||||
"""Record have + monitored + followed for a resolved album. Returns True if newly imported."""
|
"""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:
|
with conn.cursor() as cur:
|
||||||
watched_id = None
|
|
||||||
if target.artist_mbid:
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
'INSERT INTO "WatchedArtist" (id, mbid, name, "autoMonitorFuture", "monitorFrom", "createdAt") '
|
'INSERT INTO "WatchedArtist" (id, mbid, name, "autoMonitorFuture", "monitorFrom", "createdAt") '
|
||||||
"VALUES (gen_random_uuid()::text, %s, %s, false, now(), now()) "
|
"VALUES (gen_random_uuid()::text, %s, %s, false, now(), now()) "
|
||||||
@@ -41,8 +41,28 @@ def _upsert(conn, target, quality_class: int, fmt: str, path: str) -> bool:
|
|||||||
)
|
)
|
||||||
cur.execute('SELECT id FROM "WatchedArtist" WHERE mbid = %s', (target.artist_mbid,))
|
cur.execute('SELECT id FROM "WatchedArtist" WHERE mbid = %s', (target.artist_mbid,))
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
watched_id = row[0] if row else None
|
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:
|
if target.rg_mbid:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
'INSERT INTO "MonitoredRelease" (id, "watchedArtistId", "artistMbid", "artistName", '
|
'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")',
|
' "watchedArtistId" = COALESCE("MonitoredRelease"."watchedArtistId", EXCLUDED."watchedArtistId")',
|
||||||
(watched_id, target.artist_mbid, target.artist, target.rg_mbid, target.album, quality_class),
|
(watched_id, target.artist_mbid, target.artist, target.rg_mbid, target.album, quality_class),
|
||||||
)
|
)
|
||||||
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
'INSERT INTO "LibraryItem" (id, "requestId", artist, album, path, source, format, '
|
'INSERT INTO "LibraryItem" (id, "requestId", artist, album, path, source, format, '
|
||||||
'"qualityClass", "importedAt") '
|
'"qualityClass", "importedAt") '
|
||||||
@@ -68,11 +87,14 @@ def _upsert(conn, target, quality_class: int, fmt: str, path: str) -> bool:
|
|||||||
return newly
|
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
|
"""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
|
imported = 0
|
||||||
skipped = 0
|
skipped = 0
|
||||||
|
browsed: set[str] = set() # artists whose discography we've populated this run
|
||||||
if not os.path.isdir(dest_root):
|
if not os.path.isdir(dest_root):
|
||||||
return ScanResult(0, 0)
|
return ScanResult(0, 0)
|
||||||
for artist_name in sorted(os.listdir(dest_root)):
|
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:
|
if target is None:
|
||||||
skipped += 1
|
skipped += 1
|
||||||
continue
|
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
|
imported += 1
|
||||||
return ScanResult(imported, skipped)
|
return ScanResult(imported, skipped)
|
||||||
|
|||||||
@@ -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.probe import ProbeResult
|
||||||
from lyra_worker.scan import scan_library
|
from lyra_worker.scan import scan_library
|
||||||
from lyra_worker.types import MBTarget
|
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(
|
("John Mayer", "Continuum"): MBTarget(
|
||||||
artist="John Mayer", album="Continuum", year=2006, rg_mbid="rg1", artist_mbid="a1"),
|
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 (result.imported, result.skipped) == (1, 0)
|
||||||
assert _counts(conn) == {"LibraryItem": 1, "MonitoredRelease": 1, "WatchedArtist": 1}
|
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):
|
def test_scan_skips_unmatched_albums(conn, tmp_path):
|
||||||
_album(tmp_path, "Obscure", "Nope (1999)")
|
_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 (result.imported, result.skipped) == (0, 1)
|
||||||
assert _counts(conn) == {"LibraryItem": 0, "MonitoredRelease": 0, "WatchedArtist": 0}
|
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"),
|
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
|
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)
|
assert (result.imported, result.skipped) == (1, 0)
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute('SELECT count(*) FROM "MonitoredRelease" WHERE "rgMbid"=%s', ("rg9",))
|
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 = tmp_path / "Artist" / "Album"
|
||||||
d.mkdir(parents=True)
|
d.mkdir(parents=True)
|
||||||
(d / "cover.jpg").write_bytes(b"") # no audio
|
(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)
|
assert (result.imported, result.skipped) == (0, 0)
|
||||||
|
|
||||||
|
|
||||||
@@ -92,8 +94,9 @@ def test_scan_is_idempotent(conn, tmp_path):
|
|||||||
("John Mayer", "Continuum"): MBTarget(
|
("John Mayer", "Continuum"): MBTarget(
|
||||||
artist="John Mayer", album="Continuum", year=2006, rg_mbid="rg1", artist_mbid="a1"),
|
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))
|
browser = FakeMbBrowser()
|
||||||
second = scan_library(conn, resolver, FakeProbe(quality_class=3), dest_root=str(tmp_path))
|
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 second.imported == 0 # already recorded
|
||||||
assert _counts(conn) == {"LibraryItem": 1, "MonitoredRelease": 1, "WatchedArtist": 1}
|
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(
|
("John Mayer", "Sob Rock"): MBTarget(
|
||||||
artist="John Mayer", album="Sob Rock", year=2021, rg_mbid="rg2", artist_mbid="a1"),
|
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:
|
with conn.cursor() as cur:
|
||||||
cur.execute('SELECT format, "qualityClass" FROM "LibraryItem" WHERE album=%s', ("Sob Rock",))
|
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
|
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 = tmp_path / "John Mayer" / ".sync"
|
||||||
hidden.mkdir(parents=True)
|
hidden.mkdir(parents=True)
|
||||||
(hidden / "01 Track.flac").write_bytes(b"")
|
(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 (result.imported, result.skipped) == (0, 0) # hidden album dir not scanned
|
||||||
assert _counts(conn) == {"LibraryItem": 0, "MonitoredRelease": 0, "WatchedArtist": 0}
|
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
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from lyra_worker.adapters.fakes import FakeMbBrowser
|
||||||
from lyra_worker.main import _run_scan
|
from lyra_worker.main import _run_scan
|
||||||
from lyra_worker.types import MBTarget
|
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"")
|
(tmp_path / "John Mayer" / "Continuum (2006)" / "01.flac").write_bytes(b"")
|
||||||
_set(conn, "scan.requested", "true")
|
_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 _get(conn, "scan.requested") == "false" # flag cleared
|
||||||
assert "imported" in (_get(conn, "scan.result") or "") # summary written
|
assert "imported" in (_get(conn, "scan.result") or "") # summary written
|
||||||
|
|||||||
Reference in New Issue
Block a user