| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369 |
- # -*- coding: utf-8 -*-
- """
- Enrichment-Orchestrator fuer TV-Programm-Metadaten.
- Routet Titel basierend auf Typ/Genre an den richtigen API-Client:
- - Anime → AniList (kostenlos, kein Key)
- - Filme → TMDB (Film-Suche)
- - Serien → TMDB (Serien-Suche) → TVDB (Fallback)
- Features:
- - Batch-Verarbeitung mit konfigurierbarer Pause
- - Resume-sicher ueber SyncState-Queue
- - On-Demand-Enrichment fuer Streaming-Abfragen
- - Max-Limit pro Sync-Durchlauf
- """
- from __future__ import annotations
- import asyncio
- import re
- from typing import TYPE_CHECKING
- from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
- from plugins.tv_program.enrichment.anilist import AniListClient
- from plugins.tv_program.enrichment.tmdb import TMDBClient
- from plugins.tv_program.enrichment.tvdb import TVDBClient
- if TYPE_CHECKING:
- from plugins.tv_program.epg.cache import EPGCache
- from plugins.tv_program.sync.sync_state import SyncState
- _SLUG_RE = re.compile(r"[^a-z0-9]+")
- def _normalize_series_id(title: str) -> str:
- """
- Erzeugt eine normalisierte series_id aus einem Titel.
- Gleiche Logik wie EPGCache._generate_series_id() —
- damit Cache-Lookup und Enrichment-Queue die gleichen IDs verwenden.
- Z.B. "Pirates of the Caribbean" → "pirates-of-the-caribbean"
- """
- if not title:
- return ""
- return _SLUG_RE.sub("-", title.strip().lower()).strip("-")
- class Enricher:
- """Orchestriert Enrichment ueber mehrere API-Quellen."""
- def __init__(self, config: dict, cache: EPGCache, sync_state: SyncState):
- """
- Initialisiert den Enricher mit API-Clients basierend auf Config.
- Args:
- config: Enrichment-Konfiguration aus dem Plugin
- cache: EPGCache-Instanz fuer Programmzugriff
- sync_state: SyncState-Instanz fuer Queue und Status
- """
- self._cache = cache
- self._sync_state = sync_state
- # Batch-Konfiguration
- self._batch_size: int = config.get("batch_size", 10)
- self._delay: float = config.get("delay_between_batches_seconds", 2)
- self._max_per_sync: int = config.get("max_enrichments_per_sync", 200)
- # API-Clients initialisieren (nur wenn aktiviert und Key vorhanden)
- anilist_cfg = config.get("anilist", {})
- self._anilist: AniListClient | None = (
- AniListClient() if anilist_cfg.get("enabled", True) else None
- )
- tmdb_cfg = config.get("tmdb", {})
- tmdb_key = tmdb_cfg.get("api_key", "")
- self._tmdb: TMDBClient | None = (
- TMDBClient(
- api_key=tmdb_key,
- language=tmdb_cfg.get("language", "de-DE"),
- )
- if tmdb_cfg.get("enabled", True) and tmdb_key
- else None
- )
- tvdb_cfg = config.get("tvdb", {})
- tvdb_key = tvdb_cfg.get("api_key", "")
- self._tvdb: TVDBClient | None = (
- TVDBClient(api_key=tvdb_key)
- if tvdb_cfg.get("enabled", False) and tvdb_key
- else None
- )
- # Log welche Quellen aktiv sind
- sources = []
- if self._anilist:
- sources.append("AniList")
- if self._tmdb:
- sources.append("TMDB")
- if self._tvdb:
- sources.append("TVDB")
- pinfo(f"[Enricher] Aktive Quellen: {', '.join(sources) or 'keine'}")
- async def enrich_pending(self) -> int:
- """
- Verarbeitet die Enrichment-Queue aus sync_state.
- Resume-sicher: Liest die Queue, enriched Titel einzeln und
- speichert den Fortschritt nach jedem erfolgreichen Enrichment.
- Batch-Verarbeitung: self._batch_size Titel werden verarbeitet,
- dann self._delay Sekunden Pause, um Rate-Limits zu vermeiden.
- Returns:
- Anzahl erfolgreich angereicherter Titel
- """
- queue = list(self._sync_state.enrichment_queue)
- if not queue:
- pdebug("[Enricher] Keine Titel in der Enrichment-Queue")
- return 0
- total = min(len(queue), self._max_per_sync)
- pinfo(f"[Enricher] Starte Enrichment fuer {total} von {len(queue)} Titeln")
- enriched_count = 0
- processed = 0
- for i, series_id in enumerate(queue[:total]):
- # Batch-Pause nach jeweils batch_size Titeln
- if processed > 0 and processed % self._batch_size == 0:
- pdebug(f"[Enricher] Batch-Pause ({self._delay}s) nach {processed} Titeln")
- await asyncio.sleep(self._delay)
- try:
- result = await self._enrich_title(series_id)
- if result:
- # Enrichment im SyncState speichern
- self._cache.update_enrichment(series_id, result)
- self._sync_state.mark_title_enriched(series_id, result.get("src", "unknown"))
- enriched_count += 1
- pdebug(f"[Enricher] [{i + 1}/{total}] '{series_id}' erfolgreich ({result.get('src', '?')})")
- else:
- # Titel als "versucht" markieren (leeres Dict), damit
- # er nicht bei jedem Sync erneut versucht wird
- self._sync_state.mark_title_enriched(series_id, "none")
- pdebug(f"[Enricher] [{i + 1}/{total}] '{series_id}' — kein Ergebnis")
- except Exception as exc:
- perror(f"[Enricher] Fehler bei '{series_id}': {exc}")
- # Bei Fehler nicht aus Queue entfernen → naechster Versuch
- continue
- processed += 1
- pinfo(f"[Enricher] Fertig: {enriched_count}/{processed} Titel erfolgreich enriched")
- return enriched_count
- def build_enrichment_queue(self, programs: list[dict]) -> list[str]:
- """
- Scannt Programme fuer neue, noch nicht enriched Titel.
- Generiert eine normalisierte series_id fuer jeden Titel und
- prueft gegen die bereits enriched Titel im SyncState.
- Neue Titel werden zur Queue hinzugefuegt.
- Args:
- programs: Liste von Programm-Dicts aus dem EPG-Cache
- Returns:
- Liste der neu zur Queue hinzugefuegten series_ids
- """
- new_ids: list[str] = []
- seen: set[str] = set()
- for program in programs:
- title = program.get("t", "")
- if not title:
- continue
- series_id = _normalize_series_id(title)
- if not series_id:
- continue
- # Bereits verarbeitet oder in Queue?
- if series_id in seen:
- continue
- if self._sync_state.is_enriched(series_id):
- continue
- if self._sync_state.is_in_enrichment_queue(series_id):
- continue
- seen.add(series_id)
- self._sync_state.add_to_enrichment_queue(series_id)
- new_ids.append(series_id)
- if new_ids:
- pdebug(f"[Enricher] {len(new_ids)} neue Titel zur Queue hinzugefuegt")
- return new_ids
- async def _enrich_title(self, series_id: str) -> dict | None:
- """
- Enriched einen einzelnen Titel ueber die passende API.
- Entscheidungsbaum:
- 1. Finde ein Programm mit dieser series_id im Cache
- 2. Ist es Anime? → AniList
- 3. Ist es ein Film? → TMDB search_movie
- 4. Ist es eine Serie? → TMDB search_tv → TVDB Fallback
- Args:
- series_id: Normalisierte Titel-ID
- Returns:
- Enrichment-Dict oder None
- """
- # Programm aus Cache finden fuer Kontext (Titel, Genre, Sender)
- program = self._find_program_for_series_id(series_id)
- if not program:
- pdebug(f"[Enricher] Kein Programm im Cache fuer '{series_id}'")
- return None
- title = program.get("t", "")
- genre = program.get("g", "")
- channel_id = program.get("ch", "")
- program_type = program.get("ty", "")
- if not title:
- return None
- # Entscheidungsbaum: Anime?
- if self._anilist and AniListClient.is_likely_anime(title, genre, channel_id):
- pdebug(f"[Enricher] '{title}' als Anime erkannt → AniList")
- result = await self._anilist.search(title)
- if result:
- return result
- # Film?
- if program_type == "film" or self._is_likely_movie(program):
- if self._tmdb:
- pdebug(f"[Enricher] '{title}' als Film erkannt → TMDB")
- # Erscheinungsjahr extrahieren falls vorhanden
- year = program.get("year")
- return await self._tmdb.search_movie(title, year)
- # Serie (Standard-Fallback)
- if self._tmdb:
- pdebug(f"[Enricher] '{title}' als Serie → TMDB")
- result = await self._tmdb.search_tv(title)
- if result:
- return result
- # TVDB als Fallback
- if self._tvdb:
- pdebug(f"[Enricher] '{title}' → TVDB Fallback")
- result = await self._tvdb.search(title)
- if result:
- return result
- return None
- async def enrich_on_demand(self, title: str) -> dict | None:
- """
- On-Demand Enrichment fuer Streaming-Abfragen.
- Wenn ein Nutzer "Wo kann ich X streamen?" fragt und der Titel
- noch nicht enriched ist, versuchen wir es sofort ueber alle
- verfuegbaren Quellen.
- Args:
- title: Titel der Sendung/des Films
- Returns:
- Enrichment-Dict oder None
- """
- if not title or not title.strip():
- return None
- series_id = _normalize_series_id(title)
- # Schon enriched? Direkt zurueckgeben
- existing = self._cache.get_enrichment(series_id)
- if existing and existing.get("src") != "none":
- pdebug(f"[Enricher] On-Demand: '{title}' bereits enriched")
- return existing
- pdebug(f"[Enricher] On-Demand Enrichment fuer '{title}'")
- # Alle Quellen durchprobieren
- # 1. AniList (kein Key noetig)
- if self._anilist:
- result = await self._anilist.search(title)
- if result:
- self._cache.update_enrichment(series_id, result)
- self._sync_state.mark_title_enriched(series_id, result.get("src", "unknown"))
- return result
- # 2. TMDB Film
- if self._tmdb:
- result = await self._tmdb.search_movie(title)
- if result and result.get("rt") and result["rt"] > 0:
- self._cache.update_enrichment(series_id, result)
- self._sync_state.mark_title_enriched(series_id, result.get("src", "unknown"))
- return result
- # 3. TMDB Serie
- if self._tmdb:
- result = await self._tmdb.search_tv(title)
- if result:
- self._cache.update_enrichment(series_id, result)
- self._sync_state.mark_title_enriched(series_id, result.get("src", "unknown"))
- return result
- # 4. TVDB Fallback
- if self._tvdb:
- result = await self._tvdb.search(title)
- if result:
- self._cache.update_enrichment(series_id, result)
- self._sync_state.mark_title_enriched(series_id, result.get("src", "unknown"))
- return result
- pdebug(f"[Enricher] On-Demand: Kein Ergebnis fuer '{title}'")
- return None
- def _find_program_for_series_id(self, series_id: str) -> dict | None:
- """
- Findet ein Programm im Cache anhand der series_id.
- Nutzt den _by_series_id Index des Cache fuer O(1) Lookup.
- Args:
- series_id: Normalisierte Titel-ID
- Returns:
- Erstes passendes Programm-Dict oder None
- """
- return self._cache.get_program_by_series_id(series_id)
- @staticmethod
- def _is_likely_movie(program: dict) -> bool:
- """
- Heuristik: Ist ein EPG-Eintrag wahrscheinlich ein Film?
- Prueft:
- - Genre enthaelt "Film", "Spielfilm", "Kinofilm"
- - Programm-Typ ist "film"
- - Dauer > 70 Minuten (typisch fuer Spielfilme)
- Args:
- program: Programm-Dict aus dem EPG-Cache
- Returns:
- True wenn der Eintrag wahrscheinlich ein Film ist
- """
- genre = (program.get("g", "") or "").lower()
- if any(kw in genre for kw in ("spielfilm", "kinofilm", "film", "movie")):
- return True
- # Dauer-Check: Programme > 70 Min sind oft Filme
- duration = program.get("dur", 0)
- if isinstance(duration, (int, float)) and duration > 70:
- # Aber nur wenn Genre nicht auf Serie hindeutet
- if "serie" not in genre:
- return True
- return False
|