"""Save.TV Online-Videorecorder — EPG Scanner + Film-Tipps + Aufnahme-Steuerung. Architektur: - EPG-Daten kommen von Save.TV TvProgramm-Seiten (eingebettetes JSON) - Nur TVCATEGORYID 1 (Spielfilm) wird beachtet - LLM bewertet Filme per Titel + Beschreibung + Genre - Aufnahmen werden per tcJWriteRecord.cfm angelegt """ import re import json import logging import requests from datetime import datetime log = logging.getLogger("savetv") SAVETV_URL = "https://www.save.tv" SAVETV_USER = "" SAVETV_PASS = "" _session = None _session_ts = None SESSION_MAX_AGE = 1800 EPG_PAGES = [ "/STV/M/obj/TVProgCtr/TvProgramm2015.cfm", "/STV/M/obj/TVProgCtr/TvProgramm2215.cfm", ] SPAM_SUBCATEGORIES = { "teleshop", "shopping", "dauerwerbesendung", "volksmusik", "casting", "reality", "quiz/spiel", "comic", "zeichentrick", "erotik", "kindersendung", } GOOD_SUBCATEGORIES = { "action", "thriller", "krimi", "drama", "komödie", "komodie", "science fiction", "sci-fi", "fantasy", "abenteuer", "horror", "western", "historienfilm", "animation", "mystery", } TOOLS = [ { "type": "function", "function": { "name": "get_savetv_status", "description": "Save.TV Status: Aufnahmen im Archiv, geplante Aufnahmen anzeigen.", "parameters": {"type": "object", "properties": {}, "required": []}, }, }, { "type": "function", "function": { "name": "get_savetv_tipps", "description": "TV-Filmtipps: Sehenswerte Spielfilme aus dem heutigen TV-Programm. " "Nutze bei 'was laeuft heute', 'gute Filme', 'TV Tipps', 'Fernsehen', 'Save.TV'.", "parameters": {"type": "object", "properties": {}, "required": []}, }, }, { "type": "function", "function": { "name": "savetv_record", "description": "Save.TV Aufnahme anlegen fuer eine bestimmte TelecastId. " "Nutze wenn User sagt 'nimm auf', 'aufnehmen', 'record'.", "parameters": { "type": "object", "properties": { "telecast_id": {"type": "number", "description": "TelecastId der Sendung"} }, "required": ["telecast_id"], }, }, }, ] SYSTEM_PROMPT_EXTRA = """TV / Save.TV Tools: - get_savetv_tipps: Zeigt sehenswerte Spielfilme aus dem heutigen TV-Programm - savetv_record: Nimmt einen Film per TelecastId auf - get_savetv_status: Zeigt Archiv und geplante Aufnahmen Wenn der User einen Film aufnehmen will, nutze savetv_record mit der TelecastId. """ def _init_creds(): global SAVETV_USER, SAVETV_PASS if SAVETV_USER: return try: from core import config cfg = config.parse_config() SAVETV_USER = cfg.raw.get("SAVETV_USER", "") SAVETV_PASS = cfg.raw.get("SAVETV_PASS", "") except Exception: pass def _get_session() -> requests.Session | None: """Login und Session cachen.""" global _session, _session_ts _init_creds() now = datetime.now() if _session and _session_ts and (now - _session_ts).seconds < SESSION_MAX_AGE: return _session s = requests.Session() s.headers.update({"User-Agent": "Mozilla/5.0 Hausmeister-Bot/1.0"}) try: r = s.post( f"{SAVETV_URL}/STV/M/Index.cfm?sk=PREMIUM", data={"sUsername": SAVETV_USER, "sPassword": SAVETV_PASS, "value": "Login"}, allow_redirects=True, timeout=15, ) cookies = s.cookies.get_dict() if not cookies.get("savetv_active_login"): log.warning("Save.TV Login fehlgeschlagen (kein savetv_active_login Cookie)") return None except Exception as e: log.error("Save.TV Login Error: %s", e) return None _session = s _session_ts = now log.info("Save.TV Login erfolgreich") return s def _get_archive(state: int = 0, count: int = 20) -> dict: """Archiv abrufen. state: 0=geplant, 1=fertig.""" s = _get_session() if not s: return {"error": "Login fehlgeschlagen"} try: r = s.get( f"{SAVETV_URL}/STV/M/obj/archive/JSON/VideoArchiveApi.cfm", params={ "bAggregateEntries": "false", "iEntriesPerPage": str(count), "iRecordingState": str(state), }, headers={"X-Requested-With": "XMLHttpRequest"}, timeout=15, ) return r.json() except Exception as e: return {"error": str(e)} def _scrape_epg() -> list[dict]: """Holt Filme aus den Save.TV Programmseiten (JSON im HTML).""" s = _get_session() if not s: return [] all_telecasts = [] seen_ids = set() for page_path in EPG_PAGES: try: r = s.get(f"{SAVETV_URL}{page_path}", timeout=15) m = re.search( r'model\s*=\s*(\{"TvCategoryId".*?"SortedTelecasts":\[.*?\]\})', r.text, re.DOTALL, ) if not m: log.warning("Kein model-JSON in %s", page_path) continue data = json.loads(m.group(1)) for tc in data.get("SortedTelecasts", []): tid = int(tc.get("ITELECASTID", 0)) if tid and tid not in seen_ids: seen_ids.add(tid) all_telecasts.append(tc) except Exception as e: log.error("EPG Scrape %s: %s", page_path, e) log.info("EPG: %d Sendungen gesamt", len(all_telecasts)) return all_telecasts def _filter_films(telecasts: list[dict]) -> list[dict]: """Filtert auf Spielfilme und bewertet sie.""" films = [] now = datetime.now() for tc in telecasts: cat_id = tc.get("TVCATEGORYID", 0) if cat_id != 1.0: continue title = tc.get("STITLE", "") if not title or len(title) < 2: continue subcat = (tc.get("SSUBCATEGORYNAME") or "").lower() if subcat in SPAM_SUBCATEGORIES: continue start_str = tc.get("DSTARTDATE", "") try: start_dt = datetime.strptime(start_str, "%Y-%m-%d %H:%M:%S") except (ValueError, TypeError): continue if start_dt < now: continue score = 50 if subcat in GOOD_SUBCATEGORIES: score += 20 hour = start_dt.hour if 20 <= hour <= 22: score += 15 elif 14 <= hour <= 19: score += 5 desc = tc.get("STHEMA") or tc.get("SFULLSUBTITLE") or "" if len(desc) > 50: score += 10 already_recorded = tc.get("BEXISTRECORD", False) if already_recorded: score -= 30 tc["_score"] = score tc["_start_dt"] = start_dt films.append(tc) films.sort(key=lambda x: (-x["_score"], x["_start_dt"])) return films def _record_telecast(telecast_id: int) -> str: """Aufnahme anlegen.""" s = _get_session() if not s: return "Login fehlgeschlagen" try: r = s.post( f"{SAVETV_URL}/STV/M/obj/TC/tcJWriteRecord.cfm", data={"TelecastId": telecast_id, "iRecordingBuffer": 0}, headers={"X-Requested-With": "XMLHttpRequest"}, timeout=15, ) data = r.json() return data.get("SMESSAGE", "Unbekannte Antwort") except Exception as e: return f"Fehler: {e}" def handle_get_savetv_status(**kw): archive = _get_archive(state=1, count=5) planned = _get_archive(state=0, count=10) if "error" in archive: return f"Save.TV Fehler: {archive['error']}" lines = ["📺 Save.TV Status\n"] total = int(archive.get("ITOTALENTRIESINARCHIVE", 0)) lines.append(f"Archiv: {total} Aufnahmen gesamt") fertig = archive.get("ARRVIDEOARCHIVEENTRIES", []) if fertig: lines.append("\n🎬 Letzte fertige Aufnahmen:") for e in fertig[:5]: tc = e.get("STRTELECASTENTRY", {}) lines.append( f" • {tc.get('STITLE', '?')[:40]} | " f"{tc.get('DSTARTDATE', '?')[:10]} | " f"{tc.get('STVSTATIONNAME', '?')}" ) geplant = planned.get("ARRVIDEOARCHIVEENTRIES", []) plan_total = int(planned.get("ITOTALENTRIES", 0)) if geplant: lines.append(f"\n⏰ Geplante Aufnahmen ({plan_total}):") for e in geplant[:10]: tc = e.get("STRTELECASTENTRY", {}) lines.append( f" • {tc.get('STITLE', '?')[:40]} | " f"{tc.get('DSTARTDATE', '?')[:16]} | " f"{tc.get('STVSTATIONNAME', '?')}" ) return "\n".join(lines) def handle_get_savetv_tipps(**kw): telecasts = _scrape_epg() if not telecasts: return "Konnte keine Programmdaten von Save.TV laden." films = _filter_films(telecasts) if not films: return "Keine sehenswerten Spielfilme im heutigen Programm gefunden." lines = ["🎬 TV-Filmtipps heute\n"] for f in films[:8]: subcat = f.get("SSUBCATEGORYNAME", "") station = f.get("STVSTATIONNAME", "?") start = f.get("DSTARTDATE", "?")[:16] title = f.get("STITLE", "?") subtitle = f.get("SFULLSUBTITLE") or f.get("SSUBTITLE") or "" desc = f.get("STHEMA") or "" tid = int(f.get("ITELECASTID", 0)) recorded = "✅" if f.get("BEXISTRECORD") else "" lines.append(f"🎬 {title} {recorded}") if subtitle and subtitle != title: lines.append(f" {subtitle[:60]}") lines.append(f" 📺 {station} | ⏰ {start} | 🎭 {subcat}") if desc and len(desc) > 10: lines.append(f" {desc[:120]}...") lines.append(f" → Aufnahme: TelecastId {tid}") lines.append("") lines.append("💡 Sage 'Nimm [Filmname] auf' oder 'Aufnahme TelecastId XXXXX'") return "\n".join(lines) def handle_savetv_record(telecast_id=0, **kw): if not telecast_id: return "Keine TelecastId angegeben." tid = int(telecast_id) telecasts = _scrape_epg() title = f"ID {tid}" for tc in telecasts: if int(tc.get("ITELECASTID", 0)) == tid: title = tc.get("STITLE", title) break result = _record_telecast(tid) return f"📺 Save.TV: {result}\n🎬 Sendung: {title}" HANDLERS = { "get_savetv_status": handle_get_savetv_status, "get_savetv_tipps": handle_get_savetv_tipps, "savetv_record": handle_savetv_record, }