0115b82867
Replace scan_chunk's O(N^2) tree re-walk-and-skip-to-cursor with a persisted ScanWorkItem worklist: build_worklist walks the tree once at scan start and inserts one row per album (idempotent via ON CONFLICT), scan_chunk pops up to `limit` not-done rows by indexed cursor and marks them done, and clear_worklist drops the scan's rows once drained. scan_library is reimplemented on top of the worklist (one-shot scan_id, drain, clear) with an unchanged signature/behavior. main.py's maybe_run_scan now drives a generated scan.id through Config (scan.id/scan.inProgress/scan.progress/scan.result) instead of a scan.cursor string, so a mid-scan library change no longer risks skipping or re-walking albums.
89 lines
4.0 KiB
Python
89 lines
4.0 KiB
Python
from lyra_worker.adapters.fakes import FakeMbBrowser
|
|
from lyra_worker.browser import ReleaseGroupInfo
|
|
from lyra_worker.scan import build_worklist, scan_chunk, clear_worklist
|
|
from lyra_worker.types import MBTarget
|
|
from tests.test_scan import FakeProbe, FakeResolver, _album, _counts
|
|
|
|
|
|
def _tree(tmp_path):
|
|
"""Three albums across two artists: Artist A/Alpha, Artist A/Beta, Artist B/Gamma."""
|
|
_album(tmp_path, "Artist A", "Alpha (2001)")
|
|
_album(tmp_path, "Artist A", "Beta (2002)")
|
|
_album(tmp_path, "Artist B", "Gamma (2003)")
|
|
resolver = FakeResolver({
|
|
("Artist A", "Alpha"): MBTarget(artist="Artist A", album="Alpha", year=2001, rg_mbid="rg-a1", artist_mbid="ma"),
|
|
("Artist A", "Beta"): MBTarget(artist="Artist A", album="Beta", year=2002, rg_mbid="rg-a2", artist_mbid="ma"),
|
|
("Artist B", "Gamma"): MBTarget(artist="Artist B", album="Gamma", year=2003, rg_mbid="rg-b1", artist_mbid="mb"),
|
|
})
|
|
browser = FakeMbBrowser(releases={
|
|
"ma": [ReleaseGroupInfo("rg-a1", "Alpha", "Album", (), "2001"),
|
|
ReleaseGroupInfo("rg-a2", "Beta", "Album", (), "2002")],
|
|
"mb": [ReleaseGroupInfo("rg-b1", "Gamma", "Album", (), "2003")],
|
|
})
|
|
return resolver, browser
|
|
|
|
|
|
def test_build_worklist_inserts_one_row_per_album(conn, tmp_path):
|
|
resolver, _ = _tree(tmp_path)
|
|
n = build_worklist(conn, "scan1", str(tmp_path))
|
|
assert n == 3
|
|
with conn.cursor() as cur:
|
|
cur.execute('SELECT count(*) FROM "ScanWorkItem" WHERE "scanId" = %s AND NOT done', ("scan1",))
|
|
assert cur.fetchone()[0] == 3
|
|
|
|
|
|
def test_build_worklist_is_idempotent(conn, tmp_path):
|
|
resolver, _ = _tree(tmp_path)
|
|
assert build_worklist(conn, "scan1", str(tmp_path)) == 3
|
|
assert build_worklist(conn, "scan1", str(tmp_path)) == 0 # ON CONFLICT DO NOTHING
|
|
with conn.cursor() as cur:
|
|
cur.execute('SELECT count(*) FROM "ScanWorkItem" WHERE "scanId" = %s', ("scan1",))
|
|
assert cur.fetchone()[0] == 3
|
|
|
|
|
|
def test_scan_chunk_respects_limit_and_marks_done(conn, tmp_path):
|
|
resolver, browser = _tree(tmp_path)
|
|
build_worklist(conn, "scan1", str(tmp_path))
|
|
imported, skipped, done = scan_chunk(conn, resolver, FakeProbe(quality_class=2), browser, "scan1", limit=2)
|
|
assert (imported, skipped, done) == (2, 0, False) # 2 of 3 processed, more remain
|
|
with conn.cursor() as cur:
|
|
cur.execute('SELECT count(*) FROM "ScanWorkItem" WHERE "scanId" = %s AND done', ("scan1",))
|
|
assert cur.fetchone()[0] == 2
|
|
cur.execute('SELECT count(*) FROM "LibraryItem"')
|
|
assert cur.fetchone()[0] == 2
|
|
|
|
|
|
def test_scan_chunk_resumes_and_finishes(conn, tmp_path):
|
|
resolver, browser = _tree(tmp_path)
|
|
build_worklist(conn, "scan1", str(tmp_path))
|
|
scan_chunk(conn, resolver, FakeProbe(quality_class=2), browser, "scan1", limit=2)
|
|
imported, skipped, done = scan_chunk(conn, resolver, FakeProbe(quality_class=2), browser, "scan1", limit=25)
|
|
assert (imported, skipped, done) == (1, 0, True) # last album, then drained
|
|
with conn.cursor() as cur:
|
|
cur.execute('SELECT album FROM "LibraryItem" ORDER BY album')
|
|
assert cur.fetchall() == [("Alpha",), ("Beta",), ("Gamma",)]
|
|
|
|
|
|
def test_scan_chunk_one_per_chunk_totals_correctly(conn, tmp_path):
|
|
resolver, browser = _tree(tmp_path)
|
|
build_worklist(conn, "scan1", str(tmp_path))
|
|
total_i = total_s = guard = 0
|
|
done = False
|
|
while not done and guard < 10:
|
|
i, s, done = scan_chunk(conn, resolver, FakeProbe(quality_class=2), browser, "scan1", limit=1)
|
|
total_i += i
|
|
total_s += s
|
|
guard += 1
|
|
assert (total_i, total_s) == (3, 0)
|
|
assert guard == 3
|
|
assert _counts(conn) == {"LibraryItem": 3, "MonitoredRelease": 3, "WatchedArtist": 2}
|
|
|
|
|
|
def test_clear_worklist_removes_this_scans_rows(conn, tmp_path):
|
|
resolver, _ = _tree(tmp_path)
|
|
build_worklist(conn, "scan1", str(tmp_path))
|
|
clear_worklist(conn, "scan1")
|
|
with conn.cursor() as cur:
|
|
cur.execute('SELECT count(*) FROM "ScanWorkItem" WHERE "scanId" = %s', ("scan1",))
|
|
assert cur.fetchone()[0] == 0
|