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:
@@ -116,6 +116,9 @@ _FFMPEG_TIMEOUT: Final = 600.0
|
|||||||
#: Where a show folder remembers episodes that failed to download, so a permanently
|
#: Where a show folder remembers episodes that failed to download, so a permanently
|
||||||
#: dead enclosure (e.g. pulled from the host's CDN) isn't retried every poll.
|
#: dead enclosure (e.g. pulled from the host's CDN) isn't retried every poll.
|
||||||
_FAILED_DOWNLOADS_FILENAME: Final = ".failed-downloads.json"
|
_FAILED_DOWNLOADS_FILENAME: Final = ".failed-downloads.json"
|
||||||
|
#: The same, for episodes on disk whose cover could not be found or fetched - so an
|
||||||
|
#: episode with no art anywhere doesn't cost a page fetch on every poll.
|
||||||
|
_MISSING_COVERS_FILENAME: Final = ".missing-covers.json"
|
||||||
#: How long a failed episode is left alone before it's given another chance - long
|
#: How long a failed episode is left alone before it's given another chance - long
|
||||||
#: enough to stop hammering a dead URL every 6 hours, short enough that a genuinely
|
#: enough to stop hammering a dead URL every 6 hours, short enough that a genuinely
|
||||||
#: transient failure (a host outage, a flaky network) still recovers on its own.
|
#: transient failure (a host outage, a flaky network) still recovers on its own.
|
||||||
@@ -379,15 +382,16 @@ def prune_show(folder: Path, keep: int | None, extensions: frozenset[str] | None
|
|||||||
return removed
|
return removed
|
||||||
|
|
||||||
|
|
||||||
def _load_failed_downloads(folder: Path) -> dict[str, datetime]:
|
def _load_failed(folder: Path, record_name: str) -> dict[str, datetime]:
|
||||||
"""Filename -> when it last failed to download, for episodes ``sync_show`` should
|
"""Filename -> when it last failed, for episodes ``sync_show`` should leave alone
|
||||||
leave alone until :data:`_RETRY_BACKOFF` has passed.
|
until :data:`_RETRY_BACKOFF` has passed. ``record_name`` picks which record: failed
|
||||||
|
downloads, or covers that could not be found.
|
||||||
|
|
||||||
A missing or corrupt record is just an empty one - nothing here is precious enough
|
A missing or corrupt record is just an empty one - nothing here is precious enough
|
||||||
to raise over, matching the module's overall rule that bad input is logged and
|
to raise over, matching the module's overall rule that bad input is logged and
|
||||||
skipped rather than fatal.
|
skipped rather than fatal.
|
||||||
"""
|
"""
|
||||||
path = folder / _FAILED_DOWNLOADS_FILENAME
|
path = folder / record_name
|
||||||
try:
|
try:
|
||||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
@@ -402,12 +406,12 @@ def _load_failed_downloads(folder: Path) -> dict[str, datetime]:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def _save_failed_downloads(folder: Path, failed: dict[str, datetime]) -> None:
|
def _save_failed(folder: Path, record_name: str, failed: dict[str, datetime]) -> None:
|
||||||
path = folder / _FAILED_DOWNLOADS_FILENAME
|
path = folder / record_name
|
||||||
if not failed:
|
if not failed:
|
||||||
path.unlink(missing_ok=True)
|
path.unlink(missing_ok=True)
|
||||||
return
|
return
|
||||||
temp_path = folder / f".{_FAILED_DOWNLOADS_FILENAME}.tmp"
|
temp_path = folder / f".{record_name}.tmp"
|
||||||
payload = {filename: when.isoformat() for filename, when in failed.items()}
|
payload = {filename: when.isoformat() for filename, when in failed.items()}
|
||||||
temp_path.write_text(json.dumps(payload), encoding="utf-8")
|
temp_path.write_text(json.dumps(payload), encoding="utf-8")
|
||||||
temp_path.replace(path)
|
temp_path.replace(path)
|
||||||
@@ -529,6 +533,65 @@ async def resolve_episode_cover(client: httpx2.AsyncClient, episode: Episode) ->
|
|||||||
return _extract_og_image(response.text)
|
return _extract_og_image(response.text)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_sidecar_cover(audio_path: Path) -> bool:
|
||||||
|
return any(audio_path.with_suffix(ext).is_file() for ext in _IMAGE_EXTENSIONS)
|
||||||
|
|
||||||
|
|
||||||
|
async def _sync_covers(
|
||||||
|
client: httpx2.AsyncClient, folder: Path, episodes: list[Episode], now: datetime
|
||||||
|
) -> bool:
|
||||||
|
"""Fetch the sidecar cover of every episode in ``episodes`` that is on disk without
|
||||||
|
one. Returns whether any cover was added.
|
||||||
|
|
||||||
|
Covers every episode on disk, not just the ones this pass downloaded: episodes
|
||||||
|
placed by hand, or downloaded before a cover route existed (Wissen macht Ah's
|
||||||
|
``og:image`` fallback), would otherwise never get theirs. An episode whose cover
|
||||||
|
can't be found or fetched is remembered and left alone for :data:`_RETRY_BACKOFF`,
|
||||||
|
like a failed download, so a feed with no per-episode art doesn't cost one page
|
||||||
|
fetch per episode on every poll.
|
||||||
|
"""
|
||||||
|
missing = _load_failed(folder, _MISSING_COVERS_FILENAME)
|
||||||
|
on_disk: set[str] = set()
|
||||||
|
added = False
|
||||||
|
for episode in episodes:
|
||||||
|
filename = episode_filename(
|
||||||
|
episode.published, episode.title, episode.enclosure_type, episode.enclosure_url
|
||||||
|
)
|
||||||
|
audio_path = folder / filename
|
||||||
|
if not audio_path.is_file():
|
||||||
|
continue
|
||||||
|
on_disk.add(filename)
|
||||||
|
if _has_sidecar_cover(audio_path):
|
||||||
|
missing.pop(filename, None)
|
||||||
|
continue
|
||||||
|
last_miss = missing.get(filename)
|
||||||
|
if last_miss is not None and now - last_miss < _RETRY_BACKOFF:
|
||||||
|
continue
|
||||||
|
|
||||||
|
cover_url = await resolve_episode_cover(client, episode)
|
||||||
|
if cover_url is None:
|
||||||
|
missing[filename] = now
|
||||||
|
continue
|
||||||
|
cover_filename = episode_cover_filename(episode.published, episode.title, cover_url)
|
||||||
|
try:
|
||||||
|
await download_episode_cover(client, folder, cover_url, cover_filename)
|
||||||
|
except (httpx2.HTTPError, OSError) as exc:
|
||||||
|
_log.warning(
|
||||||
|
"Could not download cover for episode %r in %s: %s",
|
||||||
|
episode.title,
|
||||||
|
folder.name,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
missing[filename] = now
|
||||||
|
continue
|
||||||
|
missing.pop(filename, None)
|
||||||
|
added = True
|
||||||
|
|
||||||
|
missing = {filename: when for filename, when in missing.items() if filename in on_disk}
|
||||||
|
_save_failed(folder, _MISSING_COVERS_FILENAME, missing)
|
||||||
|
return added
|
||||||
|
|
||||||
|
|
||||||
async def sync_show(
|
async def sync_show(
|
||||||
client: httpx2.AsyncClient,
|
client: httpx2.AsyncClient,
|
||||||
folder: Path,
|
folder: Path,
|
||||||
@@ -542,9 +605,9 @@ async def sync_show(
|
|||||||
Both halves use the same ``keep``, which is what makes the folder settle - see
|
Both halves use the same ``keep``, which is what makes the folder settle - see
|
||||||
:data:`DEFAULT_EPISODE_LIMIT`.
|
:data:`DEFAULT_EPISODE_LIMIT`.
|
||||||
|
|
||||||
Returns whether anything changed, deletions included: a pruned folder needs a
|
Returns whether anything changed, deletions and backfilled covers included: a
|
||||||
rescan just as much as a downloaded episode does, or the library keeps offering
|
pruned folder needs a rescan just as much as a downloaded episode does, or the
|
||||||
tracks whose files are gone.
|
library keeps offering tracks whose files are gone.
|
||||||
|
|
||||||
Errors - an unreachable feed, a malformed one, a single broken enclosure - are
|
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
|
logged and swallowed here so one bad show never stops the others or takes down the
|
||||||
@@ -559,8 +622,9 @@ async def sync_show(
|
|||||||
_log.warning("Could not fetch podcast feed %s for %s: %s", feed_url, folder.name, exc)
|
_log.warning("Could not fetch podcast feed %s for %s: %s", feed_url, folder.name, exc)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
pending = missing_episodes(folder, newest_episodes(parse_feed(response.content), keep))
|
window = newest_episodes(parse_feed(response.content), keep)
|
||||||
failed = _load_failed_downloads(folder)
|
pending = missing_episodes(folder, window)
|
||||||
|
failed = _load_failed(folder, _FAILED_DOWNLOADS_FILENAME)
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
changed = False
|
changed = False
|
||||||
for episode, filename in pending:
|
for episode, filename in pending:
|
||||||
@@ -579,23 +643,12 @@ async def sync_show(
|
|||||||
_log.info("Downloaded new episode %r for %s", episode.title, folder.name)
|
_log.info("Downloaded new episode %r for %s", episode.title, folder.name)
|
||||||
changed = True
|
changed = True
|
||||||
|
|
||||||
cover_url = await resolve_episode_cover(client, episode)
|
|
||||||
if cover_url:
|
|
||||||
cover_filename = episode_cover_filename(episode.published, episode.title, cover_url)
|
|
||||||
if not (folder / cover_filename).exists():
|
|
||||||
try:
|
|
||||||
await download_episode_cover(client, folder, cover_url, cover_filename)
|
|
||||||
except (httpx2.HTTPError, OSError) as exc:
|
|
||||||
_log.warning(
|
|
||||||
"Could not download cover for episode %r in %s: %s",
|
|
||||||
episode.title,
|
|
||||||
folder.name,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
|
|
||||||
pending_filenames = {filename for _episode, filename in pending}
|
pending_filenames = {filename for _episode, filename in pending}
|
||||||
failed = {filename: when for filename, when in failed.items() if filename in pending_filenames}
|
failed = {filename: when for filename, when in failed.items() if filename in pending_filenames}
|
||||||
_save_failed_downloads(folder, failed)
|
_save_failed(folder, _FAILED_DOWNLOADS_FILENAME, failed)
|
||||||
|
|
||||||
|
if await _sync_covers(client, folder, window, now):
|
||||||
|
changed = True
|
||||||
|
|
||||||
# After downloading, not before: an episode that just arrived is one of the newest
|
# After downloading, not before: an episode that just arrived is one of the newest
|
||||||
# and must be counted when deciding what falls off the end.
|
# and must be counted when deciding what falls off the end.
|
||||||
|
|||||||
@@ -262,7 +262,11 @@ def scan_episodes(
|
|||||||
out: dict[str, tuple[Album, Fingerprint]] = {}
|
out: dict[str, tuple[Album, Fingerprint]] = {}
|
||||||
for path in paths:
|
for path in paths:
|
||||||
identifier = album_id(root, path)
|
identifier = album_id(root, path)
|
||||||
fingerprint = Fingerprint.of([path])
|
# The sidecar cover counts too: one fetched after the episode was first scanned
|
||||||
|
# (``podcast_feeds.py`` backfills them) must invalidate the cached entry, or the
|
||||||
|
# episode keeps showing the show's shared cover.
|
||||||
|
sidecars = [path.with_suffix(ext) for ext in _SIDECAR_COVER_EXTENSIONS]
|
||||||
|
fingerprint = Fingerprint.of([path, *(p for p in sidecars if p.is_file())])
|
||||||
cached = known.get(identifier)
|
cached = known.get(identifier)
|
||||||
if cached is not None and cached[1] == fingerprint:
|
if cached is not None and cached[1] == fingerprint:
|
||||||
out[identifier] = cached
|
out[identifier] = cached
|
||||||
|
|||||||
@@ -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"
|
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
|
# --------------------------------------------------------------------------- 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()
|
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
|
# ---------------------------------------------------------------------- video shows
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user