Compare commits

..

6 commits

8 changed files with 602 additions and 18 deletions

View file

@ -20,6 +20,7 @@
| **KI-Video Pipeline** | ki-video/PLAN.md | Lokale Videoproduktion: Qwen + FLUX + XTTS + SadTalker (ki-tower) |
| **Webcam Kambodscha** | — | Dahua 4K → cam.arakavanews.com (CT 600 auf pve-ka-3) |
| **YouTube Desktop PP** | — | Remote Desktop (xrdp) für YT-Uploads (pve-pp-1/pp-2) |
| **Lokale KI / Vibe Coding** | hardware-ai-local-coding.md | Hardware-Entscheidung MacBook Pro M5 Max 128 GB, NVIDIA-Vergleich, Verkauf Alt-GPUs |
## Prioritäten
@ -45,8 +46,9 @@
| Muldenstein | CT 116 (pve-mu-2) | **monitoring-bot** | 100.88.230.74 | Cursor IDE + Tailscale-Gateway + SSH-Hub |
| Muldenstein | ki-tower | **ki-tower** (in Bau) | — | **Jellyfin-Ersatz** (Medienserver) — KEINE KI-Rolle |
| Muldenstein | MSI B550 | **test-rig** (in Bau) | — | Test-Bett für alte 3080er/AMD-Karten vor Verkauf |
| Muldenstein | AMD-Rig | **gpu-reserve** | — | 8x RX 6600 XT — Tendenz Verkauf |
| — | — | — | — | **Nur EINE KI-Maschine geplant**: bestehender KI-Server (Windows, RTX 3090). Kein zweiter KI-Rechner vorgesehen. |
| Muldenstein | AMD-Rig | **gpu-reserve** | — | 8x RX 6600 XT — Verkauf |
| mobil | MacBook Pro 16" | **m5-max** | — | M5 Max 128 GB / 2 TB — lokale KI / Vibe Coding / Jervais (Stand 2026-04-26, siehe hardware-ai-local-coding.md) |
| — | — | — | — | **Lokale KI: M5 Max** (mobil) + **CUDA-Pflicht: KI-Server RTX 3090**. Kein 2× 5090, kein zweiter NVIDIA-Build. |
## Telegram Bots

192
hardware-ai-local-coding.md Normal file
View file

@ -0,0 +1,192 @@
# Hardware für lokales Vibe-Coding & lokale KI
**Stand: 2026-04-26**
**Status: Plan beschlossen, Umsetzung läuft (Verkauf Alt-GPUs → MacBook Pro M5 Max 128 GB)**
---
## 1 — Entscheidung in einem Satz
Eine einzige Maschine für lokales Vibe-Coding mit großen Modellen
(Qwen 3.6 Coder, GLM, Devstral, Kimi-Coder etc.):
**MacBook Pro 16" M5 Max, 128 GB Unified Memory, 2 TB SSD**, gekauft
in Hongkong (deutlich günstiger als DE), eingerichtet vor Ort,
ohne OVP nach Kambodscha mitgenommen.
Begründung kurz: Großer **zusammenhängender** Speicher,
Apple-Silicon-Effizienz, hohe Bandbreite, MLX-Ökosystem,
fertiges leises Gerät. Kein Bastel-Build, keine Kühl-/Strom-Gymnastik,
keine VRAM-Fragmentierung.
---
## 2 — Warum NICHT NVIDIA-Multi-GPU
### Harter Fakt zuerst
Bei aktuellen NVIDIA-Consumer-Karten (RTX 4090, **RTX 5090**) gibt es
**kein NVLink mehr**. Das heißt:
- 2× RTX 5090 = **2× 32 GB getrennter VRAM**, **NICHT** 64 GB Unified.
- Modelle, die nicht in 32 GB passen, müssen partitioniert werden
(Tensor-Parallel, Pipeline-Parallel) — mit hohen Latenzen über PCIe,
Treiber-Friktion und massivem Verlust bei Token-Generation und
KV-Cache-Verwaltung.
- Für **lokale Single-Model-Inferenz mit großem Kontext** ist das
ein **Ausschlusskriterium**, kein "weicher Nachteil".
### Vollkostenrechnung 2× RTX 5090 (DE-Preise, realistisch 2026)
| Posten | Preis (€) |
|--------------------------------|--------------------|
| 2× RTX 5090 | 4.000 4.800 |
| Mainboard mit 2× PCIe 5.0 x16 | 500 800 |
| CPU + RAM (>128 GB DDR5 ECC) | 900 1.400 |
| 2 TB NVMe SSD | 150 250 |
| 1500 W Platinum Netzteil | 300 450 |
| Großes Gehäuse + Lüfter | 200 400 |
| Wasserkühlung (für 2× 5090) | 400 800 |
| Windows / Linux Setup-Zeit | pauschal viel |
| **Summe Hardware** | **6.450 8.900** |
Plus laufende Kosten:
- Strom unter Last realistisch **600900 W** sustained.
- Hitze, Lautstärke, Standortzwang (nicht im Schlafzimmer, nicht im
Hotelzimmer, nicht in Phnom Penh ohne Klimaanlagen-Backup).
- Keine Mobilität.
### Vergleich M5 Max Komplettpaket
| Posten | Preis (€) |
|-------------------------------------|-------------------|
| MacBook Pro 16" M5 Max 128 GB / 2 TB (HK Listenpreis, EUR-äquivalent) | ~4.790 |
| Externer Ultrawide Monitor (später) | 400 900 |
| HDMI/Thunderbolt Adapter | 30 100 |
| **Summe** | **5.220 5.790** |
- Stromaufnahme unter Volllast **~120150 W**. Eine Größenordnung
unter NVIDIA-Doppel-GPU.
- Leise, fertig, mobil (Handgepäck-Koffer mit Polsterung).
- 128 GB **als ein Pool** — passt für Modelle, die auf 2× 5090 nicht
laufen können.
### Konsequenz
Für den konkreten Use-Case (lokales Coding-Modell, ein Dev-Workplatz,
auch unterwegs nutzbar) ist die NVIDIA-Multi-GPU-Variante
**teurer** *und* **schlechter geeignet**. Der „mehr-Rohleistung"-Reflex
ist ohne NVLink für große Einzelmodelle irrelevant.
---
## 3 — Roadmap
### Phase 1 — *jetzt*
1. **Verkauf der Alt-GPUs** (alle mit OVP):
- 4× RTX 3080 (Mining-Historie) → Test-Rig MSI B550 zum Durchtesten,
dann Verkauf eBay-Kleinanzeigen.
- 1× RTX 3090 (kein Mining) → aktuell 7001.100 € erzielbar.
- 8× AMD RX 6600 XT (8 GB) → Verkauf, ROCm auf Navi 23 für LLM
irrelevant.
- **Außerdem:** MacBook Pro 14" M1 Pro 16 GB mit OVP, kratzerlos,
wird kaum genutzt → Verkauf hilft Finanzierung.
2. Aus Verkaufserlös + Cashflow PP: **MacBook Pro 16" M5 Max 128 GB**
in HK kaufen. US-Tastatur ist OK (extern Tastatur/Maus daheim).
3. Einreise nach Kambodscha **ohne OVP**, im gepolsterten Handgepäck.
Customs-relevant: persönliches Elektronikgerät, getragen, nicht
neu/originalverpackt.
### Phase 2 — *wenn M5 Ultra erscheint und ein Modell ihn ausreizt*
- Wahrscheinliche Specs M5 Ultra: ~256 GB Unified Memory,
~1.0001.200 GB/s Bandbreite. Releasezeitpunkt offen, kein Termin.
- Ohne passendes Modell (>128 GB sinnvoll) **kein Kauf**.
- Plan: M5 Max später ggf. in Zahlung geben oder per Thunderbolt
als Co-Worker behalten.
- Realistisch: Heute existiert **kein Coder-Modell**, das 256 GB
Unified Memory voll auslastet. Erst wenn Qwen / DeepSeek / GLM
ein Coder-Modell jenseits aktueller MoE-Größen liefern, das
Cursor 4.7-Niveau lokal erreicht, lohnt der Sprung.
### Phase 3 — *Jervais lokal*
- Sobald M5 Max steht: Jervais (Assistent + Simultanübersetzung,
STT/TTS) lokal auf MLX. Kein Cloud-Token-Verbrauch mehr für
Routine-Coding und Übersetzung.
---
## 4 — Was M5 Max **nicht** kann
Damit die Doku ehrlich bleibt:
- **CUDA-Only-Workloads** (manche Vision-/Diffusion-Pipelines, einige
Trainings-Scripts) laufen nicht oder schlecht. Inferenz ist meist
per MLX/llama.cpp gelöst, Training größerer Modelle bleibt
NVIDIA-Domäne.
- **Kein Hot-Swap** von Modellen mit kalten Karten — alles teilt sich
den Unified Memory.
- **Mobil ist nicht passiv-still** unter Volllast: Lüfter dreht
hörbar hoch, dauert aber nicht lange. 16"-Chassis kühlt sustained
Loads spürbar besser als 14".
- **Geschlossener Deckel**: Reduziert Performance/Cooling, nicht
Standard-Modus für lokale LLM-Sessions. Jervais-Zentrale "im Koffer
mit zugeklapptem Deckel" funktioniert nur eingeschränkt.
---
## 5 — Lehre aus der Beratung am 2026-04-26 (selbstkritisch)
Während des Beratungsgesprächs zur Hardware-Entscheidung hat der
Assistent die Frage „M5 Max vs. NVIDIA-Multi-GPU" zunächst mit einem
generischen „mehr Rohleistung bei NVIDIA"-Argument relativiert,
**obwohl bekannt war**, dass:
- RTX 4090/5090 **kein NVLink** haben.
- Daraus folgt **kein gemeinsamer VRAM-Pool**.
- Damit ist 2× 5090 **kein Ersatz** für 128 GB Unified Memory bei
großen Einzelmodellen — sondern **architektonisch ungeeignet**.
Der User hat das zurecht als **Lüge durch falsche Gewichtung**
gewertet, nicht als bloßen Irrtum. Bekannte harte Fakten dürfen in
einer Kaufberatung nicht weichgespült werden, um „ausgewogen" zu
wirken.
**Konsequenz für künftige Beratung (Pflicht):**
1. Wenn ein Architektur-Fakt einen Vergleich zum Kippen bringt
(hier: NVLink/Unified Memory), wird er **zuerst** genannt und
**nicht** unter „Nuancen" eingebettet.
2. Vollständige Systemkosten (Mainboard, Netzteil, Kühlung, Strom,
Mobilität, Friktion) werden bei GPU-Vergleichen **immer**
beziffert, nicht nur die Karte.
3. Wenn der User eine binäre Entscheidung anfragt
(„links oder rechts"), wird klar gewichtet, nicht relativiert.
4. Bei Abweichung zwischen bekanntem Fakt und ausgegebenem Satz:
sofortige Korrektur ohne Ausreden.
Diese Regel ist auch als OpenMemory-Eintrag gespeichert
(Hardware-Beratung / Vibe-Coding / lokale KI).
---
## 6 — Status der bisherigen „HARDWARE-ZUKUNFT" (überholt)
Die alte Leitlinie in `homelab.conf`
("Es gibt genau EINE KI-Maschine — den bestehenden KI-Server,
Windows, RTX 3090") ist **mit dieser Entscheidung überholt**.
Neue Leitlinie:
- **Lokale KI für Coding & Jervais** läuft auf dem **MacBook Pro
M5 Max 128 GB** (mobil, Phnom Penh + Muldenstein).
- **Bestehender KI-Server (Windows + RTX 3090)** bleibt für Workloads,
die zwingend CUDA brauchen (SDXL, XTTS, einzelne Pipelines).
Keine Erweiterung mehr geplant.
- **Alte GPUs** (4× 3080, 8× 6600 XT) → **Verkauf**.
- **Kein zweiter NVIDIA-Build, kein 2× 5090, kein Multi-GPU-Rig.**
- **M5 Ultra** nur dann, wenn ein konkretes Modell das rechtfertigt.
`homelab.conf` und `MASTER_INDEX.md` werden parallel angepasst.

View file

@ -30,6 +30,19 @@ 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.
### 5. Wunschfilm-Liste
- **Datei:** `tools/savetv.py`, Konstante `WISHLIST_MOVIES`
- **Ziel:** Hochwertige Klassiker und Reihen automatisch aufnehmen, auch wenn Save.TV-Score oder Qualitätsfilter sie sonst nur vorschlagen würden.
- **Aktive Startliste:** `Der Clou`, `Déjà Vu`, `Die üblichen Verdächtigen`, `L.A. Confidential`, `Chinatown`, `French Connection`, `Bullitt`, `Serpico`, `Der Pate`, `GoodFellas`, `Casino`, `Scarface`, `Departed`, Western-Klassiker, Sci-Fi-Klassiker, starke Kriegs-/Politthriller, Gerichts-/Drama-Klassiker sowie Reihen wie `Jesse Stone`, `Columbo`, `Wallander`, `Jack Reacher`, `Bourne`, `Mission Impossible`, `Indiana Jones` und `James Bond`.
- **Schutz gegen Fehlgriffe:** Mehrdeutige Ein-Wort-Titel laufen nur mit Kontext. `Sting` wird nur mit Paul-Newman/Robert-Redford/1930er-Hinweisen als `Der Clou` akzeptiert; `Heat` braucht De-Niro/Pacino/Mann-Kontext; `Crash` braucht Haggis/Don-Cheadle/Los-Angeles-Kontext.
## 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

@ -3,7 +3,7 @@
SaveTV NAS Sync
Wartet 12h ± 0-30min nach Download auf Hetzner bevor Datei auf NAS kommt.
"""
import os, json, time, random, urllib.request, urllib.parse, urllib.error, email.utils
import os, json, time, random, fcntl, urllib.request, urllib.parse, urllib.error, email.utils
from datetime import datetime
ZIEL = "/mnt/nas/Filme zum nachbearbeiten"
@ -11,6 +11,7 @@ BASE = "http://138.201.84.95:9443/files"
API = "http://138.201.84.95:9443/api/films"
CALLBACK = "http://138.201.84.95:9443/api/nas_synced"
LOG = "/var/log/savetv_sync.log"
LOCKFILE = "/tmp/savetv_sync.lock"
MIN_ALTER_H = 24
JITTER_MIN = 30 # ± bis zu 30 Minuten Zufall
@ -115,4 +116,10 @@ def sync():
log(f"Fertig. {kopiert} neue Filme kopiert.")
if __name__ == "__main__":
sync()
with open(LOCKFILE, "w") as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
log("Sync läuft bereits, überspringe diesen Start.")
raise SystemExit(0)
sync()

View file

@ -43,6 +43,119 @@ DOWNLOAD_FORMAT_SD = 5
AUTO_RECORD_SCORE = 80
SUGGEST_SCORE = 60
def _title_pattern(title):
"""Titel exakt genug matchen, aber Leerzeichen/Interpunktion tolerant halten."""
parts = [re.escape(p) for p in re.findall(r"\w+", title.strip().lower(), re.UNICODE)]
return r"(?<!\w)" + r"[\W_]+".join(parts) + r"(?!\w)"
def _wishlist_title(name, *aliases):
return {
"name": name,
"title_patterns": [_title_pattern(a) for a in aliases],
"ambiguous_title_patterns": [],
"required_context_patterns": [],
}
def _wishlist_ambiguous(name, aliases, context):
return {
"name": name,
"title_patterns": [],
"ambiguous_title_patterns": [_title_pattern(a) for a in aliases],
"required_context_patterns": context,
}
WISHLIST_MOVIES = [
{
"name": "Der Clou",
"title_patterns": [
r"\bder\s+clou\b",
r"\bthe\s+sting\b",
],
# "Sting" alleine ist mehrdeutig; nur mit inhaltlichem Treffer aufnehmen.
"ambiguous_title_patterns": [
r"\bsting\b",
],
"required_context_patterns": [
r"\bpaul\s+newman\b",
r"\brobert\s+redford\b",
r"\bjohnny\s+hooker\b",
r"\bgondorff\b",
r"\blonnegan\b",
r"\b1930\b",
],
},
_wishlist_title("Déjà Vu", "Déjà Vu - Wettlauf gegen die Zeit", "Deja Vu - Wettlauf gegen die Zeit"),
_wishlist_title("Die üblichen Verdächtigen", "Die üblichen Verdächtigen", "The Usual Suspects"),
_wishlist_title("L.A. Confidential", "L.A. Confidential"),
_wishlist_title("Chinatown", "Chinatown"),
_wishlist_title("French Connection", "French Connection", "The French Connection"),
_wishlist_title("Bullitt", "Bullitt"),
_wishlist_title("Serpico", "Serpico"),
_wishlist_title("Dog Day Afternoon", "Dog Day Afternoon", "Hundstage"),
_wishlist_title("Bonnie und Clyde", "Bonnie und Clyde", "Bonnie and Clyde"),
_wishlist_title("Der Pate", "Der Pate", "The Godfather"),
_wishlist_title("GoodFellas", "GoodFellas", "GoodFellas - Drei Jahrzehnte in der Mafia"),
_wishlist_title("Casino", "Casino"),
_wishlist_title("Scarface", "Scarface"),
_wishlist_title("Carlito's Way", "Carlito's Way", "Carlitos Way"),
_wishlist_title("Es war einmal in Amerika", "Es war einmal in Amerika", "Once Upon a Time in America"),
_wishlist_title("Departed", "Departed", "Departed - Unter Feinden"),
_wishlist_title("Spiel mir das Lied vom Tod", "Spiel mir das Lied vom Tod", "Once Upon a Time in the West"),
_wishlist_title("Zwei glorreiche Halunken", "Zwei glorreiche Halunken", "The Good, the Bad and the Ugly"),
_wishlist_title("Der mit dem Wolf tanzt", "Der mit dem Wolf tanzt", "Dances with Wolves"),
_wishlist_title("Open Range", "Open Range"),
_wishlist_title("Erbarmungslos", "Erbarmungslos", "Unforgiven"),
_wishlist_title("True Grit", "True Grit"),
_wishlist_title("Butch Cassidy and the Sundance Kid", "Butch Cassidy and the Sundance Kid", "Zwei Banditen"),
_wishlist_title("Blade Runner", "Blade Runner"),
_wishlist_title("Alien", "Alien - Das unheimliche Wesen aus einer fremden Welt"),
_wishlist_title("Aliens", "Aliens - Die Rückkehr", "Aliens - Die Rueckkehr"),
_wishlist_title("Terminator", "Terminator"),
_wishlist_title("Terminator 2", "Terminator 2", "Terminator 2 - Tag der Abrechnung"),
_wishlist_title("Matrix", "Matrix", "The Matrix"),
_wishlist_title("12 Monkeys", "12 Monkeys", "Twelve Monkeys"),
_wishlist_title("Children of Men", "Children of Men"),
_wishlist_title("Gattaca", "Gattaca"),
_wishlist_title("Minority Report", "Minority Report"),
_wishlist_title("Apocalypse Now", "Apocalypse Now"),
_wishlist_title("Full Metal Jacket", "Full Metal Jacket"),
_wishlist_title("Platoon", "Platoon"),
_wishlist_title("Der Soldat James Ryan", "Der Soldat James Ryan", "Saving Private Ryan"),
_wishlist_title("Black Hawk Down", "Black Hawk Down"),
_wishlist_title("Die Unbestechlichen", "Die Unbestechlichen", "All the President's Men"),
_wishlist_title("JFK", "JFK"),
_wishlist_title("Argo", "Argo"),
_wishlist_title("Zeugin der Anklage", "Zeugin der Anklage", "Witness for the Prosecution"),
_wishlist_title("Die zwölf Geschworenen", "Die zwölf Geschworenen", "12 Angry Men"),
_wishlist_title("Einer flog über das Kuckucksnest", "Einer flog über das Kuckucksnest", "One Flew Over the Cuckoo's Nest"),
_wishlist_title("Die Verurteilten", "Die Verurteilten", "The Shawshank Redemption"),
_wishlist_title("Forrest Gump", "Forrest Gump"),
_wishlist_title("Rain Man", "Rain Man"),
_wishlist_title("Million Dollar Baby", "Million Dollar Baby"),
_wishlist_title("Jesse Stone", "Jesse Stone"),
_wishlist_title("Columbo", "Columbo"),
_wishlist_title("Wallander", "Wallander"),
_wishlist_title("Jack Reacher", "Jack Reacher", "Reacher"),
_wishlist_title("Bourne", "Bourne"),
_wishlist_title("Mission Impossible", "Mission Impossible", "Mission: Impossible"),
_wishlist_title("Indiana Jones", "Indiana Jones"),
_wishlist_title("James Bond", "James Bond 007", "007"),
_wishlist_ambiguous(
"Heat",
["Heat"],
[r"\bdeniro\b", r"\bde\s+niro\b", r"\bal\s+pacino\b", r"\bmann\b", r"\bwaingro\b"],
),
_wishlist_ambiguous(
"Crash",
["Crash"],
[r"\bpaul\s+haggis\b", r"\bdon\s+cheadle\b", r"\bmatt\s+dillon\b", r"\blos\s+angeles\b"],
),
]
SPAM_SUBCATEGORIES = {
"teleshop", "shopping", "dauerwerbesendung", "volksmusik",
"casting", "reality", "quiz/spiel", "comic", "zeichentrick",
@ -353,6 +466,11 @@ def _filter_films(telecasts, only_new=False):
if is_highlight:
score += 10
wishlist_match = _wishlist_match(tc)
if wishlist_match:
score = max(score, 100)
tc["_wishlist_match"] = wishlist_match
tc["_score"] = score
tc["_start_dt"] = start_dt
films.append(tc)
@ -372,6 +490,23 @@ def _mark_seen(films):
_save_seen(seen)
def _wishlist_match(tc):
"""Gibt den Wunschtitel zurueck, wenn eine Sendung sicher passt."""
haystack = " ".join(
str(tc.get(k) or "")
for k in ("STITLE", "STHEMA", "SFULLSUBTITLE", "SDESCRIPTION")
).lower()
title = str(tc.get("STITLE") or "").lower()
for movie in WISHLIST_MOVIES:
for pattern in movie["title_patterns"]:
if re.search(pattern, title, re.IGNORECASE):
return movie["name"]
if any(re.search(p, title, re.IGNORECASE) for p in movie["ambiguous_title_patterns"]):
if any(re.search(p, haystack, re.IGNORECASE) for p in movie["required_context_patterns"]):
return movie["name"]
return None
def _record_telecast(telecast_id):
"""Aufnahme anlegen."""
s = _get_session()
@ -727,11 +862,24 @@ 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:
wishlist_match = f.get("_wishlist_match")
allow_auto, quality_reason = savetv_country_filter.classify_auto_quality(
f.get("STITLE", ""),
f.get("STVSTATIONNAME", ""),
filminfo_cache,
)
if not allow_auto and not wishlist_match:
log.info("Auto-Aufnahme übersprungen: %s (%s)",
f.get("STITLE"), quality_reason)
continue
score = f["_score"]
if score < SUGGEST_SCORE:
continue
@ -744,8 +892,12 @@ def get_new_films():
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)
if wishlist_match:
log.info("Wunschfilm-Aufnahme (%s): %s -> %s",
wishlist_match, f.get("STITLE"), result)
else:
log.info("Auto-Aufnahme: %s (Score %d) -> %s",
f.get("STITLE"), score, result)
else:
suggestions.append(f)

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,75 @@ _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", "appaloosa", "bastille day", "ben hur",
"blacklight", "blade runner", "bodyguard", "bohemian rhapsody",
"bourne", "braveheart", "carlito", "casino", "chinatown",
"colombiana", "contraband", "crank", "deadpool", "der staatsfeind",
"der clou", "the sting",
"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 +136,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():

View file

@ -300,12 +300,20 @@ TUNNEL_600_MU3="arakavanews.com|:80|standby"
TUNNEL_601_MU3="rss-manager|:8080|standby"
# ============================================================
# HARDWARE-ZUKUNFT — Muldenstein (Stand 2026-04-20)
# HARDWARE-ZUKUNFT — Muldenstein/PP (Stand 2026-04-26)
# ============================================================
# LEITLINIE: Es gibt genau EINE KI-Maschine — den bestehenden KI-Server
# (Windows, RTX 3090, Tailscale 100.84.255.83). KEIN zweiter KI-Rechner
# ist geplant. Wenn irgendwann ein Dual-GPU- oder Upgrade-Setup kommt,
# ist das ein SEPARATER neuer Build — weder ki-tower noch MSI-B550-Build.
# LEITLINIE NEU (ersetzt 2026-04-20):
# Lokale KI fuer Coding & Jervais laeuft auf dem MacBook Pro 16"
# M5 Max 128 GB / 2 TB (Kauf in HK, mobil DE+KH). Details und
# Begruendung: hardware-ai-local-coding.md im Repo-Root.
#
# Bestehender KI-Server (Windows + RTX 3090, Tailscale 100.84.255.83)
# bleibt fuer CUDA-Pflicht-Workloads (SDXL/XTTS/spezifische
# Pipelines). KEINE Erweiterung. Alte GPUs (4x 3080, 8x 6600 XT)
# werden verkauft. KEIN zweiter NVIDIA-Build, KEIN 2x5090-Rig.
# Aktueller M5 Max ist ein einzelnes Geraet, kein Cluster.
# M5 Ultra spaeter nur, wenn ein konkretes Coder-Modell den Sprung
# rechtfertigt.
# ============================================================
# --- ki-tower (geplant, in Bau) --- JELLYFIN-ERSATZ
@ -340,10 +348,28 @@ TUNNEL_601_MU3="rss-manager|:8080|standby"
# ROCm auf Navi 23 weiter inoffiziell. Fuer LLM praktisch irrelevant.
# Status: offen, Tendenz Verkauf.
# --- Dedizierte zweite KI-Maschine --- NICHT GEPLANT
# Sollte der Bedarf (Dual-GPU, Mac Studio, RTX Pro 6000) jemals
# aufkommen, waere das ein KOMPLETT NEUER Build — NICHT ki-tower,
# NICHT MSI-B550, NICHT KI-Server-Umbau.
# --- Dedizierte zweite KI-Maschine --- ERLEDIGT durch M5 Max
# Statt zweitem NVIDIA-Build wurde ein MacBook Pro 16" M5 Max
# 128 GB / 2 TB als lokale KI-Maschine entschieden (siehe
# hardware-ai-local-coding.md). Mobil, leise, 128 GB Unified
# Memory als ein Pool. Zaehlt logisch als die "zweite KI-Maschine".
# Standort: Phnom Penh primaer, Muldenstein bei Aufenthalt.
# Kein Eintrag in der Tailscale-Server-Tabelle, weil mobil.
# --- M5 Ultra (zukuenftig, optional) --- NICHT GEPLANT VOR MODELL
# Erst kaufen, wenn ein Coder-Modell vorliegt, das Cursor-4.7-Niveau
# lokal mit >128 GB Unified Memory erreicht. Bis dahin: kein Kauf.
# Pfad spaeter: M5 Max in Zahlung geben oder per Thunderbolt
# als Co-Worker behalten.
# --- Verkauf Alt-GPUs --- AKTIV (Test-Marathon naechste Woche)
# 4x RTX 3080 10 GB (Mining-Historie) -> testen auf MSI-B550-Rig,
# dann eBay-Kleinanzeigen.
# 1x RTX 3090 24 GB (kein Mining) -> aktuell 700-1100 EUR.
# 8x AMD RX 6600 XT 8 GB -> Verkauf, ROCm fuer LLM
# praktisch irrelevant.
# 1x MacBook Pro 14" M1 Pro 16 GB OVP -> Verkauf, finanziert M5 Max
# mit.
# ============================================================
# NAMENS-MAPPING (Alt → Neu) — Referenz für Migration