feat(worker): persisted scan worklist (O(N) scan, robust to mid-scan changes)
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.
This commit is contained in:
+24
-13
@@ -1,5 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
import psycopg
|
import psycopg
|
||||||
|
|
||||||
@@ -14,7 +15,7 @@ from lyra_worker.registry import (
|
|||||||
build_adapters, build_browser, build_probe, build_resolver,
|
build_adapters, build_browser, build_probe, build_resolver,
|
||||||
build_similarity_sources, build_tagger,
|
build_similarity_sources, build_tagger,
|
||||||
)
|
)
|
||||||
from lyra_worker.scan import scan_chunk
|
from lyra_worker.scan import build_worklist, clear_worklist, scan_chunk
|
||||||
|
|
||||||
IDLE_SLEEP = 2.0
|
IDLE_SLEEP = 2.0
|
||||||
HEARTBEAT_SECONDS = 15.0 # how often to stamp worker.heartbeat (its updatedAt = liveness)
|
HEARTBEAT_SECONDS = 15.0 # how often to stamp worker.heartbeat (its updatedAt = liveness)
|
||||||
@@ -58,29 +59,39 @@ def _parse_progress(s: str) -> tuple[int, int]:
|
|||||||
def maybe_run_scan(conn, resolver, probe, browser, config, dest_root: str = DEST_ROOT) -> None:
|
def maybe_run_scan(conn, resolver, probe, browser, config, dest_root: str = DEST_ROOT) -> None:
|
||||||
"""Advance a chunked library scan by at most one chunk per call, so the worker loop
|
"""Advance a chunked library scan by at most one chunk per call, so the worker loop
|
||||||
keeps claiming jobs and ticking the monitor between chunks. `scan.requested` starts a
|
keeps claiming jobs and ticking the monitor between chunks. `scan.requested` starts a
|
||||||
fresh scan; `scan.inProgress`/`scan.cursor`/`scan.progress` persist state until the walk
|
fresh scan (walk once -> ScanWorkItem worklist); `scan.inProgress`/`scan.id`/`scan.progress`
|
||||||
is exhausted, at which point `scan.result` is written (matching the old one-shot output)."""
|
persist state until the worklist drains, at which point `scan.result` is written."""
|
||||||
requested = str(config.get("scan.requested", "")).strip().lower() in _TRUE
|
requested = str(config.get("scan.requested", "")).strip().lower() in _TRUE
|
||||||
in_progress = str(config.get("scan.inProgress", "")).strip().lower() in _TRUE
|
in_progress = str(config.get("scan.inProgress", "")).strip().lower() in _TRUE
|
||||||
if not requested and not in_progress:
|
if not requested and not in_progress:
|
||||||
return
|
return
|
||||||
if requested and not in_progress: # start a fresh scan
|
if requested and not in_progress: # start a fresh scan
|
||||||
|
scan_id = uuid.uuid4().hex
|
||||||
_set_config(conn, "scan.inProgress", "true")
|
_set_config(conn, "scan.inProgress", "true")
|
||||||
_set_config(conn, "scan.requested", "false")
|
_set_config(conn, "scan.requested", "false")
|
||||||
_set_config(conn, "scan.cursor", "")
|
_set_config(conn, "scan.id", scan_id)
|
||||||
_set_config(conn, "scan.progress", "0/0")
|
_set_config(conn, "scan.progress", "0/0")
|
||||||
cursor, imported_so_far, skipped_so_far = "", 0, 0
|
try:
|
||||||
else: # resume the in-progress scan from its persisted cursor
|
build_worklist(conn, scan_id, dest_root)
|
||||||
cursor = config.get("scan.cursor", "") or ""
|
except Exception as e: # a scan error must never kill the worker
|
||||||
|
print(f"worker: library scan worklist build failed: {e}", flush=True)
|
||||||
|
conn.rollback()
|
||||||
|
_set_config(conn, "scan.inProgress", "false")
|
||||||
|
_set_config(conn, "scan.requested", "false")
|
||||||
|
return
|
||||||
|
imported_so_far, skipped_so_far = 0, 0
|
||||||
|
else: # resume the in-progress scan
|
||||||
|
scan_id = config.get("scan.id", "") or ""
|
||||||
imported_so_far, skipped_so_far = _parse_progress(config.get("scan.progress", ""))
|
imported_so_far, skipped_so_far = _parse_progress(config.get("scan.progress", ""))
|
||||||
|
if not scan_id: # corrupt/absent state -> abandon; a new request restarts it
|
||||||
|
_set_config(conn, "scan.inProgress", "false")
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
new_cursor, imp, skp, done = scan_chunk(
|
imp, skp, done = scan_chunk(conn, resolver, probe, browser, scan_id, _scan_chunk_size(config))
|
||||||
conn, resolver, probe, browser, dest_root, cursor, _scan_chunk_size(config)
|
|
||||||
)
|
|
||||||
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 chunk failed: {e}", flush=True)
|
print(f"worker: library scan chunk failed: {e}", flush=True)
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
_set_config(conn, "scan.inProgress", "false") # abandon the scan; a new request restarts it
|
_set_config(conn, "scan.inProgress", "false")
|
||||||
_set_config(conn, "scan.requested", "false")
|
_set_config(conn, "scan.requested", "false")
|
||||||
return
|
return
|
||||||
imported_total = imported_so_far + imp
|
imported_total = imported_so_far + imp
|
||||||
@@ -88,11 +99,11 @@ def maybe_run_scan(conn, resolver, probe, browser, config, dest_root: str = DEST
|
|||||||
if done:
|
if done:
|
||||||
_set_config(conn, "scan.result", f"imported {imported_total}, skipped {skipped_total}")
|
_set_config(conn, "scan.result", f"imported {imported_total}, skipped {skipped_total}")
|
||||||
_set_config(conn, "scan.progress", "")
|
_set_config(conn, "scan.progress", "")
|
||||||
_set_config(conn, "scan.cursor", "")
|
|
||||||
_set_config(conn, "scan.inProgress", "false")
|
_set_config(conn, "scan.inProgress", "false")
|
||||||
|
clear_worklist(conn, scan_id)
|
||||||
|
_set_config(conn, "scan.id", "")
|
||||||
print(f"worker: library scan done — {imported_total} imported, {skipped_total} skipped", flush=True)
|
print(f"worker: library scan done — {imported_total} imported, {skipped_total} skipped", flush=True)
|
||||||
else:
|
else:
|
||||||
_set_config(conn, "scan.cursor", new_cursor)
|
|
||||||
_set_config(conn, "scan.progress", f"{imported_total}/{skipped_total}")
|
_set_config(conn, "scan.progress", f"{imported_total}/{skipped_total}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+65
-37
@@ -1,5 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import uuid
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
import psycopg
|
import psycopg
|
||||||
@@ -107,17 +108,6 @@ def _iter_album_entries(dest_root: str):
|
|||||||
yield artist_name, album_folder, album_path
|
yield artist_name, album_folder, album_path
|
||||||
|
|
||||||
|
|
||||||
def _cursor_key(artist_name: str, album_folder: str) -> str:
|
|
||||||
return f"{artist_name}/{album_folder}"
|
|
||||||
|
|
||||||
|
|
||||||
def _cursor_tuple(cursor: str) -> tuple[str, str]:
|
|
||||||
# A cursor is "<artist>/<album>"; artist and album are single path segments (no "/"),
|
|
||||||
# so partitioning on the first "/" recovers them, and tuple order matches the walk.
|
|
||||||
artist, _, album = (cursor or "").partition("/")
|
|
||||||
return artist, album
|
|
||||||
|
|
||||||
|
|
||||||
def _process_album(conn, resolver, probe, browser, artist_name, album_folder, album_path, browsed) -> str:
|
def _process_album(conn, resolver, probe, browser, artist_name, album_folder, album_path, browsed) -> str:
|
||||||
"""Identify and record one album folder. Returns 'imported' (newly recorded),
|
"""Identify and record one album folder. Returns 'imported' (newly recorded),
|
||||||
'recorded' (matched but already present), 'skipped' (no MB match) or 'none' (no audio)."""
|
'recorded' (matched but already present), 'skipped' (no MB match) or 'none' (no audio)."""
|
||||||
@@ -139,38 +129,76 @@ def _process_album(conn, resolver, probe, browser, artist_name, album_folder, al
|
|||||||
return "imported" if _record_owned(conn, target, pr.quality_class, pr.fmt, album_path, watched_id) else "recorded"
|
return "imported" if _record_owned(conn, target, pr.quality_class, pr.fmt, album_path, watched_id) else "recorded"
|
||||||
|
|
||||||
|
|
||||||
def scan_chunk(conn: psycopg.Connection, resolver, probe: AudioProbe, browser, dest_root: str = "/music",
|
def build_worklist(conn: psycopg.Connection, scan_id: str, dest_root: str = "/music") -> int:
|
||||||
cursor: str = "", limit: int | None = None) -> tuple[str, int, int, bool]:
|
"""Walk the tree once and persist this scan's album worklist. Idempotent
|
||||||
"""Resolve and record album folders whose (artist, album) sorts after `cursor`, up to
|
(ON CONFLICT DO NOTHING), so a re-run after a crash adds only missing rows.
|
||||||
`limit` of them (unbounded when limit is None). Returns (new_cursor, imported, skipped,
|
Returns the number of rows inserted."""
|
||||||
done); `done` is True when the walk was exhausted within this chunk. All writes are
|
inserted = 0
|
||||||
ON CONFLICT, so an overlapping or repeated cursor is idempotent."""
|
with conn.cursor() as cur:
|
||||||
after = _cursor_tuple(cursor)
|
for artist_name, album_folder, album_path in _iter_album_entries(dest_root):
|
||||||
imported = skipped = processed = 0
|
cur.execute(
|
||||||
|
'INSERT INTO "ScanWorkItem" (id, "scanId", artist, album, path, done) '
|
||||||
|
"VALUES (gen_random_uuid()::text, %s, %s, %s, %s, false) "
|
||||||
|
'ON CONFLICT ("scanId", path) DO NOTHING',
|
||||||
|
(scan_id, artist_name, album_folder, album_path),
|
||||||
|
)
|
||||||
|
inserted += cur.rowcount
|
||||||
|
conn.commit()
|
||||||
|
return inserted
|
||||||
|
|
||||||
|
|
||||||
|
def scan_chunk(conn: psycopg.Connection, resolver, probe: AudioProbe, browser,
|
||||||
|
scan_id: str, limit: int) -> tuple[int, int, bool]:
|
||||||
|
"""Process up to `limit` not-yet-done worklist items for `scan_id`, in stable
|
||||||
|
(artist, album) order, marking each done. Returns (imported, skipped, done) where
|
||||||
|
`done` is True when no undone rows remain. Idempotent: all record writes are
|
||||||
|
ON CONFLICT, and an item is marked done only after it is processed."""
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
'SELECT id, artist, album, path FROM "ScanWorkItem" '
|
||||||
|
'WHERE "scanId" = %s AND NOT done ORDER BY artist, album LIMIT %s',
|
||||||
|
(scan_id, limit),
|
||||||
|
)
|
||||||
|
items = cur.fetchall()
|
||||||
|
|
||||||
|
imported = skipped = 0
|
||||||
browsed: set[str] = set() # artists whose discography we've populated this chunk
|
browsed: set[str] = set() # artists whose discography we've populated this chunk
|
||||||
new_cursor = cursor
|
for item_id, artist_name, album_folder, album_path in items:
|
||||||
done = True
|
|
||||||
for artist_name, album_folder, album_path in _iter_album_entries(dest_root):
|
|
||||||
if (artist_name, album_folder) <= after:
|
|
||||||
continue # already processed by an earlier chunk
|
|
||||||
if limit is not None and processed >= limit:
|
|
||||||
done = False # stopped early; more albums remain after new_cursor
|
|
||||||
break
|
|
||||||
outcome = _process_album(conn, resolver, probe, browser, artist_name, album_folder, album_path, browsed)
|
outcome = _process_album(conn, resolver, probe, browser, artist_name, album_folder, album_path, browsed)
|
||||||
if outcome == "imported":
|
if outcome == "imported":
|
||||||
imported += 1
|
imported += 1
|
||||||
elif outcome == "skipped":
|
elif outcome == "skipped":
|
||||||
skipped += 1
|
skipped += 1
|
||||||
new_cursor = _cursor_key(artist_name, album_folder)
|
with conn.cursor() as cur:
|
||||||
processed += 1
|
cur.execute('UPDATE "ScanWorkItem" SET done = true WHERE id = %s', (item_id,))
|
||||||
return new_cursor, imported, skipped, done
|
conn.commit()
|
||||||
|
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute('SELECT count(*) FROM "ScanWorkItem" WHERE "scanId" = %s AND NOT done', (scan_id,))
|
||||||
|
remaining = cur.fetchone()[0]
|
||||||
|
return imported, skipped, remaining == 0
|
||||||
|
|
||||||
|
|
||||||
def scan_library(conn: psycopg.Connection, resolver, probe: AudioProbe, browser, dest_root: str = "/music") -> ScanResult:
|
def clear_worklist(conn: psycopg.Connection, scan_id: str) -> None:
|
||||||
"""Walk `{dest_root}/{Artist}/{Album}/` in one unbounded pass, resolving each album
|
with conn.cursor() as cur:
|
||||||
against MusicBrainz and recording matched albums as have + monitored + followed (plus
|
cur.execute('DELETE FROM "ScanWorkItem" WHERE "scanId" = %s', (scan_id,))
|
||||||
each followed artist's full discography as unmonitored releases). Unmatched or audio-less
|
conn.commit()
|
||||||
folders are skipped. This is `scan_chunk` with no limit; the worker loop uses the chunked
|
|
||||||
form so a large library doesn't block job-claim."""
|
|
||||||
_cursor, imported, skipped, _done = scan_chunk(conn, resolver, probe, browser, dest_root, cursor="", limit=None)
|
def scan_library(conn: psycopg.Connection, resolver, probe: AudioProbe, browser,
|
||||||
|
dest_root: str = "/music") -> ScanResult:
|
||||||
|
"""Walk `{dest_root}/{Artist}/{Album}/` in one unbounded pass (build the worklist,
|
||||||
|
drain it, clean up), recording matched albums as have + monitored + followed plus
|
||||||
|
each followed artist's full discography. Test/one-shot convenience; the worker loop
|
||||||
|
uses the chunked build_worklist + scan_chunk so a large library doesn't block
|
||||||
|
job-claim."""
|
||||||
|
scan_id = "oneshot-" + uuid.uuid4().hex
|
||||||
|
build_worklist(conn, scan_id, dest_root)
|
||||||
|
imported = skipped = 0
|
||||||
|
done = False
|
||||||
|
while not done:
|
||||||
|
imp, skp, done = scan_chunk(conn, resolver, probe, browser, scan_id, limit=1000)
|
||||||
|
imported += imp
|
||||||
|
skipped += skp
|
||||||
|
clear_worklist(conn, scan_id)
|
||||||
return ScanResult(imported, skipped)
|
return ScanResult(imported, skipped)
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
from lyra_worker.adapters.fakes import FakeMbBrowser
|
from lyra_worker.adapters.fakes import FakeMbBrowser
|
||||||
from lyra_worker.browser import ReleaseGroupInfo
|
from lyra_worker.browser import ReleaseGroupInfo
|
||||||
from lyra_worker.scan import scan_chunk
|
from lyra_worker.scan import build_worklist, scan_chunk, clear_worklist
|
||||||
from lyra_worker.types import MBTarget
|
from lyra_worker.types import MBTarget
|
||||||
from tests.test_scan import FakeProbe, FakeResolver, _album, _counts
|
from tests.test_scan import FakeProbe, FakeResolver, _album, _counts
|
||||||
|
|
||||||
|
|
||||||
def _tree(tmp_path):
|
def _tree(tmp_path):
|
||||||
"""Three albums across two artists, in nested-sorted order:
|
"""Three albums across two artists: Artist A/Alpha, Artist A/Beta, Artist B/Gamma."""
|
||||||
Artist A/Alpha, Artist A/Beta, Artist B/Gamma."""
|
|
||||||
_album(tmp_path, "Artist A", "Alpha (2001)")
|
_album(tmp_path, "Artist A", "Alpha (2001)")
|
||||||
_album(tmp_path, "Artist A", "Beta (2002)")
|
_album(tmp_path, "Artist A", "Beta (2002)")
|
||||||
_album(tmp_path, "Artist B", "Gamma (2003)")
|
_album(tmp_path, "Artist B", "Gamma (2003)")
|
||||||
@@ -24,58 +23,66 @@ def _tree(tmp_path):
|
|||||||
return resolver, browser
|
return resolver, browser
|
||||||
|
|
||||||
|
|
||||||
def test_scan_chunk_respects_limit_and_returns_cursor(conn, tmp_path):
|
def test_build_worklist_inserts_one_row_per_album(conn, tmp_path):
|
||||||
resolver, browser = _tree(tmp_path)
|
resolver, _ = _tree(tmp_path)
|
||||||
cursor, imported, skipped, done = scan_chunk(
|
n = build_worklist(conn, "scan1", str(tmp_path))
|
||||||
conn, resolver, FakeProbe(quality_class=2), browser, str(tmp_path), cursor="", limit=2)
|
assert n == 3
|
||||||
assert (imported, skipped, done) == (2, 0, False) # processed 2 of 3, more remain
|
|
||||||
assert cursor == "Artist A/Beta (2002)" # last processed key
|
|
||||||
with conn.cursor() as cur:
|
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"')
|
cur.execute('SELECT count(*) FROM "LibraryItem"')
|
||||||
assert cur.fetchone()[0] == 2
|
assert cur.fetchone()[0] == 2
|
||||||
|
|
||||||
|
|
||||||
def test_scan_chunk_resumes_from_cursor(conn, tmp_path):
|
def test_scan_chunk_resumes_and_finishes(conn, tmp_path):
|
||||||
resolver, browser = _tree(tmp_path)
|
resolver, browser = _tree(tmp_path)
|
||||||
cursor, imported, skipped, done = scan_chunk(
|
build_worklist(conn, "scan1", str(tmp_path))
|
||||||
conn, resolver, FakeProbe(quality_class=2), browser, str(tmp_path),
|
scan_chunk(conn, resolver, FakeProbe(quality_class=2), browser, "scan1", limit=2)
|
||||||
cursor="Artist A/Beta (2002)", limit=25)
|
imported, skipped, done = scan_chunk(conn, resolver, FakeProbe(quality_class=2), browser, "scan1", limit=25)
|
||||||
assert (imported, skipped, done) == (1, 0, True) # only Gamma remains
|
assert (imported, skipped, done) == (1, 0, True) # last album, then drained
|
||||||
assert cursor == "Artist B/Gamma (2003)"
|
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute('SELECT album FROM "LibraryItem"')
|
cur.execute('SELECT album FROM "LibraryItem" ORDER BY album')
|
||||||
assert cur.fetchall() == [("Gamma",)] # only Gamma imported
|
assert cur.fetchall() == [("Alpha",), ("Beta",), ("Gamma",)]
|
||||||
|
|
||||||
|
|
||||||
def test_scan_chunk_cursor_past_end_is_noop(conn, tmp_path):
|
def test_scan_chunk_one_per_chunk_totals_correctly(conn, tmp_path):
|
||||||
resolver, browser = _tree(tmp_path)
|
resolver, browser = _tree(tmp_path)
|
||||||
cursor, imported, skipped, done = scan_chunk(
|
build_worklist(conn, "scan1", str(tmp_path))
|
||||||
conn, resolver, FakeProbe(), browser, str(tmp_path), cursor="zzzz/zzzz", limit=10)
|
total_i = total_s = guard = 0
|
||||||
assert (imported, skipped, done) == (0, 0, True)
|
done = False
|
||||||
assert cursor == "zzzz/zzzz" # unchanged
|
|
||||||
assert _counts(conn)["LibraryItem"] == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_scan_chunk_splits_artist_across_chunks_idempotently(conn, tmp_path):
|
|
||||||
# limit=1 forces Artist A's two albums into separate chunks, re-browsing its
|
|
||||||
# discography — which must stay idempotent (ON CONFLICT) and total the same as one scan.
|
|
||||||
resolver, browser = _tree(tmp_path)
|
|
||||||
cursor, done, total_i, total_s, guard = "", False, 0, 0, 0
|
|
||||||
while not done and guard < 10:
|
while not done and guard < 10:
|
||||||
cursor, i, s, done = scan_chunk(
|
i, s, done = scan_chunk(conn, resolver, FakeProbe(quality_class=2), browser, "scan1", limit=1)
|
||||||
conn, resolver, FakeProbe(quality_class=2), browser, str(tmp_path), cursor=cursor, limit=1)
|
|
||||||
total_i += i
|
total_i += i
|
||||||
total_s += s
|
total_s += s
|
||||||
guard += 1
|
guard += 1
|
||||||
assert (total_i, total_s) == (3, 0)
|
assert (total_i, total_s) == (3, 0)
|
||||||
assert guard == 3 # three albums, one per chunk, then done
|
assert guard == 3
|
||||||
assert _counts(conn) == {"LibraryItem": 3, "MonitoredRelease": 3, "WatchedArtist": 2}
|
assert _counts(conn) == {"LibraryItem": 3, "MonitoredRelease": 3, "WatchedArtist": 2}
|
||||||
|
|
||||||
|
|
||||||
def test_scan_chunk_unbounded_matches_full_walk(conn, tmp_path):
|
def test_clear_worklist_removes_this_scans_rows(conn, tmp_path):
|
||||||
resolver, browser = _tree(tmp_path)
|
resolver, _ = _tree(tmp_path)
|
||||||
cursor, imported, skipped, done = scan_chunk(
|
build_worklist(conn, "scan1", str(tmp_path))
|
||||||
conn, resolver, FakeProbe(quality_class=2), browser, str(tmp_path), cursor="", limit=None)
|
clear_worklist(conn, "scan1")
|
||||||
assert (imported, skipped, done) == (3, 0, True)
|
with conn.cursor() as cur:
|
||||||
assert cursor == "Artist B/Gamma (2003)"
|
cur.execute('SELECT count(*) FROM "ScanWorkItem" WHERE "scanId" = %s', ("scan1",))
|
||||||
assert _counts(conn) == {"LibraryItem": 3, "MonitoredRelease": 3, "WatchedArtist": 2}
|
assert cur.fetchone()[0] == 0
|
||||||
|
|||||||
Reference in New Issue
Block a user