Files
musicmouse/python-backend/tests/test_podcast_service.py
Martin Bauer fa3d92189c Keep only the newest 50 episodes of each podcast
A show that has published for years is unbounded: GEOlino Spezial alone is 358
episodes and 5.8 GB, and the device it syncs onto is a 30 GB SD card that also
holds the rest of the library. Nothing stopped the 6-hourly poll from eventually
filling it.

Cap each show's folder at general.podcast_episode_limit (default 50, null to
keep everything), pruning the oldest past that after each sync pass.

The same limit caps what is downloaded, and it has to be one number for both.
Prune to the newest N but keep fetching everything the feed offers, and every
poll would re-download exactly the episodes the previous one deleted - forever,
at full size, since missing_episodes() decides purely from what is on disk.
There is a test for that specific loop.

Pruning only touches files named the way this module names them
(YYYYMMDD - Title.ext), so feed.txt, folder.jpg, the failed-download record and
anything placed by hand are all left alone; a parse that fails means "not ours",
not "delete it". An episode's sidecar cover goes with it. A pass that only
deleted still reports a change, because the library needs the rescan just as
much as it does after a download.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 20:40:53 +02:00

92 lines
2.5 KiB
Python

"""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, *, keep: int | None = None
) -> 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, *, keep: int | None = None
) -> 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