main.py 60 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536
  1. # -*- coding: utf-8 -*-
  2. """
  3. TV-Programm Plugin fuer Trixy.
  4. Beantwortet Fragen zum TV-Programm per Sprache:
  5. - "Was kommt um 20:15 auf RTL2?"
  6. - "Wann kommt die naechste Folge Naruto?"
  7. - "Gibt es diese Woche Ecchi-Animes?"
  8. - "Wo kann ich Naruto streamen?"
  9. Datenquellen:
  10. - EPG: XMLTV via iptv-org/epg (hoerzu.de)
  11. - Anime-Tags: AniList GraphQL (kostenlos, kein Key)
  12. - Filme/Serien: TMDB API (kostenloser Key)
  13. - Fallback: TVDB API (kostenloser Key)
  14. Sync laeuft im Hintergrund, resume-faehig bei Neustarts.
  15. """
  16. from __future__ import annotations
  17. import asyncio
  18. import re
  19. from datetime import datetime, date, timedelta
  20. from pathlib import Path
  21. from typing import TYPE_CHECKING
  22. from trixy_core.plugins import TrixyPlugin
  23. from trixy_core.nlp.intent_registry import IntentRegistry
  24. from trixy_core.nlp.decorators import intent, pattern, example
  25. from trixy_core.nlp.handler import IntentReceivedData, IntentResult
  26. from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
  27. from plugins.tv_program.epg.channels import ChannelMapper
  28. from plugins.tv_program.epg.collector import EPGCollector
  29. from plugins.tv_program.epg.cache import EPGCache
  30. from plugins.tv_program.sync.sync_state import SyncState
  31. from plugins.tv_program.enrichment.enricher import Enricher
  32. class TVProgramPlugin(TrixyPlugin):
  33. """TV-Programm Abfragen per Sprache."""
  34. NAME = "tv_program"
  35. VERSION = "1.1.0"
  36. DESCRIPTION = "TV-Programm, Anime-Tags, Streaming-Infos per Sprache"
  37. AUTHOR = "Trixy"
  38. def __init__(self, application, plugin_path, config=None) -> None:
  39. super().__init__(application, plugin_path, config)
  40. self._registry = IntentRegistry.get_instance()
  41. self._cache: EPGCache | None = None
  42. self._channel_mapper: ChannelMapper | None = None
  43. self._sync_state: SyncState | None = None
  44. self._enricher: Enricher | None = None
  45. self._sync_task: asyncio.Task | None = None
  46. self._reminder_task: asyncio.Task | None = None
  47. self._epg_job_id: str = ""
  48. self._enrichment_job_id: str = ""
  49. self._stt_aliases: dict[str, str] = {} # Vosk-Fehlerkennungen korrigieren
  50. # ======================================================================
  51. # Lifecycle
  52. # ======================================================================
  53. async def on_load(self) -> None:
  54. """Initialisiert Cache, Channels und startet Background-Sync."""
  55. pinfo(f"TVProgramPlugin: Lade v{self.VERSION}")
  56. cache_dir = Path(self.get_config_value("cache_dir", "cache/tv_program"))
  57. cache_dir.mkdir(parents=True, exist_ok=True)
  58. # Channel-Mapper (Whitelist hat Vorrang, Blacklist schliesst aus)
  59. whitelist = self.get_config_value("channels.whitelist", [])
  60. blacklist = self.get_config_value("channels.blacklist", [])
  61. self._channel_mapper = ChannelMapper(
  62. channel_filter=whitelist or None,
  63. blacklist=blacklist or None,
  64. custom_aliases=self.get_config_value("channels.custom_aliases", {}),
  65. )
  66. # EPG-Cache laden (schnell, ~50ms fuer 3MB JSON)
  67. self._cache = EPGCache(cache_dir)
  68. loaded = self._cache.load()
  69. stats = self._cache.get_stats()
  70. if loaded:
  71. pinfo(
  72. f"TVProgramPlugin: Cache geladen — "
  73. f"{stats['program_count']} Sendungen, "
  74. f"{stats['channel_count']} Sender, "
  75. f"{stats['enriched_count']} angereichert"
  76. )
  77. else:
  78. pdebug("TVProgramPlugin: Kein Cache vorhanden — erster Sync noetig")
  79. # Sync-State
  80. self._sync_state = SyncState(cache_dir)
  81. self._sync_state.load()
  82. # Enricher
  83. self._enricher = Enricher(
  84. config=self.get_config_value("enrichment", {}),
  85. cache=self._cache,
  86. sync_state=self._sync_state,
  87. )
  88. # Initialen Sync starten (NICHT blockierend)
  89. self._sync_task = asyncio.create_task(self._initial_sync())
  90. # Scheduler-Jobs registrieren
  91. self._register_scheduler_jobs()
  92. # Reminder-Task starten
  93. if self.get_config_value("reminders.enabled", True):
  94. self._reminder_task = asyncio.create_task(self._reminder_check_loop())
  95. # STT-Aliases laden (Vosk-Fehlerkennungen fuer Anime/TV-Begriffe)
  96. self._load_stt_aliases()
  97. # Slot-Listen fuer Intent-Training registrieren
  98. self._register_slot_lists()
  99. pinfo(f"TVProgramPlugin: Geladen v{self.VERSION}")
  100. async def on_unload(self) -> None:
  101. """Stoppt Background-Tasks, Scheduler-Jobs und deregistriert Intents."""
  102. for task in (self._sync_task, self._reminder_task):
  103. if task and not task.done():
  104. task.cancel()
  105. try:
  106. await task
  107. except asyncio.CancelledError:
  108. pass
  109. # Scheduler-Jobs entfernen
  110. self._remove_scheduler_jobs()
  111. self._registry.unregister_plugin(self.NAME)
  112. pdebug("TVProgramPlugin: Entladen")
  113. # ======================================================================
  114. # Background-Sync (Resume-Faehig)
  115. # ======================================================================
  116. async def _initial_sync(self) -> None:
  117. """Initialer Sync beim Server-Start (einmalig, non-blocking)."""
  118. await asyncio.sleep(5) # Server fertig starten lassen
  119. try:
  120. await self._run_sync()
  121. except asyncio.CancelledError:
  122. raise
  123. except Exception as e:
  124. perror(f"TVProgramPlugin: Sync-Fehler: {e}")
  125. # ======================================================================
  126. # Scheduler-Jobs
  127. # ======================================================================
  128. def _register_scheduler_jobs(self) -> None:
  129. """
  130. Registriert Scheduler-Jobs fuer EPG-Fetch und Enrichment.
  131. - EPG-Fetch: Taeglich um konfigurierte Uhrzeit (CronTrigger)
  132. - Enrichment: Stuendlich wenn Queue > 0 (IntervalTrigger)
  133. """
  134. scheduler = self.application.services.get_service("SchedulerService")
  135. if not scheduler:
  136. pdebug("TVProgramPlugin: SchedulerService nicht verfuegbar — keine Jobs registriert")
  137. return
  138. try:
  139. from trixy_core.scheduler.trigger.cron import CronTrigger
  140. from trixy_core.scheduler.trigger.interval import IntervalTrigger
  141. from trixy_core.scheduler.action.callback import CallbackAction
  142. # Job 1: Taeglicher EPG-Fetch
  143. sync_hour = self.get_config_value("epg.sync_hour", 6)
  144. sync_minute = self.get_config_value("epg.sync_minute", 0)
  145. epg_job = scheduler.create_job(
  146. trigger=CronTrigger(f"{sync_minute} {sync_hour} * * *"),
  147. actions=CallbackAction(self._scheduled_epg_sync),
  148. job_id="tv_program_epg_sync",
  149. save=False,
  150. )
  151. self._epg_job_id = epg_job.job_id
  152. pdebug(
  153. f"TVProgramPlugin: EPG-Sync Job registriert "
  154. f"(taeglich um {sync_hour:02d}:{sync_minute:02d})"
  155. )
  156. # Job 2: Stuendliches Enrichment (fuer Rueckstand-Aufholung)
  157. enrichment_job = scheduler.create_job(
  158. trigger=IntervalTrigger(hours=1),
  159. actions=CallbackAction(self._scheduled_enrichment),
  160. job_id="tv_program_enrichment",
  161. save=False,
  162. )
  163. self._enrichment_job_id = enrichment_job.job_id
  164. pdebug("TVProgramPlugin: Enrichment Job registriert (stuendlich)")
  165. except Exception as e:
  166. perror(f"TVProgramPlugin: Scheduler-Jobs konnten nicht registriert werden: {e}")
  167. def _remove_scheduler_jobs(self) -> None:
  168. """Entfernt die registrierten Scheduler-Jobs."""
  169. scheduler = self.application.services.get_service("SchedulerService")
  170. if not scheduler:
  171. return
  172. for job_id in (self._epg_job_id, self._enrichment_job_id):
  173. if job_id:
  174. try:
  175. scheduler.remove_job(job_id)
  176. except Exception:
  177. pass
  178. async def _scheduled_epg_sync(self, *args, **kwargs) -> None:
  179. """Vom Scheduler aufgerufener EPG-Sync (taeglich)."""
  180. pinfo("TVProgramPlugin: Geplanter EPG-Sync gestartet")
  181. try:
  182. await self._run_sync()
  183. except Exception as e:
  184. perror(f"TVProgramPlugin: Geplanter Sync fehlgeschlagen: {e}")
  185. async def _scheduled_enrichment(self, *args, **kwargs) -> None:
  186. """
  187. Vom Scheduler aufgerufenes Enrichment (stuendlich).
  188. Ueberspringt wenn Queue leer — kein unnuetiger Overhead.
  189. """
  190. if not self._sync_state or not self._sync_state.needs_enrichment():
  191. return # Queue leer — nichts zu tun
  192. queue_len = len(self._sync_state.enrichment_queue)
  193. pinfo(f"TVProgramPlugin: Geplantes Enrichment ({queue_len} ausstehend)")
  194. try:
  195. enriched = await self._enricher.enrich_pending()
  196. if enriched > 0:
  197. await self._cache.save_async()
  198. self._sync_state.mark_enrichment_done()
  199. remaining = len(self._sync_state.enrichment_queue)
  200. pinfo(
  201. f"TVProgramPlugin: Enrichment fertig — "
  202. f"{enriched} angereichert, {remaining} verbleibend"
  203. )
  204. except Exception as e:
  205. perror(f"TVProgramPlugin: Geplantes Enrichment fehlgeschlagen: {e}")
  206. async def _run_sync(self) -> None:
  207. """
  208. Vollstaendiger Sync-Zyklus mit Resume.
  209. 1. EPG-Daten frisch genug? → Sonst XMLTV downloaden + parsen
  210. 2. Enrichment-Queue abarbeiten (resume-faehig)
  211. """
  212. # Schritt 1: EPG-Fetch noetig?
  213. if self._sync_state.needs_epg_fetch():
  214. pinfo("TVProgramPlugin: Starte EPG-Sync...")
  215. collector = EPGCollector(
  216. sources=self.get_config_value("epg.sources", []),
  217. cache_dir=Path(self.get_config_value("cache_dir", "cache/tv_program")),
  218. channel_mapper=self._channel_mapper,
  219. )
  220. programs = await collector.fetch_all()
  221. if programs:
  222. new_count = self._cache.update_programs(programs)
  223. pruned = self._cache.prune_old(
  224. self.get_config_value("epg.cleanup_after_days", 7)
  225. )
  226. await self._cache.save_async()
  227. self._sync_state.mark_epg_fetched(
  228. retention_days=self.get_config_value("epg.retention_days", 14),
  229. min_interval_hours=self.get_config_value("epg.min_sync_interval_hours", 20),
  230. )
  231. pinfo(
  232. f"TVProgramPlugin: EPG-Sync fertig — "
  233. f"{new_count} neu, {pruned} entfernt, "
  234. f"{len(programs)} gesamt"
  235. )
  236. # Enrichment-Queue fuer neue Titel bauen
  237. if self.get_config_value("enrichment.enabled", True):
  238. self._enricher.build_enrichment_queue(programs)
  239. self._sync_state.save()
  240. else:
  241. pwarn("TVProgramPlugin: Keine EPG-Daten empfangen")
  242. else:
  243. pdebug("TVProgramPlugin: EPG-Daten noch aktuell, Sync uebersprungen")
  244. # Schritt 2: Initiales Enrichment (beim Start einmal, danach via Scheduler)
  245. if self._sync_state.needs_enrichment():
  246. queue_len = len(self._sync_state.enrichment_queue)
  247. pinfo(f"TVProgramPlugin: Starte initiales Enrichment ({queue_len} ausstehend)...")
  248. enriched = await self._enricher.enrich_pending()
  249. if enriched > 0:
  250. await self._cache.save_async()
  251. self._sync_state.mark_enrichment_done()
  252. remaining = len(self._sync_state.enrichment_queue)
  253. pinfo(
  254. f"TVProgramPlugin: Enrichment fertig — "
  255. f"{enriched} angereichert, {remaining} verbleibend"
  256. )
  257. if remaining > 0:
  258. pinfo(
  259. f"TVProgramPlugin: Restliche {remaining} Titel werden "
  260. f"stuendlich via Scheduler nachgeladen"
  261. )
  262. # ======================================================================
  263. # STT-Korrektur (Plugin-spezifisch)
  264. # ======================================================================
  265. def _load_stt_aliases(self) -> None:
  266. """
  267. Laedt STT-Aliases aus stt_aliases.json.
  268. Diese Korrekturen werden NUR auf Slots in TV-Intents angewendet,
  269. nicht global auf alle STT-Ergebnisse. So wird "nach utopie" nur
  270. in TV-Kontexten zu "naruto" korrigiert.
  271. """
  272. import json as _json
  273. alias_file = Path(self.plugin_path) / "stt_aliases.json"
  274. if not alias_file.is_file():
  275. return
  276. try:
  277. with open(alias_file, encoding="utf-8") as f:
  278. data = _json.load(f)
  279. for key, value in data.items():
  280. if not key.startswith("_"): # _comment ueberspringen
  281. self._stt_aliases[key.lower()] = value
  282. if self._stt_aliases:
  283. pdebug(f"TVProgramPlugin: {len(self._stt_aliases)} STT-Aliases geladen")
  284. except Exception as e:
  285. pdebug(f"TVProgramPlugin: STT-Aliases Fehler: {e}")
  286. def _correct_query(self, text: str) -> str:
  287. """
  288. Korrigiert bekannte Vosk-Fehlerkennungen in Slot-Werten.
  289. Wird auf query/channel/genre Slots angewendet NACHDEM der
  290. Intent erkannt wurde — nur in TV-Plugin Handlern.
  291. Beispiele:
  292. "nach utopie" → "naruto"
  293. "etc anime" → "ecchi anime"
  294. "etchi" → "ecchi"
  295. """
  296. if not self._stt_aliases or not text:
  297. return text
  298. result = text.lower()
  299. # Mehrwort-Phrasen zuerst (laengere zuerst)
  300. for phrase, replacement in sorted(
  301. self._stt_aliases.items(), key=lambda x: len(x[0]), reverse=True,
  302. ):
  303. if " " in phrase and phrase in result:
  304. result = result.replace(phrase, replacement)
  305. # Einzelwoerter
  306. words = result.split()
  307. for i, word in enumerate(words):
  308. if word in self._stt_aliases:
  309. words[i] = self._stt_aliases[word]
  310. corrected = " ".join(words)
  311. # Fuellwoerter am Anfang entfernen die nicht zum Sendungstitel gehoeren
  312. filler_prefixes = [
  313. "die nächste folge von ", "die naechste folge von ",
  314. "der nächste folge von ", "der naechste folge von ",
  315. "nächste folge von ", "naechste folge von ",
  316. "die nächste folge ", "die naechste folge ",
  317. "der nächste folge ", "der naechste folge ",
  318. "nächste folge ", "naechste folge ",
  319. "der nächste ", "die nächste ", "das nächste ",
  320. "der naechste ", "die naechste ", "das naechste ",
  321. "den nächsten ", "die nächsten ", "den naechsten ",
  322. "nächste ", "naechste ",
  323. "nächsten ", "naechsten ",
  324. "folge von ", "folge ",
  325. ]
  326. for prefix in filler_prefixes:
  327. if corrected.startswith(prefix):
  328. corrected = corrected[len(prefix):]
  329. break
  330. corrected = corrected.strip()
  331. if corrected != text.lower().strip():
  332. pdebug(f"TVProgramPlugin: STT-Korrektur '{text}' → '{corrected}'")
  333. return corrected
  334. # ======================================================================
  335. # Slot-Listen fuer Intent-Training
  336. # ======================================================================
  337. def _register_slot_lists(self) -> None:
  338. """
  339. Registriert Slot-Listen fuer das Intent-Training.
  340. Der IntentTrainer laedt slot_lists.json automatisch aus jedem
  341. Plugin-Verzeichnis (trixy_core/trainer Erweiterung). Hier
  342. aktualisieren wir die JSON-Datei dynamisch mit den aktuellen
  343. Sendernamen aus CHANNELS — so sind die Slot-Listen immer
  344. synchron mit der Sender-Definition.
  345. Ergebnis: Der Trainer generiert Trainingsbeispiele wie:
  346. "was kommt auf ProSieben" → tv_whats_on, channel="ProSieben"
  347. "gibt es ecchi animes" → tv_genre_search, genre="Ecchi"
  348. """
  349. import json as _json
  350. slot_file = Path(self.plugin_path) / "slot_lists.json"
  351. if not slot_file.is_file():
  352. return
  353. try:
  354. # Aktuelle Sendernamen aus CHANNELS extrahieren
  355. from plugins.tv_program.epg.channels import CHANNELS
  356. channel_values = set()
  357. for info in CHANNELS.values():
  358. channel_values.add(info["name"])
  359. for alias in info.get("aliases", []):
  360. channel_values.add(alias)
  361. # slot_lists.json aktualisieren (channel-Werte synchronisieren)
  362. with open(slot_file, "r", encoding="utf-8") as f:
  363. data = _json.load(f)
  364. if "channel" in data:
  365. existing = set(data["channel"].get("values", []))
  366. if channel_values != existing:
  367. data["channel"]["values"] = sorted(channel_values)
  368. with open(slot_file, "w", encoding="utf-8") as f:
  369. _json.dump(data, f, indent=4, ensure_ascii=False)
  370. pdebug(
  371. f"TVProgramPlugin: slot_lists.json aktualisiert — "
  372. f"{len(channel_values)} Sender-Aliase"
  373. )
  374. except Exception as e:
  375. pdebug(f"TVProgramPlugin: Slot-Listen Aktualisierung fehlgeschlagen: {e}")
  376. # ======================================================================
  377. # Konversations-Kontext
  378. # ======================================================================
  379. def _build_context(
  380. self,
  381. data: IntentReceivedData,
  382. program: dict | None = None,
  383. **extra,
  384. ) -> dict:
  385. """
  386. Baut Follow-Up-Kontext aus aktueller Abfrage.
  387. Der Kontext wird bei follow_up() als `data` mitgegeben und
  388. beim naechsten Turn als Slots an den tv_follow_up Handler
  389. weitergereicht. So weiss der Follow-Up-Handler was zuletzt
  390. gefragt wurde.
  391. """
  392. ctx = {
  393. "ctx_time": extra.get("time_iso", ""),
  394. "ctx_channel": extra.get("channel_id", ""),
  395. "ctx_date": extra.get("date_iso", ""),
  396. "ctx_intent": data.intent,
  397. }
  398. if program:
  399. ctx["ctx_program"] = program.get("t", "")
  400. ctx["ctx_program_sid"] = program.get("sid", "")
  401. ctx["ctx_program_ch"] = program.get("ch", "")
  402. ctx["ctx_program_start"] = program.get("s", "")
  403. return ctx
  404. def _respond_with_followup(self, text: str, ctx: dict) -> IntentResult:
  405. """Antwortet und haelt die Konversation offen fuer Nachfragen."""
  406. return IntentResult.follow_up(
  407. response_text=text,
  408. follow_up_intent="tv_follow_up",
  409. **ctx,
  410. )
  411. # ======================================================================
  412. # Follow-Up Handler
  413. # ======================================================================
  414. @intent("tv_follow_up", description="TV-Programm Nachfrage in laufender Konversation")
  415. async def handle_follow_up(self, data: IntentReceivedData) -> IntentResult:
  416. """
  417. Verarbeitet Nachfragen im Konversations-Kontext.
  418. Erkennt Muster wie:
  419. - "Und auf RTL2?" → gleiche Zeit, anderer Sender
  420. - "Und morgen?" → gleicher Sender, anderer Tag
  421. - "Und um 21 Uhr?" → gleicher Sender, andere Zeit
  422. - "Erinnere mich daran" → Reminder fuer letztes Programm
  423. - "Wo kann ich das streamen?" → Streaming fuer letztes Programm
  424. - "Was ist das?" / "Erzaehl mir mehr" → Info zum letzten Programm
  425. """
  426. if not self._cache:
  427. return self._no_data()
  428. text = (data.original_text or "").strip().lower()
  429. ctx_time = data.get_slot("ctx_time", "")
  430. ctx_channel = data.get_slot("ctx_channel", "")
  431. ctx_date = data.get_slot("ctx_date", "")
  432. ctx_program = data.get_slot("ctx_program", "")
  433. ctx_program_sid = data.get_slot("ctx_program_sid", "")
  434. ctx_program_ch = data.get_slot("ctx_program_ch", "")
  435. ctx_intent = data.get_slot("ctx_intent", "")
  436. # --- "Erinnere mich daran" / "Merke dir das" ---
  437. if self._matches_reminder(text):
  438. if ctx_program:
  439. data.slots["query"] = ctx_program
  440. return await self.handle_set_reminder(data, query=ctx_program)
  441. return IntentResult.success_with_response(
  442. "Ich weiss leider nicht mehr an was ich dich erinnern soll."
  443. )
  444. # --- "Wo kann ich das streamen?" / "Kann ich das streamen?" ---
  445. if self._matches_streaming(text):
  446. query = ctx_program or ""
  447. if query:
  448. data.slots["query"] = query
  449. return await self.handle_streaming(data, query=query)
  450. return IntentResult.success_with_response(
  451. "Welche Sendung meinst du?"
  452. )
  453. # --- "Was ist das?" / "Erzaehl mir mehr" / "Info" ---
  454. if self._matches_info(text):
  455. query = ctx_program or ""
  456. if query:
  457. data.slots["query"] = query
  458. return await self.handle_show_info(data, query=query)
  459. return IntentResult.success_with_response("Worueber moechtest du mehr erfahren?")
  460. # --- "Und auf RTL2?" / "Und auf ProSieben?" → Sender-Wechsel ---
  461. new_channel = self._extract_channel_from_text(text)
  462. if new_channel:
  463. target_time = self._time_from_context(ctx_time, ctx_date)
  464. program = self._cache.get_program_at(new_channel, target_time)
  465. ctx = self._build_context(
  466. data, program,
  467. channel_id=new_channel,
  468. time_iso=ctx_time,
  469. date_iso=ctx_date,
  470. )
  471. if not program:
  472. ch_name = self._channel_mapper.get_display_name(new_channel)
  473. return self._respond_with_followup(
  474. f"Um {target_time.strftime('%H:%M')} laeuft nichts auf {ch_name}.",
  475. ctx,
  476. )
  477. return self._respond_with_followup(self._format_program(program), ctx)
  478. # --- "Und morgen?" / "Und uebermorgen?" → Datums-Wechsel ---
  479. new_date = self._extract_date_from_text(text)
  480. if new_date and ctx_channel:
  481. target_time = self._time_from_context(ctx_time, new_date.isoformat())
  482. target_time = target_time.replace(
  483. year=new_date.year, month=new_date.month, day=new_date.day
  484. )
  485. program = self._cache.get_program_at(ctx_channel, target_time)
  486. ctx = self._build_context(
  487. data, program,
  488. channel_id=ctx_channel,
  489. time_iso=ctx_time,
  490. date_iso=new_date.isoformat(),
  491. )
  492. if not program:
  493. ch_name = self._channel_mapper.get_display_name(ctx_channel)
  494. date_label = self._format_date_tts(target_time.isoformat())
  495. return self._respond_with_followup(
  496. f"{date_label} um {target_time.strftime('%H:%M')} laeuft nichts auf {ch_name}.",
  497. ctx,
  498. )
  499. return self._respond_with_followup(self._format_program(program), ctx)
  500. # --- "Und um 21 Uhr?" → Zeit-Wechsel ---
  501. new_time = self._extract_time_from_text(text)
  502. if new_time is not None and ctx_channel:
  503. target_date = date.fromisoformat(ctx_date) if ctx_date else date.today()
  504. target_time = datetime(
  505. target_date.year, target_date.month, target_date.day,
  506. new_time[0], new_time[1],
  507. )
  508. program = self._cache.get_program_at(ctx_channel, target_time)
  509. ctx = self._build_context(
  510. data, program,
  511. channel_id=ctx_channel,
  512. time_iso=target_time.isoformat(),
  513. date_iso=ctx_date,
  514. )
  515. if not program:
  516. ch_name = self._channel_mapper.get_display_name(ctx_channel)
  517. return self._respond_with_followup(
  518. f"Um {target_time.strftime('%H:%M')} laeuft nichts auf {ch_name}.",
  519. ctx,
  520. )
  521. return self._respond_with_followup(self._format_program(program), ctx)
  522. # --- Nichts erkannt → Konversation beenden ---
  523. return IntentResult.success_with_response(
  524. "Das habe ich leider nicht verstanden."
  525. )
  526. # ======================================================================
  527. # Follow-Up Text-Erkennung
  528. # ======================================================================
  529. @staticmethod
  530. def _matches_reminder(text: str) -> bool:
  531. """Erkennt Erinnerungs-Absicht in Follow-Up."""
  532. keywords = [
  533. "erinner", "erinnerung", "merke", "merk", "vormerken",
  534. "benachrichtig", "daran erinnern", "nicht vergessen",
  535. ]
  536. return any(kw in text for kw in keywords)
  537. @staticmethod
  538. def _matches_streaming(text: str) -> bool:
  539. """Erkennt Streaming-Absicht in Follow-Up."""
  540. keywords = [
  541. "streamen", "stream", "streaming", "online schauen",
  542. "online gucken", "online sehen", "netflix", "amazon",
  543. "crunchyroll", "disney",
  544. ]
  545. return any(kw in text for kw in keywords)
  546. @staticmethod
  547. def _matches_info(text: str) -> bool:
  548. """Erkennt Info-Absicht in Follow-Up."""
  549. keywords = [
  550. "was ist das", "worum geht", "erzaehl", "erzaehl mir",
  551. "mehr darueber", "mehr dazu", "info", "beschreibung",
  552. "wovon handelt", "genre",
  553. ]
  554. return any(kw in text for kw in keywords)
  555. def _extract_channel_from_text(self, text: str) -> str | None:
  556. """Versucht einen Sender-Namen aus dem Follow-Up-Text zu extrahieren."""
  557. # "und auf RTL2?" → entferne Fuellwoerter, versuche Channel-Resolve
  558. cleaned = text
  559. for prefix in ["und auf ", "auf ", "und ", "was kommt auf ", "was laeuft auf "]:
  560. if cleaned.startswith(prefix):
  561. cleaned = cleaned[len(prefix):]
  562. break
  563. # Fragezeichen und Satzzeichen entfernen
  564. cleaned = cleaned.rstrip("?!. ")
  565. if cleaned:
  566. return self._channel_mapper.resolve(cleaned)
  567. return None
  568. @staticmethod
  569. def _extract_date_from_text(text: str) -> date | None:
  570. """Erkennt Datumsbezug im Follow-Up-Text."""
  571. if "morgen" in text:
  572. return date.today() + timedelta(days=1)
  573. if "uebermorgen" in text or "übermorgen" in text:
  574. return date.today() + timedelta(days=2)
  575. # Wochentage
  576. tage = {
  577. "montag": 0, "dienstag": 1, "mittwoch": 2, "donnerstag": 3,
  578. "freitag": 4, "samstag": 5, "sonntag": 6,
  579. }
  580. for tag_name, weekday in tage.items():
  581. if tag_name in text:
  582. today = date.today()
  583. days_ahead = weekday - today.weekday()
  584. if days_ahead <= 0:
  585. days_ahead += 7
  586. return today + timedelta(days=days_ahead)
  587. return None
  588. @staticmethod
  589. def _extract_time_from_text(text: str) -> tuple[int, int] | None:
  590. """Erkennt Zeitangabe im Follow-Up-Text."""
  591. # "und um 21 Uhr?" / "um 21:30" / "um 22 Uhr 15"
  592. cleaned = text.lower().replace("uhr", "").strip()
  593. match = re.search(r"(\d{1,2})\s*[:\s]\s*(\d{2})", cleaned)
  594. if match:
  595. h, m = int(match.group(1)), int(match.group(2))
  596. if 0 <= h <= 23 and 0 <= m <= 59:
  597. return (h, m)
  598. match = re.search(r"(\d{1,2})", cleaned)
  599. if match:
  600. h = int(match.group(1))
  601. if 0 <= h <= 23:
  602. return (h, 0)
  603. return None
  604. def _time_from_context(self, ctx_time: str, ctx_date: str) -> datetime:
  605. """Rekonstruiert datetime aus Kontext-Strings."""
  606. now = datetime.now().astimezone()
  607. if ctx_time:
  608. try:
  609. return datetime.fromisoformat(ctx_time)
  610. except ValueError:
  611. pass
  612. if ctx_date:
  613. try:
  614. d = date.fromisoformat(ctx_date)
  615. return datetime(d.year, d.month, d.day, now.hour, now.minute)
  616. except ValueError:
  617. pass
  618. return now
  619. # ======================================================================
  620. # Intent-Handler (mit Follow-Up-Kontext)
  621. # ======================================================================
  622. @intent("tv_whats_on", description="TV-Programm fuer Sender und Uhrzeit")
  623. @pattern("was kommt [heute|morgen] [um] {time} [uhr] auf {channel}")
  624. @pattern("was laeuft [heute|morgen] [um] {time} [uhr] [auf] {channel}")
  625. @pattern("programm [von|auf] {channel} [heute|morgen]")
  626. @example("Was kommt heute um 20 Uhr auf RTL2?")
  627. @example("Was laeuft auf Pro7?")
  628. async def handle_whats_on(
  629. self, data: IntentReceivedData, channel: str = "", time: str = "",
  630. ) -> IntentResult:
  631. """Was kommt um X auf Sender Y?"""
  632. if not self._cache:
  633. return self._no_data()
  634. channel_name = self._correct_query(data.get_slot("channel", ""))
  635. channel_id = self._channel_mapper.resolve(channel_name) if channel_name else None
  636. if not channel_id:
  637. return IntentResult.failure(
  638. "Sender nicht erkannt",
  639. response_text=f"Ich kenne den Sender '{channel_name}' leider nicht.",
  640. )
  641. # Wurde eine explizite Uhrzeit genannt?
  642. has_explicit_time = bool(data.get_slot("time", ""))
  643. target_time = self._parse_time(data)
  644. program = self._cache.get_program_at(channel_id, target_time)
  645. ctx = self._build_context(
  646. data, program,
  647. channel_id=channel_id,
  648. time_iso=target_time.isoformat(),
  649. date_iso=target_time.date().isoformat(),
  650. )
  651. display_name = self._channel_mapper.get_display_name(channel_id)
  652. if not program:
  653. if has_explicit_time:
  654. return self._respond_with_followup(
  655. f"Um {self._format_time_tts(target_time.isoformat())} "
  656. f"laeuft nichts auf {display_name}.",
  657. ctx,
  658. )
  659. return self._respond_with_followup(
  660. f"Gerade laeuft nichts auf {display_name}.",
  661. ctx,
  662. )
  663. if has_explicit_time:
  664. return self._respond_with_followup(
  665. self._format_program(program), ctx,
  666. )
  667. # Ohne explizite Zeit: "Gerade laeuft X auf Y" statt "Um 02:47 kommt X"
  668. return self._respond_with_followup(
  669. self._format_program_now(program, display_name), ctx,
  670. )
  671. @intent("tv_primetime", description="Primetime-Programm aller Sender")
  672. @pattern("was kommt [heute|morgen] (abend|abends|im fernsehen)")
  673. @pattern("fernsehprogramm [heute|morgen]")
  674. @example("Was kommt heute Abend?")
  675. @example("Fernsehprogramm heute")
  676. async def handle_primetime(self, data: IntentReceivedData) -> IntentResult:
  677. """Was kommt heute Abend auf den Top-Sendern?"""
  678. if not self._cache:
  679. return self._no_data()
  680. target_date = self._parse_date(data)
  681. top_channels = ["ard", "zdf", "rtl", "sat1", "pro7", "vox"]
  682. highlights = []
  683. last_program = None
  684. for ch_id in top_channels:
  685. program = self._cache.get_primetime(ch_id, target_date)
  686. if program:
  687. name = self._channel_mapper.get_display_name(ch_id)
  688. highlights.append(f"Auf {name}: {program['t']}")
  689. last_program = program
  690. if not highlights:
  691. return IntentResult.success_with_response(
  692. "Ich habe kein Primetime-Programm gefunden."
  693. )
  694. date_label = "Heute" if target_date == date.today() else "Morgen"
  695. ctx = self._build_context(
  696. data, last_program,
  697. time_iso=datetime(target_date.year, target_date.month, target_date.day, 20, 15).isoformat(),
  698. date_iso=target_date.isoformat(),
  699. )
  700. return self._respond_with_followup(
  701. f"{date_label} Abend um 20 Uhr 15. {'. '.join(highlights)}.",
  702. ctx,
  703. )
  704. @intent("tv_when_next", description="Naechste Ausstrahlung einer Sendung")
  705. @pattern("wann kommt [die naechste folge] {query}")
  706. @pattern("wann laeuft {query} [wieder]")
  707. @pattern("naechste folge [von] {query}")
  708. @example("Wann kommt die naechste Folge Naruto?")
  709. @example("Wann laeuft Tatort wieder?")
  710. async def handle_when_next(
  711. self, data: IntentReceivedData, query: str = "",
  712. ) -> IntentResult:
  713. """Wann kommt Sendung X als naechstes?"""
  714. if not self._cache:
  715. return self._no_data()
  716. query = self._correct_query(data.get_slot("query", ""))
  717. if not query:
  718. return IntentResult.failure(
  719. "Kein Suchbegriff",
  720. response_text="Welche Sendung meinst du?",
  721. )
  722. results = self._cache.search_title(query, limit=1)
  723. if not results:
  724. return IntentResult.success_with_response(
  725. f"Ich habe '{query}' nicht im Programm gefunden."
  726. )
  727. program = results[0]
  728. channel_name = self._channel_mapper.get_display_name(program["ch"])
  729. date_label = self._format_date_tts(program["s"])
  730. time_label = self._format_time_tts(program["s"])
  731. response = f"{program['t']} kommt {date_label} um {time_label} auf {channel_name}."
  732. if program.get("sn") and program.get("ep"):
  733. response += f" Staffel {program['sn']}, Folge {program['ep']}."
  734. ctx = self._build_context(
  735. data, program,
  736. channel_id=program["ch"],
  737. time_iso=program["s"],
  738. date_iso=program["s"][:10],
  739. )
  740. return self._respond_with_followup(response, ctx)
  741. @intent("tv_which_channel", description="Sender einer Sendung finden")
  742. @pattern("auf welchem (sender|kanal) kommt {query}")
  743. @pattern("wo kommt {query}")
  744. @pattern("wo laeuft {query}")
  745. @example("Auf welchem Sender kommt DSDS?")
  746. @example("Wo laeuft Naruto?")
  747. async def handle_which_channel(
  748. self, data: IntentReceivedData, query: str = "",
  749. ) -> IntentResult:
  750. """Auf welchem Sender laeuft X?"""
  751. if not self._cache:
  752. return self._no_data()
  753. query = self._correct_query(data.get_slot("query", ""))
  754. if not query:
  755. return IntentResult.failure("Kein Suchbegriff", response_text="Welche Sendung meinst du?")
  756. results = self._cache.search_title(query, limit=10)
  757. if not results:
  758. return IntentResult.success_with_response(
  759. f"Ich habe '{query}' nicht im Programm gefunden."
  760. )
  761. # Eindeutige Sender sammeln
  762. channels = list(dict.fromkeys(
  763. self._channel_mapper.get_display_name(p["ch"]) for p in results
  764. ))
  765. first = results[0]
  766. ctx = self._build_context(
  767. data, first,
  768. channel_id=first["ch"],
  769. time_iso=first.get("s", ""),
  770. date_iso=first.get("s", "")[:10] if first.get("s") else "",
  771. )
  772. if len(channels) == 1:
  773. return self._respond_with_followup(f"{query} laeuft auf {channels[0]}.", ctx)
  774. return self._respond_with_followup(
  775. f"{query} laeuft auf {', '.join(channels[:5])}.", ctx,
  776. )
  777. @intent("tv_genre_search", description="Sendungen nach Genre/Tag suchen")
  778. @pattern("gibt es [heute|morgen|diese woche] {genre} [im fernsehen]")
  779. @pattern("welche {genre} (kommen|laufen) [heute|morgen|diese woche]")
  780. @example("Gibt es diese Woche Ecchi-Animes?")
  781. @example("Welche Animes kommen heute?")
  782. async def handle_genre_search(
  783. self, data: IntentReceivedData, genre: str = "",
  784. ) -> IntentResult:
  785. """Sendungen nach Genre oder Tag suchen."""
  786. if not self._cache:
  787. return self._no_data()
  788. genre_text = self._correct_query(data.get_slot("genre", ""))
  789. if not genre_text:
  790. return IntentResult.failure("Kein Genre", response_text="Welches Genre meinst du?")
  791. # "Ecchi-Animes" → tag="Ecchi", "Action Filme" → tag="Action"
  792. tag = self._extract_genre_tag(genre_text)
  793. results = self._cache.search_by_tag(tag)
  794. if not results:
  795. # Fallback: Genre-Suche
  796. results = self._cache.search_by_genre(tag)
  797. if not results:
  798. return IntentResult.success_with_response(
  799. f"Ich habe keine {genre_text} im Programm gefunden."
  800. )
  801. # Auf Zeitraum filtern und Top 5 ausgeben
  802. now = datetime.now().astimezone()
  803. future = [r for r in results if datetime.fromisoformat(r["s"]) > now]
  804. future.sort(key=lambda r: r["s"])
  805. if not future:
  806. return IntentResult.success_with_response(
  807. f"Aktuell sind keine {genre_text} geplant."
  808. )
  809. items = []
  810. for p in future[:5]:
  811. ch = self._channel_mapper.get_display_name(p["ch"])
  812. items.append(f"{p['t']} — {self._format_date_tts(p['s'])} um {self._format_time_tts(p['s'])} auf {ch}")
  813. last = future[0]
  814. ctx = self._build_context(
  815. data, last,
  816. channel_id=last["ch"],
  817. time_iso=last.get("s", ""),
  818. date_iso=last.get("s", "")[:10] if last.get("s") else "",
  819. )
  820. return self._respond_with_followup(
  821. f"{len(future)} {genre_text} gefunden. {'. '.join(items)}.",
  822. ctx,
  823. )
  824. @intent("tv_streaming", description="Streaming-Verfuegbarkeit abfragen")
  825. @pattern("wo kann ich {query} (streamen|schauen|sehen)")
  826. @pattern("auf welchem streamingdienst gibt es {query}")
  827. @example("Wo kann ich Naruto streamen?")
  828. @example("Auf welchem Streamingdienst gibt es Breaking Bad?")
  829. async def handle_streaming(
  830. self, data: IntentReceivedData, query: str = "",
  831. ) -> IntentResult:
  832. """Wo kann man X streamen?"""
  833. if not self._cache:
  834. return self._no_data()
  835. query = self._correct_query(data.get_slot("query", ""))
  836. if not query:
  837. return IntentResult.failure("Kein Suchbegriff", response_text="Welche Sendung meinst du?")
  838. series_id = EPGCache._generate_series_id(query)
  839. services = self._cache.get_streaming_info(series_id)
  840. # On-Demand Enrichment falls noch nicht vorhanden
  841. if not services and self._enricher:
  842. enrichment = await self._enricher.enrich_on_demand(query)
  843. if enrichment:
  844. services = enrichment.get("str", [])
  845. if not services:
  846. return IntentResult.success_with_response(
  847. f"Ich habe keine Streaming-Informationen fuer {query}."
  848. )
  849. ctx = self._build_context(data)
  850. ctx["ctx_program"] = query
  851. ctx["ctx_program_sid"] = series_id
  852. return self._respond_with_followup(
  853. f"{query} ist verfuegbar auf {', '.join(services)}.",
  854. ctx,
  855. )
  856. @intent("tv_show_info", description="Informationen zu einer Sendung")
  857. @pattern("(was ist|worum geht es bei) {query}")
  858. @pattern("info [zu|ueber] {query}")
  859. @example("Was ist Naruto?")
  860. @example("Worum geht es bei Breaking Bad?")
  861. async def handle_show_info(
  862. self, data: IntentReceivedData, query: str = "",
  863. ) -> IntentResult:
  864. """Info ueber eine Sendung."""
  865. if not self._cache:
  866. return self._no_data()
  867. query = self._correct_query(data.get_slot("query", ""))
  868. if not query:
  869. return IntentResult.failure("Kein Suchbegriff", response_text="Ueber welche Sendung?")
  870. series_id = EPGCache._generate_series_id(query)
  871. enrichment = self._cache.get_enrichment(series_id)
  872. # On-Demand Enrichment
  873. if not enrichment and self._enricher:
  874. enrichment = await self._enricher.enrich_on_demand(query)
  875. if not enrichment:
  876. return IntentResult.success_with_response(
  877. f"Ich habe keine Informationen zu {query}."
  878. )
  879. parts = [enrichment.get("t", query)]
  880. genres = enrichment.get("g", [])
  881. if genres:
  882. parts.append(f"Genre: {', '.join(genres)}")
  883. desc = enrichment.get("d", "")
  884. if desc:
  885. # Max 2 Saetze fuer TTS
  886. sentences = desc.split(". ")
  887. short = ". ".join(sentences[:2])
  888. if not short.endswith("."):
  889. short += "."
  890. parts.append(short)
  891. rating = enrichment.get("rt")
  892. if rating:
  893. parts.append(f"Bewertung: {rating} von 10")
  894. streaming = enrichment.get("str", [])
  895. if streaming:
  896. parts.append(f"Verfuegbar auf: {', '.join(streaming)}")
  897. ctx = self._build_context(data)
  898. ctx["ctx_program"] = enrichment.get("t", query)
  899. ctx["ctx_program_sid"] = series_id
  900. return self._respond_with_followup(". ".join(parts) + ".", ctx)
  901. @intent("tv_language_search", description="Anime/Serien nach Sprache suchen (Syncro/Sub)")
  902. @pattern("welche animes kommen [heute|morgen] mit deutscher (syncro|synchro|synchronisation)")
  903. @pattern("wann kommt {query} (auf deutsch|mit deutscher syncro)")
  904. @pattern("wann kommt {query} mit (sub|subs|untertitel)")
  905. @pattern("gibt es {query} (auf deutsch|mit untertitel)")
  906. @example("Welche Animes kommen heute mit deutscher Syncro?")
  907. @example("Wann kommt Naruto auf deutsch?")
  908. async def handle_language_search(
  909. self, data: IntentReceivedData, query: str = "",
  910. ) -> IntentResult:
  911. """Sucht nach Sendungen mit bestimmter Sprache (Syncro/Sub)."""
  912. if not self._cache:
  913. return self._no_data()
  914. text = (data.original_text or "").lower()
  915. query = self._correct_query(data.get_slot("query", ""))
  916. # Was sucht der Nutzer? Syncro (deutsch audio) oder Sub (OmU)?
  917. wants_dub = any(w in text for w in [
  918. "syncro", "synchro", "synchronisation", "auf deutsch",
  919. "deutscher", "deutsche", "deutsch",
  920. ])
  921. wants_sub = any(w in text for w in [
  922. "sub", "subs", "untertitel", "subtitles", "omu",
  923. "originalsprache", "japanisch",
  924. ])
  925. # Im deutschen TV ist fast alles synchronisiert
  926. # Spaeter bei Streaming-Integration: sub/dub Info aus Enrichment
  927. if wants_dub and not wants_sub:
  928. # Deutsche Syncro gesucht
  929. if query:
  930. # Spezifischer Titel
  931. results = self._cache.search_title(query, limit=1)
  932. if results:
  933. program = results[0]
  934. ch = self._channel_mapper.get_display_name(program["ch"])
  935. date_l = self._format_date_tts(program["s"])
  936. time_l = self._format_time_tts(program["s"])
  937. # Enrichment pruefen fuer Originalsprache
  938. sid = program.get("sid", self._cache._generate_series_id(program["t"]))
  939. enrichment = self._cache.get_enrichment(sid)
  940. orig = ""
  941. if enrichment and enrichment.get("orig_lang"):
  942. lang_names = {"ja": "Japanisch", "ko": "Koreanisch", "zh": "Chinesisch"}
  943. orig = lang_names.get(enrichment["orig_lang"], "")
  944. response = (
  945. f"{program['t']} kommt {date_l} um {time_l} auf {ch} "
  946. f"mit deutscher Synchronisation."
  947. )
  948. if orig:
  949. response += f" Originalsprache: {orig}."
  950. ctx = self._build_context(data, program, channel_id=program["ch"],
  951. time_iso=program["s"], date_iso=program["s"][:10])
  952. return self._respond_with_followup(response, ctx)
  953. else:
  954. return IntentResult.success_with_response(
  955. f"Ich habe '{query}' nicht im Programm gefunden."
  956. )
  957. else:
  958. # Alle Anime mit deutscher Syncro (= alle Anime im deutschen TV)
  959. tag = "anime"
  960. results = self._cache.search_by_genre(tag)
  961. now = datetime.now().astimezone()
  962. future = [r for r in results if datetime.fromisoformat(r["s"]) > now]
  963. future.sort(key=lambda r: r["s"])
  964. if not future:
  965. return IntentResult.success_with_response(
  966. "Aktuell sind keine Animes mit deutscher Syncro geplant."
  967. )
  968. items = []
  969. for p in future[:5]:
  970. ch = self._channel_mapper.get_display_name(p["ch"])
  971. items.append(f"{p['t']} — {self._format_date_tts(p['s'])} um {self._format_time_tts(p['s'])} auf {ch}")
  972. return IntentResult.success_with_response(
  973. f"Im deutschen TV laufen Animes immer mit deutscher Synchronisation. "
  974. f"{len(future)} Animes geplant. {'. '.join(items)}."
  975. )
  976. elif wants_sub and not wants_dub:
  977. # Untertitel / OmU gesucht
  978. return IntentResult.success_with_response(
  979. "Im linearen TV gibt es leider selten Untertitel-Versionen. "
  980. "Auf Streaming-Diensten wie Crunchyroll oder Netflix sind "
  981. "die meisten Animes mit japanischem Originalton und deutschen "
  982. "Untertiteln verfuegbar."
  983. )
  984. else:
  985. # Beides oder unklar
  986. if query:
  987. results = self._cache.search_title(query, limit=1)
  988. if results:
  989. program = results[0]
  990. ch = self._channel_mapper.get_display_name(program["ch"])
  991. return IntentResult.success_with_response(
  992. f"{program['t']} laeuft auf {ch} mit deutscher Synchronisation. "
  993. f"Fuer die Originalversion mit Untertiteln schau auf "
  994. f"Crunchyroll oder Netflix."
  995. )
  996. return IntentResult.success_with_response(
  997. "Im deutschen Fernsehen laufen Animes mit deutscher Synchronisation. "
  998. "Fuer japanischen Originalton mit Untertiteln empfehle ich "
  999. "Streaming-Dienste wie Crunchyroll oder Netflix."
  1000. )
  1001. @intent("tv_set_reminder", description="TV-Erinnerung setzen")
  1002. @pattern("(erinnere|erinner) [mich] [wenn] {query} (kommt|laeuft)")
  1003. @pattern("erinnerung [fuer] {query}")
  1004. @pattern("(merke|merk) [dir] {query} [vor]")
  1005. @example("Erinnere mich wenn Naruto kommt")
  1006. @example("Merke dir DSDS vor")
  1007. async def handle_set_reminder(
  1008. self, data: IntentReceivedData, query: str = "",
  1009. ) -> IntentResult:
  1010. """Setzt eine TV-Erinnerung."""
  1011. if not self._cache:
  1012. return self._no_data()
  1013. query = self._correct_query(data.get_slot("query", ""))
  1014. if not query:
  1015. return IntentResult.failure("Kein Suchbegriff", response_text="An was soll ich dich erinnern?")
  1016. series_id = EPGCache._generate_series_id(query)
  1017. # Duplikat-Check
  1018. reminders = self._cache.reminders
  1019. for r in reminders:
  1020. if r.get("sid") == series_id and r.get("active"):
  1021. return IntentResult.success_with_response(
  1022. f"Erinnerung fuer {query} ist bereits aktiv."
  1023. )
  1024. # Erinnerung speichern
  1025. reminders.append({
  1026. "title": query,
  1027. "sid": series_id,
  1028. "active": True,
  1029. "satellite_id": data.satellite_id,
  1030. "created_at": datetime.now().astimezone().isoformat(),
  1031. })
  1032. await self._cache.save_async()
  1033. # Naechste Ausstrahlung suchen
  1034. next_airing = self._cache.get_next_airing(series_id)
  1035. if not next_airing:
  1036. results = self._cache.search_title(query, limit=1)
  1037. if results:
  1038. next_airing = results[0]
  1039. if next_airing:
  1040. ch = self._channel_mapper.get_display_name(next_airing["ch"])
  1041. return IntentResult.success_with_response(
  1042. f"Erinnerung fuer {query} gespeichert. "
  1043. f"Naechste Ausstrahlung: {self._format_date_tts(next_airing['s'])} "
  1044. f"um {self._format_time_tts(next_airing['s'])} auf {ch}."
  1045. )
  1046. return IntentResult.success_with_response(
  1047. f"Erinnerung fuer {query} gespeichert."
  1048. )
  1049. @intent("tv_list_reminders", description="TV-Erinnerungen auflisten")
  1050. @pattern("welche tv erinnerungen [habe ich]")
  1051. @pattern("[meine] tv (erinnerungen|merkliste)")
  1052. @example("Welche TV Erinnerungen habe ich?")
  1053. @example("Meine TV Merkliste")
  1054. async def handle_list_reminders(self, data: IntentReceivedData) -> IntentResult:
  1055. """Listet alle aktiven TV-Erinnerungen."""
  1056. if not self._cache:
  1057. return self._no_data()
  1058. reminders = [
  1059. r for r in self._cache.reminders
  1060. if r.get("active")
  1061. ]
  1062. if not reminders:
  1063. return IntentResult.success_with_response(
  1064. "Du hast keine aktiven TV-Erinnerungen."
  1065. )
  1066. titles = [r["title"] for r in reminders]
  1067. return IntentResult.success_with_response(
  1068. f"Du hast {len(titles)} TV-Erinnerungen: {', '.join(titles)}."
  1069. )
  1070. @intent("tv_delete_reminder", description="TV-Erinnerung loeschen")
  1071. @pattern("(loesche|entferne) [die] tv erinnerung [fuer] {query}")
  1072. @pattern("{query} [von der] merkliste (loeschen|entfernen|streichen)")
  1073. @example("Loesche die TV Erinnerung fuer Naruto")
  1074. @example("Naruto von der Merkliste streichen")
  1075. async def handle_delete_reminder(
  1076. self, data: IntentReceivedData, query: str = "",
  1077. ) -> IntentResult:
  1078. """Loescht eine TV-Erinnerung."""
  1079. if not self._cache:
  1080. return self._no_data()
  1081. query = self._correct_query(data.get_slot("query", ""))
  1082. if not query:
  1083. return IntentResult.failure("Kein Suchbegriff", response_text="Welche Erinnerung?")
  1084. series_id = EPGCache._generate_series_id(query)
  1085. reminders = self._cache.reminders
  1086. found = False
  1087. for r in reminders:
  1088. if r.get("sid") == series_id and r.get("active"):
  1089. r["active"] = False
  1090. found = True
  1091. if found:
  1092. await self._cache.save_async()
  1093. return IntentResult.success_with_response(
  1094. f"Erinnerung fuer {query} geloescht."
  1095. )
  1096. return IntentResult.success_with_response(
  1097. f"Keine aktive Erinnerung fuer {query} gefunden."
  1098. )
  1099. # ======================================================================
  1100. # Reminder-System
  1101. # ======================================================================
  1102. async def _reminder_check_loop(self) -> None:
  1103. """Prueft alle 60s ob eine erinnerte Sendung gleich anfaengt."""
  1104. interval = self.get_config_value("reminders.check_interval_seconds", 60)
  1105. notify_before = self.get_config_value("reminders.notify_minutes_before", 5)
  1106. while True:
  1107. try:
  1108. await asyncio.sleep(interval)
  1109. await self._check_reminders(notify_before)
  1110. except asyncio.CancelledError:
  1111. break
  1112. except Exception as e:
  1113. perror(f"TVProgramPlugin: Reminder-Fehler: {e}")
  1114. async def _check_reminders(self, notify_before_minutes: int) -> None:
  1115. """Prueft Erinnerungen und sendet TTS-Benachrichtigungen."""
  1116. if not self._cache:
  1117. return
  1118. now = datetime.now().astimezone()
  1119. threshold = now + timedelta(minutes=notify_before_minutes)
  1120. for reminder in self._cache.reminders:
  1121. if not reminder.get("active"):
  1122. continue
  1123. sid = reminder.get("sid", "")
  1124. next_airing = self._cache.get_next_airing(sid)
  1125. if not next_airing:
  1126. continue
  1127. start = datetime.fromisoformat(next_airing["s"])
  1128. # Schon benachrichtigt fuer diese Ausstrahlung?
  1129. if reminder.get("last_notified") == next_airing["s"]:
  1130. continue
  1131. if now <= start <= threshold:
  1132. channel = self._channel_mapper.get_display_name(next_airing["ch"])
  1133. text = (
  1134. f"{reminder['title']} kommt in {notify_before_minutes} Minuten "
  1135. f"auf {channel}."
  1136. )
  1137. events = getattr(self.application, "events", None)
  1138. if events:
  1139. await events.emit("tts_request", {
  1140. "request_id": f"tv-reminder-{sid}",
  1141. "satellite_id": reminder.get("satellite_id", ""),
  1142. "text": text,
  1143. })
  1144. # Markieren um Doppel-Benachrichtigung zu vermeiden
  1145. reminder["last_notified"] = next_airing["s"]
  1146. await self._cache.save_async()
  1147. pdebug(f"TVProgramPlugin: Reminder gesendet — {reminder['title']}")
  1148. # ======================================================================
  1149. # Hilfsmethoden
  1150. # ======================================================================
  1151. def _no_data(self) -> IntentResult:
  1152. """Standard-Antwort wenn kein Cache vorhanden."""
  1153. return IntentResult.failure(
  1154. "Keine Daten",
  1155. response_text="Die TV-Programm-Daten werden noch geladen. Bitte versuche es gleich nochmal.",
  1156. )
  1157. def _parse_time(self, data: IntentReceivedData) -> datetime:
  1158. """Parst Uhrzeit aus Slot oder nutzt aktuelle Zeit."""
  1159. time_str = data.get_slot("time", "")
  1160. target_date = self._parse_date(data)
  1161. now = datetime.now().astimezone()
  1162. if not time_str:
  1163. # Kein expliziter Zeitpunkt → jetzt
  1164. return now
  1165. # "20:15", "20 Uhr 15", "20 Uhr", "halb neun", etc.
  1166. time_str = time_str.lower().replace("uhr", "").strip()
  1167. # "20:15" oder "20 15"
  1168. match = re.match(r"(\d{1,2})\s*[:\s]\s*(\d{2})", time_str)
  1169. if match:
  1170. h, m = int(match.group(1)), int(match.group(2))
  1171. return datetime(target_date.year, target_date.month, target_date.day, h, m)
  1172. # "20" (nur Stunde)
  1173. match = re.match(r"(\d{1,2})$", time_str.strip())
  1174. if match:
  1175. h = int(match.group(1))
  1176. if 0 <= h <= 23:
  1177. return datetime(target_date.year, target_date.month, target_date.day, h, 0)
  1178. return now
  1179. def _parse_date(self, data: IntentReceivedData) -> date:
  1180. """Erkennt 'heute'/'morgen' aus dem gesamten Intent-Text."""
  1181. text = self._correct_query(getattr(data, "original_text", "") or "")
  1182. text_lower = text.lower()
  1183. if "morgen" in text_lower:
  1184. return date.today() + timedelta(days=1)
  1185. return date.today()
  1186. def _format_program_now(self, program: dict, channel_name: str) -> str:
  1187. """Formatiert ein aktuell laufendes Programm fuer TTS."""
  1188. title = program["t"]
  1189. genre = program.get("g", "")
  1190. parts = [f"Gerade laeuft '{title}' auf {channel_name}"]
  1191. if genre:
  1192. parts.append(f"Genre: {genre}")
  1193. if program.get("sn") and program.get("ep"):
  1194. parts.append(f"Staffel {program['sn']}, Folge {program['ep']}")
  1195. # Restdauer berechnen
  1196. try:
  1197. end = datetime.fromisoformat(program["e"])
  1198. remaining = int((end - datetime.now().astimezone()).total_seconds() / 60)
  1199. if remaining > 0:
  1200. parts.append(f"Noch {remaining} Minuten")
  1201. except (ValueError, KeyError):
  1202. pass
  1203. return ". ".join(parts) + "."
  1204. def _format_program(self, program: dict) -> str:
  1205. """Formatiert ein Programm fuer TTS-Ausgabe."""
  1206. ch = self._channel_mapper.get_display_name(program["ch"])
  1207. time_label = self._format_time_tts(program["s"])
  1208. title = program["t"]
  1209. genre = program.get("g", "")
  1210. parts = [f"Um {time_label} kommt '{title}' auf {ch}"]
  1211. if genre:
  1212. parts.append(f"Genre: {genre}")
  1213. if program.get("sn") and program.get("ep"):
  1214. parts.append(f"Staffel {program['sn']}, Folge {program['ep']}")
  1215. if program.get("et"):
  1216. parts.append(f"Titel: {program['et']}")
  1217. # Dauer berechnen
  1218. try:
  1219. start = datetime.fromisoformat(program["s"])
  1220. end = datetime.fromisoformat(program["e"])
  1221. duration = int((end - start).total_seconds() / 60)
  1222. if duration > 0:
  1223. parts.append(f"Dauer: {duration} Minuten")
  1224. except (ValueError, KeyError):
  1225. pass
  1226. return ". ".join(parts) + "."
  1227. @staticmethod
  1228. def _format_date_tts(iso_str: str) -> str:
  1229. """ISO-Datum → 'heute' / 'morgen' / 'am Freitag' / '12. April'."""
  1230. try:
  1231. dt = datetime.fromisoformat(iso_str)
  1232. today = date.today()
  1233. delta = (dt.date() - today).days
  1234. if delta == 0:
  1235. return "heute"
  1236. elif delta == 1:
  1237. return "morgen"
  1238. elif 2 <= delta <= 6:
  1239. tage = ["Montag", "Dienstag", "Mittwoch", "Donnerstag",
  1240. "Freitag", "Samstag", "Sonntag"]
  1241. return f"am {tage[dt.weekday()]}"
  1242. else:
  1243. monate = ["Januar", "Februar", "Maerz", "April", "Mai", "Juni",
  1244. "Juli", "August", "September", "Oktober", "November", "Dezember"]
  1245. return f"am {dt.day}. {monate[dt.month - 1]}"
  1246. except (ValueError, IndexError):
  1247. return ""
  1248. @staticmethod
  1249. def _format_time_tts(iso_str: str) -> str:
  1250. """ISO-Zeit → '20 Uhr 15' / '20 Uhr'."""
  1251. try:
  1252. dt = datetime.fromisoformat(iso_str)
  1253. if dt.minute == 0:
  1254. return f"{dt.hour} Uhr"
  1255. return f"{dt.hour} Uhr {dt.minute}"
  1256. except ValueError:
  1257. return ""
  1258. @staticmethod
  1259. def _extract_genre_tag(genre_text: str) -> str:
  1260. """
  1261. Extrahiert den eigentlichen Tag/Genre aus gesprochenem Text.
  1262. 'Ecchi-Animes' → 'Ecchi'
  1263. 'Horrorfilme' → 'Horror'
  1264. 'Action Filme' → 'Action'
  1265. 'Shounen Animes' → 'Shounen'
  1266. 'Dokumentationen' → 'Dokumentation'
  1267. 'Krimis' → 'Krimi'
  1268. 'Komödien' → 'Komoedie'
  1269. """
  1270. text = genre_text.strip()
  1271. lower = text.lower()
  1272. # Zusammengeschriebene Suffixe (Horrorfilme, Actionserien, etc.)
  1273. for suffix in ["filme", "film", "serien", "serie",
  1274. "sendungen", "sendung"]:
  1275. if lower.endswith(suffix) and len(lower) > len(suffix) + 2:
  1276. text = text[:len(text) - len(suffix)]
  1277. lower = text.lower()
  1278. break
  1279. # Getrennte Suffixe mit Bindestrich oder Leerzeichen
  1280. for suffix in ["-animes", "-anime", " animes", " anime",
  1281. " filme", " film", " serien", " serie",
  1282. " sendungen", " sendung", " dokus", " doku"]:
  1283. if lower.endswith(suffix):
  1284. text = text[:len(text) - len(suffix)]
  1285. lower = text.lower()
  1286. break
  1287. # Deutsche Pluralformen → Singular
  1288. plural_map = {
  1289. "krimis": "Krimi",
  1290. "komödien": "Komoedie",
  1291. "komoedien": "Komoedie",
  1292. "dokumentationen": "Dokumentation",
  1293. "dokus": "Doku",
  1294. "thriller": "Thriller", # bleibt gleich
  1295. "animes": "Anime",
  1296. "cartoons": "Cartoon",
  1297. }
  1298. if lower in plural_map:
  1299. return plural_map[lower]
  1300. # Generischer Plural: trailing 's' entfernen
  1301. if lower.endswith("s") and len(text) > 3:
  1302. text = text[:-1]
  1303. return text.strip()