diff --git a/homelab-ai-bot/SAVETV_ENRICHER.md b/homelab-ai-bot/SAVETV_ENRICHER.md index 088efd354..682383d23 100644 --- a/homelab-ai-bot/SAVETV_ENRICHER.md +++ b/homelab-ai-bot/SAVETV_ENRICHER.md @@ -30,6 +30,13 @@ Automatische KI-Anreicherung von Save.TV-Filmarchiv mit Beschreibungen, Darstell - **Downloads:** `/files/` 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` diff --git a/homelab-ai-bot/savetv_auto_download.py b/homelab-ai-bot/savetv_auto_download.py index c2eef7829..aee9db3f2 100755 --- a/homelab-ai-bot/savetv_auto_download.py +++ b/homelab-ai-bot/savetv_auto_download.py @@ -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: diff --git a/homelab-ai-bot/tools/savetv.py b/homelab-ai-bot/tools/savetv.py index 92f4de486..51adffc71 100644 --- a/homelab-ai-bot/tools/savetv.py +++ b/homelab-ai-bot/tools/savetv.py @@ -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 diff --git a/homelab-ai-bot/tools/savetv_country_filter.py b/homelab-ai-bot/tools/savetv_country_filter.py index 90db9f516..9070d1c50 100644 --- a/homelab-ai-bot/tools/savetv_country_filter.py +++ b/homelab-ai-bot/tools/savetv_country_filter.py @@ -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():