""" Podcast-Feed-Manager: - RSS-Feed parsen - Episoden mit lokalen Dateien abgleichen - Periodisches Update """ import os import re import logging import httpx import feedparser from datetime import datetime from difflib import SequenceMatcher from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from ..database import AsyncSessionLocal from ..models.library import Library from ..models.media_item import LibraryItem from ..models.podcast import Podcast, PodcastEpisode from ..services.matcher import _download_cover logger = logging.getLogger(__name__) def _similarity(a: str, b: str) -> float: if not a or not b: return 0.0 return SequenceMatcher(None, a.lower(), b.lower()).ratio() def _parse_duration(s: str | None) -> float: """Parst "HH:MM:SS" oder "MM:SS" oder reine Sekunden.""" if not s: return 0.0 s = s.strip() try: if ":" in s: parts = s.split(":") if len(parts) == 3: return int(parts[0]) * 3600 + int(parts[1]) * 60 + float(parts[2]) elif len(parts) == 2: return int(parts[0]) * 60 + float(parts[1]) return float(s) except (ValueError, IndexError): return 0.0 async def search_podcast_feeds(query: str) -> list[dict]: """Sucht Podcast-Feeds über iTunes Search API.""" results = [] try: async with httpx.AsyncClient(timeout=12) as client: r = await client.get( "https://itunes.apple.com/search", params={"term": query, "media": "podcast", "limit": 10, "country": "de"}, ) r.raise_for_status() data = r.json() for item in data.get("results", []): results.append({ "title": item.get("collectionName", ""), "author": item.get("artistName", ""), "feedUrl": item.get("feedUrl", ""), "artworkUrl": item.get("artworkUrl600") or item.get("artworkUrl100", ""), "trackCount": item.get("trackCount", 0), "itunesId": item.get("collectionId"), }) except Exception as e: logger.warning(f"iTunes-Suche fehlgeschlagen: {e}") return results async def fetch_and_update_feed(library_item_id: str): """ Holt RSS-Feed und aktualisiert Metadaten + Episoden in der DB. """ async with AsyncSessionLocal() as db: item_result = await db.execute(select(LibraryItem).where(LibraryItem.id == library_item_id)) item = item_result.scalar_one_or_none() if not item: return podcast_result = await db.execute(select(Podcast).where(Podcast.library_item_id == library_item_id)) podcast = podcast_result.scalar_one_or_none() if not podcast or not podcast.feed_url: logger.warning(f"Kein Feed für Item {library_item_id}") return try: async with httpx.AsyncClient(timeout=20, follow_redirects=True) as client: r = await client.get(podcast.feed_url) r.raise_for_status() raw = r.text except Exception as e: logger.error(f"Feed-Abruf fehlgeschlagen ({podcast.feed_url}): {e}") return feed = feedparser.parse(raw) channel = feed.feed # Podcast-Metadaten aktualisieren if channel.get("title") and not item.title: item.title = channel.get("title") if channel.get("author") and not item.author: item.author = channel.get("author") if channel.get("summary") and not item.description: item.description = channel.get("summary") if channel.get("language") and not item.language: item.language = channel.get("language") # Cover cover_url = None if channel.get("image"): cover_url = channel.image.get("href") or channel.image.get("url") if cover_url and not item.cover_path: cover_path = await _download_cover(cover_url, item.id) if cover_path: item.cover_path = cover_path podcast.feed_last_checked = datetime.utcnow() # Lokale Episode-Dateien holen episodes_result = await db.execute( select(PodcastEpisode).where(PodcastEpisode.podcast_id == podcast.id) ) existing_episodes = {ep.feed_episode_id: ep for ep in episodes_result.scalars().all()} # Feed-Einträge verarbeiten for entry in feed.entries: feed_ep_id = entry.get("id") or entry.get("link", "") title = entry.get("title", "") description = entry.get("summary") or entry.get("content", [{}])[0].get("value", "") if entry.get("content") else "" pub_date = None if entry.get("published_parsed"): import time pub_date = datetime(*entry.published_parsed[:6]) enclosure_url = None duration_s = 0.0 for enc in entry.get("enclosures", []): if enc.get("type", "").startswith("audio/"): enclosure_url = enc.get("href") or enc.get("url") break duration_s = _parse_duration(entry.get("itunes_duration")) ep_num = entry.get("itunes_episode") season_num = entry.get("itunes_season") if feed_ep_id in existing_episodes: # Vorhandene Episode aktualisieren ep = existing_episodes[feed_ep_id] ep.title = title ep.description = description ep.feed_episode_url = enclosure_url ep.duration_seconds = duration_s or ep.duration_seconds else: # Neue Episode anlegen ep = PodcastEpisode( podcast_id=podcast.id, title=title, description=description, pub_date=pub_date, duration_seconds=duration_s, feed_episode_id=feed_ep_id, feed_episode_url=enclosure_url, episode_number=str(ep_num) if ep_num else None, season_number=str(season_num) if season_num else None, ) db.add(ep) item.updated_at = datetime.utcnow() await db.commit() logger.info(f"Feed aktualisiert: {item.title} ({len(feed.entries)} Einträge)") async def update_all_feeds(): """Aktualisiert alle Podcast-Feeds (wird vom Scheduler aufgerufen).""" async with AsyncSessionLocal() as db: result = await db.execute(select(Podcast).where(Podcast.feed_url.isnot(None))) podcasts = result.scalars().all() for podcast in podcasts: try: await fetch_and_update_feed(podcast.library_item_id) except Exception as e: logger.error(f"Feed-Update fehlgeschlagen für {podcast.id}: {e}")