Backend: - Matching-Orchestrator mit deutschen Serien-Patterns (drei ???, TKKG, ...) - Vollständige MusicBrainz-Integration (Tracklist → Kapitel, Cover Art Archive) - OpenLibrary + Google Books als Fallback-Quellen - Auto-Accept (≥0.75) vs zu_prüfen (0.5-0.75) vs kein Match - Manuelles Matching: GET /api/items/:id/match/search, POST apply - RSS-Feed-Manager: feedparser, iTunes Search, periodisches Update - APScheduler für Podcast-Feed-Updates (konfigurierbares Intervall) - Podcast-Router: Feed-URL setzen, Episoden, Feed-Suche - HLS: FFmpeg läuft als Background-Task, wartet auf ersten Segment - main.py: APScheduler + neue Router eingebunden Frontend (React + Vite + Tailwind + HLS.js): - Login-Seite mit Fehlerbehandlung - Library-Seite: Grid/Listen-Ansicht, Suche, Tag-Filter, Pagination, Scan - BookCard: Cover, Fortschrittsbalken, zu_prüfen Badge, Quick-Play - BookDetail: Metadaten, Matching-Panel, Kapitel-Liste, Lesezeichen - AudioPlayer: HLS.js, Kapitel-Marker auf Fortschrittsbalken, Speed, Sleep-Timer, Lesezeichen, Keyboard-Shortcuts (Space/Arrows) - MiniPlayer: persistent an Fußzeile, expandierbar - PodcastDetail: Feed-URL, iTunes-Suche, Episoden-Liste - Admin-Panel: Benutzer/Bibliotheken/Einstellungen verwalten - App.tsx: React Router, Auth-Guard, Player-Overlay Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
187 lines
6.9 KiB
Python
187 lines
6.9 KiB
Python
"""
|
|
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}")
|