"""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")) == []