18a22db7fb
Own-state ("in library") relied on an exact artist-NAME match, so an owned
album whose name differed from the MB canonical (punctuation/locale/feat.)
showed as not-owned even when followed. Capture the MusicBrainz artist MBID
on LibraryItem and prefer it for matching.
- New LibraryItem.artistMbid (migration add_library_artist_mbid). Populated at
import (pipeline) + scan, both of which already hold the resolved target;
the pipeline also now stores rgMbid (it previously didn't). COALESCE on
conflict backfills MBIDs on re-scan without clobbering.
- /api/artists "in library, not followed" matches by artistMbid first, name
case-insensitively as fallback (rows predating the column). Library route
prefers the item's own artistMbid over the release join.
Last.fm/discover own-state still use name-match (they work) — a smaller
follow-up. worker 233, web 186 tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
229 lines
11 KiB
Python
229 lines
11 KiB
Python
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
|
|
|
|
|
|
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), FakeMbBrowser(), 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", "artistMbid", "rgMbid" FROM "LibraryItem" '
|
|
'WHERE artist=%s AND album=%s', ("John Mayer", "Continuum"))
|
|
assert cur.fetchone() == ("scan", 3, "a1", "rg1") # MBIDs captured for own-state matching
|
|
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_a_corrupt_album_and_captures_tracknames(conn, tmp_path):
|
|
_album(tmp_path, "Good Artist", "Good Album (2020)", files=("01 One.flac", "02 Two.flac"))
|
|
_album(tmp_path, "Bad Artist", "Bad Album (2020)")
|
|
|
|
class RaisingProbe:
|
|
def probe(self, path):
|
|
if "Bad Artist" in path: # a truncated/corrupt file mutagen chokes on
|
|
raise RuntimeError("file said 4 bytes, read 0 bytes")
|
|
return ProbeResult(artist="", album="", fmt="FLAC", quality_class=3)
|
|
|
|
resolver = FakeResolver({
|
|
("Good Artist", "Good Album"): MBTarget(
|
|
artist="Good Artist", album="Good Album", year=2020, rg_mbid="rgG", artist_mbid="aG"),
|
|
})
|
|
result = scan_library(conn, resolver, RaisingProbe(), FakeMbBrowser(), dest_root=str(tmp_path))
|
|
|
|
# the corrupt album is skipped, the good one still imports — the scan completes, not aborts
|
|
assert (result.imported, result.skipped) == (1, 1)
|
|
with conn.cursor() as cur:
|
|
cur.execute('SELECT "trackNames" FROM "LibraryItem" WHERE artist=%s', ("Good Artist",))
|
|
assert cur.fetchone()[0] == ["01 One.flac", "02 Two.flac"] # on-disk tracks captured at scan
|
|
|
|
|
|
def _unmatched(conn):
|
|
with conn.cursor() as cur:
|
|
cur.execute('SELECT artist, album, reason FROM "UnmatchedAlbum" ORDER BY album')
|
|
return cur.fetchall()
|
|
|
|
|
|
def test_scan_skips_unmatched_albums(conn, tmp_path):
|
|
_album(tmp_path, "Obscure", "Nope (1999)")
|
|
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}
|
|
# the unmatched album is surfaced (folder name kept for display) with a reason
|
|
assert _unmatched(conn) == [("Obscure", "Nope (1999)", "no MusicBrainz match")]
|
|
|
|
|
|
def test_scan_records_corrupt_album_as_unmatched_with_error_reason(conn, tmp_path):
|
|
_album(tmp_path, "Bad Artist", "Bad Album (2020)")
|
|
|
|
class RaisingProbe:
|
|
def probe(self, path):
|
|
raise RuntimeError("file said 4 bytes, read 0 bytes")
|
|
|
|
scan_library(conn, FakeResolver({}), RaisingProbe(), FakeMbBrowser(), dest_root=str(tmp_path))
|
|
rows = _unmatched(conn)
|
|
assert len(rows) == 1 and rows[0][:2] == ("Bad Artist", "Bad Album (2020)")
|
|
assert rows[0][2].startswith("unreadable:")
|
|
|
|
|
|
def test_scan_clears_unmatched_when_album_resolves(conn, tmp_path):
|
|
_album(tmp_path, "Obscure", "Nope (1999)")
|
|
# first scan: no resolver match → recorded as unmatched
|
|
scan_library(conn, FakeResolver({}), FakeProbe(), FakeMbBrowser(), dest_root=str(tmp_path))
|
|
assert len(_unmatched(conn)) == 1
|
|
# second scan: now it resolves → the unmatched row is dropped
|
|
resolver = FakeResolver({
|
|
("Obscure", "Nope"): MBTarget(
|
|
artist="Obscure", album="Nope", year=1999, rg_mbid="rgN", artist_mbid="aN"),
|
|
})
|
|
scan_library(conn, resolver, FakeProbe(quality_class=2), FakeMbBrowser(), dest_root=str(tmp_path))
|
|
assert _unmatched(conn) == []
|
|
|
|
|
|
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, 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",))
|
|
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(), FakeMbBrowser(), 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"),
|
|
})
|
|
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}
|
|
|
|
|
|
def test_scan_records_the_probed_format(conn, tmp_path):
|
|
_album(tmp_path, "John Mayer", "Sob Rock (2021)")
|
|
resolver = FakeResolver({
|
|
("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), 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
|
|
|
|
|
|
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(), 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
|
|
|
|
|
|
def test_scan_backfills_type_on_preexisting_typeless_owned_row(conn, tmp_path):
|
|
from lyra_worker.adapters.fakes import FakeMbBrowser
|
|
from lyra_worker.browser import ReleaseGroupInfo
|
|
# simulate an owned row recorded by an older scan: monitored+fulfilled but NULL primaryType
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
'INSERT INTO "MonitoredRelease" (id, "artistMbid", "artistName", "rgMbid", album, '
|
|
'"secondaryTypes", monitored, state, "currentQualityClass", "createdAt") '
|
|
"VALUES (gen_random_uuid()::text, 'a50', '50 Cent', 'rg-owned', 'Get Rich or Die Tryin''', "
|
|
"'{}', true, 'fulfilled', 2, now())"
|
|
)
|
|
conn.commit()
|
|
_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"),
|
|
]})
|
|
scan_library(conn, resolver, FakeProbe(quality_class=2), browser, dest_root=str(tmp_path))
|
|
with conn.cursor() as cur:
|
|
cur.execute('SELECT "primaryType", monitored, state, "currentQualityClass" FROM "MonitoredRelease" WHERE "rgMbid"=%s', ("rg-owned",))
|
|
assert cur.fetchone() == ("Album", True, "fulfilled", 2) # type backfilled, state preserved
|