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:
2026-09-10 22:43:50 +02:00
parent 2e0e6ad199
commit 57afc32f4a
9 changed files with 652 additions and 8 deletions

View 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