diff --git a/python-backend/musicmouse/library/__init__.py b/python-backend/musicmouse/library/__init__.py index 789a7d6..606da34 100644 --- a/python-backend/musicmouse/library/__init__.py +++ b/python-backend/musicmouse/library/__init__.py @@ -11,6 +11,7 @@ import asyncio import contextlib import logging import os +import time from collections.abc import Awaitable, Callable, Collection, Mapping from dataclasses import replace from pathlib import Path @@ -62,6 +63,11 @@ _BUSY_POLL_SECONDS: Final = 2.0 #: browser tabs rather than only after the whole thing finishes. _PUBLISH_BATCH_SIZE: Final = 25 +#: How often a long analysis pass reports where it is, so a first-time run over a large +#: library - minutes of DSP per track - doesn't sit silent with nothing on the console +#: to say it is still going. +_PROGRESS_INTERVAL_SECONDS: Final = 5.0 + def _analyze_one( analyzer: Analyzer, path: Path @@ -273,6 +279,7 @@ class MusicLibrary: return 0 done = 0 + last_report = time.monotonic() for album in self.albums: if album.kind not in kinds: continue @@ -283,6 +290,10 @@ class MusicLibrary: cached = self.cache.load_analysis(key) if cached is not None and cached.version >= analyzer.version: continue + now = time.monotonic() + if now - last_report >= _PROGRESS_INTERVAL_SECONDS: + _log.info("Analyzing library: %d tracks done so far, now on %s", done, track.path) + last_report = now try: analysis, grid, curve = await asyncio.to_thread( _analyze_one, analyzer, track.path diff --git a/python-backend/musicmouse/library/podcast_feeds.py b/python-backend/musicmouse/library/podcast_feeds.py index 20ad530..9ceedf8 100644 --- a/python-backend/musicmouse/library/podcast_feeds.py +++ b/python-backend/musicmouse/library/podcast_feeds.py @@ -7,15 +7,21 @@ its shape entirely off the folder tree (see :mod:`musicmouse.library.sections`). Downloaded episodes land in the show folder using the exact ``YYYYMMDD - Title.ext`` convention ``sections.py`` already documents for ``Kinderpodcasts``, so a freshly -downloaded episode sorts and scans exactly like one a person dropped in by hand. +downloaded episode sorts and scans exactly like one a person dropped in by hand. When +a feed offers real per-episode artwork, it's saved alongside as a same-named sidecar +image, which ``scanner.py``'s ``_cover_for_episode`` picks up automatically. A +video-only enclosure (some shows publish no audio feed at all) is transcoded to audio +via the ``ffmpeg`` binary, which must be on ``PATH`` for those shows to sync. Nothing here raises on bad input - an unreachable feed or a broken enclosure is logged and skipped, not a crash, mirroring ``scanner.py``'s own rule. """ from __future__ import annotations +import asyncio import logging import re +import shutil from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path @@ -31,12 +37,16 @@ _log = logging.getLogger(__name__) __all__ = [ "FEED_MARKER_NAME", + "AudioExtractionError", "Episode", "download_episode", + "download_episode_cover", + "episode_cover_filename", "episode_filename", "find_feed_shows", "missing_episodes", "parse_feed", + "resolve_episode_cover", "sync_all_shows", "sync_show", ] @@ -59,11 +69,34 @@ _EXTENSION_BY_TYPE: Final[dict[str, str]] = { _DEFAULT_EXTENSION: Final = ".mp3" _KNOWN_EXTENSIONS: Final = frozenset({".mp3", ".m4a", ".aac", ".ogg", ".opus", ".wav", ".flac"}) +#: Cover URL suffix -> file extension, for the per-episode sidecar image. +_IMAGE_EXTENSIONS: Final[frozenset[str]] = frozenset({".jpg", ".jpeg", ".png", ".webp"}) +_DEFAULT_IMAGE_EXTENSION: Final = ".jpg" + #: Characters illegal (or awkward) in a filename, plus the path separators themselves. +#: Replaced with "_" rather than dropped, matching the convention every episode +#: already downloaded by hand (via the ``podcast-dl`` CLI) was named with - a +#: mismatch here would make every one of them look "new" to `missing_episodes`. _ILLEGAL_FILENAME_CHARS: Final = re.compile(r'[\\/:*?"<>|]') _MAX_TITLE_LENGTH: Final = 120 _HTTP_TIMEOUT: Final = 30.0 +#: `og:image` scraping is a fallback for a page we don't control; kept short so one +#: slow or hanging host can't stall a whole sync pass. +_OG_IMAGE_TIMEOUT: Final = 15.0 +_OG_IMAGE_RE: Final = re.compile( + r']+property=["\']og:image["\'][^>]*content=["\']([^"\']+)["\']' + r'|]+content=["\']([^"\']+)["\'][^>]*property=["\']og:image["\']', + re.IGNORECASE, +) + +#: How long a video enclosure is given to download-and-transcode before it's treated +#: as failed - generous, since this runs on a Pi and a long video can take a while. +_FFMPEG_TIMEOUT: Final = 600.0 + + +class AudioExtractionError(Exception): + """Raised when a video enclosure could not be turned into an audio file.""" @dataclass(frozen=True, slots=True) @@ -72,6 +105,15 @@ class Episode: published: datetime enclosure_url: str enclosure_type: str + #: The feed's own per-episode artwork, when it has one distinct from the show's + #: overall cover. ``None`` when the feed has no image at all, or (as with GEOlino) + #: every item merely repeats the channel's own image. + cover_url: str | None = None + #: The episode's own page, when the feed links to one distinct from the show's + #: general page - the fallback route to real per-episode art for a feed (like + #: Wissen macht Ah) that has no per-item image of its own, via that page's + #: `og:image`. ``None`` when the feed has no such per-episode page. + link: str | None = None def find_feed_shows(root: Path) -> list[tuple[str, Path, str]]: @@ -110,12 +152,19 @@ def _read_feed_url(marker: Path) -> str | None: def parse_feed(content: bytes) -> list[Episode]: - """Every episode in a feed that has both a publish date and an audio enclosure. + """Every episode in a feed that has both a publish date and a playable enclosure. Anything else is skipped and logged rather than raising - a malformed or unusual entry in one feed must never stop the episodes around it from being picked up. """ parsed = feedparser.parse(content) + # feedparser exposes an `itunes:image` identically at the channel and item level, + # under the same "image" key an ordinary RSS `` uses - there is no separate + # `itunes_image` field. Read the channel's own image/link once so each entry can + # tell whether it has something genuinely its own, or is just repeating them + # (as GEOlino's per-item `itunes:image` does). + channel_image = parsed.feed.get("image", {}).get("href") + channel_link = parsed.feed.get("link") episodes: list[Episode] = [] for entry in parsed.entries: published = _entry_published(entry) @@ -124,15 +173,26 @@ def parse_feed(content: bytes) -> list[Episode]: continue enclosure = _entry_enclosure(entry) if enclosure is None: - _log.debug("Feed entry %r has no audio enclosure; skipping", entry.get("title")) + _log.debug( + "Feed entry %r has no audio or video enclosure; skipping", entry.get("title") + ) continue url, enclosure_type = enclosure + + item_image = entry.get("image", {}).get("href") + cover_url = item_image if item_image and item_image != channel_image else None + + item_link = entry.get("link") + link = item_link if item_link and item_link != channel_link else None + episodes.append( Episode( title=entry.get("title") or url, published=published, enclosure_url=url, enclosure_type=enclosure_type, + cover_url=cover_url, + link=link, ) ) return episodes @@ -147,12 +207,32 @@ def _entry_published(entry: Any) -> datetime | None: def _entry_enclosure(entry: Any) -> tuple[str, str] | None: + """The enclosure to download for one entry, preferring audio when both are offered. + + A video enclosure (some shows, like Wissen macht Ah, publish no audio version at + all) is still returned rather than dropped - :func:`download_episode` turns it + into audio via ffmpeg. It's just the least preferred of the three: an audio + enclosure, or one with no declared type at all (assumed audio), both win outright. + """ + video: tuple[str, str] | None = None for enclosure in entry.get("enclosures", []): url = enclosure.get("href") or enclosure.get("url") enclosure_type = enclosure.get("type") or "" - if url and (enclosure_type.startswith("audio/") or not enclosure_type): + if not url: + continue + if enclosure_type.startswith("audio/") or not enclosure_type: return str(url), str(enclosure_type) - return None + if video is None and enclosure_type.startswith("video/"): + video = (str(url), str(enclosure_type)) + return video + + +def _episode_stem(published: datetime, title: str) -> str: + """``YYYYMMDD - Title``, with no extension yet - shared by the audio filename and + its sidecar cover image, so the two always line up.""" + sanitized = _ILLEGAL_FILENAME_CHARS.sub("_", title).strip().strip(".") + sanitized = " ".join(sanitized.split())[:_MAX_TITLE_LENGTH] or "Episode" + return f"{published:%Y%m%d} - {sanitized}" def episode_filename( @@ -160,9 +240,13 @@ def episode_filename( ) -> str: """``YYYYMMDD - Title.ext``, matching the convention every hand-placed episode already follows (see ``sections.py``'s module comment on ``Kinderpodcasts``).""" - sanitized = _ILLEGAL_FILENAME_CHARS.sub("", title).strip().strip(".") - sanitized = " ".join(sanitized.split())[:_MAX_TITLE_LENGTH] or "Episode" - return f"{published:%Y%m%d} - {sanitized}{_extension_for(enclosure_type, enclosure_url)}" + return f"{_episode_stem(published, title)}{_extension_for(enclosure_type, enclosure_url)}" + + +def episode_cover_filename(published: datetime, title: str, cover_url: str) -> str: + """The sidecar image filename for an episode's cover, sharing its stem so the + scanner (``scanner.py``'s ``_cover_for_episode``) can find it next to the audio.""" + return f"{_episode_stem(published, title)}{_image_extension_for(cover_url)}" def _extension_for(enclosure_type: str, url: str) -> str: @@ -172,6 +256,11 @@ def _extension_for(enclosure_type: str, url: str) -> str: return _EXTENSION_BY_TYPE.get(enclosure_type, _DEFAULT_EXTENSION) +def _image_extension_for(url: str) -> str: + suffix = Path(urlsplit(url).path).suffix.lower() + return suffix if suffix in _IMAGE_EXTENSIONS else _DEFAULT_IMAGE_EXTENSION + + def missing_episodes(folder: Path, episodes: list[Episode]) -> list[tuple[Episode, str]]: """Episodes whose target filename isn't already on disk, paired with that filename. @@ -192,26 +281,117 @@ def missing_episodes(folder: Path, episodes: list[Episode]) -> list[tuple[Episod async def download_episode( client: httpx2.AsyncClient, folder: Path, episode: Episode, filename: str ) -> None: - """Stream an episode to ``folder / filename`` via a dotfile temp path. + """Get an episode to ``folder / filename`` via a dotfile temp path. A half-written file must never look like a track: the scanner already skips dotfiles for exactly this reason (see ``scanner.py``'s ``_audio_files``), so the - rename to the real name only happens once the download is complete. + rename to the real name only happens once the download is complete. A video + enclosure is routed through ffmpeg instead of being streamed as-is - see + :func:`_extract_audio`. """ temp_path = folder / f".downloading-{filename}.tmp" try: - async with client.stream( - "GET", episode.enclosure_url, timeout=_HTTP_TIMEOUT - ) as response: - response.raise_for_status() - with temp_path.open("wb") as handle: - async for chunk in response.aiter_bytes(): - handle.write(chunk) + if episode.enclosure_type.startswith("video/"): + await _extract_audio(episode.enclosure_url, temp_path) + else: + async with client.stream( + "GET", episode.enclosure_url, timeout=_HTTP_TIMEOUT + ) as response: + response.raise_for_status() + with temp_path.open("wb") as handle: + async for chunk in response.aiter_bytes(): + handle.write(chunk) temp_path.replace(folder / filename) finally: temp_path.unlink(missing_ok=True) +async def _extract_audio(source_url: str, dest: Path) -> None: + """Pull ``source_url`` (a video enclosure) through ffmpeg, writing just its audio + track to ``dest``. ffmpeg fetches the URL itself, so the video is never stored. + + Raises :class:`AudioExtractionError` rather than a bare ``OSError`` or timeout, so + callers can tell a missing/failing ffmpeg apart from an ordinary network error - + but either way this is meant to be logged and skipped, not fatal. + """ + if shutil.which("ffmpeg") is None: + raise AudioExtractionError( + "ffmpeg is not installed; cannot extract audio from a video enclosure" + ) + process = await asyncio.create_subprocess_exec( + "ffmpeg", + "-y", + "-i", + source_url, + "-vn", + "-acodec", + "libmp3lame", + "-q:a", + "2", + str(dest), + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.PIPE, + ) + try: + _, stderr = await asyncio.wait_for(process.communicate(), timeout=_FFMPEG_TIMEOUT) + except TimeoutError: + process.kill() + await process.wait() + raise AudioExtractionError(f"ffmpeg timed out extracting audio from {source_url}") from None + if process.returncode != 0: + raise AudioExtractionError( + f"ffmpeg exited {process.returncode} extracting audio from {source_url}: " + f"{stderr.decode(errors='replace')[-500:]}" + ) + + +async def download_episode_cover( + client: httpx2.AsyncClient, folder: Path, cover_url: str, filename: str +) -> None: + """Best-effort sidecar cover image fetch, atomic like :func:`download_episode`. + + Cover art is small enough that streaming it in chunks isn't worth the extra code. + """ + temp_path = folder / f".downloading-{filename}.tmp" + try: + response = await client.get(cover_url, timeout=_HTTP_TIMEOUT, follow_redirects=True) + response.raise_for_status() + temp_path.write_bytes(response.content) + temp_path.replace(folder / filename) + finally: + temp_path.unlink(missing_ok=True) + + +def _extract_og_image(html: str) -> str | None: + match = _OG_IMAGE_RE.search(html) + if match is None: + return None + return match.group(1) or match.group(2) + + +async def resolve_episode_cover(client: httpx2.AsyncClient, episode: Episode) -> str | None: + """The best cover URL available for ``episode``, or ``None`` when there isn't one. + + Prefers the feed's own per-episode image. Failing that, when the feed links to a + page of the episode's own (as Wissen macht Ah does, despite having no per-item + image tag), falls back to that page's ``og:image`` - one targeted fetch of a URL + the feed itself provided, not a scrape of a list page. Never raises: an + unreachable page, a redirect loop, or a page with no such tag all just mean no + cover was found here. + """ + if episode.cover_url: + return episode.cover_url + if not episode.link: + return None + try: + response = await client.get(episode.link, timeout=_OG_IMAGE_TIMEOUT, follow_redirects=True) + response.raise_for_status() + except httpx2.HTTPError as exc: + _log.debug("Could not fetch %s for its og:image: %s", episode.link, exc) + return None + return _extract_og_image(response.text) + + async def sync_show(client: httpx2.AsyncClient, folder: Path, feed_url: str) -> bool: """Download every episode in ``feed_url`` that ``folder`` doesn't have yet. @@ -231,13 +411,27 @@ async def sync_show(client: httpx2.AsyncClient, folder: Path, feed_url: str) -> for episode, filename in pending: try: await download_episode(client, folder, episode, filename) - except (httpx2.HTTPError, OSError) as exc: + except (httpx2.HTTPError, OSError, AudioExtractionError) as exc: _log.warning( "Could not download episode %r for %s: %s", episode.title, folder.name, exc ) continue _log.info("Downloaded new episode %r for %s", episode.title, folder.name) 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, + ) return changed diff --git a/python-backend/musicmouse/library/scanner.py b/python-backend/musicmouse/library/scanner.py index b20fdd5..451524f 100644 --- a/python-backend/musicmouse/library/scanner.py +++ b/python-backend/musicmouse/library/scanner.py @@ -9,8 +9,9 @@ input - an unreadable file loses its metadata, not the boot. from __future__ import annotations import logging +import time from collections import Counter -from collections.abc import Mapping +from collections.abc import Iterator, Mapping from pathlib import Path from musicmouse.library.cache import Fingerprint, LibraryCache @@ -25,6 +26,33 @@ __all__ = ["scan_library"] #: Checked in order for a cover sitting next to the audio. _COVER_NAMES = ("cover.jpg", "cover.jpeg", "cover.png", "folder.jpg") +#: Extensions tried for an episode's own same-stem sidecar cover image (see +#: :func:`_cover_for_episode`), matching what `podcast_feeds.py`'s +#: ``episode_cover_filename`` can produce. +_SIDECAR_COVER_EXTENSIONS = (".jpg", ".jpeg", ".png", ".webp") + +#: How often a long scan reports where it is, so a library of thousands of albums +#: doesn't sit silent for minutes with nothing on the console to say it is still going. +_PROGRESS_INTERVAL_SECONDS = 5.0 + + +def _album_folders(folder: Path, extensions: frozenset[str]) -> Iterator[Path]: + """Every leaf album folder under ``folder``, however deep it is nested. + + A folder that holds audio files directly *is* an album - an artist who groups + their books under an extra "ab 3" / "ab 5" age-range folder, or a series folder + that groups its episodes one directory further down than usual, still bottoms out + here without needing to be special-cased. Only descended into when a folder holds + no audio of its own, so an ordinary album folder is never mistaken for a grouping + folder just because it also happens to contain subfolders. + """ + if _audio_files(folder, extensions): + yield folder + return + for sub in sorted(folder.iterdir(), key=lambda path: path.name): + if sub.is_dir() and not sub.name.startswith("."): + yield from _album_folders(sub, extensions) + def _audio_files(folder: Path, extensions: frozenset[str]) -> list[Path]: """The playable files in ``folder``, alphabetically. @@ -106,9 +134,16 @@ def _cover_for( def _cover_for_episode( folder: Path, path: Path, identifier: str, cache: LibraryCache ) -> tuple[Path | None, bytes | None]: - """The reverse priority from :func:`_cover_for`: with one album per episode, the - episode's own embedded art is the more specific and more correct source. The - folder's shared cover is the fallback for an episode whose file carries none.""" + """The reverse priority from :func:`_cover_for`: with one album per episode, art + specific to this one episode wins over the folder's shared cover. A same-stem + sidecar image (what ``podcast_feeds.py`` saves when a feed has real per-episode + art) is tried first - it's a plain file stat, cheaper than reading tags - then the + episode's own embedded art, then the folder's shared cover as the last resort for + an episode with neither.""" + for extension in _SIDECAR_COVER_EXTENSIONS: + candidate = path.with_suffix(extension) + if candidate.is_file(): + return candidate, candidate.read_bytes() art = _embedded_art(path) if art is not None: return cache.store_cover(identifier, art), art @@ -262,6 +297,7 @@ def scan_library( cache.prepare() known = known or {} out: dict[str, tuple[Album, Fingerprint]] = {} + last_report = time.monotonic() for section_name, section in SECTIONS.items(): section_root = root / section_name @@ -269,16 +305,17 @@ def scan_library( _log.warning("Library section %r has no folder at %s", section_name, section_root) continue - for folder in sorted(section_root.iterdir(), key=lambda path: path.name): - if not folder.is_dir() or folder.name.startswith("."): + for top in sorted(section_root.iterdir(), key=lambda path: path.name): + if not top.is_dir() or top.name.startswith("."): continue if section.album_unit == "episode": # Fingerprinted per episode inside scan_episodes; the whole-folder - # shortcut below does not apply since one folder yields many albums. + # shortcut below does not apply since one folder yields many albums, + # and an episode show is never nested deeper than this either. out.update( scan_episodes( - folder, + top, root=root, section_name=section_name, section=section, @@ -289,24 +326,34 @@ def scan_library( ) continue - identifier = album_id(root, folder) - cached = known.get(identifier) - if cached is not None: - paths = _audio_files(folder, extensions) - if paths and Fingerprint.of(paths) == cached[1]: - out[identifier] = cached - continue - scanned = scan_album( - folder, - root=root, - section_name=section_name, - section=section, - extensions=extensions, - cache=cache, - figure_kinds=figure_kinds, - ) - if scanned is not None: - out[identifier] = scanned + for folder in _album_folders(top, extensions): + now = time.monotonic() + if now - last_report >= _PROGRESS_INTERVAL_SECONDS: + _log.info( + "Scanning library: %d albums found so far, now in %s", + len(out), + folder.relative_to(root), + ) + last_report = now + + identifier = album_id(root, folder) + cached = known.get(identifier) + if cached is not None: + paths = _audio_files(folder, extensions) + if paths and Fingerprint.of(paths) == cached[1]: + out[identifier] = cached + continue + scanned = scan_album( + folder, + root=root, + section_name=section_name, + section=section, + extensions=extensions, + cache=cache, + figure_kinds=figure_kinds, + ) + if scanned is not None: + out[identifier] = scanned _log.info("Library: %d albums under %s", len(out), root) return out diff --git a/python-backend/tests/test_library.py b/python-backend/tests/test_library.py index c4dba9b..5c30b2b 100644 --- a/python-backend/tests/test_library.py +++ b/python-backend/tests/test_library.py @@ -237,6 +237,22 @@ async def test_a_cover_file_is_found_and_used(config_dir: Path) -> None: assert album.colors[0] != colors_from_id(album.id)[0] +async def test_an_episodes_sidecar_cover_wins_over_the_shows_shared_one(config_dir: Path) -> None: + from PIL import Image + + # Downloaded by `podcast_feeds.py` when a feed has real per-episode art (see its + # `episode_cover_filename`) - same stem as the episode's audio file. + podcast = config_dir / "music" / "Kinderpodcasts" / "Wissen macht Ah" + Image.new("RGB", (32, 32), (10, 20, 30)).save(podcast / "cover.jpg") + Image.new("RGB", (32, 32), (200, 40, 30)).save(podcast / "20260101 - Neu.jpg") + + library = await build(config_dir) + + assert album_named(library, "Neu").cover == podcast / "20260101 - Neu.jpg" + # No sidecar for this one, so it still falls back to the show's shared cover. + assert album_named(library, "Alt").cover == podcast / "cover.jpg" + + # --------------------------------------------------------------------------- cache @@ -359,6 +375,28 @@ def test_album_ids_are_stable_and_path_derived(tmp_path: Path) -> None: assert first != other +async def test_an_album_nested_under_an_extra_grouping_folder_is_still_found( + config_dir: Path, +) -> None: + """The bug this fixed: an artist folder that groups its books one level deeper than + usual (an age-range folder, say) has no audio directly in it, so the scanner used to + treat it as an empty album and skip the whole artist rather than looking further + down for the actual album folders.""" + for index in range(2): + write_track( + config_dir / "music" / "Hörbücher" / "Petzi" / "ab 5" / "Petzi und der Wal" + / f"{index:02d} - teil.mp3", + title=f"Teil {index}", + album="Petzi und der Wal", + albumartist="Petzi", + ) + library = await build(config_dir) + + album = album_named(library, "Petzi und der Wal") + assert album.kind == "book" + assert len(album.tracks) == 2 + + # -------------------------------------------------------------------- figure kinds diff --git a/python-backend/tests/test_podcast_feeds.py b/python-backend/tests/test_podcast_feeds.py index 2b5b11c..48b4ec5 100644 --- a/python-backend/tests/test_podcast_feeds.py +++ b/python-backend/tests/test_podcast_feeds.py @@ -7,13 +7,18 @@ 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, parse_feed, + resolve_episode_cover, sync_all_shows, sync_show, ) @@ -43,6 +48,46 @@ _FEED = b""" """ +_FEED_WITH_COVERS = b""" + + +Cover Show +http://example.com/show + + + Unique Cover + http://example.com/episodes/unique + Wed, 01 Jan 2025 08:00:00 GMT + + + ep1 + + + Same As Channel + http://example.com/show + Thu, 02 Jan 2025 08:00:00 GMT + + + ep2 + + + +""" + +_VIDEO_FEED = b""" + + +Video Show + + Video Only + Wed, 01 Jan 2025 08:00:00 GMT + + ep1 + + + +""" + def _root_with_show( tmp_path: Path, *, feed_url: str | None = "http://example.com/feed.xml" @@ -99,7 +144,19 @@ def test_episode_filename_sanitizes_illegal_characters_and_falls_back_on_type() name = episode_filename( episode.published, episode.title, episode.enclosure_type, episode.enclosure_url ) - assert name == "20250103 - Episode Two SpecialChars.mp3" + # 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: @@ -151,7 +208,7 @@ async def test_sync_show_downloads_new_episodes_and_is_a_noop_on_rerun(tmp_path: 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() + assert (folder / "20250103 - Episode Two_ Special_Chars_.mp3").exists() @pytest.mark.asyncio @@ -211,3 +268,245 @@ async def test_sync_all_shows_keeps_going_past_one_broken_show(tmp_path: Path) - 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'' + b'' + b"", + ) + + 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"" + + 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. + 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")) == []