Files
Lyra/worker/lyra_worker/monitor.py
T
Jonathan 082365005c fix(worker): reconcile every job + backfill owned releases
Wanted releases pressed while the monitor is off never got reconciled, so
they lingered on the wanted list. Extract finish_job() and call it for every
finished job (not just when monitor.enabled) so the MonitoredRelease
transitions to fulfilled. Add fulfill_owned_releases(), run once at startup,
to backfill releases already owned at/above the quality cutoff (self-heals
items stuck from before this fix). Both idempotent + tested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:08:24 +02:00

206 lines
8.5 KiB
Python

from dataclasses import dataclass
from datetime import date, datetime
import psycopg
from lyra_worker.browser import MbBrowser
from lyra_worker.release_filter import is_core_release
_TRUE = {"1", "true", "yes", "on"}
@dataclass(frozen=True)
class MonitorConfig:
enabled: bool = False
poll_interval_hours: int = 24
retry_interval_hours: int = 6
quality_cutoff: int = 2
upgrade_window_days: int = 14
@classmethod
def from_config(cls, config: dict) -> "MonitorConfig":
def _int(key: str, default: int) -> int:
try:
return int(config[key])
except (KeyError, TypeError, ValueError):
return default
return cls(
enabled=str(config.get("monitor.enabled", "")).strip().lower() in _TRUE,
poll_interval_hours=_int("monitor.pollIntervalHours", 24),
retry_interval_hours=_int("monitor.retryIntervalHours", 6),
quality_cutoff=_int("monitor.qualityCutoff", 2),
upgrade_window_days=_int("monitor.upgradeWindowDays", 14),
)
def _is_new(first_release_date: str, monitor_from: datetime) -> bool:
"""A release is 'new going forward' if its first-release date is after monitorFrom."""
if not first_release_date:
return False
try:
frd = date.fromisoformat(first_release_date[:10])
except ValueError:
return False
return frd > monitor_from.date()
def discover(conn: psycopg.Connection, browser: MbBrowser, cfg: MonitorConfig) -> int:
"""Poll each due watched artist; insert newly-seen release-groups. Returns #rows inserted."""
with conn.cursor() as cur:
cur.execute(
'SELECT id, mbid, name, "autoMonitorFuture", "monitorFrom" FROM "WatchedArtist" '
'WHERE "lastPolledAt" IS NULL '
' OR "lastPolledAt" < now() - make_interval(hours => %s)',
(cfg.poll_interval_hours,),
)
artists = cur.fetchall()
inserted = 0
for artist_id, mbid, name, auto_future, monitor_from in artists:
for rg in browser.browse_release_groups(mbid):
monitored = (
bool(auto_future)
and _is_new(rg.first_release_date, monitor_from)
and is_core_release(rg.primary_type, rg.secondary_types)
)
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, %s, 'wanted', now()) "
'ON CONFLICT ("rgMbid") DO NOTHING',
(artist_id, mbid, name, rg.rg_mbid, rg.title, rg.primary_type or None,
list(rg.secondary_types), rg.first_release_date or None, monitored),
)
inserted += cur.rowcount
with conn.cursor() as cur:
cur.execute('UPDATE "WatchedArtist" SET "lastPolledAt" = now() WHERE id = %s', (artist_id,))
conn.commit()
return inserted
def _set_state(conn: psycopg.Connection, rel_id: str, state: str) -> None:
with conn.cursor() as cur:
cur.execute('UPDATE "MonitoredRelease" SET state = %s WHERE id = %s', (state, rel_id))
def _in_library_at_cutoff(conn: psycopg.Connection, artist: str, album: str, cutoff: int) -> bool:
with conn.cursor() as cur:
cur.execute(
'SELECT 1 FROM "LibraryItem" WHERE artist = %s AND album = %s '
'AND "qualityClass" >= %s LIMIT 1',
(artist, album, cutoff),
)
return cur.fetchone() is not None
def _enqueue_one(conn: psycopg.Connection, rel_id: str, artist: str, album: str) -> None:
with conn.cursor() as cur:
cur.execute(
'INSERT INTO "Request" (id, artist, album, status, "monitoredReleaseId", "createdAt") '
"VALUES (gen_random_uuid()::text, %s, %s, 'pending', %s, now()) RETURNING id",
(artist, album, rel_id),
)
request_id = cur.fetchone()[0]
cur.execute(
'INSERT INTO "Job" (id, "requestId", state, "currentStage", attempts, "createdAt", "updatedAt") '
"VALUES (gen_random_uuid()::text, %s, 'requested', 'intake', 0, now(), now())",
(request_id,),
)
cur.execute('UPDATE "MonitoredRelease" SET "lastSearchedAt" = now() WHERE id = %s', (rel_id,))
def enqueue_due(conn: psycopg.Connection, cfg: MonitorConfig) -> int:
"""Enqueue due wanted/upgrade releases as pipeline jobs. Returns #jobs enqueued."""
with conn.cursor() as cur:
cur.execute(
'SELECT mr.id, mr."artistName", mr.album, mr.state, mr."currentQualityClass", '
' (mr."firstGrabbedAt" IS NOT NULL '
' AND mr."firstGrabbedAt" < now() - make_interval(days => %s)) AS window_expired '
'FROM "MonitoredRelease" mr '
'WHERE mr.monitored = true '
" AND mr.state <> 'fulfilled' "
' AND (mr."lastSearchedAt" IS NULL '
' OR mr."lastSearchedAt" < now() - make_interval(hours => %s)) '
' AND NOT EXISTS ('
' SELECT 1 FROM "Request" r JOIN "Job" j ON j."requestId" = r.id '
' WHERE r."monitoredReleaseId" = mr.id '
" AND j.state NOT IN ('imported', 'needs_attention'))",
(cfg.upgrade_window_days, cfg.retry_interval_hours),
)
rows = cur.fetchall()
enqueued = 0
for rel_id, artist, album, state, quality, window_expired in rows:
if _in_library_at_cutoff(conn, artist, album, cfg.quality_cutoff):
_set_state(conn, rel_id, "fulfilled")
continue
if state == "grabbed" and (window_expired or (quality is not None and quality >= cfg.quality_cutoff)):
_set_state(conn, rel_id, "fulfilled")
continue
_enqueue_one(conn, rel_id, artist, album)
enqueued += 1
conn.commit()
return enqueued
def fulfill_owned_releases(conn: psycopg.Connection, cfg: MonitorConfig) -> int:
"""Mark fulfilled any monitored, not-yet-fulfilled release already owned at/above the
quality cutoff. Run once at startup so releases pressed while the monitor was off (and
therefore never reconciled) drop off the wanted list. Idempotent. Returns #rows fixed."""
with conn.cursor() as cur:
cur.execute(
'UPDATE "MonitoredRelease" mr SET state = \'fulfilled\', '
' "currentQualityClass" = li.q, '
' "firstGrabbedAt" = COALESCE(mr."firstGrabbedAt", now()) '
'FROM (SELECT artist, album, max("qualityClass") AS q FROM "LibraryItem" '
' GROUP BY artist, album) li '
'WHERE li.artist = mr."artistName" AND li.album = mr.album '
" AND mr.monitored = true AND mr.state <> 'fulfilled' AND li.q >= %s",
(cfg.quality_cutoff,),
)
fixed = cur.rowcount
conn.commit()
return fixed
def reconcile(conn: psycopg.Connection, job_id: str, cfg: MonitorConfig) -> None:
"""Feed a finished job's outcome back into its MonitoredRelease (if any)."""
with conn.cursor() as cur:
cur.execute(
'SELECT j.state, r."monitoredReleaseId", r.artist, r.album '
'FROM "Job" j JOIN "Request" r ON r.id = j."requestId" WHERE j.id = %s',
(job_id,),
)
row = cur.fetchone()
if row is None:
return
state, rel_id, artist, album = row
if rel_id is None or state != "imported":
return # needs_attention (or in-flight) leaves the release wanted
with conn.cursor() as cur:
cur.execute(
'SELECT "qualityClass" FROM "LibraryItem" WHERE artist = %s AND album = %s',
(artist, album),
)
lib = cur.fetchone()
quality = lib[0] if lib else None
new_state = "fulfilled" if (quality is not None and quality >= cfg.quality_cutoff) else "grabbed"
with conn.cursor() as cur:
cur.execute(
'UPDATE "MonitoredRelease" '
'SET "currentQualityClass" = %s, state = %s, '
' "firstGrabbedAt" = COALESCE("firstGrabbedAt", now()) WHERE id = %s',
(quality, new_state, rel_id),
)
conn.commit()
def sweep(conn: psycopg.Connection, browser: MbBrowser, cfg: MonitorConfig) -> None:
"""One monitor pass: discover new releases, then enqueue everything due."""
discover(conn, browser, cfg)
enqueue_due(conn, cfg)