feat: monitor discovery phase + MonitorConfig
This commit is contained in:
@@ -0,0 +1,76 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
|
||||||
|
from lyra_worker.browser import MbBrowser
|
||||||
|
|
||||||
|
_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)
|
||||||
|
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
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
from lyra_worker.adapters.fakes import FakeMbBrowser
|
||||||
|
from lyra_worker.browser import ReleaseGroupInfo
|
||||||
|
from lyra_worker.monitor import MonitorConfig, discover
|
||||||
|
from tests.conftest import insert_watched_artist
|
||||||
|
|
||||||
|
|
||||||
|
def _releases():
|
||||||
|
return [
|
||||||
|
ReleaseGroupInfo("rg-old", "Old Album", "Album", (), "2001-01-01"),
|
||||||
|
ReleaseGroupInfo("rg-new", "New Album", "Album", (), "2999-01-01"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _monitored_map(conn):
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute('SELECT "rgMbid", monitored FROM "MonitoredRelease"')
|
||||||
|
return dict(cur.fetchall())
|
||||||
|
|
||||||
|
|
||||||
|
def test_discover_auto_monitor_future_only_flags_new(conn):
|
||||||
|
aid = insert_watched_artist(conn, mbid="a1", name="John Mayer", auto_monitor_future=True)
|
||||||
|
browser = FakeMbBrowser(releases={"a1": _releases()})
|
||||||
|
inserted = discover(conn, browser, MonitorConfig(enabled=True))
|
||||||
|
assert inserted == 2
|
||||||
|
assert _monitored_map(conn) == {"rg-old": False, "rg-new": True} # only the future one is monitored
|
||||||
|
|
||||||
|
|
||||||
|
def test_discover_default_off_monitors_nothing(conn):
|
||||||
|
insert_watched_artist(conn, mbid="a1", name="X", auto_monitor_future=False)
|
||||||
|
browser = FakeMbBrowser(releases={"a1": _releases()})
|
||||||
|
discover(conn, browser, MonitorConfig(enabled=True))
|
||||||
|
assert _monitored_map(conn) == {"rg-old": False, "rg-new": False}
|
||||||
|
|
||||||
|
|
||||||
|
def test_discover_is_idempotent_and_sets_last_polled(conn):
|
||||||
|
insert_watched_artist(conn, mbid="a1", name="X", auto_monitor_future=True)
|
||||||
|
browser = FakeMbBrowser(releases={"a1": _releases()})
|
||||||
|
assert discover(conn, browser, MonitorConfig(enabled=True)) == 2
|
||||||
|
assert discover(conn, browser, MonitorConfig(enabled=True)) == 0 # lastPolledAt now recent; not re-polled
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute('SELECT "lastPolledAt" IS NOT NULL FROM "WatchedArtist" WHERE mbid = %s', ("a1",))
|
||||||
|
assert cur.fetchone()[0] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_from_config_parses_strings():
|
||||||
|
cfg = MonitorConfig.from_config({"monitor.enabled": "true", "monitor.qualityCutoff": "3"})
|
||||||
|
assert cfg.enabled is True and cfg.quality_cutoff == 3
|
||||||
|
assert MonitorConfig.from_config({}).enabled is False
|
||||||
Reference in New Issue
Block a user