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:
213
python-backend/tests/test_podcast_feeds.py
Normal file
213
python-backend/tests/test_podcast_feeds.py
Normal 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")) == []
|
||||
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