diff --git a/src/ntr_fetcher/poller.py b/src/ntr_fetcher/poller.py new file mode 100644 index 0000000..dffe43a --- /dev/null +++ b/src/ntr_fetcher/poller.py @@ -0,0 +1,69 @@ +import asyncio +import logging +from datetime import datetime, timezone + +from ntr_fetcher.db import Database +from ntr_fetcher.soundcloud import SoundCloudClient +from ntr_fetcher.week import get_show_week + +logger = logging.getLogger(__name__) + + +class Poller: + def __init__( + self, + db: Database, + soundcloud: SoundCloudClient, + soundcloud_user: str, + show_day: int, + show_hour: int, + poll_interval: float, + ): + self._db = db + self._sc = soundcloud + self._user = soundcloud_user + self._show_day = show_day + self._show_hour = show_hour + self._poll_interval = poll_interval + self._user_id: int | None = None + self.last_fetch: datetime | None = None + self.alive = True + + async def _get_user_id(self) -> int: + if self._user_id is None: + self._user_id = await self._sc.resolve_user(self._user) + return self._user_id + + async def poll_once(self, full: bool = False) -> None: + user_id = await self._get_user_id() + now = datetime.now(timezone.utc) + week_start, week_end = get_show_week(now, self._show_day, self._show_hour) + show = self._db.get_or_create_show(week_start, week_end) + + tracks = await self._sc.fetch_likes( + user_id=user_id, + since=week_start, + until=week_end, + ) + + for track in tracks: + self._db.upsert_track(track) + + track_ids = [t.id for t in tracks] + self._db.set_show_tracks(show.id, track_ids) + self.last_fetch = datetime.now(timezone.utc) + logger.info("Fetched %d tracks for show %d", len(tracks), show.id) + + async def run_supervised(self, restart_delay: float = 30.0) -> None: + while True: + try: + self.alive = True + await self.poll_once() + await asyncio.sleep(self._poll_interval) + except asyncio.CancelledError: + self.alive = False + raise + except Exception: + self.alive = False + logger.exception("Poller failed, restarting in %.1fs", restart_delay) + await asyncio.sleep(restart_delay) diff --git a/tests/test_poller.py b/tests/test_poller.py new file mode 100644 index 0000000..ca482ce --- /dev/null +++ b/tests/test_poller.py @@ -0,0 +1,153 @@ +import asyncio +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from ntr_fetcher.poller import Poller +from ntr_fetcher.models import Track + + +def _make_track(id: int, liked_at: str) -> Track: + return Track( + id=id, + title=f"Track {id}", + artist="Artist", + permalink_url=f"https://soundcloud.com/a/t-{id}", + artwork_url=None, + duration_ms=180000, + license="cc-by", + liked_at=datetime.fromisoformat(liked_at), + raw_json="{}", + ) + + +@pytest.mark.asyncio +async def test_poll_once_fetches_and_stores(): + mock_sc = AsyncMock() + mock_sc.resolve_user.return_value = 206979918 + mock_sc.fetch_likes.return_value = [ + _make_track(1, "2026-03-14T01:00:00+00:00"), + _make_track(2, "2026-03-14T02:00:00+00:00"), + ] + + mock_db = MagicMock() + mock_show = MagicMock() + mock_show.id = 1 + mock_db.get_or_create_show.return_value = mock_show + + poller = Poller( + db=mock_db, + soundcloud=mock_sc, + soundcloud_user="nicktherat", + show_day=2, + show_hour=22, + poll_interval=3600, + ) + + await poller.poll_once() + + assert mock_sc.resolve_user.called + assert mock_sc.fetch_likes.called + assert mock_db.upsert_track.call_count == 2 + assert mock_db.set_show_tracks.called + call_args = mock_db.set_show_tracks.call_args + assert call_args[0][0] == 1 + assert call_args[0][1] == [1, 2] + + +@pytest.mark.asyncio +async def test_poll_once_removes_unliked_tracks(): + mock_sc = AsyncMock() + mock_sc.resolve_user.return_value = 206979918 + mock_sc.fetch_likes.return_value = [ + _make_track(1, "2026-03-14T01:00:00+00:00"), + _make_track(2, "2026-03-14T02:00:00+00:00"), + ] + + mock_db = MagicMock() + mock_show = MagicMock() + mock_show.id = 1 + mock_db.get_or_create_show.return_value = mock_show + + poller = Poller( + db=mock_db, + soundcloud=mock_sc, + soundcloud_user="nicktherat", + show_day=2, + show_hour=22, + poll_interval=3600, + ) + + await poller.poll_once() + + mock_sc.fetch_likes.return_value = [ + _make_track(1, "2026-03-14T01:00:00+00:00"), + ] + mock_db.reset_mock() + mock_db.get_or_create_show.return_value = mock_show + + await poller.poll_once() + + call_args = mock_db.set_show_tracks.call_args + assert call_args[0][1] == [1] + + +@pytest.mark.asyncio +async def test_poll_once_full_refresh(): + mock_sc = AsyncMock() + mock_sc.resolve_user.return_value = 206979918 + mock_sc.fetch_likes.return_value = [ + _make_track(1, "2026-03-14T01:00:00+00:00"), + ] + + mock_db = MagicMock() + mock_show = MagicMock() + mock_show.id = 1 + mock_db.get_or_create_show.return_value = mock_show + + poller = Poller( + db=mock_db, + soundcloud=mock_sc, + soundcloud_user="nicktherat", + show_day=2, + show_hour=22, + poll_interval=3600, + ) + + await poller.poll_once(full=True) + assert mock_sc.fetch_likes.called + + +@pytest.mark.asyncio +async def test_supervisor_restarts_poller_on_failure(): + call_count = 0 + + async def failing_poll(**kwargs): + nonlocal call_count + call_count += 1 + if call_count <= 2: + raise RuntimeError("Simulated failure") + + mock_sc = AsyncMock() + mock_db = MagicMock() + + poller = Poller( + db=mock_db, + soundcloud=mock_sc, + soundcloud_user="nicktherat", + show_day=2, + show_hour=22, + poll_interval=0.01, + ) + poller.poll_once = failing_poll + + task = asyncio.create_task(poller.run_supervised(restart_delay=0.01)) + await asyncio.sleep(0.2) + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + assert call_count >= 3