diff --git a/worker/lyra_worker/monitor.py b/worker/lyra_worker/monitor.py new file mode 100644 index 0000000..9c13766 --- /dev/null +++ b/worker/lyra_worker/monitor.py @@ -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 diff --git a/worker/tests/test_monitor_discover.py b/worker/tests/test_monitor_discover.py new file mode 100644 index 0000000..ee76141 --- /dev/null +++ b/worker/tests/test_monitor_discover.py @@ -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