806 lines
26 KiB
Python
806 lines
26 KiB
Python
"""Save.TV Online-Videorecorder — EPG Scanner + Film-Tipps + Aufnahme + Download.
|
|
|
|
Architektur:
|
|
- EPG-Daten von Save.TV: TvProgrammFilm.cfm (3 Tage) + TvProgrammFilmHighlights.cfm (4 Wochen)
|
|
- Nur TVCATEGORYID 1 (Spielfilm), Spam-Genres rausgefiltert
|
|
- Seen-Cache: Nur neue Filme werden gemeldet (nicht erneut bei jedem Scan)
|
|
- Aufnahmen per tcJWriteRecord.cfm
|
|
- Downloads per croGetDownloadUrl2.cfm -> /mnt/savetv/ (Bind-Mount auf 2.7 TB Platte)
|
|
"""
|
|
|
|
import re
|
|
import json
|
|
import logging
|
|
import subprocess
|
|
import requests
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
|
|
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/TvProgrammFilm.cfm",
|
|
"/STV/M/obj/TVProgCtr/TvProgrammFilmHighlights.cfm",
|
|
"/STV/M/obj/TVProgCtr/TvProgramm2015.cfm",
|
|
"/STV/M/obj/TVProgCtr/TvProgramm2215.cfm",
|
|
]
|
|
|
|
SEEN_CACHE = Path("/tmp/savetv_seen_ids.json")
|
|
SEEN_MAX_AGE_DAYS = 30
|
|
|
|
DOWNLOAD_DIR = Path("/mnt/savetv")
|
|
DOWNLOAD_FORMAT_HD = 6
|
|
DOWNLOAD_FORMAT_SD = 5
|
|
|
|
AUTO_RECORD_SCORE = 80
|
|
SUGGEST_SCORE = 60
|
|
|
|
SPAM_SUBCATEGORIES = {
|
|
"teleshop", "shopping", "dauerwerbesendung", "volksmusik",
|
|
"casting", "reality", "quiz/spiel", "comic", "zeichentrick",
|
|
"erotik", "kindersendung", "sonstige",
|
|
}
|
|
|
|
GOOD_SUBCATEGORIES = {
|
|
"action", "thriller", "krimi", "drama", "komödie", "komodie",
|
|
"comedy", "science fiction", "sci-fi", "fantasy", "abenteuer",
|
|
"horror", "western", "historienfilm", "animation", "mystery",
|
|
"romanze",
|
|
}
|
|
|
|
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 der naechsten Tage/Wochen. "
|
|
"Nutze bei 'was laeuft', 'gute Filme', 'TV Tipps', 'Fernsehen', 'Save.TV'.",
|
|
"parameters": {"type": "object", "properties": {}, "required": []},
|
|
},
|
|
},
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "get_savetv_archive_filme",
|
|
"description": "Save.TV Archiv-Filme bewerten: Alle fertigen Aufnahmen holen, "
|
|
"nach Qualitaet bewerten, deduplizieren. Zeigt Top-Filme, dringende "
|
|
"(bald ablaufend) und weitere. Nutze bei 'gute Filme im Archiv', "
|
|
"'welche Filme habe ich', 'was ist sehenswert', 'Archiv bewerten'.",
|
|
"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"],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "savetv_download",
|
|
"description": "Save.TV Film downloaden auf Hetzner-Festplatte (HD, werbefrei). "
|
|
"Nutze wenn User sagt 'download', 'runterladen', 'sichern', 'speichern'. "
|
|
"Filme landen auf pve-hetzner /var/lib/vz/savetv/ (2.7 TB frei).",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"telecast_id": {"type": "number", "description": "TelecastId der Sendung"},
|
|
"title": {"type": "string", "description": "Filmtitel fuer den Dateinamen"},
|
|
},
|
|
"required": ["telecast_id"],
|
|
},
|
|
},
|
|
},
|
|
]
|
|
|
|
SYSTEM_PROMPT_EXTRA = """TV / Save.TV Tools:
|
|
- get_savetv_tipps: Zeigt sehenswerte Spielfilme der naechsten Tage/Wochen
|
|
- get_savetv_archive_filme: Bewertet alle fertigen Aufnahmen im Archiv nach Qualitaet
|
|
- savetv_record: Nimmt einen Film per TelecastId auf
|
|
- savetv_download: Laedt fertigen Film auf pve-hetzner Festplatte (HD, werbefrei, 2.7 TB frei)
|
|
- get_savetv_status: Zeigt Archiv und geplante Aufnahmen
|
|
Wenn der User nach Archiv-Filmen/Bewertung fragt, nutze get_savetv_archive_filme.
|
|
WICHTIG bei Archiv-Bewertung: Das Tool liefert KINO-HIGHLIGHTS (echte Kinofilme, Klassiker,
|
|
preisgekroente Filme) getrennt von deutschem Fernsehprogramm. Praesentiere dem User die
|
|
KINO-HIGHLIGHTS zuerst und erklaere kurz warum jeder Film sehenswert ist (Regisseur, Preise,
|
|
Stars). Hebe DRINGEND ablaufende Kino-Highlights besonders hervor — die muss er schnell sichern.
|
|
Download: Wenn User einen Film sichern/downloaden will, nutze savetv_download mit der TelecastId.
|
|
Dateien landen auf pve-hetzner (/var/lib/vz/savetv/) und koennen spaeter manuell geholt werden.
|
|
"""
|
|
|
|
|
|
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():
|
|
"""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 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"})
|
|
|
|
try:
|
|
s.post(
|
|
SAVETV_URL + "/STV/M/Index.cfm?sk=PREMIUM",
|
|
data={"sUsername": SAVETV_USER, "sPassword": SAVETV_PASS, "value": "Login"},
|
|
allow_redirects=True,
|
|
timeout=(8, 20), # (connect_timeout, read_timeout)
|
|
)
|
|
cookies = s.cookies.get_dict()
|
|
if not cookies.get("savetv_active_login"):
|
|
log.warning("Save.TV Login fehlgeschlagen")
|
|
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 _load_seen():
|
|
"""Lade gesehene TelecastIDs. Format: {id_str: 'YYYY-MM-DD'}."""
|
|
if not SEEN_CACHE.exists():
|
|
return {}
|
|
try:
|
|
data = json.loads(SEEN_CACHE.read_text())
|
|
cutoff = (datetime.now() - timedelta(days=SEEN_MAX_AGE_DAYS)).strftime("%Y-%m-%d")
|
|
return {k: v for k, v in data.items() if v >= cutoff}
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
def _save_seen(seen):
|
|
try:
|
|
SEEN_CACHE.write_text(json.dumps(seen))
|
|
except Exception as e:
|
|
log.error("Seen-Cache schreiben: %s", e)
|
|
|
|
|
|
def _get_archive(state=0, count=20):
|
|
"""Archiv abrufen (POST, wie Web-UI). state: 0=geplant, 1=fertig."""
|
|
s = _get_session()
|
|
if not s:
|
|
return {"error": "Login fehlgeschlagen"}
|
|
try:
|
|
end = datetime.now().strftime("%Y-%m-%d")
|
|
start = (datetime.now() - timedelta(days=60)).strftime("%Y-%m-%d")
|
|
r = s.post(
|
|
SAVETV_URL + "/STV/M/obj/archive/JSON/VideoArchiveApi.cfm",
|
|
data={
|
|
"bAggregateEntries": "false",
|
|
"iEntriesPerPage": str(count),
|
|
"iRecordingState": str(state),
|
|
"dStartdate": start,
|
|
"dEnddate": end,
|
|
},
|
|
headers={"X-Requested-With": "XMLHttpRequest"},
|
|
timeout=15,
|
|
)
|
|
return r.json()
|
|
except Exception as e:
|
|
return {"error": str(e)}
|
|
|
|
|
|
def _get_full_archive():
|
|
"""Alle fertigen Aufnahmen paginiert holen."""
|
|
s = _get_session()
|
|
if not s:
|
|
return []
|
|
end = datetime.now().strftime("%Y-%m-%d")
|
|
start = (datetime.now() - timedelta(days=60)).strftime("%Y-%m-%d")
|
|
all_entries = []
|
|
for page in range(1, 20):
|
|
try:
|
|
r = s.post(
|
|
SAVETV_URL + "/STV/M/obj/archive/JSON/VideoArchiveApi.cfm",
|
|
data={
|
|
"bAggregateEntries": "false",
|
|
"iEntriesPerPage": "100",
|
|
"iCurrentPage": str(page),
|
|
"iRecordingState": "1",
|
|
"dStartdate": start,
|
|
"dEnddate": end,
|
|
},
|
|
headers={"X-Requested-With": "XMLHttpRequest"},
|
|
timeout=15,
|
|
)
|
|
data = r.json()
|
|
entries = data.get("ARRVIDEOARCHIVEENTRIES", [])
|
|
if not entries:
|
|
break
|
|
all_entries.extend(entries)
|
|
except Exception as e:
|
|
log.error("Archive page %d: %s", page, e)
|
|
break
|
|
return all_entries
|
|
|
|
|
|
def _scrape_epg():
|
|
"""Holt Filme aus Save.TV Programmseiten (JSON im HTML).
|
|
|
|
Quellen:
|
|
- TvProgrammFilm.cfm: Alle Filme der naechsten 3 Tage (~35)
|
|
- TvProgrammFilmHighlights.cfm: Kuratierte Highlights 4 Wochen (~22)
|
|
- TvProgramm2015/2215.cfm: Primetime alle Genres (Filme rausfiltern)
|
|
"""
|
|
s = _get_session()
|
|
if not s:
|
|
return []
|
|
|
|
all_telecasts = []
|
|
seen_ids = set()
|
|
|
|
for page_path in EPG_PAGES:
|
|
try:
|
|
r = s.get(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)
|
|
|
|
count = len(data.get("SortedTelecasts", []))
|
|
log.debug("EPG %s: %d Sendungen", page_path.split("/")[-1], count)
|
|
except Exception as e:
|
|
log.error("EPG Scrape %s: %s", page_path, e)
|
|
|
|
log.info("EPG gesamt: %d Sendungen aus %d Quellen", len(all_telecasts), len(EPG_PAGES))
|
|
return all_telecasts
|
|
|
|
|
|
def _filter_films(telecasts, only_new=False):
|
|
"""Filtert auf sehenswerte Spielfilme.
|
|
|
|
only_new=True: Nur Filme die noch nicht im Seen-Cache sind (fuer Cronjob).
|
|
"""
|
|
films = []
|
|
now = datetime.now()
|
|
seen = _load_seen() if only_new else {}
|
|
|
|
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
|
|
|
|
tid = str(int(tc.get("ITELECASTID", 0)))
|
|
if only_new and tid in seen:
|
|
continue
|
|
|
|
score = 50
|
|
if subcat in GOOD_SUBCATEGORIES:
|
|
score += 20
|
|
|
|
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
|
|
|
|
is_highlight = tc.get("BISTIPOFDAY", False)
|
|
if is_highlight:
|
|
score += 10
|
|
|
|
tc["_score"] = score
|
|
tc["_start_dt"] = start_dt
|
|
films.append(tc)
|
|
|
|
films.sort(key=lambda x: (-x["_score"], x["_start_dt"]))
|
|
return films
|
|
|
|
|
|
def _mark_seen(films):
|
|
"""Markiere Filme als gesehen im Cache."""
|
|
seen = _load_seen()
|
|
today = datetime.now().strftime("%Y-%m-%d")
|
|
for f in films:
|
|
tid = str(int(f.get("ITELECASTID", 0)))
|
|
if tid != "0":
|
|
seen[tid] = today
|
|
_save_seen(seen)
|
|
|
|
|
|
def _record_telecast(telecast_id):
|
|
"""Aufnahme anlegen."""
|
|
s = _get_session()
|
|
if not s:
|
|
return "Login fehlgeschlagen"
|
|
try:
|
|
r = s.post(
|
|
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 "Fehler: " + str(e)
|
|
|
|
|
|
def _delete_telecast(telecast_id):
|
|
"""Loescht einen Eintrag aus dem Save.TV Online-Archiv nach erfolgreichem Download."""
|
|
s = _get_session()
|
|
if not s:
|
|
return False, "Login fehlgeschlagen"
|
|
try:
|
|
r = s.get(
|
|
SAVETV_URL + "/STV/M/obj/cRecordOrder/croDelete.cfm",
|
|
params={"TelecastID": telecast_id},
|
|
headers={"X-Requested-With": "XMLHttpRequest"},
|
|
timeout=15,
|
|
)
|
|
if r.status_code == 200:
|
|
return True, None
|
|
return False, f"HTTP {r.status_code}"
|
|
except Exception as e:
|
|
return False, str(e)
|
|
|
|
|
|
def _get_download_url(telecast_id, fmt=DOWNLOAD_FORMAT_HD, adfree=True):
|
|
"""Holt die temporaere Download-URL fuer eine fertige Aufnahme."""
|
|
s = _get_session()
|
|
if not s:
|
|
return None, "Login fehlgeschlagen"
|
|
try:
|
|
r = s.get(
|
|
SAVETV_URL + "/STV/M/obj/cRecordOrder/croGetDownloadUrl2.cfm",
|
|
params={
|
|
"TelecastId": telecast_id,
|
|
"iFormat": fmt,
|
|
"bAdFree": str(adfree).lower(),
|
|
},
|
|
headers={"X-Requested-With": "XMLHttpRequest"},
|
|
timeout=15,
|
|
)
|
|
data = r.json()
|
|
if data.get("SUCCESS"):
|
|
return data["DOWNLOADURL"], None
|
|
return None, data.get("ERROR", "Unbekannter Fehler")
|
|
except Exception as e:
|
|
return None, str(e)
|
|
|
|
|
|
def _download_film(telecast_id, title="film"):
|
|
"""Startet HD-Download im Hintergrund nach /mnt/savetv/."""
|
|
url, err = _get_download_url(telecast_id)
|
|
if err:
|
|
url, err = _get_download_url(telecast_id, fmt=DOWNLOAD_FORMAT_SD)
|
|
if err:
|
|
return None, f"Download-URL Fehler: {err}"
|
|
|
|
DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
|
safe_title = re.sub(r'[^\w\-.]', '_', title)[:80]
|
|
filename = f"{safe_title}_{telecast_id}.mp4"
|
|
target = DOWNLOAD_DIR / filename
|
|
|
|
try:
|
|
subprocess.Popen(
|
|
["wget", "-q", "-O", str(target), url],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
return filename, None
|
|
except Exception as e:
|
|
return None, f"wget Fehler: {e}"
|
|
|
|
|
|
def _format_film(f, with_tid=True):
|
|
"""Formatiert einen Film als Text."""
|
|
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 "")[:120]
|
|
tid = int(f.get("ITELECASTID", 0))
|
|
recorded = " [geplant]" if f.get("BEXISTRECORD") else ""
|
|
|
|
days_until = ""
|
|
try:
|
|
start_dt = datetime.strptime(f.get("DSTARTDATE", ""), "%Y-%m-%d %H:%M:%S")
|
|
delta = (start_dt.date() - datetime.now().date()).days
|
|
if delta == 0:
|
|
days_until = " (heute)"
|
|
elif delta == 1:
|
|
days_until = " (morgen)"
|
|
else:
|
|
days_until = " (in " + str(delta) + " Tagen)"
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
lines = [" " + title + recorded + days_until]
|
|
lines.append(" " + station + " | " + start + " | " + subcat)
|
|
if desc:
|
|
lines.append(" " + desc + "...")
|
|
if with_tid:
|
|
lines.append(" TelecastId " + str(tid))
|
|
return "\n".join(lines)
|
|
|
|
|
|
DOKU_KEYWORDS = {
|
|
"schlangen", "giftig", "gefährlich", "tiere", "tierwelt",
|
|
"wildtiere", "safari", "ozean", "meer", "ozeane",
|
|
"doku", "dokumentation", "reportage", "magazin",
|
|
"botschafter der meere", "dynastie", "leoparden",
|
|
"schildkröten", "wale", "australien", "afrika",
|
|
"gehirn unter strom",
|
|
}
|
|
|
|
KNOWN_CINEMA = {
|
|
"gravity", "blood diamond", "dunkirk",
|
|
"die fabelhafte welt der amelie", "children of men",
|
|
"die üblichen verdächtigen", "mord im orient express",
|
|
"aviator", "12 monkeys", "bullet train", "salt",
|
|
"anatomie eines falls", "i, tonya", "die wannseekonferenz",
|
|
"die vögel", "gosford park",
|
|
"albert nobbs", "der mit dem wolf tanzt", "zeugin der anklage",
|
|
"crazy heart", "talk to me", "das massaker von katyn",
|
|
"fallende blätter", "das lehrerzimmer", "die aussprache",
|
|
"old henry", "unternehmen petticoat",
|
|
"die reise zum mittelpunkt der erde",
|
|
"tagebuch einer kammerzofe",
|
|
"deutschstunde", "disco boy",
|
|
"die kinder der seidenstraße",
|
|
"führer und verführer", "nur fliegen ist schöner",
|
|
"der mit dem wolf tanzt", "nightwatch",
|
|
"der dritte", "blind willow",
|
|
"the revenant", "lion", "undisputed",
|
|
"no turning back", "parker", "stirb langsam",
|
|
"shooter", "valerian", "the suicide squad",
|
|
"mile 22", "open range", "hot summer nights",
|
|
"local hero", "z for zachariah", "the good neighbor",
|
|
"the informer", "la coda del diavolo",
|
|
"ladykillers", "hi-lo country", "yalda",
|
|
"bad director", "powder girl",
|
|
}
|
|
|
|
ENGLISH_WORDS = {
|
|
"the", "of", "and", "in", "for", "from", "with", "on", "at",
|
|
"to", "is", "has", "men", "man", "last", "night", "day", "club",
|
|
"girl", "boy", "road", "way", "no", "dead", "kill", "out",
|
|
"black", "good", "old", "new", "one", "two", "fallen",
|
|
"international", "redemption", "revenge", "spirit",
|
|
}
|
|
|
|
|
|
def _is_known_cinema(title):
|
|
"""Prueft ob ein Film als bekannter Kinofilm erkannt wird."""
|
|
t = title.lower().strip()
|
|
for known in KNOWN_CINEMA:
|
|
if t.startswith(known) or t == known:
|
|
return True
|
|
|
|
words = set(re.findall(r'[a-z]+', t))
|
|
english_count = len(words & ENGLISH_WORDS)
|
|
if re.match(r'^[A-Za-z0-9:,\'\-\.\! ]+$', title) and english_count >= 2:
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def _is_excluded(title):
|
|
"""Filtert Programmänderungen und Dokus."""
|
|
t = title.lower()
|
|
if "programmänderung" in t:
|
|
return True
|
|
for kw in DOKU_KEYWORDS:
|
|
if kw in t:
|
|
return True
|
|
return False
|
|
|
|
|
|
def handle_get_savetv_archive_filme(**kw):
|
|
"""Alle fertigen Archiv-Filme holen, in Kino vs. TV trennen, sortiert ausgeben."""
|
|
entries = _get_full_archive()
|
|
if not entries:
|
|
return "Keine Archiv-Eintraege gefunden."
|
|
|
|
seen_titles = {}
|
|
series_count = 0
|
|
excluded_count = 0
|
|
from tools import savetv_country_filter
|
|
|
|
filminfo_cache = savetv_country_filter.load_filminfo_cache()
|
|
|
|
for e in entries:
|
|
tc = e.get("STRTELECASTENTRY", {})
|
|
episode = tc.get("SFOLGE", "")
|
|
if episode:
|
|
series_count += 1
|
|
continue
|
|
|
|
title = tc.get("STITLE", "?")
|
|
if _is_excluded(title):
|
|
excluded_count += 1
|
|
continue
|
|
if savetv_country_filter.should_exclude_production_country(title, filminfo_cache):
|
|
excluded_count += 1
|
|
continue
|
|
|
|
station = tc.get("STVSTATIONNAME", "?")
|
|
days_left = int(tc.get("IDAYSLEFTBEFOREDELETE", 0))
|
|
tid = int(tc.get("ITELECASTID", 0))
|
|
is_cinema = _is_known_cinema(title)
|
|
|
|
key = title.lower().strip()
|
|
if key in seen_titles:
|
|
if days_left > seen_titles[key]["days_left"]:
|
|
seen_titles[key].update(days_left=days_left, tid=tid)
|
|
continue
|
|
|
|
seen_titles[key] = {
|
|
"title": title, "station": station,
|
|
"days_left": days_left, "tid": tid,
|
|
"cinema": is_cinema,
|
|
}
|
|
|
|
all_films = list(seen_titles.values())
|
|
cinema = sorted(
|
|
[f for f in all_films if f["cinema"]],
|
|
key=lambda x: x["days_left"],
|
|
)
|
|
tv_films = sorted(
|
|
[f for f in all_films if not f["cinema"]],
|
|
key=lambda x: x["days_left"],
|
|
)
|
|
|
|
lines = [
|
|
f"Save.TV Archiv: {len(all_films)} Filme "
|
|
f"({series_count} Serien, {excluded_count} Dokus/Spam gefiltert)\n"
|
|
]
|
|
|
|
cinema_urgent = [f for f in cinema if f["days_left"] <= 7]
|
|
cinema_safe = [f for f in cinema if f["days_left"] > 7]
|
|
|
|
if cinema_urgent:
|
|
lines.append(
|
|
f"KINO-HIGHLIGHTS DRINGEND — laufen bald ab, JETZT sichern:"
|
|
)
|
|
for f in cinema_urgent:
|
|
lines.append(
|
|
f" [{f['days_left']}d] {f['title']} ({f['station']})"
|
|
)
|
|
lines.append("")
|
|
|
|
if cinema_safe:
|
|
lines.append(f"KINO-HIGHLIGHTS ({len(cinema_safe)} Filme):")
|
|
for f in cinema_safe:
|
|
lines.append(
|
|
f" {f['title']} ({f['station']}, {f['days_left']}d)"
|
|
)
|
|
lines.append("")
|
|
|
|
if tv_films:
|
|
tv_urgent = [f for f in tv_films if f["days_left"] <= 7]
|
|
tv_safe = [f for f in tv_films if f["days_left"] > 7]
|
|
lines.append(
|
|
f"DEUTSCHE TV-FILME ({len(tv_films)}, "
|
|
f"davon {len(tv_urgent)} bald ablaufend):"
|
|
)
|
|
for f in tv_urgent:
|
|
lines.append(f" [{f['days_left']}d] {f['title']} ({f['station']})")
|
|
if tv_safe:
|
|
lines.append(f" ... und {len(tv_safe)} weitere mit >7 Tagen")
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
def handle_get_savetv_status(**kw):
|
|
archive = _get_archive(state=1, count=20)
|
|
planned = _get_archive(state=0, count=20)
|
|
|
|
if "error" in archive:
|
|
return "Save.TV Fehler: " + archive["error"]
|
|
|
|
lines = ["Save.TV Status\n"]
|
|
|
|
total = int(archive.get("ITOTALENTRIESINARCHIVE", 0))
|
|
fertig_total = int(archive.get("ITOTALENTRIES", 0))
|
|
lines.append(f"Archiv: {total} Aufnahmen gesamt, {fertig_total} fertig")
|
|
|
|
fertig = archive.get("ARRVIDEOARCHIVEENTRIES", [])
|
|
if fertig:
|
|
lines.append("\nLetzte fertige Aufnahmen:")
|
|
for e in fertig[:10]:
|
|
tc = e.get("STRTELECASTENTRY", {})
|
|
lines.append(
|
|
" " + tc.get("STITLE", "?")[:40] + " | "
|
|
+ tc.get("DSTARTDATE", "?")[:10] + " | "
|
|
+ tc.get("STVSTATIONNAME", "?")
|
|
)
|
|
|
|
geplant = planned.get("ARRVIDEOARCHIVEENTRIES", [])
|
|
plan_total = int(planned.get("ITOTALENTRIES", 0))
|
|
if geplant:
|
|
lines.append(f"\nGeplante Aufnahmen ({plan_total}):")
|
|
for e in geplant[:10]:
|
|
tc = e.get("STRTELECASTENTRY", {})
|
|
lines.append(
|
|
" " + tc.get("STITLE", "?")[:40] + " | "
|
|
+ tc.get("DSTARTDATE", "?")[:16] + " | "
|
|
+ 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, only_new=False)
|
|
if not films:
|
|
return "Keine sehenswerten Spielfilme in den naechsten Tagen gefunden."
|
|
|
|
lines = ["TV-Filmtipps\n"]
|
|
for f in films[:10]:
|
|
lines.append(_format_film(f))
|
|
lines.append("")
|
|
|
|
lines.append("Sage 'Nimm [Filmname] auf' oder nenne die TelecastId")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def get_new_films():
|
|
"""Fuer den Cronjob: Neue Filme scannen, Top-Filme automatisch aufnehmen.
|
|
|
|
Returns: (auto_recorded, suggestions)
|
|
auto_recorded: Filme die automatisch aufgenommen wurden (Score >= 85)
|
|
suggestions: Filme die dem User vorgeschlagen werden (Score 60-84)
|
|
"""
|
|
telecasts = _scrape_epg()
|
|
if not telecasts:
|
|
return [], []
|
|
|
|
films = _filter_films(telecasts, only_new=True)
|
|
_mark_seen(films)
|
|
|
|
auto_recorded = []
|
|
suggestions = []
|
|
|
|
for f in films:
|
|
score = f["_score"]
|
|
if score < SUGGEST_SCORE:
|
|
continue
|
|
|
|
if f.get("BEXISTRECORD"):
|
|
continue
|
|
|
|
if score >= AUTO_RECORD_SCORE:
|
|
tid = int(f.get("ITELECASTID", 0))
|
|
result = _record_telecast(tid)
|
|
f["_record_result"] = result
|
|
auto_recorded.append(f)
|
|
log.info("Auto-Aufnahme: %s (Score %d) -> %s",
|
|
f.get("STITLE"), score, result)
|
|
else:
|
|
suggestions.append(f)
|
|
|
|
return auto_recorded, suggestions
|
|
|
|
|
|
def handle_savetv_record(telecast_id=0, **kw):
|
|
if not telecast_id:
|
|
return "Keine TelecastId angegeben."
|
|
tid = int(telecast_id)
|
|
|
|
telecasts = _scrape_epg()
|
|
title = "ID " + str(tid)
|
|
for tc in telecasts:
|
|
if int(tc.get("ITELECASTID", 0)) == tid:
|
|
title = tc.get("STITLE", title)
|
|
break
|
|
|
|
result = _record_telecast(tid)
|
|
return "Save.TV: " + result + "\nSendung: " + title
|
|
|
|
|
|
def handle_savetv_download(telecast_id=0, title="", **kw):
|
|
"""Film von Save.TV auf pve-hetzner Festplatte downloaden."""
|
|
if not telecast_id:
|
|
return "Keine TelecastId angegeben."
|
|
tid = int(telecast_id)
|
|
|
|
if not title:
|
|
entries = _get_full_archive()
|
|
for e in entries:
|
|
tc = e.get("STRTELECASTENTRY", {})
|
|
if int(tc.get("ITELECASTID", 0)) == tid:
|
|
title = tc.get("STITLE", f"film_{tid}")
|
|
break
|
|
if not title:
|
|
title = f"film_{tid}"
|
|
|
|
filename, err = _download_film(tid, title)
|
|
if err:
|
|
return f"Download fehlgeschlagen: {err}"
|
|
|
|
return (
|
|
f"Download gestartet: {title}\n"
|
|
f"Datei: /var/lib/vz/savetv/{filename}\n"
|
|
f"Format: H.264 HD (werbefrei)\n"
|
|
f"Server: pve-hetzner (2.7 TB frei)\n"
|
|
f"Hinweis: Download laeuft im Hintergrund (1-3 GB, ca. 5-15 Min)."
|
|
)
|
|
|
|
|
|
HANDLERS = {
|
|
"get_savetv_status": handle_get_savetv_status,
|
|
"get_savetv_tipps": handle_get_savetv_tipps,
|
|
"get_savetv_archive_filme": handle_get_savetv_archive_filme,
|
|
"savetv_record": handle_savetv_record,
|
|
"savetv_download": handle_savetv_download,
|
|
}
|