Files
musicmouse/python-backend/tests/test_podcast_feeds.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

780 lines
28 KiB
Python

"""Podcast feed polling: finding shows, parsing feeds, and downloading only what's new."""
from __future__ import annotations
import json
from datetime import UTC, datetime, timedelta
from pathlib import Path
import httpx2
import pytest
from musicmouse.library import podcast_feeds
from musicmouse.library.podcast_feeds import (
AudioExtractionError,
Episode,
_entry_enclosure,
download_episode,
episode_cover_filename,
episode_filename,
find_feed_shows,
missing_episodes,
newest_episodes,
parse_feed,
prune_show,
resolve_episode_cover,
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>
"""
_FEED_WITH_COVERS = b"""<?xml version="1.0"?>
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
<channel>
<title>Cover Show</title>
<link>http://example.com/show</link>
<itunes:image href="http://example.com/channel.jpg" />
<item>
<title>Unique Cover</title>
<link>http://example.com/episodes/unique</link>
<pubDate>Wed, 01 Jan 2025 08:00:00 GMT</pubDate>
<itunes:image href="http://example.com/unique.jpg" />
<enclosure url="http://example.com/ep1.mp3" length="100" type="audio/mpeg" />
<guid>ep1</guid>
</item>
<item>
<title>Same As Channel</title>
<link>http://example.com/show</link>
<pubDate>Thu, 02 Jan 2025 08:00:00 GMT</pubDate>
<itunes:image href="http://example.com/channel.jpg" />
<enclosure url="http://example.com/ep2.mp3" length="100" type="audio/mpeg" />
<guid>ep2</guid>
</item>
</channel>
</rss>
"""
_VIDEO_FEED = b"""<?xml version="1.0"?>
<rss version="2.0">
<channel>
<title>Video Show</title>
<item>
<title>Video Only</title>
<pubDate>Wed, 01 Jan 2025 08:00:00 GMT</pubDate>
<enclosure url="http://example.com/ep1.mp4" length="100" type="video/mpeg" />
<guid>ep1</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
)
# Illegal characters become "_" rather than being dropped, matching the
# convention every episode already on disk (downloaded by hand via the
# `podcast-dl` CLI) was named with.
assert name == "20250103 - Episode Two_ Special_Chars_.mp3"
def test_episode_filename_matches_the_podcast_dl_convention_on_a_real_title() -> None:
# Pinned against a real file already in the library: `podcast-dl` replaced the
# "?" with "_" rather than dropping it, so ours must too or the whole back
# catalog would look "new" the moment a feed marker is added for that show.
published = parse_feed(_FEED)[0].published
name = episode_filename(published, "Wie funktioniert Bogenschießen?", "audio/mpeg", "x.mp3")
assert name == "20250101 - Wie funktioniert Bogenschießen_.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_ Special_Chars_.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")) == []
# ------------------------------------------------------------------- per-episode art
def test_parse_feed_extracts_cover_and_link_only_when_distinct_from_the_channel() -> None:
unique, same = parse_feed(_FEED_WITH_COVERS)
assert unique.cover_url == "http://example.com/unique.jpg"
assert unique.link == "http://example.com/episodes/unique"
# This item's image/link both just repeat the channel's own - not real per-episode
# art, so both come out None rather than a false positive.
assert same.cover_url is None
assert same.link is None
def test_episode_cover_filename_shares_the_audio_stem_and_takes_the_url_extension() -> None:
episode = parse_feed(_FEED_WITH_COVERS)[0]
name = episode_cover_filename(episode.published, episode.title, episode.cover_url)
assert name == "20250101 - Unique Cover.jpg"
@pytest.mark.asyncio
async def test_resolve_episode_cover_prefers_the_feed_image_without_a_request() -> None:
episode = parse_feed(_FEED_WITH_COVERS)[0]
def handler(request: httpx2.Request) -> httpx2.Response:
raise AssertionError("must not fetch the episode page when cover_url is already set")
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client:
cover = await resolve_episode_cover(client, episode)
assert cover == "http://example.com/unique.jpg"
@pytest.mark.asyncio
async def test_resolve_episode_cover_falls_back_to_og_image_on_the_linked_page() -> None:
episode = Episode(
title="No Feed Image",
published=parse_feed(_FEED)[0].published,
enclosure_url="http://example.com/ep.mp3",
enclosure_type="audio/mpeg",
link="http://example.com/episode-page",
)
def handler(request: httpx2.Request) -> httpx2.Response:
return httpx2.Response(
200,
content=b'<html><head>'
b'<meta property="og:image" content="http://example.com/scraped.jpg"/>'
b"</head></html>",
)
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client:
cover = await resolve_episode_cover(client, episode)
assert cover == "http://example.com/scraped.jpg"
@pytest.mark.asyncio
async def test_resolve_episode_cover_returns_none_when_the_link_is_unreachable() -> None:
episode = Episode(
title="Broken Link",
published=parse_feed(_FEED)[0].published,
enclosure_url="http://example.com/ep.mp3",
enclosure_type="audio/mpeg",
link="http://example.com/missing",
)
async with httpx2.AsyncClient(
transport=httpx2.MockTransport(lambda _: httpx2.Response(404))
) as client:
cover = await resolve_episode_cover(client, episode)
assert cover is None
@pytest.mark.asyncio
async def test_resolve_episode_cover_returns_none_without_a_cover_or_link() -> None:
episode = Episode(
title="Nothing",
published=parse_feed(_FEED)[0].published,
enclosure_url="http://example.com/ep.mp3",
enclosure_type="audio/mpeg",
)
async def handler(request: httpx2.Request) -> httpx2.Response: # pragma: no cover
raise AssertionError("must not make a request with neither a cover nor a link")
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client:
cover = await resolve_episode_cover(client, episode)
assert cover is None
@pytest.mark.asyncio
async def test_sync_show_downloads_the_episode_cover_alongside_the_audio(tmp_path: Path) -> None:
folder = tmp_path / "show"
folder.mkdir()
def handler(request: httpx2.Request) -> httpx2.Response:
url = str(request.url)
if url == "http://example.com/feed.xml":
return httpx2.Response(200, content=_FEED_WITH_COVERS)
if url == "http://example.com/unique.jpg":
return httpx2.Response(200, content=b"cover-bytes")
return httpx2.Response(200, content=b"audio-bytes")
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client:
changed = await sync_show(client, folder, "http://example.com/feed.xml")
assert changed is True
assert (folder / "20250101 - Unique Cover.jpg").read_bytes() == b"cover-bytes"
# No cover file for the episode whose image just repeats the channel's.
assert not (folder / "20250102 - Same As Channel.jpg").exists()
assert list(folder.glob(".downloading-*.tmp")) == []
@pytest.mark.asyncio
async def test_sync_show_survives_a_failed_cover_download(tmp_path: Path) -> None:
folder = tmp_path / "show"
folder.mkdir()
def handler(request: httpx2.Request) -> httpx2.Response:
url = str(request.url)
if url == "http://example.com/feed.xml":
return httpx2.Response(200, content=_FEED_WITH_COVERS)
if url == "http://example.com/unique.jpg":
return httpx2.Response(404)
return httpx2.Response(200, content=b"audio-bytes")
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client:
changed = await sync_show(client, folder, "http://example.com/feed.xml")
# The episode itself still downloaded fine - only its cover failed.
assert changed is True
assert (folder / "20250101 - Unique Cover.mp3").exists()
assert not (folder / "20250101 - Unique Cover.jpg").exists()
# ---------------------------------------------------------------------- video shows
def test_entry_enclosure_accepts_video_when_no_audio_is_offered() -> None:
entry = {"enclosures": [{"href": "http://example.com/ep.mp4", "type": "video/mpeg"}]}
assert _entry_enclosure(entry) == ("http://example.com/ep.mp4", "video/mpeg")
def test_entry_enclosure_prefers_audio_over_video_when_both_are_offered() -> None:
entry = {
"enclosures": [
{"href": "http://example.com/ep.mp4", "type": "video/mpeg"},
{"href": "http://example.com/ep.mp3", "type": "audio/mpeg"},
]
}
assert _entry_enclosure(entry) == ("http://example.com/ep.mp3", "audio/mpeg")
def test_parse_feed_keeps_a_video_only_episode() -> None:
episodes = parse_feed(_VIDEO_FEED)
assert episodes[0].enclosure_url == "http://example.com/ep1.mp4"
assert episodes[0].enclosure_type == "video/mpeg"
@pytest.mark.asyncio
async def test_download_episode_raises_when_ffmpeg_is_not_installed(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(podcast_feeds.shutil, "which", lambda _: None)
folder = tmp_path / "show"
folder.mkdir()
episode = parse_feed(_VIDEO_FEED)[0]
async with httpx2.AsyncClient(
transport=httpx2.MockTransport(lambda _: httpx2.Response(200))
) as client:
with pytest.raises(AudioExtractionError):
await download_episode(client, folder, episode, "20250101 - Video Only.mp3")
assert list(folder.glob(".downloading-*.tmp")) == []
@pytest.mark.asyncio
async def test_download_episode_raises_when_ffmpeg_exits_nonzero(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(podcast_feeds.shutil, "which", lambda _: "/usr/bin/ffmpeg")
class _FailingProcess:
returncode = 1
async def communicate(self) -> tuple[bytes, bytes]:
return b"", b"boom"
async def fake_exec(*args: object, **kwargs: object) -> _FailingProcess:
return _FailingProcess()
monkeypatch.setattr(podcast_feeds.asyncio, "create_subprocess_exec", fake_exec)
folder = tmp_path / "show"
folder.mkdir()
episode = parse_feed(_VIDEO_FEED)[0]
async with httpx2.AsyncClient(
transport=httpx2.MockTransport(lambda _: httpx2.Response(200))
) as client:
with pytest.raises(AudioExtractionError, match="boom"):
await download_episode(client, folder, episode, "20250101 - Video Only.mp3")
assert list(folder.glob(".downloading-*.tmp")) == []
@pytest.mark.asyncio
async def test_download_episode_extracts_audio_via_ffmpeg(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(podcast_feeds.shutil, "which", lambda _: "/usr/bin/ffmpeg")
class _SucceedingProcess:
returncode = 0
async def communicate(self) -> tuple[bytes, bytes]:
return b"", b""
captured_args: list[object] = []
async def fake_exec(*args: object, **kwargs: object) -> _SucceedingProcess:
# ffmpeg's destination path is the last positional argument; write to it here
# to stand in for what the real binary would have produced.
captured_args.extend(args)
Path(str(args[-1])).write_bytes(b"extracted-audio")
return _SucceedingProcess()
monkeypatch.setattr(podcast_feeds.asyncio, "create_subprocess_exec", fake_exec)
folder = tmp_path / "show"
folder.mkdir()
episode = parse_feed(_VIDEO_FEED)[0]
async with httpx2.AsyncClient(
transport=httpx2.MockTransport(lambda _: httpx2.Response(200))
) as client:
await download_episode(client, folder, episode, "20250101 - Video Only.mp3")
assert (folder / "20250101 - Video Only.mp3").read_bytes() == b"extracted-audio"
assert list(folder.glob(".downloading-*.tmp")) == []
# The destination path ends in ".tmp", not ".mp3" - ffmpeg can't guess the muxer
# from that extension, so the format must be passed explicitly.
assert captured_args[captured_args.index("-f") + 1] == "mp3"
@pytest.mark.asyncio
async def test_sync_show_does_not_retry_a_failed_episode_within_the_backoff(
tmp_path: Path,
) -> None:
folder = tmp_path / "show"
folder.mkdir()
enclosure_requests: list[str] = []
def handler(request: httpx2.Request) -> httpx2.Response:
url = str(request.url)
if url == "http://example.com/feed.xml":
return httpx2.Response(200, content=_FEED)
enclosure_requests.append(url)
return httpx2.Response(404)
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client:
await sync_show(client, folder, "http://example.com/feed.xml")
await sync_show(client, folder, "http://example.com/feed.xml")
# Both episodes 404 on the first pass and get remembered; the second pass must not
# re-request either enclosure while the backoff is still in effect.
assert len(enclosure_requests) == 2
assert (folder / podcast_feeds._FAILED_DOWNLOADS_FILENAME).exists()
@pytest.mark.asyncio
async def test_sync_show_retries_a_failed_episode_once_the_backoff_expires(
tmp_path: Path,
) -> None:
folder = tmp_path / "show"
folder.mkdir()
enclosure_requests: list[str] = []
def handler(request: httpx2.Request) -> httpx2.Response:
url = str(request.url)
if url == "http://example.com/feed.xml":
return httpx2.Response(200, content=_FEED)
enclosure_requests.append(url)
return httpx2.Response(404)
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client:
await sync_show(client, folder, "http://example.com/feed.xml")
# Backdate both failure records past the backoff window, simulating that a
# week has gone by since the first attempt.
record_path = folder / podcast_feeds._FAILED_DOWNLOADS_FILENAME
stale = datetime.now(UTC) - podcast_feeds._RETRY_BACKOFF - timedelta(seconds=1)
record = json.loads(record_path.read_text(encoding="utf-8"))
record_path.write_text(
json.dumps({filename: stale.isoformat() for filename in record}), encoding="utf-8"
)
await sync_show(client, folder, "http://example.com/feed.xml")
assert len(enclosure_requests) == 4
@pytest.mark.asyncio
async def test_sync_show_clears_the_failure_record_once_an_episode_succeeds(
tmp_path: Path,
) -> None:
folder = tmp_path / "show"
folder.mkdir()
should_fail = True
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 should_fail:
return httpx2.Response(404)
return httpx2.Response(200, content=b"audio-bytes")
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client:
await sync_show(client, folder, "http://example.com/feed.xml")
assert (folder / podcast_feeds._FAILED_DOWNLOADS_FILENAME).exists()
should_fail = False
# Backdate the record so the retry isn't just skipped by the backoff window.
record_path = folder / podcast_feeds._FAILED_DOWNLOADS_FILENAME
stale = datetime.now(UTC) - podcast_feeds._RETRY_BACKOFF - timedelta(seconds=1)
record = json.loads(record_path.read_text(encoding="utf-8"))
record_path.write_text(
json.dumps({filename: stale.isoformat() for filename in record}), encoding="utf-8"
)
changed = await sync_show(client, folder, "http://example.com/feed.xml")
assert changed is True
assert not (folder / podcast_feeds._FAILED_DOWNLOADS_FILENAME).exists()
@pytest.mark.asyncio
async def test_sync_show_survives_a_corrupt_failure_record(tmp_path: Path) -> None:
folder = tmp_path / "show"
folder.mkdir()
(folder / podcast_feeds._FAILED_DOWNLOADS_FILENAME).write_text(
"not valid json", encoding="utf-8"
)
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 = await sync_show(client, folder, "http://example.com/feed.xml")
assert changed is True
assert (folder / "20250101 - Episode One.mp3").exists()
# --------------------------------------------------------------- episode retention
def _episode(day: int, *, title: str | None = None) -> Episode:
return Episode(
title=title or f"Episode {day}",
published=datetime(2025, 1, day, tzinfo=UTC),
enclosure_url=f"http://example.com/ep{day}.mp3",
enclosure_type="audio/mpeg",
)
def _write_episode(folder: Path, name: str) -> Path:
path = folder / name
path.write_bytes(b"audio")
return path
def test_newest_episodes_takes_the_most_recent_and_orders_them_newest_first() -> None:
episodes = [_episode(1), _episode(5), _episode(3)]
assert [e.published.day for e in newest_episodes(episodes, 2)] == [5, 3]
def test_newest_episodes_keeps_everything_when_the_limit_is_none() -> None:
episodes = [_episode(1), _episode(5), _episode(3)]
assert len(newest_episodes(episodes, None)) == 3
def test_prune_show_deletes_only_the_oldest_beyond_the_limit(tmp_path: Path) -> None:
folder = tmp_path / "show"
folder.mkdir()
for day in range(1, 6):
_write_episode(folder, f"2025010{day} - Episode {day}.mp3")
assert prune_show(folder, 2) == 3
remaining = sorted(path.name for path in folder.iterdir())
assert remaining == ["20250104 - Episode 4.mp3", "20250105 - Episode 5.mp3"]
def test_prune_show_is_a_noop_below_the_limit(tmp_path: Path) -> None:
folder = tmp_path / "show"
folder.mkdir()
_write_episode(folder, "20250101 - Episode 1.mp3")
assert prune_show(folder, 50) == 0
assert prune_show(folder, None) == 0
assert (folder / "20250101 - Episode 1.mp3").exists()
def test_prune_show_takes_the_sidecar_cover_with_the_episode(tmp_path: Path) -> None:
folder = tmp_path / "show"
folder.mkdir()
_write_episode(folder, "20250101 - Old.mp3")
(folder / "20250101 - Old.jpg").write_bytes(b"art")
_write_episode(folder, "20250102 - New.mp3")
(folder / "20250102 - New.jpg").write_bytes(b"art")
assert prune_show(folder, 1) == 1
assert not (folder / "20250101 - Old.jpg").exists()
assert (folder / "20250102 - New.jpg").exists()
def test_prune_show_leaves_everything_it_did_not_name(tmp_path: Path) -> None:
"""A folder holds more than episodes, and a hand-placed file is not ours to delete."""
folder = tmp_path / "show"
folder.mkdir()
for day in range(1, 4):
_write_episode(folder, f"2025010{day} - Episode {day}.mp3")
(folder / "feed.txt").write_text("http://example.com/feed.xml\n")
(folder / "folder.jpg").write_bytes(b"art")
(folder / ".failed-downloads.json").write_text("{}")
_write_episode(folder, "Grandpa's tape.mp3")
prune_show(folder, 1)
assert (folder / "feed.txt").exists()
assert (folder / "folder.jpg").exists()
assert (folder / ".failed-downloads.json").exists()
assert (folder / "Grandpa's tape.mp3").exists()
@pytest.mark.asyncio
async def test_sync_show_prunes_to_the_limit_after_downloading(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 = await sync_show(client, folder, "http://example.com/feed.xml", keep=1)
assert changed is True
episodes = sorted(path.name for path in folder.iterdir() if path.suffix == ".mp3")
assert episodes == ["20250103 - Episode Two_ Special_Chars_.mp3"]
@pytest.mark.asyncio
async def test_sync_show_does_not_redownload_what_it_pruned(tmp_path: Path) -> None:
"""The whole point of capping downloads as well as deletions.
Prune to the newest N but fetch everything the feed offers, and each poll would
re-download the episodes the previous one deleted - forever, at full size.
"""
folder = tmp_path / "show"
folder.mkdir()
downloads: list[str] = []
def handler(request: httpx2.Request) -> httpx2.Response:
if str(request.url) == "http://example.com/feed.xml":
return httpx2.Response(200, content=_FEED)
downloads.append(str(request.url))
return httpx2.Response(200, content=b"audio-bytes")
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client:
await sync_show(client, folder, "http://example.com/feed.xml", keep=1)
first_pass = len(downloads)
changed_again = await sync_show(client, folder, "http://example.com/feed.xml", keep=1)
assert changed_again is False
assert len(downloads) == first_pass
@pytest.mark.asyncio
async def test_sync_show_reports_a_change_when_it_only_pruned(tmp_path: Path) -> None:
"""A deletion needs a rescan as much as a download does, or the library goes on
offering tracks whose files are gone."""
folder = tmp_path / "show"
folder.mkdir()
_write_episode(folder, "20250101 - Episode One.mp3")
_write_episode(folder, "20250103 - Episode Two_ Special_Chars_.mp3")
def handler(request: httpx2.Request) -> httpx2.Response:
return httpx2.Response(200, content=_FEED)
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client:
changed = await sync_show(client, folder, "http://example.com/feed.xml", keep=1)
assert changed is True
assert not (folder / "20250101 - Episode One.mp3").exists()