A show that has published for years is unbounded: GEOlino Spezial alone is 358 episodes and 5.8 GB, and the device it syncs onto is a 30 GB SD card that also holds the rest of the library. Nothing stopped the 6-hourly poll from eventually filling it. Cap each show's folder at general.podcast_episode_limit (default 50, null to keep everything), pruning the oldest past that after each sync pass. The same limit caps what is downloaded, and it has to be one number for both. Prune to the newest N but keep fetching everything the feed offers, and every poll would re-download exactly the episodes the previous one deleted - forever, at full size, since missing_episodes() decides purely from what is on disk. There is a test for that specific loop. Pruning only touches files named the way this module names them (YYYYMMDD - Title.ext), so feed.txt, folder.jpg, the failed-download record and anything placed by hand are all left alone; a parse that fails means "not ours", not "delete it". An episode's sidecar cover goes with it. A pass that only deleted still reports a change, because the library needs the rescan just as much as it does after a download. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
64 lines
2.2 KiB
Python
64 lines
2.2 KiB
Python
"""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 DEFAULT_EPISODE_LIMIT, 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,
|
|
episode_limit: int | None = DEFAULT_EPISODE_LIMIT,
|
|
) -> None:
|
|
self.library = library
|
|
self.client = client
|
|
self.on_change = on_change
|
|
self.interval = interval
|
|
self.episode_limit = episode_limit
|
|
|
|
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:
|
|
keep = self.episode_limit
|
|
if await sync_all_shows(self.client, self.library.root, keep=keep):
|
|
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()
|