feat: add poller with supervised restart loop
Made-with: Cursor
This commit is contained in:
69
src/ntr_fetcher/poller.py
Normal file
69
src/ntr_fetcher/poller.py
Normal file
@@ -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)
|
||||
153
tests/test_poller.py
Normal file
153
tests/test_poller.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user