fix(savetv): auto-download nur fuer Kino-Profil erlauben

This commit is contained in:
root 2026-04-25 15:08:18 +02:00
parent d0b2c182de
commit ba9c55a916
4 changed files with 213 additions and 3 deletions

View file

@ -30,6 +30,13 @@ Automatische KI-Anreicherung von Save.TV-Filmarchiv mit Beschreibungen, Darstell
- **Downloads:** `/files/<filename>` zum Browser-Download
- **Wikidata:** Fallback für Jahr-Lookup wenn nicht im KI-Cache
### 4. Auto-Qualitätsfilter
- **Datei:** `tools/savetv_country_filter.py`
- **Zielprofil:** Jellyfin-ähnliche Kinofilme, vor allem Action, Thriller, Krimi, Abenteuer, Sci-Fi, Western, Horror und starke Dramen.
- **Blockiert automatisch:** Deutschland/Frankreich-Produktionen, Doku-/Musik-/Familien-/TV-Film-Genres, typische öffentlich-rechtliche TV-Film-Titel und unerkannte ÖR-/Regionaltitel ohne Cache.
- **Erlaubt automatisch:** internationale Kinofilme aus USA/UK/Kanada/Australien/Japan/Südkorea/Hongkong mit passendem Genre oder klar erkennbarem Kino-Titel.
- **Konsequenz:** Unbekannte RBB/MDR/hr/WDR/BR/SWR/ZDF/ARD/arte/ONE-Titel werden nicht mehr automatisch aufgenommen oder heruntergeladen, bis sie angereichert sind oder eindeutig als Kinofilm erkannt werden.
## Erfahrungen / Gotchas
- `qwen2.5:14b` ist zuverlässiger für JSON-Output als `qwen3:30b-a3b`

View file

@ -88,7 +88,13 @@ def run():
if savetv._is_excluded(title):
continue
if savetv_country_filter.should_exclude_production_country(title, filminfo_cache):
allow_download, quality_reason = savetv_country_filter.classify_auto_quality(
title,
tc.get("STVSTATIONNAME", ""),
filminfo_cache,
)
if not allow_download:
log.info("Überspringe Download: %s (%s)", title, quality_reason)
continue
if tid in dl_log:

View file

@ -727,11 +727,23 @@ def get_new_films():
films = _filter_films(telecasts, only_new=True)
_mark_seen(films)
from tools import savetv_country_filter
filminfo_cache = savetv_country_filter.load_filminfo_cache()
auto_recorded = []
suggestions = []
for f in films:
allow_auto, quality_reason = savetv_country_filter.classify_auto_quality(
f.get("STITLE", ""),
f.get("STVSTATIONNAME", ""),
filminfo_cache,
)
if not allow_auto:
log.info("Auto-Aufnahme übersprungen: %s (%s)",
f.get("STITLE"), quality_reason)
continue
score = f["_score"]
if score < SUGGEST_SCORE:
continue

View file

@ -1,7 +1,9 @@
"""Save.TV — Filter: Deutschland/Frankreich (Produktionsland via Wikidata-Cache).
"""Save.TV — Filter: Produktionsland und Kino-Qualitaet.
Nutzt dieselbe .filminfo_cache.json wie savetv_extra_routes (Wikidata countries).
Ohne Cache-Eintrag fuer einen Titel wird nicht gefiltert (Film bleibt sichtbar).
Ohne Cache-Eintrag wird fuer Auto-Aufnahme/Auto-Download konservativ entschieden:
oeffentlich-rechtliche/regional ausgestrahlte Titel bleiben liegen, bis sie
angereichert sind oder eindeutig wie internationale Kinofilme aussehen.
Deaktivieren: Umgebungsvariable SAVETV_FILTER_DE_FR=0
"""
@ -25,6 +27,74 @@ _COUNTRY_EXCL = re.compile(
re.IGNORECASE,
)
_DACH_COUNTRY = re.compile(
r"\b("
r"deutschland|germany|allemagne|bundesrepublik(\s+deutschland)?|"
r"west\s+germany|east\s+germany|german\s+democratic\s+republic|"
r"rfa|\bbrd\b|\bddr\b|\bgdr\b|"
r"oesterreich|österreich|austria|schweiz|switzerland|suisse"
r")\b",
re.IGNORECASE,
)
_TRUSTED_CINEMA_COUNTRY = re.compile(
r"\b("
r"vereinigte\s+staaten|usa|united\s+states|"
r"vereinigtes\s+königreich|grossbritannien|großbritannien|"
r"great\s+britain|united\s+kingdom|kanada|canada|"
r"australien|australia|neuseeland|new\s+zealand|"
r"japan|südkorea|suedkorea|south\s+korea|hongkong|hong\s+kong"
r")\b",
re.IGNORECASE,
)
_PUBLIC_TV_STATIONS = {
"das erste", "ard", "zdf", "zdfneo", "3sat", "arte", "one hd", "one",
"rbb", "mdr", "br", "swr", "hr", "wdr", "ndr", "sr", "ard alpha",
}
_JUNK_TITLE = re.compile(
r"\b("
r"programmänderung|programmänderung!|doku|dokumentation|reportage|"
r"kommissar(?:in)?|krimi\s*-|dorfkrimi|krause[s]?|"
r"ein\s+fall\s+von\s+liebe|liebe\s+auf|"
r"ein\s+sommer\s+an|am\s+kap\s+der\s+liebe|"
r"das\s+geheimnis\s+(?:der|des)|willkommen\s+daheim|"
r"weihnachten|rosamunde\s+pilcher|inga\s+lindström|"
r"udo\s+witte|j\.d\.\s+vance"
r")\b",
re.IGNORECASE,
)
_REJECT_GENRE = {
"dokumentarfilm", "dokumentation", "musik", "musikfilm", "musical",
"familie", "familienfilm", "kinderfilm", "tv-film",
}
_DESIRED_GENRE_WORDS = {
"action", "thriller", "krimi", "kriminal", "abenteuer", "science",
"fiction", "sci-fi", "mystery", "western", "horror", "historie",
"krieg", "war", "drama",
}
_KNOWN_CINEMA_TITLE = {
"96 hours", "absolute power", "air america", "alarmstufe rot",
"american fighter", "anna", "appaloosa", "bastille day", "ben hur",
"blacklight", "blade runner", "bodyguard", "bohemian rhapsody",
"bourne", "braveheart", "carlito", "casino", "chinatown",
"colombiana", "contraband", "crank", "deadpool", "der staatsfeind",
"die bourne identität", "die bourne identitaet", "das vermächtnis",
"das vermaechtnis", "g20", "green zone", "hitman", "manchurian",
"network", "stirb langsam", "the nice guys", "tom clancy",
}
_ENGLISH_WORDS = {
"the", "of", "and", "in", "for", "from", "with", "on", "at", "to",
"man", "men", "girl", "boy", "black", "white", "red", "blue", "last",
"night", "day", "dead", "death", "kill", "killer", "war", "zone",
"sea", "job", "agent", "daughter", "flood", "marine", "carnage",
}
def _enabled() -> bool:
v = (os.environ.get("SAVETV_FILTER_DE_FR") or "1").strip().lower()
@ -65,6 +135,121 @@ def _countries_for_title(title: str, cache: dict) -> list[str] | None:
return [str(c)]
def _info_for_title(title: str, cache: dict) -> dict:
if not title or not cache:
return {}
entry = cache.get(title)
if entry is None:
nk = _norm_title_key(title)
for k, v in cache.items():
if isinstance(k, str) and _norm_title_key(k) == nk:
entry = v
break
return entry if isinstance(entry, dict) else {}
def _station_key(station: str) -> str:
return re.sub(r"\s+", " ", station or "").strip().lower()
def _is_public_tv_station(station: str) -> bool:
s = _station_key(station)
return s in _PUBLIC_TV_STATIONS
def _has_country(pattern: re.Pattern, countries: list[str]) -> bool:
return any(pattern.search(c or "") for c in countries)
def _genres(info: dict) -> list[str]:
raw = info.get("genres") if isinstance(info, dict) else []
if isinstance(raw, list):
return [str(g).lower() for g in raw if g]
if raw:
return [str(raw).lower()]
return []
def _has_desired_genre(genres: list[str]) -> bool:
joined = " ".join(genres)
return any(word in joined for word in _DESIRED_GENRE_WORDS)
def _has_reject_genre(genres: list[str]) -> bool:
joined = " ".join(genres)
return any(g in joined for g in _REJECT_GENRE)
def _looks_like_cinema_title(title: str) -> bool:
t = _norm_title_key(title)
if _is_known_cinema_seed(t):
return True
words = set(re.findall(r"[a-z]+", title.lower()))
return len(words & _ENGLISH_WORDS) >= 2
def _is_known_cinema_seed(norm_title: str) -> bool:
return any(seed in norm_title for seed in _KNOWN_CINEMA_TITLE)
def classify_auto_quality(
title: str,
station: str = "",
cache: dict | None = None,
) -> tuple[bool, str]:
"""Entscheidet konservativ, ob ein Titel automatisch aufgenommen/geladen wird.
Zielprofil aus Jellyfin: internationale Kinofilme, vor allem Action, Thriller,
Krimi, Abenteuer, Sci-Fi, Western, Horror und starke Dramen. Deutsche/regionale
TV-Produktionen und unerkannte OeR-Titel werden nicht automatisch verarbeitet.
"""
if not _enabled():
return True, "filter-disabled"
if not title or _JUNK_TITLE.search(title):
return False, "title-junk-pattern"
cache = load_filminfo_cache() if cache is None else cache
info = _info_for_title(title, cache)
countries = _countries_for_title(title, cache) or []
genres = _genres(info)
public_tv = _is_public_tv_station(station)
known_cinema = _is_known_cinema_seed(_norm_title_key(title))
cinema_title = _looks_like_cinema_title(title)
description = str(info.get("description") or "").strip() if info else ""
if info and not known_cinema and len(description) < 40:
return False, "weak-enrichment"
if countries and _has_country(_COUNTRY_EXCL, countries):
return False, "country-de-fr"
if public_tv and countries and _has_country(_DACH_COUNTRY, countries):
return False, "public-tv-dach-production"
if genres and _has_reject_genre(genres):
return False, "reject-genre"
if countries and _has_country(_TRUSTED_CINEMA_COUNTRY, countries):
if _has_desired_genre(genres) or cinema_title:
return True, "trusted-cinema-profile"
return False, "trusted-country-weak-genre"
if known_cinema:
return True, "known-cinema-title"
if public_tv and not info:
return False, "public-tv-needs-enrichment"
if not info:
return False, "needs-enrichment"
if _has_desired_genre(genres) and cinema_title:
return True, "cinema-genre-profile"
return False, "not-jellyfin-profile"
def should_exclude_production_country(title: str, cache: dict | None = None) -> bool:
"""True, wenn ein Produktionsland DE oder FR ist (laut Wikidata-Cache)."""
if not _enabled():