Auto-download new podcast episodes from an RSS feed
A show folder under Kinderpodcasts/ opts in by containing a feed.txt marker naming its RSS feed. A new PodcastFeedService polls every such feed every 6 hours (and once at startup), downloads any episode not already on disk using the existing YYYYMMDD - Title.ext convention, and triggers the same rescan-and-broadcast sequence "Bibliothek neu einlesen" already uses - now shared via App.rescan_library() instead of duplicated. A show with no feed.txt is untouched, so there is no new config section for this. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -165,6 +165,12 @@ Only files whose suffix is in `audio_extensions` are read, and dotfiles are skip
|
|||||||
a podcast downloader's `archive.json` and its half-finished `.download.tmp` never reach
|
a podcast downloader's `archive.json` and its half-finished `.download.tmp` never reach
|
||||||
a playlist.
|
a playlist.
|
||||||
|
|
||||||
|
Drop a `feed.txt` into a show folder (its first line the show's RSS feed URL) and the
|
||||||
|
backend becomes that podcast downloader itself: every six hours it checks the feed and
|
||||||
|
saves any episode not already on disk, named `YYYYMMDD - Title.ext` like a hand-placed
|
||||||
|
one so it sorts and scans identically. A show with no `feed.txt` is untouched, exactly
|
||||||
|
as before - the file is the opt-in, there is no separate setting for it.
|
||||||
|
|
||||||
Each album carries three colours, pulled out of its cover art with Pillow (or
|
Each album carries three colours, pulled out of its cover art with Pillow (or
|
||||||
synthesised from a hash of its id when it has none). The frontend paints cards with
|
synthesised from a hash of its id when it has none). The frontend paints cards with
|
||||||
them and the LED strips run the first of them, so shelf and screen agree.
|
them and the LED strips run the first of them, so shelf and screen agree.
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ from collections.abc import Awaitable, Callable, Coroutine
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx2
|
||||||
|
|
||||||
from musicmouse import __version__
|
from musicmouse import __version__
|
||||||
from musicmouse.app import App
|
from musicmouse.app import App
|
||||||
from musicmouse.bus import EventBus
|
from musicmouse.bus import EventBus
|
||||||
@@ -35,6 +37,7 @@ from musicmouse.library.analysis import build_analyzer
|
|||||||
from musicmouse.reactions import register_all
|
from musicmouse.reactions import register_all
|
||||||
from musicmouse.services.base import Service
|
from musicmouse.services.base import Service
|
||||||
from musicmouse.services.mqtt import MqttService, build_entities
|
from musicmouse.services.mqtt import MqttService, build_entities
|
||||||
|
from musicmouse.services.podcasts import PodcastFeedService
|
||||||
from musicmouse.services.web import WebService
|
from musicmouse.services.web import WebService
|
||||||
|
|
||||||
_log = logging.getLogger("musicmouse")
|
_log = logging.getLogger("musicmouse")
|
||||||
@@ -327,6 +330,19 @@ def _build_services(
|
|||||||
else:
|
else:
|
||||||
services.append(WebService(app, web_config, config_path))
|
services.append(WebService(app, web_config, config_path))
|
||||||
|
|
||||||
|
# Unconditional: a show only starts downloading once someone drops a `feed.txt`
|
||||||
|
# into its folder, so there is nothing to gate here with its own config section.
|
||||||
|
web_service = next((s for s in services if isinstance(s, WebService)), None)
|
||||||
|
services.append(
|
||||||
|
PodcastFeedService(
|
||||||
|
app.library,
|
||||||
|
client=httpx2.AsyncClient(timeout=30.0),
|
||||||
|
on_change=lambda: app.rescan_library(
|
||||||
|
broadcast=web_service.hub.broadcast_library if web_service else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return services
|
return services
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
from musicmouse.bus import EventBus
|
from musicmouse.bus import EventBus
|
||||||
@@ -50,3 +51,17 @@ class App:
|
|||||||
if playlist is None:
|
if playlist is None:
|
||||||
_log.warning("No playlist for figure %r", figure)
|
_log.warning("No playlist for figure %r", figure)
|
||||||
return playlist
|
return playlist
|
||||||
|
|
||||||
|
async def rescan_library(
|
||||||
|
self, *, broadcast: Callable[[], Awaitable[None]] | None = None
|
||||||
|
) -> None:
|
||||||
|
"""Rescan from disk, rebuild figure playlists, and tell whoever's listening.
|
||||||
|
|
||||||
|
Shared by the manual "Bibliothek neu einlesen" endpoint and anything else that
|
||||||
|
can change what's on disk on its own, such as the podcast feed poller.
|
||||||
|
"""
|
||||||
|
await self.library.refresh()
|
||||||
|
self.playlists.clear()
|
||||||
|
self.playlists.update(self.library.figure_playlists())
|
||||||
|
if broadcast is not None:
|
||||||
|
await broadcast()
|
||||||
|
|||||||
250
python-backend/musicmouse/library/podcast_feeds.py
Normal file
250
python-backend/musicmouse/library/podcast_feeds.py
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
"""Auto-download new episodes for podcast shows that name their RSS feed.
|
||||||
|
|
||||||
|
A show folder opts in by containing a small marker file, ``feed.txt``, whose first
|
||||||
|
non-blank line is the feed's URL. That file is the only thing this module needs to find
|
||||||
|
a show - nothing here is turned on by config, matching how the rest of the library reads
|
||||||
|
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.
|
||||||
|
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 logging
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Final
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
import feedparser
|
||||||
|
import httpx2
|
||||||
|
|
||||||
|
from musicmouse.library.sections import SECTIONS
|
||||||
|
|
||||||
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"FEED_MARKER_NAME",
|
||||||
|
"Episode",
|
||||||
|
"download_episode",
|
||||||
|
"episode_filename",
|
||||||
|
"find_feed_shows",
|
||||||
|
"missing_episodes",
|
||||||
|
"parse_feed",
|
||||||
|
"sync_all_shows",
|
||||||
|
"sync_show",
|
||||||
|
]
|
||||||
|
|
||||||
|
FEED_MARKER_NAME: Final = "feed.txt"
|
||||||
|
|
||||||
|
#: Enclosure content-type -> file extension, for a URL whose own suffix is missing or
|
||||||
|
#: not a real extension (tracking-redirect URLs are common in the wild).
|
||||||
|
_EXTENSION_BY_TYPE: Final[dict[str, str]] = {
|
||||||
|
"audio/mpeg": ".mp3",
|
||||||
|
"audio/mp3": ".mp3",
|
||||||
|
"audio/mp4": ".m4a",
|
||||||
|
"audio/x-m4a": ".m4a",
|
||||||
|
"audio/aac": ".aac",
|
||||||
|
"audio/ogg": ".ogg",
|
||||||
|
"audio/opus": ".opus",
|
||||||
|
"audio/wav": ".wav",
|
||||||
|
"audio/x-wav": ".wav",
|
||||||
|
}
|
||||||
|
_DEFAULT_EXTENSION: Final = ".mp3"
|
||||||
|
_KNOWN_EXTENSIONS: Final = frozenset({".mp3", ".m4a", ".aac", ".ogg", ".opus", ".wav", ".flac"})
|
||||||
|
|
||||||
|
#: Characters illegal (or awkward) in a filename, plus the path separators themselves.
|
||||||
|
_ILLEGAL_FILENAME_CHARS: Final = re.compile(r'[\\/:*?"<>|]')
|
||||||
|
_MAX_TITLE_LENGTH: Final = 120
|
||||||
|
|
||||||
|
_HTTP_TIMEOUT: Final = 30.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Episode:
|
||||||
|
title: str
|
||||||
|
published: datetime
|
||||||
|
enclosure_url: str
|
||||||
|
enclosure_type: str
|
||||||
|
|
||||||
|
|
||||||
|
def find_feed_shows(root: Path) -> list[tuple[str, Path, str]]:
|
||||||
|
"""Every show folder with a feed marker, as ``(section_name, folder, feed_url)``.
|
||||||
|
|
||||||
|
Restricted to sections whose ``album_unit`` is ``"episode"`` (today just
|
||||||
|
``Kinderpodcasts``) - that is what marks a folder as a show rather than a single
|
||||||
|
release, the same distinction the scanner already makes.
|
||||||
|
"""
|
||||||
|
out: list[tuple[str, Path, str]] = []
|
||||||
|
for section_name, section in SECTIONS.items():
|
||||||
|
if section.album_unit != "episode":
|
||||||
|
continue
|
||||||
|
section_root = root / section_name
|
||||||
|
if not section_root.is_dir():
|
||||||
|
continue
|
||||||
|
for folder in sorted(section_root.iterdir(), key=lambda path: path.name):
|
||||||
|
if not folder.is_dir() or folder.name.startswith("."):
|
||||||
|
continue
|
||||||
|
marker = folder / FEED_MARKER_NAME
|
||||||
|
if not marker.is_file():
|
||||||
|
continue
|
||||||
|
feed_url = _read_feed_url(marker)
|
||||||
|
if feed_url is not None:
|
||||||
|
out.append((section_name, folder, feed_url))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _read_feed_url(marker: Path) -> str | None:
|
||||||
|
for line in marker.read_text(encoding="utf-8").splitlines():
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped:
|
||||||
|
return stripped
|
||||||
|
_log.warning("%s is empty; no feed URL to read", marker)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_feed(content: bytes) -> list[Episode]:
|
||||||
|
"""Every episode in a feed that has both a publish date and an audio 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)
|
||||||
|
episodes: list[Episode] = []
|
||||||
|
for entry in parsed.entries:
|
||||||
|
published = _entry_published(entry)
|
||||||
|
if published is None:
|
||||||
|
_log.debug("Feed entry %r has no publish date; skipping", entry.get("title"))
|
||||||
|
continue
|
||||||
|
enclosure = _entry_enclosure(entry)
|
||||||
|
if enclosure is None:
|
||||||
|
_log.debug("Feed entry %r has no audio enclosure; skipping", entry.get("title"))
|
||||||
|
continue
|
||||||
|
url, enclosure_type = enclosure
|
||||||
|
episodes.append(
|
||||||
|
Episode(
|
||||||
|
title=entry.get("title") or url,
|
||||||
|
published=published,
|
||||||
|
enclosure_url=url,
|
||||||
|
enclosure_type=enclosure_type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return episodes
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_published(entry: Any) -> datetime | None:
|
||||||
|
parsed_time = entry.get("published_parsed") or entry.get("updated_parsed")
|
||||||
|
if parsed_time is None:
|
||||||
|
return None
|
||||||
|
year, month, day, hour, minute, second = tuple(parsed_time)[:6]
|
||||||
|
return datetime(year, month, day, hour, minute, second, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_enclosure(entry: Any) -> tuple[str, str] | 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):
|
||||||
|
return str(url), str(enclosure_type)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def episode_filename(
|
||||||
|
published: datetime, title: str, enclosure_type: str, enclosure_url: str
|
||||||
|
) -> 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)}"
|
||||||
|
|
||||||
|
|
||||||
|
def _extension_for(enclosure_type: str, url: str) -> str:
|
||||||
|
suffix = Path(urlsplit(url).path).suffix.lower()
|
||||||
|
if suffix in _KNOWN_EXTENSIONS:
|
||||||
|
return suffix
|
||||||
|
return _EXTENSION_BY_TYPE.get(enclosure_type, _DEFAULT_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.
|
||||||
|
|
||||||
|
Dedup is purely by filename - no separate manifest of what has been downloaded
|
||||||
|
before, matching how the rest of the library already treats the filesystem as the
|
||||||
|
only source of truth (see the fingerprinting in ``cache.py``).
|
||||||
|
"""
|
||||||
|
out: list[tuple[Episode, str]] = []
|
||||||
|
for episode in episodes:
|
||||||
|
filename = episode_filename(
|
||||||
|
episode.published, episode.title, episode.enclosure_type, episode.enclosure_url
|
||||||
|
)
|
||||||
|
if not (folder / filename).exists():
|
||||||
|
out.append((episode, filename))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
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)
|
||||||
|
temp_path.replace(folder / filename)
|
||||||
|
finally:
|
||||||
|
temp_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Returns whether anything changed. 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 poll loop.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = await client.get(feed_url, timeout=_HTTP_TIMEOUT, follow_redirects=True)
|
||||||
|
response.raise_for_status()
|
||||||
|
except httpx2.HTTPError as exc:
|
||||||
|
_log.warning("Could not fetch podcast feed %s for %s: %s", feed_url, folder.name, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
pending = missing_episodes(folder, parse_feed(response.content))
|
||||||
|
changed = False
|
||||||
|
for episode, filename in pending:
|
||||||
|
try:
|
||||||
|
await download_episode(client, folder, episode, filename)
|
||||||
|
except (httpx2.HTTPError, OSError) 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
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_all_shows(client: httpx2.AsyncClient, root: Path) -> bool:
|
||||||
|
"""Poll every show with a feed marker under ``root``. Returns whether any changed."""
|
||||||
|
changed = False
|
||||||
|
for _section_name, folder, feed_url in find_feed_shows(root):
|
||||||
|
if await sync_show(client, folder, feed_url):
|
||||||
|
changed = True
|
||||||
|
return changed
|
||||||
60
python-backend/musicmouse/services/podcasts.py
Normal file
60
python-backend/musicmouse/services/podcasts.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""Front-end for nothing: the only "intent" this service ever produces is new files on
|
||||||
|
disk. It polls every podcast show that named its feed via a ``feed.txt`` marker (see
|
||||||
|
:mod:`musicmouse.library.podcast_feeds`) and downloads whatever episode is missing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
import httpx2
|
||||||
|
|
||||||
|
from musicmouse.library import MusicLibrary
|
||||||
|
from musicmouse.library.podcast_feeds import sync_all_shows
|
||||||
|
|
||||||
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
__all__ = ["PodcastFeedService"]
|
||||||
|
|
||||||
|
#: Podcasts a children's player subscribes to publish at most a few times a week;
|
||||||
|
#: checking a few times a day is plenty and kind to the feed hosts.
|
||||||
|
_CHECK_INTERVAL_SECONDS: Final = 6 * 3600
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastFeedService:
|
||||||
|
name = "podcast-feeds"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
library: MusicLibrary,
|
||||||
|
*,
|
||||||
|
client: httpx2.AsyncClient,
|
||||||
|
on_change: Callable[[], Awaitable[None]],
|
||||||
|
interval: float = _CHECK_INTERVAL_SECONDS,
|
||||||
|
) -> None:
|
||||||
|
self.library = library
|
||||||
|
self.client = client
|
||||||
|
self.on_change = on_change
|
||||||
|
self.interval = interval
|
||||||
|
|
||||||
|
async def run(self) -> None:
|
||||||
|
"""Checks immediately at startup, then every `interval` seconds.
|
||||||
|
|
||||||
|
A no-op when no show has a ``feed.txt`` - which is the common case, so this can
|
||||||
|
run unconditionally instead of needing its own config toggle. Never lets a
|
||||||
|
single bad pass end the task: `Service.run` is cancelled on shutdown and is
|
||||||
|
otherwise expected to keep going on its own.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
if await sync_all_shows(self.client, self.library.root):
|
||||||
|
await self.on_change()
|
||||||
|
except Exception:
|
||||||
|
_log.exception("Podcast feed check failed; will retry next interval")
|
||||||
|
await asyncio.sleep(self.interval)
|
||||||
|
finally:
|
||||||
|
await self.client.aclose()
|
||||||
@@ -100,14 +100,8 @@ def build_router(
|
|||||||
|
|
||||||
@router.post("/library/refresh", status_code=202)
|
@router.post("/library/refresh", status_code=202)
|
||||||
async def refresh_library() -> Response:
|
async def refresh_library() -> Response:
|
||||||
async def rescan() -> None:
|
|
||||||
await app.library.refresh()
|
|
||||||
app.playlists.clear()
|
|
||||||
app.playlists.update(app.library.figure_playlists())
|
|
||||||
await hub.broadcast_library()
|
|
||||||
|
|
||||||
# Returns immediately: a cold rescan reads tags from every file.
|
# Returns immediately: a cold rescan reads tags from every file.
|
||||||
asyncio.create_task(rescan()) # noqa: RUF006
|
asyncio.create_task(app.rescan_library(broadcast=hub.broadcast_library)) # noqa: RUF006
|
||||||
return Response(status_code=202)
|
return Response(status_code=202)
|
||||||
|
|
||||||
# --------------------------------------------------------------------- state
|
# --------------------------------------------------------------------- state
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ requires-python = ">=3.13"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"aiomqtt>=2.0",
|
"aiomqtt>=2.0",
|
||||||
"fastapi>=0.115",
|
"fastapi>=0.115",
|
||||||
|
# Podcast RSS feeds in the wild are full of small quirks (odd dates, missing
|
||||||
|
# namespaces); parsing them by hand invites silently dropping episodes.
|
||||||
|
"feedparser>=6.0",
|
||||||
# Also what tests drive the ASGI app with - plain httpx is deprecated in favour of
|
# Also what tests drive the ASGI app with - plain httpx is deprecated in favour of
|
||||||
# this for exactly that. Runtime uses it to proxy the room page's Home Assistant
|
# this for exactly that. Runtime uses it to proxy the room page's Home Assistant
|
||||||
# calls, so the long-lived token never has to leave the backend.
|
# calls, so the long-lived token never has to leave the backend.
|
||||||
@@ -68,5 +71,5 @@ warn_unreachable = true
|
|||||||
[[tool.mypy.overrides]]
|
[[tool.mypy.overrides]]
|
||||||
# numpy ships its own types, but only when the `analysis` extra is installed - a plain
|
# numpy ships its own types, but only when the `analysis` extra is installed - a plain
|
||||||
# checkout must still type-check clean, so it needs the same treatment as librosa.
|
# checkout must still type-check clean, so it needs the same treatment as librosa.
|
||||||
module = ["vlc", "serial_asyncio", "ruamel.*", "librosa.*", "numpy"]
|
module = ["vlc", "serial_asyncio", "ruamel.*", "librosa.*", "numpy", "feedparser"]
|
||||||
ignore_missing_imports = true
|
ignore_missing_imports = true
|
||||||
|
|||||||
213
python-backend/tests/test_podcast_feeds.py
Normal file
213
python-backend/tests/test_podcast_feeds.py
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
"""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"""<?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>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
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")) == []
|
||||||
87
python-backend/tests/test_podcast_service.py
Normal file
87
python-backend/tests/test_podcast_service.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
"""The podcast poll loop itself: cadence, error resilience, and client lifecycle."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx2
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import musicmouse.services.podcasts as podcasts_module
|
||||||
|
from musicmouse.services.podcasts import PodcastFeedService
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeLibrary:
|
||||||
|
def __init__(self, root: Path) -> None:
|
||||||
|
self.root = root
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_briefly(service: PodcastFeedService) -> None:
|
||||||
|
"""Let `run()` complete a few fast-interval passes, then cancel it like shutdown does."""
|
||||||
|
task = asyncio.create_task(service.run())
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
task.cancel()
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_checks_immediately_and_calls_on_change_only_when_something_changed(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
async def fake_sync_all_shows(client: httpx2.AsyncClient, root: Path) -> bool:
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
return calls == 1 # only the very first pass finds something new
|
||||||
|
|
||||||
|
monkeypatch.setattr(podcasts_module, "sync_all_shows", fake_sync_all_shows)
|
||||||
|
|
||||||
|
changes = 0
|
||||||
|
|
||||||
|
async def on_change() -> None:
|
||||||
|
nonlocal changes
|
||||||
|
changes += 1
|
||||||
|
|
||||||
|
client = httpx2.AsyncClient(transport=httpx2.MockTransport(lambda _r: httpx2.Response(200)))
|
||||||
|
service = PodcastFeedService(
|
||||||
|
_FakeLibrary(tmp_path), client=client, on_change=on_change, interval=0.01
|
||||||
|
)
|
||||||
|
|
||||||
|
await _run_briefly(service)
|
||||||
|
|
||||||
|
assert changes == 1
|
||||||
|
assert calls >= 2
|
||||||
|
assert client.is_closed
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_survives_a_failing_pass(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
async def fake_sync_all_shows(client: httpx2.AsyncClient, root: Path) -> bool:
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
if calls == 1:
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
return False
|
||||||
|
|
||||||
|
monkeypatch.setattr(podcasts_module, "sync_all_shows", fake_sync_all_shows)
|
||||||
|
|
||||||
|
async def on_change() -> None:
|
||||||
|
pytest.fail("on_change must not run when nothing changed")
|
||||||
|
|
||||||
|
client = httpx2.AsyncClient(transport=httpx2.MockTransport(lambda _r: httpx2.Response(200)))
|
||||||
|
service = PodcastFeedService(
|
||||||
|
_FakeLibrary(tmp_path), client=client, on_change=on_change, interval=0.01
|
||||||
|
)
|
||||||
|
|
||||||
|
await _run_briefly(service)
|
||||||
|
|
||||||
|
# The RuntimeError from the first pass must not have ended the loop.
|
||||||
|
assert calls >= 2
|
||||||
|
assert client.is_closed
|
||||||
Reference in New Issue
Block a user