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:
2026-09-10 22:43:50 +02:00
parent 2e0e6ad199
commit 57afc32f4a
9 changed files with 652 additions and 8 deletions

View 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()

View File

@@ -100,14 +100,8 @@ def build_router(
@router.post("/library/refresh", status_code=202)
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.
asyncio.create_task(rescan()) # noqa: RUF006
asyncio.create_task(app.rescan_library(broadcast=hub.broadcast_library)) # noqa: RUF006
return Response(status_code=202)
# --------------------------------------------------------------------- state