From eeec98303ddc42d4ecefe2e8ac1535a8c0d4137d Mon Sep 17 00:00:00 2001 From: root Date: Mon, 16 Mar 2026 22:04:28 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20Save.TV=20Integration=20=E2=80=94=20EPG?= =?UTF-8?q?-Scanner,=20Filmtipps,=20Aufnahme=20per=20Inline-Button=20+=20t?= =?UTF-8?q?=C3=A4glicher=20Cronjob=2014:00?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__pycache__/loki_client.cpython-313.pyc | Bin 7044 -> 9454 bytes .../__pycache__/mail_client.cpython-313.pyc | Bin 20672 -> 22173 bytes homelab-ai-bot/telegram_bot.py | 89 ++++- homelab-ai-bot/tools/savetv.py | 349 ++++++++++++++++++ homelab.conf | 5 + 5 files changed, 442 insertions(+), 1 deletion(-) create mode 100644 homelab-ai-bot/tools/savetv.py 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 056e5ba7ad0f92e1b9c64d73ebd51856c79e8ceb..117fc52bec1bb4b343a4fa016ecd72b9f187ba29 100644 GIT binary patch delta 3013 zcmb7GZ%kX)6~FJirQ?7v@uN+ly*|9eb_ng zVVdrbrd`Rud+t5wo_p?j_x!HE)A_gKuC21NQUdK)-@2RVs#V9T0vpjvU)G;GTA0JS$N3LGIuTh~1s(OE%+MBHD63(P-0Ln&vfNQrH0tlW zJ1UG#MH~kaa3yWk|L$JoLMZm@H$6XYZ3nU7(h^CCYa*3Uvl&Gb9rOrVSP+c(ttjo+ zQ{G+e2S5}{DDG1z2`7^i@w5if?;Nwhna(JBhS+}Y?P7-SZF$Go)|YXn9>EBx45e;> z88&z=20gSVz3cc9Xpery-wGLg*T2tq7$yo92#^~2utPrn;y=T@s`pm>Atzcz_Hzf- zw6;`O4aXEp!(cA5GtO_OY4kExKr^+kP_i=;*@AO2tEiJxX&R2iGc;n_yW;7T0-vKT zoM^j{&J-N+a8^qx2`!`02m?07RssTMW@{fyh~8&Ao?~QY9A}Lt6N%|!+Thlzti-C8 zaYs=jS!ByN9@=O!dtlq^GjmLFPO?fii3`~dz#iCCt7Mn0hd>JRgvc<$G)cTv5@LM~ zWZaEL*rd{GQ0woz0>@q;#nmHTQ~4}P&h{OBIl-@rt+>Bxdx5B&=_vRAI2x28Si5Zh zk6V`g4^J-1T?|>}Wp3L?&J1lEWnSWoM&sp0)qmO0^Tip9C&RiMjqe2{mfC_TZg(G9wHq@uWiAUdg3nZT$%-+XwH&6yd=yE+|w`5~`vKV@e`J z!`YNDl?V$44WtyP?uhVOR-soyv;myauOKu6B<3Jo`aBMCdurT&{Jv1nC>LyzbXLuL z>9W~jelOP6)+oRpi1rqdNjVu5AkED9&b^eG5sSXpvpZKj<`lVQ6UY!08XhsmbXq1E z*as{BB=qCZoBY92JhDMY8U`Z>v=S$d0~G3vh$f(_(o_p|C)1H|QtJtI#}rkWzD#?* z3r1Qqz&e>F8^q$taeoYS&kcNHAtlbafg2~TpO~Ls@~t`>9`TKNzOfMK{!BwMd84Tr zhzkVTihv)*B#akP!YD-xokY1Fpuk^Ns6jMh>A$fO8X25{oS37#(cYqEQ^xU(g92Tl zmoGSq_Tmt?FW55aOgKpsII$CjhSe+-n~{%#^Vw9;Bp6J?Ppg1WFiW16`fjv-ul44% zev_}vZLp*yVwvrG>hR`$t!rN2W3PW+)~g@8ee)e}POo_@)+*{Y9b7xNML4H@_SlAt z@b3ABZ|Bykb}hzk?^^b*xI6NE$782^6Ozh32~@uoUx+VGEz#9L^P@m$B@ntZec$)H zK=+mn^jpMI#?AI`loBC0J7`(+1@xS+BKLvsr8#cRU2$XP`pja}(vem7-be116?e;> zf#u_??w&d8T2=kqwYO`RqIddMt3p31Uq1V@vAbix$CnT8!nR2dm@p%FMgFCY?it7WGMa2JR*uAnEJYbA=(f%+bOAV-r&U$l2pZ`z7OjrT7$> zWy71VJion(X1Z~1yO~hKDaEX$gSg~5fP$4Ozz}#B1R-Y@kXb%~NL-0rnkcT_ahU~E z9e&zXfLZdy?VTO`Xm@CZuZG*{3e0EbC-b#~4{IN~@@LQGouhNiV_)s!@G|?O8g*55C3R?wqsc-MyV3Ioxym*PQ+vr>>t`I`dm8KP>;| zYb&+qK5&k1I!ImnhLbq`v!_1$*iJkzY4}I;PHA5=_frml9=sO}>}KHwfnzbfbRYJF zO8V)_Lt_SgVxkJxi|K?a+NqI(DV(t-;cd{2beNQeE9oTw4R6~vtC(4FOr~inoS8_b zV;Y6;Mc&r`RQcDoevsCq0HWn2#Q~LYz^v~vJ0xfrA=Qb2A5@<>iGPgfVT2LOO)5R~I_ROOh$b@n_3H0)XYwOwe?6wx)C_cf9JKk_ uJn?LLDw!k9qHTj1?Szp zBkfh#491DioGX%qd3%8*&8K;iGGEupwD~R7oKZq5uSlv-5mlkaE3u@j@97g7R?^CO zHJ}P!n4?Uoe#rBXkN?X@wmCUt?U8Ao$n1qs1pFX$Ntq?Zo5f4eLuV0W09)t{MpWw= zbRIok?>_A5hRux(T4APmfz++{;sHkv*+u_}Sce+fLmMm|m?urv4*ZGY5SgP1Ff@dm zP?@buDblhIB%R}zF;QSA=N4nmPv_9jAz&vhvP$SBUqZUVzJ!iSWuSJ@&~(tXxvLHK zM?FLK(-u~^5gh#d+L<&La_&J|w1UMhy2c)5mm2qR6biOI{h>CH1zN+%1B`feZ2-?Og#kyZ)9D=z zM_rwbvl;IWWcHYp`!Tr!Y`g^^yF1izsw|LCkV!V1Z?{ksMU7`m>mb`ezyWVvG=n@2 qV2f9Ghg82Ie-1@t-GJvY^|`${mQY=h3sFHW};32 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 5c912a25e4a34b166a277742044d0b584acb133f..50a9b576f7ac3f2fc8a6999818024078f76e4c64 100644 GIT binary patch delta 5514 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 i|jTiov9Kv*Uc4>)Bgdf+4Efh 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"