Backfill podcast covers for episodes already on disk
A podcast's episode cover was only fetched right after the feed sync downloaded that episode, so anything placed by hand (or downloaded before the og:image fallback existed) never got one - in Wissen macht Ah only the three newest episodes had their own art. The sync now fetches the cover of every on-disk episode in the feed window that lacks a sidecar image. One that can't be found or fetched is recorded in .missing-covers.json and left alone for the same 7-day backoff as failed downloads, so a feed without per-episode art doesn't cost a page fetch per episode every poll. The scanner's per-episode fingerprint now includes the sidecar cover, so one added after the episode was first scanned invalidates its cached entry instead of being ignored until the index is rebuilt. Episodes without a sidecar fingerprint exactly as before, so existing caches stay valid. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -284,6 +284,25 @@ async def test_an_episodes_sidecar_cover_wins_over_the_shows_shared_one(config_d
|
||||
assert red < 60 and blue > red, "falls back to the show's shared cover"
|
||||
|
||||
|
||||
async def test_a_sidecar_cover_added_later_replaces_the_cached_one(config_dir: Path) -> None:
|
||||
"""`podcast_feeds.py` backfills covers for episodes already on disk - the episode's
|
||||
audio is untouched, so only the sidecar can tell the cache its entry is stale."""
|
||||
from PIL import Image
|
||||
|
||||
podcast = config_dir / "music" / "Kinderpodcasts" / "Wissen macht Ah"
|
||||
Image.new("RGB", (32, 32), (10, 20, 30)).save(podcast / "cover.jpg")
|
||||
first = album_named(await build(config_dir), "Alt")
|
||||
|
||||
alt_audio = first.tracks[0].path
|
||||
Image.new("RGB", (32, 32), (200, 40, 30)).save(alt_audio.with_suffix(".jpg"))
|
||||
second = album_named(await build(config_dir), "Alt")
|
||||
|
||||
assert second.cover is not None
|
||||
with Image.open(second.cover) as image:
|
||||
red, green, _blue = image.convert("RGB").getpixel((0, 0)) # type: ignore[misc]
|
||||
assert red > 150 and green < 90, "the new sidecar should replace the shared cover"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- cache
|
||||
|
||||
|
||||
|
||||
@@ -411,6 +411,83 @@ async def test_sync_show_survives_a_failed_cover_download(tmp_path: Path) -> Non
|
||||
assert not (folder / "20250101 - Unique Cover.jpg").exists()
|
||||
|
||||
|
||||
_FEED_WITH_LINKS = b"""<?xml version="1.0"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>Link Show</title>
|
||||
<link>http://example.com/show</link>
|
||||
<item>
|
||||
<title>Old One</title>
|
||||
<link>http://example.com/episodes/old</link>
|
||||
<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>
|
||||
</channel>
|
||||
</rss>
|
||||
"""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_show_backfills_the_cover_of_an_episode_already_on_disk(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
# Placed by hand (or downloaded before covers were fetched): the audio is there,
|
||||
# so nothing is downloaded - but its cover still has to be fetched.
|
||||
folder = tmp_path / "show"
|
||||
folder.mkdir()
|
||||
(folder / "20250101 - Old One.mp3").write_bytes(b"audio-bytes")
|
||||
requests: list[str] = []
|
||||
|
||||
def handler(request: httpx2.Request) -> httpx2.Response:
|
||||
url = str(request.url)
|
||||
requests.append(url)
|
||||
if url == "http://example.com/feed.xml":
|
||||
return httpx2.Response(200, content=_FEED_WITH_LINKS)
|
||||
if url == "http://example.com/episodes/old":
|
||||
html = '<meta property="og:image" content="http://example.com/old.png">'
|
||||
return httpx2.Response(200, text=html)
|
||||
if url == "http://example.com/old.png":
|
||||
return httpx2.Response(200, content=b"cover-bytes")
|
||||
return httpx2.Response(500)
|
||||
|
||||
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client:
|
||||
changed = await sync_show(client, folder, "http://example.com/feed.xml")
|
||||
requests.clear()
|
||||
changed_again = await sync_show(client, folder, "http://example.com/feed.xml")
|
||||
|
||||
assert changed is True
|
||||
assert (folder / "20250101 - Old One.png").read_bytes() == b"cover-bytes"
|
||||
# Once the sidecar exists, a later poll doesn't go looking for it again.
|
||||
assert changed_again is False
|
||||
assert requests == ["http://example.com/feed.xml"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_show_does_not_look_for_a_missing_cover_again_within_the_backoff(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
folder = tmp_path / "show"
|
||||
folder.mkdir()
|
||||
(folder / "20250101 - Old One.mp3").write_bytes(b"audio-bytes")
|
||||
page_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_WITH_LINKS)
|
||||
page_requests.append(url)
|
||||
return httpx2.Response(200, text="<html>no og:image here</html>")
|
||||
|
||||
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client:
|
||||
changed = await sync_show(client, folder, "http://example.com/feed.xml")
|
||||
await sync_show(client, folder, "http://example.com/feed.xml")
|
||||
|
||||
assert changed is False
|
||||
assert page_requests == ["http://example.com/episodes/old"]
|
||||
assert (folder / podcast_feeds._MISSING_COVERS_FILENAME).exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- video shows
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user