Auto-download new podcast episodes from an RSS feed
A show folder under Kinderpodcasts/ opts in by containing a feed.txt marker naming its RSS feed. A new PodcastFeedService polls every such feed every 6 hours (and once at startup), downloads any episode not already on disk using the existing YYYYMMDD - Title.ext convention, and triggers the same rescan-and-broadcast sequence "Bibliothek neu einlesen" already uses - now shared via App.rescan_library() instead of duplicated. A show with no feed.txt is untouched, so there is no new config section for this. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
87
python-backend/tests/test_podcast_service.py
Normal file
87
python-backend/tests/test_podcast_service.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""The podcast poll loop itself: cadence, error resilience, and client lifecycle."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import httpx2
|
||||
import pytest
|
||||
|
||||
import musicmouse.services.podcasts as podcasts_module
|
||||
from musicmouse.services.podcasts import PodcastFeedService
|
||||
|
||||
|
||||
class _FakeLibrary:
|
||||
def __init__(self, root: Path) -> None:
|
||||
self.root = root
|
||||
|
||||
|
||||
async def _run_briefly(service: PodcastFeedService) -> None:
|
||||
"""Let `run()` complete a few fast-interval passes, then cancel it like shutdown does."""
|
||||
task = asyncio.create_task(service.run())
|
||||
await asyncio.sleep(0.05)
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_checks_immediately_and_calls_on_change_only_when_something_changed(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
calls = 0
|
||||
|
||||
async def fake_sync_all_shows(client: httpx2.AsyncClient, root: Path) -> bool:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return calls == 1 # only the very first pass finds something new
|
||||
|
||||
monkeypatch.setattr(podcasts_module, "sync_all_shows", fake_sync_all_shows)
|
||||
|
||||
changes = 0
|
||||
|
||||
async def on_change() -> None:
|
||||
nonlocal changes
|
||||
changes += 1
|
||||
|
||||
client = httpx2.AsyncClient(transport=httpx2.MockTransport(lambda _r: httpx2.Response(200)))
|
||||
service = PodcastFeedService(
|
||||
_FakeLibrary(tmp_path), client=client, on_change=on_change, interval=0.01
|
||||
)
|
||||
|
||||
await _run_briefly(service)
|
||||
|
||||
assert changes == 1
|
||||
assert calls >= 2
|
||||
assert client.is_closed
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_survives_a_failing_pass(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
calls = 0
|
||||
|
||||
async def fake_sync_all_shows(client: httpx2.AsyncClient, root: Path) -> bool:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
raise RuntimeError("boom")
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(podcasts_module, "sync_all_shows", fake_sync_all_shows)
|
||||
|
||||
async def on_change() -> None:
|
||||
pytest.fail("on_change must not run when nothing changed")
|
||||
|
||||
client = httpx2.AsyncClient(transport=httpx2.MockTransport(lambda _r: httpx2.Response(200)))
|
||||
service = PodcastFeedService(
|
||||
_FakeLibrary(tmp_path), client=client, on_change=on_change, interval=0.01
|
||||
)
|
||||
|
||||
await _run_briefly(service)
|
||||
|
||||
# The RuntimeError from the first pass must not have ended the loop.
|
||||
assert calls >= 2
|
||||
assert client.is_closed
|
||||
Reference in New Issue
Block a user