diff --git a/homelab-ai-bot/core/__pycache__/loki_client.cpython-313.pyc b/homelab-ai-bot/core/__pycache__/loki_client.cpython-313.pyc index 056e5ba7..117fc52b 100644 Binary files a/homelab-ai-bot/core/__pycache__/loki_client.cpython-313.pyc and b/homelab-ai-bot/core/__pycache__/loki_client.cpython-313.pyc differ diff --git a/homelab-ai-bot/core/__pycache__/mail_client.cpython-313.pyc b/homelab-ai-bot/core/__pycache__/mail_client.cpython-313.pyc index 5c912a25..50a9b576 100644 Binary files a/homelab-ai-bot/core/__pycache__/mail_client.cpython-313.pyc and b/homelab-ai-bot/core/__pycache__/mail_client.cpython-313.pyc differ diff --git a/homelab-ai-bot/telegram_bot.py b/homelab-ai-bot/telegram_bot.py index 482b54aa..7adfc37c 100644 --- a/homelab-ai-bot/telegram_bot.py +++ b/homelab-ai-bot/telegram_bot.py @@ -112,14 +112,17 @@ logging.basicConfig( log = logging.getLogger("hausmeister") ALLOWED_CHAT_IDS: set[int] = set() +CHAT_ID: int | None = None def _load_token_and_chat(): + global CHAT_ID cfg = config.parse_config() token = cfg.raw.get("TG_HAUSMEISTER_TOKEN", "") chat_id = cfg.raw.get("TG_CHAT_ID", "") if chat_id: - ALLOWED_CHAT_IDS.add(int(chat_id)) + CHAT_ID = int(chat_id) + ALLOWED_CHAT_IDS.add(CHAT_ID) return token @@ -725,6 +728,82 @@ async def handle_message(update: Update, ctx: ContextTypes.DEFAULT_TYPE): await update.message.reply_text(f"Fehler: {e}") +async def handle_callback(update: Update, ctx: ContextTypes.DEFAULT_TYPE): + """Inline-Button Callbacks (z.B. Save.TV Aufnahme).""" + query = update.callback_query + await query.answer() + data = query.data or "" + + if data.startswith("savetv_rec_"): + tid = data.replace("savetv_rec_", "") + try: + from tools import savetv + result = savetv.handle_savetv_record(telecast_id=int(tid)) + await query.edit_message_text( + query.message.text + f"\n\n✅ {result}" + ) + except Exception as e: + log.exception("Save.TV Aufnahme Fehler") + await query.edit_message_text( + query.message.text + f"\n\n❌ Fehler: {e}" + ) + + elif data.startswith("savetv_skip_"): + await query.edit_message_text( + query.message.text + "\n\n⏭ Übersprungen" + ) + + +async def _send_daily_filmtipps(app_context: ContextTypes.DEFAULT_TYPE): + """Täglicher Cronjob: Filmtipps via Telegram senden.""" + if not CHAT_ID: + return + try: + from tools import savetv + telecasts = savetv._scrape_epg() + if not telecasts: + return + films = savetv._filter_films(telecasts) + if not films: + return + + header = f"🎬 TV-Filmtipps für heute ({datetime.now().strftime('%d.%m.%Y')})\n" + await app_context.bot.send_message(chat_id=CHAT_ID, text=header) + + for f in films[:6]: + tid = int(f.get("ITELECASTID", 0)) + title = f.get("STITLE", "?") + station = f.get("STVSTATIONNAME", "?") + start = f.get("DSTARTDATE", "?")[:16] + subcat = f.get("SSUBCATEGORYNAME", "") + desc = (f.get("STHEMA") or f.get("SFULLSUBTITLE") or "")[:150] + recorded = " ✅ Bereits geplant" if f.get("BEXISTRECORD") else "" + + text = f"🎬 *{title}*{recorded}\n📺 {station} | ⏰ {start}\n🎭 {subcat}" + if desc: + text += f"\n_{desc}_" + + keyboard = InlineKeyboardMarkup([ + [ + InlineKeyboardButton("🔴 Aufnehmen", callback_data=f"savetv_rec_{tid}"), + InlineKeyboardButton("⏭ Nein", callback_data=f"savetv_skip_{tid}"), + ] + ]) + await app_context.bot.send_message( + chat_id=CHAT_ID, + text=text, + reply_markup=keyboard, + parse_mode="Markdown", + ) + + log.info("Tägliche Filmtipps gesendet: %d Filme", min(len(films), 6)) + except Exception: + log.exception("Fehler beim Senden der Filmtipps") + + +from datetime import datetime, time as dtime + + def main(): token = _load_token_and_chat() if not token: @@ -749,11 +828,19 @@ def main(): app.add_handler(CommandHandler("check", cmd_check)) app.add_handler(CommandHandler("feeds", cmd_feeds)) app.add_handler(CommandHandler("memory", cmd_memory)) + app.add_handler(CallbackQueryHandler(handle_callback)) app.add_handler(MessageHandler(filters.VOICE, handle_voice)) app.add_handler(MessageHandler(filters.PHOTO, handle_photo)) app.add_handler(MessageHandler(filters.Document.ALL, handle_document)) app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message)) + app.job_queue.run_daily( + _send_daily_filmtipps, + time=dtime(hour=14, minute=0), + name="daily_filmtipps", + ) + log.info("Täglicher Filmtipp-Job registriert (14:00 Uhr)") + async def post_init(application): await application.bot.set_my_commands(BOT_COMMANDS) log.info("Kommandomenü registriert") diff --git a/homelab-ai-bot/tools/savetv.py b/homelab-ai-bot/tools/savetv.py new file mode 100644 index 00000000..0e64f438 --- /dev/null +++ b/homelab-ai-bot/tools/savetv.py @@ -0,0 +1,349 @@ +"""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, +} diff --git a/homelab.conf b/homelab.conf index 0e61da45..6fa64ae2 100644 --- a/homelab.conf +++ b/homelab.conf @@ -196,6 +196,11 @@ MCP_PATH="/root/homelab-mcp" MCP_VENV="/root/homelab-mcp/.venv" MCP_TOOLS="homelab_overview,homelab_all_containers,homelab_container_status,homelab_query_logs,homelab_get_errors,homelab_check_silence,homelab_host_health,homelab_metrics,homelab_get_config,homelab_loki_labels,homelab_prometheus_targets" +# --- SAVE.TV (Online-Videorecorder) --- +SAVETV_USER="739281" +SAVETV_PASS="Astral1966" +SAVETV_URL="https://www.save.tv" + # --- E-MAIL (All-Inkl IMAP-Spiegel von GMX) --- MAIL_IMAP_SERVER="w0206aa8.kasserver.com" MAIL_IMAP_PORT="993"