From 57afc32f4a6ce5a1a6b28356b749a9f2fea3e5fd Mon Sep 17 00:00:00 2001 From: Martin Bauer Date: Thu, 10 Sep 2026 22:43:50 +0200 Subject: [PATCH] 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 --- python-backend/README.md | 6 + python-backend/musicmouse/__main__.py | 16 ++ python-backend/musicmouse/app.py | 15 ++ .../musicmouse/library/podcast_feeds.py | 250 ++++++++++++++++++ .../musicmouse/services/podcasts.py | 60 +++++ python-backend/musicmouse/services/web/api.py | 8 +- python-backend/pyproject.toml | 5 +- python-backend/tests/test_podcast_feeds.py | 213 +++++++++++++++ python-backend/tests/test_podcast_service.py | 87 ++++++ 9 files changed, 652 insertions(+), 8 deletions(-) create mode 100644 python-backend/musicmouse/library/podcast_feeds.py create mode 100644 python-backend/musicmouse/services/podcasts.py create mode 100644 python-backend/tests/test_podcast_feeds.py create mode 100644 python-backend/tests/test_podcast_service.py diff --git a/python-backend/README.md b/python-backend/README.md index 806f277..29b2d13 100644 --- a/python-backend/README.md +++ b/python-backend/README.md @@ -165,6 +165,12 @@ Only files whose suffix is in `audio_extensions` are read, and dotfiles are skip a podcast downloader's `archive.json` and its half-finished `.download.tmp` never reach a playlist. +Drop a `feed.txt` into a show folder (its first line the show's RSS feed URL) and the +backend becomes that podcast downloader itself: every six hours it checks the feed and +saves any episode not already on disk, named `YYYYMMDD - Title.ext` like a hand-placed +one so it sorts and scans identically. A show with no `feed.txt` is untouched, exactly +as before - the file is the opt-in, there is no separate setting for it. + Each album carries three colours, pulled out of its cover art with Pillow (or synthesised from a hash of its id when it has none). The frontend paints cards with them and the LED strips run the first of them, so shelf and screen agree. diff --git a/python-backend/musicmouse/__main__.py b/python-backend/musicmouse/__main__.py index 81e1d74..29c2111 100644 --- a/python-backend/musicmouse/__main__.py +++ b/python-backend/musicmouse/__main__.py @@ -21,6 +21,8 @@ from collections.abc import Awaitable, Callable, Coroutine from pathlib import Path from typing import Any +import httpx2 + from musicmouse import __version__ from musicmouse.app import App from musicmouse.bus import EventBus @@ -35,6 +37,7 @@ from musicmouse.library.analysis import build_analyzer from musicmouse.reactions import register_all from musicmouse.services.base import Service from musicmouse.services.mqtt import MqttService, build_entities +from musicmouse.services.podcasts import PodcastFeedService from musicmouse.services.web import WebService _log = logging.getLogger("musicmouse") @@ -327,6 +330,19 @@ def _build_services( else: services.append(WebService(app, web_config, config_path)) + # Unconditional: a show only starts downloading once someone drops a `feed.txt` + # into its folder, so there is nothing to gate here with its own config section. + web_service = next((s for s in services if isinstance(s, WebService)), None) + services.append( + PodcastFeedService( + app.library, + client=httpx2.AsyncClient(timeout=30.0), + on_change=lambda: app.rescan_library( + broadcast=web_service.hub.broadcast_library if web_service else None + ), + ) + ) + return services diff --git a/python-backend/musicmouse/app.py b/python-backend/musicmouse/app.py index e7b2242..b9babf2 100644 --- a/python-backend/musicmouse/app.py +++ b/python-backend/musicmouse/app.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from collections.abc import Awaitable, Callable from dataclasses import dataclass, field from musicmouse.bus import EventBus @@ -50,3 +51,17 @@ class App: if playlist is None: _log.warning("No playlist for figure %r", figure) return playlist + + async def rescan_library( + self, *, broadcast: Callable[[], Awaitable[None]] | None = None + ) -> None: + """Rescan from disk, rebuild figure playlists, and tell whoever's listening. + + Shared by the manual "Bibliothek neu einlesen" endpoint and anything else that + can change what's on disk on its own, such as the podcast feed poller. + """ + await self.library.refresh() + self.playlists.clear() + self.playlists.update(self.library.figure_playlists()) + if broadcast is not None: + await broadcast() diff --git a/python-backend/musicmouse/library/podcast_feeds.py b/python-backend/musicmouse/library/podcast_feeds.py new file mode 100644 index 0000000..20ad530 --- /dev/null +++ b/python-backend/musicmouse/library/podcast_feeds.py @@ -0,0 +1,250 @@ +"""Auto-download new episodes for podcast shows that name their RSS feed. + +A show folder opts in by containing a small marker file, ``feed.txt``, whose first +non-blank line is the feed's URL. That file is the only thing this module needs to find +a show - nothing here is turned on by config, matching how the rest of the library reads +its shape entirely off the folder tree (see :mod:`musicmouse.library.sections`). + +Downloaded episodes land in the show folder using the exact ``YYYYMMDD - Title.ext`` +convention ``sections.py`` already documents for ``Kinderpodcasts``, so a freshly +downloaded episode sorts and scans exactly like one a person dropped in by hand. +Nothing here raises on bad input - an unreachable feed or a broken enclosure is logged +and skipped, not a crash, mirroring ``scanner.py``'s own rule. +""" + +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, Final +from urllib.parse import urlsplit + +import feedparser +import httpx2 + +from musicmouse.library.sections import SECTIONS + +_log = logging.getLogger(__name__) + +__all__ = [ + "FEED_MARKER_NAME", + "Episode", + "download_episode", + "episode_filename", + "find_feed_shows", + "missing_episodes", + "parse_feed", + "sync_all_shows", + "sync_show", +] + +FEED_MARKER_NAME: Final = "feed.txt" + +#: Enclosure content-type -> file extension, for a URL whose own suffix is missing or +#: not a real extension (tracking-redirect URLs are common in the wild). +_EXTENSION_BY_TYPE: Final[dict[str, str]] = { + "audio/mpeg": ".mp3", + "audio/mp3": ".mp3", + "audio/mp4": ".m4a", + "audio/x-m4a": ".m4a", + "audio/aac": ".aac", + "audio/ogg": ".ogg", + "audio/opus": ".opus", + "audio/wav": ".wav", + "audio/x-wav": ".wav", +} +_DEFAULT_EXTENSION: Final = ".mp3" +_KNOWN_EXTENSIONS: Final = frozenset({".mp3", ".m4a", ".aac", ".ogg", ".opus", ".wav", ".flac"}) + +#: Characters illegal (or awkward) in a filename, plus the path separators themselves. +_ILLEGAL_FILENAME_CHARS: Final = re.compile(r'[\\/:*?"<>|]') +_MAX_TITLE_LENGTH: Final = 120 + +_HTTP_TIMEOUT: Final = 30.0 + + +@dataclass(frozen=True, slots=True) +class Episode: + title: str + published: datetime + enclosure_url: str + enclosure_type: str + + +def find_feed_shows(root: Path) -> list[tuple[str, Path, str]]: + """Every show folder with a feed marker, as ``(section_name, folder, feed_url)``. + + Restricted to sections whose ``album_unit`` is ``"episode"`` (today just + ``Kinderpodcasts``) - that is what marks a folder as a show rather than a single + release, the same distinction the scanner already makes. + """ + out: list[tuple[str, Path, str]] = [] + for section_name, section in SECTIONS.items(): + if section.album_unit != "episode": + continue + section_root = root / section_name + if not section_root.is_dir(): + continue + for folder in sorted(section_root.iterdir(), key=lambda path: path.name): + if not folder.is_dir() or folder.name.startswith("."): + continue + marker = folder / FEED_MARKER_NAME + if not marker.is_file(): + continue + feed_url = _read_feed_url(marker) + if feed_url is not None: + out.append((section_name, folder, feed_url)) + return out + + +def _read_feed_url(marker: Path) -> str | None: + for line in marker.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + if stripped: + return stripped + _log.warning("%s is empty; no feed URL to read", marker) + return None + + +def parse_feed(content: bytes) -> list[Episode]: + """Every episode in a feed that has both a publish date and an audio enclosure. + + Anything else is skipped and logged rather than raising - a malformed or unusual + entry in one feed must never stop the episodes around it from being picked up. + """ + parsed = feedparser.parse(content) + episodes: list[Episode] = [] + for entry in parsed.entries: + published = _entry_published(entry) + if published is None: + _log.debug("Feed entry %r has no publish date; skipping", entry.get("title")) + continue + enclosure = _entry_enclosure(entry) + if enclosure is None: + _log.debug("Feed entry %r has no audio enclosure; skipping", entry.get("title")) + continue + url, enclosure_type = enclosure + episodes.append( + Episode( + title=entry.get("title") or url, + published=published, + enclosure_url=url, + enclosure_type=enclosure_type, + ) + ) + return episodes + + +def _entry_published(entry: Any) -> datetime | None: + parsed_time = entry.get("published_parsed") or entry.get("updated_parsed") + if parsed_time is None: + return None + year, month, day, hour, minute, second = tuple(parsed_time)[:6] + return datetime(year, month, day, hour, minute, second, tzinfo=UTC) + + +def _entry_enclosure(entry: Any) -> tuple[str, str] | None: + for enclosure in entry.get("enclosures", []): + url = enclosure.get("href") or enclosure.get("url") + enclosure_type = enclosure.get("type") or "" + if url and (enclosure_type.startswith("audio/") or not enclosure_type): + return str(url), str(enclosure_type) + return None + + +def episode_filename( + published: datetime, title: str, enclosure_type: str, enclosure_url: str +) -> str: + """``YYYYMMDD - Title.ext``, matching the convention every hand-placed episode + already follows (see ``sections.py``'s module comment on ``Kinderpodcasts``).""" + sanitized = _ILLEGAL_FILENAME_CHARS.sub("", title).strip().strip(".") + sanitized = " ".join(sanitized.split())[:_MAX_TITLE_LENGTH] or "Episode" + return f"{published:%Y%m%d} - {sanitized}{_extension_for(enclosure_type, enclosure_url)}" + + +def _extension_for(enclosure_type: str, url: str) -> str: + suffix = Path(urlsplit(url).path).suffix.lower() + if suffix in _KNOWN_EXTENSIONS: + return suffix + return _EXTENSION_BY_TYPE.get(enclosure_type, _DEFAULT_EXTENSION) + + +def missing_episodes(folder: Path, episodes: list[Episode]) -> list[tuple[Episode, str]]: + """Episodes whose target filename isn't already on disk, paired with that filename. + + Dedup is purely by filename - no separate manifest of what has been downloaded + before, matching how the rest of the library already treats the filesystem as the + only source of truth (see the fingerprinting in ``cache.py``). + """ + out: list[tuple[Episode, str]] = [] + for episode in episodes: + filename = episode_filename( + episode.published, episode.title, episode.enclosure_type, episode.enclosure_url + ) + if not (folder / filename).exists(): + out.append((episode, filename)) + return out + + +async def download_episode( + client: httpx2.AsyncClient, folder: Path, episode: Episode, filename: str +) -> None: + """Stream an episode to ``folder / filename`` via a dotfile temp path. + + A half-written file must never look like a track: the scanner already skips + dotfiles for exactly this reason (see ``scanner.py``'s ``_audio_files``), so the + rename to the real name only happens once the download is complete. + """ + temp_path = folder / f".downloading-{filename}.tmp" + try: + async with client.stream( + "GET", episode.enclosure_url, timeout=_HTTP_TIMEOUT + ) as response: + response.raise_for_status() + with temp_path.open("wb") as handle: + async for chunk in response.aiter_bytes(): + handle.write(chunk) + temp_path.replace(folder / filename) + finally: + temp_path.unlink(missing_ok=True) + + +async def sync_show(client: httpx2.AsyncClient, folder: Path, feed_url: str) -> bool: + """Download every episode in ``feed_url`` that ``folder`` doesn't have yet. + + Returns whether anything changed. Errors - an unreachable feed, a malformed one, a + single broken enclosure - are logged and swallowed here so one bad show never stops + the others or takes down the poll loop. + """ + try: + response = await client.get(feed_url, timeout=_HTTP_TIMEOUT, follow_redirects=True) + response.raise_for_status() + except httpx2.HTTPError as exc: + _log.warning("Could not fetch podcast feed %s for %s: %s", feed_url, folder.name, exc) + return False + + pending = missing_episodes(folder, parse_feed(response.content)) + changed = False + for episode, filename in pending: + try: + await download_episode(client, folder, episode, filename) + except (httpx2.HTTPError, OSError) as exc: + _log.warning( + "Could not download episode %r for %s: %s", episode.title, folder.name, exc + ) + continue + _log.info("Downloaded new episode %r for %s", episode.title, folder.name) + changed = True + return changed + + +async def sync_all_shows(client: httpx2.AsyncClient, root: Path) -> bool: + """Poll every show with a feed marker under ``root``. Returns whether any changed.""" + changed = False + for _section_name, folder, feed_url in find_feed_shows(root): + if await sync_show(client, folder, feed_url): + changed = True + return changed diff --git a/python-backend/musicmouse/services/podcasts.py b/python-backend/musicmouse/services/podcasts.py new file mode 100644 index 0000000..f177052 --- /dev/null +++ b/python-backend/musicmouse/services/podcasts.py @@ -0,0 +1,60 @@ +"""Front-end for nothing: the only "intent" this service ever produces is new files on +disk. It polls every podcast show that named its feed via a ``feed.txt`` marker (see +:mod:`musicmouse.library.podcast_feeds`) and downloads whatever episode is missing. +""" + +from __future__ import annotations + +import asyncio +import logging +from collections.abc import Awaitable, Callable +from typing import Final + +import httpx2 + +from musicmouse.library import MusicLibrary +from musicmouse.library.podcast_feeds import sync_all_shows + +_log = logging.getLogger(__name__) + +__all__ = ["PodcastFeedService"] + +#: Podcasts a children's player subscribes to publish at most a few times a week; +#: checking a few times a day is plenty and kind to the feed hosts. +_CHECK_INTERVAL_SECONDS: Final = 6 * 3600 + + +class PodcastFeedService: + name = "podcast-feeds" + + def __init__( + self, + library: MusicLibrary, + *, + client: httpx2.AsyncClient, + on_change: Callable[[], Awaitable[None]], + interval: float = _CHECK_INTERVAL_SECONDS, + ) -> None: + self.library = library + self.client = client + self.on_change = on_change + self.interval = interval + + async def run(self) -> None: + """Checks immediately at startup, then every `interval` seconds. + + A no-op when no show has a ``feed.txt`` - which is the common case, so this can + run unconditionally instead of needing its own config toggle. Never lets a + single bad pass end the task: `Service.run` is cancelled on shutdown and is + otherwise expected to keep going on its own. + """ + try: + while True: + try: + if await sync_all_shows(self.client, self.library.root): + await self.on_change() + except Exception: + _log.exception("Podcast feed check failed; will retry next interval") + await asyncio.sleep(self.interval) + finally: + await self.client.aclose() diff --git a/python-backend/musicmouse/services/web/api.py b/python-backend/musicmouse/services/web/api.py index e635bf4..b0edec0 100644 --- a/python-backend/musicmouse/services/web/api.py +++ b/python-backend/musicmouse/services/web/api.py @@ -100,14 +100,8 @@ def build_router( @router.post("/library/refresh", status_code=202) async def refresh_library() -> Response: - async def rescan() -> None: - await app.library.refresh() - app.playlists.clear() - app.playlists.update(app.library.figure_playlists()) - await hub.broadcast_library() - # Returns immediately: a cold rescan reads tags from every file. - asyncio.create_task(rescan()) # noqa: RUF006 + asyncio.create_task(app.rescan_library(broadcast=hub.broadcast_library)) # noqa: RUF006 return Response(status_code=202) # --------------------------------------------------------------------- state diff --git a/python-backend/pyproject.toml b/python-backend/pyproject.toml index e18620f..e3e6495 100644 --- a/python-backend/pyproject.toml +++ b/python-backend/pyproject.toml @@ -6,6 +6,9 @@ requires-python = ">=3.13" dependencies = [ "aiomqtt>=2.0", "fastapi>=0.115", + # Podcast RSS feeds in the wild are full of small quirks (odd dates, missing + # namespaces); parsing them by hand invites silently dropping episodes. + "feedparser>=6.0", # Also what tests drive the ASGI app with - plain httpx is deprecated in favour of # this for exactly that. Runtime uses it to proxy the room page's Home Assistant # calls, so the long-lived token never has to leave the backend. @@ -68,5 +71,5 @@ warn_unreachable = true [[tool.mypy.overrides]] # numpy ships its own types, but only when the `analysis` extra is installed - a plain # checkout must still type-check clean, so it needs the same treatment as librosa. -module = ["vlc", "serial_asyncio", "ruamel.*", "librosa.*", "numpy"] +module = ["vlc", "serial_asyncio", "ruamel.*", "librosa.*", "numpy", "feedparser"] ignore_missing_imports = true diff --git a/python-backend/tests/test_podcast_feeds.py b/python-backend/tests/test_podcast_feeds.py new file mode 100644 index 0000000..2b5b11c --- /dev/null +++ b/python-backend/tests/test_podcast_feeds.py @@ -0,0 +1,213 @@ +"""Podcast feed polling: finding shows, parsing feeds, and downloading only what's new.""" + +from __future__ import annotations + +from pathlib import Path + +import httpx2 +import pytest + +from musicmouse.library.podcast_feeds import ( + Episode, + download_episode, + episode_filename, + find_feed_shows, + missing_episodes, + parse_feed, + sync_all_shows, + sync_show, +) + +_FEED = b""" + + +Test Show + + Episode One + Wed, 01 Jan 2025 08:00:00 GMT + + ep1 + + + No Audio Here + Thu, 02 Jan 2025 08:00:00 GMT + ep2 + + + Episode Two: Special/Chars? + Fri, 03 Jan 2025 08:00:00 GMT + + ep3 + + + +""" + + +def _root_with_show( + tmp_path: Path, *, feed_url: str | None = "http://example.com/feed.xml" +) -> Path: + root = tmp_path / "music" + show = root / "Kinderpodcasts" / "Test Show" + show.mkdir(parents=True) + if feed_url is not None: + (show / "feed.txt").write_text(f"{feed_url}\n") + (root / "Kinderpodcasts" / "No Feed Show").mkdir(parents=True) + # A non-episode section must never be scanned for feed markers. + music = root / "Musik" / "Some Album" + music.mkdir(parents=True) + (music / "feed.txt").write_text("http://example.com/should-be-ignored.xml\n") + return root + + +def test_find_feed_shows_only_picks_up_episode_sections_with_a_marker(tmp_path: Path) -> None: + root = _root_with_show(tmp_path) + found = find_feed_shows(root) + assert found == [ + ("Kinderpodcasts", root / "Kinderpodcasts" / "Test Show", "http://example.com/feed.xml") + ] + + +def test_find_feed_shows_skips_an_empty_marker(tmp_path: Path) -> None: + root = tmp_path / "music" + show = root / "Kinderpodcasts" / "Empty" + show.mkdir(parents=True) + (show / "feed.txt").write_text("\n\n") + assert find_feed_shows(root) == [] + + +def test_parse_feed_keeps_only_entries_with_an_audio_enclosure() -> None: + episodes = parse_feed(_FEED) + assert [episode.title for episode in episodes] == ["Episode One", "Episode Two: Special/Chars?"] + assert episodes[0].enclosure_url == "http://example.com/ep1.mp3" + assert episodes[0].published.year == 2025 + assert episodes[0].published.month == 1 + assert episodes[0].published.day == 1 + + +def test_episode_filename_matches_the_hand_placed_convention() -> None: + episode = parse_feed(_FEED)[0] + assert episode_filename( + episode.published, episode.title, episode.enclosure_type, episode.enclosure_url + ) == "20250101 - Episode One.mp3" + + +def test_episode_filename_sanitizes_illegal_characters_and_falls_back_on_type() -> None: + episode = parse_feed(_FEED)[1] + # No file suffix on the (redirect-style) URL, so the extension comes from the + # enclosure's content type instead. + name = episode_filename( + episode.published, episode.title, episode.enclosure_type, episode.enclosure_url + ) + assert name == "20250103 - Episode Two SpecialChars.mp3" + + +def test_missing_episodes_skips_files_already_on_disk(tmp_path: Path) -> None: + folder = tmp_path / "show" + folder.mkdir() + episodes = parse_feed(_FEED) + (folder / "20250101 - Episode One.mp3").write_bytes(b"already here") + + pending = missing_episodes(folder, episodes) + + assert [episode.title for episode, _ in pending] == ["Episode Two: Special/Chars?"] + + +@pytest.mark.asyncio +async def test_download_episode_writes_via_a_temp_file(tmp_path: Path) -> None: + folder = tmp_path / "show" + folder.mkdir() + episode = Episode( + title="Episode One", + published=parse_feed(_FEED)[0].published, + enclosure_url="http://example.com/ep1.mp3", + enclosure_type="audio/mpeg", + ) + + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, content=b"audio-bytes") + + async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client: + await download_episode(client, folder, episode, "20250101 - Episode One.mp3") + + assert (folder / "20250101 - Episode One.mp3").read_bytes() == b"audio-bytes" + assert list(folder.glob(".downloading-*.tmp")) == [] + + +@pytest.mark.asyncio +async def test_sync_show_downloads_new_episodes_and_is_a_noop_on_rerun(tmp_path: Path) -> None: + folder = tmp_path / "show" + folder.mkdir() + + def handler(request: httpx2.Request) -> httpx2.Response: + if str(request.url) == "http://example.com/feed.xml": + return httpx2.Response(200, content=_FEED) + return httpx2.Response(200, content=b"audio-bytes") + + async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client: + changed_first = await sync_show(client, folder, "http://example.com/feed.xml") + changed_second = await sync_show(client, folder, "http://example.com/feed.xml") + + assert changed_first is True + assert changed_second is False + assert (folder / "20250101 - Episode One.mp3").exists() + assert (folder / "20250103 - Episode Two SpecialChars.mp3").exists() + + +@pytest.mark.asyncio +async def test_sync_show_survives_an_unreachable_feed(tmp_path: Path) -> None: + folder = tmp_path / "show" + folder.mkdir() + + def handler(request: httpx2.Request) -> httpx2.Response: + raise httpx2.ConnectError("mock: connection refused") + + async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client: + changed = await sync_show(client, folder, "http://example.com/feed.xml") + + assert changed is False + assert list(folder.iterdir()) == [] + + +@pytest.mark.asyncio +async def test_sync_show_survives_a_broken_enclosure(tmp_path: Path) -> None: + folder = tmp_path / "show" + folder.mkdir() + + def handler(request: httpx2.Request) -> httpx2.Response: + if str(request.url) == "http://example.com/feed.xml": + return httpx2.Response(200, content=_FEED) + return httpx2.Response(404) + + async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client: + changed = await sync_show(client, folder, "http://example.com/feed.xml") + + # Both enclosures 404, so nothing landed - but the pass still completed cleanly. + assert changed is False + assert list(folder.glob("*.mp3")) == [] + + +@pytest.mark.asyncio +async def test_sync_all_shows_keeps_going_past_one_broken_show(tmp_path: Path) -> None: + root = tmp_path / "music" + good = root / "Kinderpodcasts" / "Good Show" + good.mkdir(parents=True) + (good / "feed.txt").write_text("http://example.com/feed.xml\n") + bad = root / "Kinderpodcasts" / "Bad Show" + bad.mkdir(parents=True) + (bad / "feed.txt").write_text("http://example.com/broken.xml\n") + + def handler(request: httpx2.Request) -> httpx2.Response: + url = str(request.url) + if url == "http://example.com/feed.xml": + return httpx2.Response(200, content=_FEED) + if url == "http://example.com/broken.xml": + raise httpx2.ConnectError("mock: connection refused") + return httpx2.Response(200, content=b"audio-bytes") + + async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client: + changed = await sync_all_shows(client, root) + + assert changed is True + assert list(good.glob("*.mp3")) + assert list(bad.glob("*.mp3")) == [] diff --git a/python-backend/tests/test_podcast_service.py b/python-backend/tests/test_podcast_service.py new file mode 100644 index 0000000..dc6d89c --- /dev/null +++ b/python-backend/tests/test_podcast_service.py @@ -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