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

@@ -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 podcast downloader's `archive.json` and its half-finished `.download.tmp` never reach
a playlist. 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 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 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. them and the LED strips run the first of them, so shelf and screen agree.

View File

@@ -21,6 +21,8 @@ from collections.abc import Awaitable, Callable, Coroutine
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import httpx2
from musicmouse import __version__ from musicmouse import __version__
from musicmouse.app import App from musicmouse.app import App
from musicmouse.bus import EventBus from musicmouse.bus import EventBus
@@ -35,6 +37,7 @@ from musicmouse.library.analysis import build_analyzer
from musicmouse.reactions import register_all from musicmouse.reactions import register_all
from musicmouse.services.base import Service from musicmouse.services.base import Service
from musicmouse.services.mqtt import MqttService, build_entities from musicmouse.services.mqtt import MqttService, build_entities
from musicmouse.services.podcasts import PodcastFeedService
from musicmouse.services.web import WebService from musicmouse.services.web import WebService
_log = logging.getLogger("musicmouse") _log = logging.getLogger("musicmouse")
@@ -327,6 +330,19 @@ def _build_services(
else: else:
services.append(WebService(app, web_config, config_path)) 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 return services

View File

@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
from musicmouse.bus import EventBus from musicmouse.bus import EventBus
@@ -50,3 +51,17 @@ class App:
if playlist is None: if playlist is None:
_log.warning("No playlist for figure %r", figure) _log.warning("No playlist for figure %r", figure)
return playlist 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()

View File

@@ -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

View File

@@ -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()

View File

@@ -100,14 +100,8 @@ def build_router(
@router.post("/library/refresh", status_code=202) @router.post("/library/refresh", status_code=202)
async def refresh_library() -> Response: 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. # 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) return Response(status_code=202)
# --------------------------------------------------------------------- state # --------------------------------------------------------------------- state

View File

@@ -6,6 +6,9 @@ requires-python = ">=3.13"
dependencies = [ dependencies = [
"aiomqtt>=2.0", "aiomqtt>=2.0",
"fastapi>=0.115", "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 # 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 # 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. # calls, so the long-lived token never has to leave the backend.
@@ -68,5 +71,5 @@ warn_unreachable = true
[[tool.mypy.overrides]] [[tool.mypy.overrides]]
# numpy ships its own types, but only when the `analysis` extra is installed - a plain # 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. # 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 ignore_missing_imports = true

View File

@@ -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"""<?xml version="1.0"?>
<rss version="2.0">
<channel>
<title>Test Show</title>
<item>
<title>Episode One</title>
<pubDate>Wed, 01 Jan 2025 08:00:00 GMT</pubDate>
<enclosure url="http://example.com/ep1.mp3" length="100" type="audio/mpeg" />
<guid>ep1</guid>
</item>
<item>
<title>No Audio Here</title>
<pubDate>Thu, 02 Jan 2025 08:00:00 GMT</pubDate>
<guid>ep2</guid>
</item>
<item>
<title>Episode Two: Special/Chars?</title>
<pubDate>Fri, 03 Jan 2025 08:00:00 GMT</pubDate>
<enclosure url="http://example.com/redirect?id=2" length="200" type="audio/mpeg" />
<guid>ep3</guid>
</item>
</channel>
</rss>
"""
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")) == []

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