
# coding: utf-8
from __future__ import annotations
import logging
from typing import Any, Optional, Callable
import common  # Gemeinsame Variablen/Funktionen
import sys
import urllib.request
import urllib.parse
import base64
import json
import xbmc
import xbmcgui
import xbmcaddon
import xbmcplugin
import os
import xbmcvfs
import ssl  # Wichtig für HTTPS-Requests
import hashlib
import time
from datetime import datetime  # <--- Wichtiger Import ganz oben
import threading
import weakref
import re
import traceback
import random  # <-- NEUER IMPORT für Zufallsfunktionen
import errno
import shutil

# --- Details-Cache Schema ---
# In frueheren Versionen gab es einen instabilen Cache-Key (Query komplett entfernt).
# Das kann zu Kollisionen fuehren (z.B. /details.json?pid=... -> alles gleich) und dann
# erscheinen Staffeln/Episoden leer, weil falsche Details aus dem Cache kommen.
# Daher versionieren wir den lokalen Details-Cache und invalidieren ihn automatisch,
# sobald sich das Schema aendert.
DETAILS_CACHE_SCHEMA_VERSION = 3

def _details_schema_file() -> str:
    try:
        if SERIES_DETAILS_JSON_CACHE_DIR:
            return os.path.join(SERIES_DETAILS_JSON_CACHE_DIR, '.schema_version')
    except Exception:
        pass
    return ''

def _details_cache_invalidate(reason: str = "") -> None:
    """Loescht den lokalen Details-Cache (nur unser Cache-Dir + seen-file).

    Wird verwendet, wenn sich das Cache-Schema aendert (um Kollisionen/Leercaches
    aus alten Versionen zu vermeiden).
    """
    try:
        # Cache-Dateien
        d = SERIES_DETAILS_JSON_CACHE_DIR
        if d and os.path.isdir(d):
            for name in os.listdir(d):
                if name in ('.schema_version',):
                    continue
                p = os.path.join(d, name)
                try:
                    if os.path.isfile(p):
                        os.remove(p)
                except Exception:
                    pass
        # seen-urls
        if SERIES_SEEN_URLS_FILE and os.path.exists(SERIES_SEEN_URLS_FILE):
            try:
                os.remove(SERIES_SEEN_URLS_FILE)
            except Exception:
                pass
        xbmc.log(f"[{common.addon_id} series.py] [details-cache] Invalidate (schema->{DETAILS_CACHE_SCHEMA_VERSION}) reason={reason}", xbmc.LOGINFO)
    except Exception:
        pass

def _ensure_details_cache_schema() -> None:
    """Stellt sicher, dass der lokale Details-Cache zum aktuellen Schema passt."""
    try:
        sf = _details_schema_file()
        if not sf:
            return

        old = None
        try:
            if os.path.exists(sf):
                with open(sf, 'r', encoding='utf-8', errors='replace') as fh:
                    txt = (fh.read() or '').strip()
                old = int(txt) if txt.isdigit() else None
        except Exception:
            old = None

        if old != DETAILS_CACHE_SCHEMA_VERSION:
            _details_cache_invalidate(reason=f"old={old}")
            try:
                ensure_parent_dir(sf)
                with open(sf, 'w', encoding='utf-8') as fh:
                    fh.write(str(DETAILS_CACHE_SCHEMA_VERSION))
            except Exception:
                # best-effort
                pass
    except Exception:
        pass




def ensure_parent_dir(path: str) -> None:
    """(Zentralisiert) Stellt sicher, dass das Parent-Verzeichnis existiert."""
    try:
        d = os.path.dirname(path) if path else ""
        if d and not common.fs_exists(d):
            common.fs_mkdirs(d)
    except Exception:
        pass

def _fsync_dir(dir_path: str) -> None:
    """(Zentralisiert) Best-effort fsync auf ein Verzeichnis."""
    try:
        common.fsync_dir_for(os.path.join(dir_path or ".", "."))
    except Exception:
        pass

def atomic_write_json_fsync(path: str, obj) -> None:
    """(Zentralisiert) JSON atomisch + durable schreiben (fsync)."""
    # common.atomic_write_json ist bereits durable + backup by default
    common.atomic_write_json(path, obj, indent=2, durable=True, backup=True)

def read_json_or_default(path: str, default):
    try:
        with open(path, "r", encoding="utf-8") as f:
            return json.load(f)
    except FileNotFoundError:
        return default
    except Exception:
        return default



# ----------------------------
# Step 4 Helpers: Atomic VFS writes + Safe JSON reads (mit .bak Fallback)
# ----------------------------
_FILE_LOCKS = weakref.WeakValueDictionary()
_FILE_LOCKS_GUARD = threading.Lock()

def _lock_for_path(path: str):
    p = (path or "").strip()
    with _FILE_LOCKS_GUARD:
        lk = _FILE_LOCKS.get(p)
        if lk is None:
            lk = threading.Lock()
            _FILE_LOCKS[p] = lk
        return lk

def _safe_read_text_vfs(path: str) -> str:
    """(Zentralisiert) Best-effort Text lesen über VFS/OS."""
    try:
        real = xbmcvfs.translatePath(path)
    except Exception:
        real = path
    try:
        if real and os.path.exists(real):
            with open(real, "r", encoding="utf-8", errors="replace") as fh:
                return fh.read()
    except Exception:
        pass
    try:
        if path and xbmcvfs.exists(path):
            f = xbmcvfs.File(path)
            try:
                raw = f.read()
            finally:
                f.close()
            if isinstance(raw, bytes):
                return raw.decode("utf-8", errors="replace")
            return raw or ""
    except Exception:
        pass
    return ""

def _safe_read_bytes_vfs(path: str) -> bytes:
    try:
        if not path or not xbmcvfs.exists(path):
            return b""
        with xbmcvfs.File(path, "rb") as f:
            return f.read() or b""
    except Exception:
        try:
            if path and os.path.exists(path):
                with open(path, "rb") as f:
                    return f.read() or b""
        except Exception:
            pass
        return b""



def _normalize_watch_key(key: str) -> str:
    """
    Normalisiert Identifier-Keys für watched_status:
      - URL-decode
      - entfernt user:pass@ aus http(s) URLs
      - lässt lokale/special:// Pfade unberührt
    """
    s = (key or "").strip()
    if not s:
        return ""

    # URL decoding (falls mal encoded kommt)
    try:
        s = urllib.parse.unquote(s)
    except Exception:
        pass

    # Nur bei http(s) wollen wir Credentials rauswerfen
    if s.startswith(("http://", "https://")):
        try:
            u = urllib.parse.urlsplit(s)
            host = u.hostname or ""
            if u.port:
                host += f":{u.port}"
            # userinfo wird absichtlich NICHT übernommen
            s = urllib.parse.urlunsplit((u.scheme, host, u.path, u.query, u.fragment))
        except Exception:
            pass

    return s



def _atomic_write_text_vfs(path: str, text: str, *, keep_bak=True) -> bool:
    """(Zentralisiert) Atomisch schreiben (Text) inkl. fsync/dir-fsync, wenn möglich."""
    try:
        data = (text or "").encode("utf-8", errors="replace")
        return common.atomic_write_bytes(path, data, backup=bool(keep_bak), durable=True)
    except Exception:
        return False

def _atomic_write_bytes_vfs(path: str, data: bytes, *, keep_bak=False) -> bool:
    """(Zentralisiert) Atomisch schreiben (Bytes) inkl. fsync/dir-fsync, wenn möglich."""
    try:
        if data is None:
            data = b""
        return common.atomic_write_bytes(path, data, backup=bool(keep_bak), durable=True)
    except Exception:
        return False

def _safe_json_read_vfs(path: str, default):
    """(Zentralisiert) Robust JSON lesen (final/.bak/.tmp) via common."""
    return common.safe_load_json_file(path, {} if default is None else default)

def _atomic_write_json_vfs(path: str, obj, *, indent=2, keep_bak=True) -> bool:
    """(Zentralisiert) JSON atomisch + durable schreiben via common.

    Wichtig: viele Call-Sites übergeben `indent=`. Das muss hier akzeptiert werden,
    sonst wird stillschweigend nichts gespeichert (weil Exceptions in Callern
    oft gefangen werden).
    """
    try:
        return bool(common.atomic_write_json(path, obj, indent=indent, durable=True, backup=bool(keep_bak)))
    except Exception:
        return False

def _vfs_exists(p: str) -> bool:
    """Existence check with fallback for local absolute paths.

    In some Kodi/Linux environments, xbmcvfs.exists() may return False for
    regular absolute filesystem paths (e.g. /home/.../.kodi/...). We therefore
    fall back to os.path.exists() when xbmcvfs.exists() is False.
    """
    try:
        if not p:
            return False
        try:
            if xbmcvfs.exists(p):
                return True
        except Exception:
            pass
        # Fallback for normal filesystem paths
        try:
            import os
            return os.path.exists(p)
        except Exception:
            return False
    except Exception:
        return False

def _vfs_delete_quiet(p: str):
    if not p:
        return
    try:
        # VFS delete
        if _vfs_exists(p):
            try:
                xbmcvfs.delete(p)
                return
            except Exception:
                pass
    except Exception:
        pass
    # OS fallback
    try:
        import os
        if os.path.exists(p):
            os.remove(p)
    except Exception:
        pass

def _vfs_read_text(p: str) -> str:
    """Read a text file via Kodi VFS with robust fallbacks.

    Do NOT rely on xbmcvfs.exists() for local absolute paths (it can be false).
    """
    if not p:
        return ""
    # Try VFS read first (works for special:// and usually also absolute paths)
    try:
        with xbmcvfs.File(p, "r") as fh:
            return fh.read() or ""
    except Exception:
        pass
    # Fallback: plain filesystem read for absolute paths
    try:
        import io, os
        if os.path.exists(p):
            with io.open(p, 'r', encoding='utf-8', errors='ignore') as fh:
                return fh.read() or ""
    except Exception:
        pass
    return ""




def _read_json_resilient(final_path: str, default):
    """
    Robust read:
      - try final
      - else try .bak
      - else try .tmp
    Wenn .bak oder .tmp ok ist -> restore to final (atomisch).
    """
    if not final_path:
        return default

    candidates = [final_path, final_path + ".bak", final_path + ".tmp"]
    for p in candidates:
        raw = _vfs_read_text(p)
        if not raw:
            continue
        try:
            data = json.loads(raw)
        except Exception:
            continue

        # wenn nicht final, restore
        if p != final_path:
            try:
                _atomic_write_json_vfs(final_path, data)
            except Exception:
                pass
        return data

    return default


def _safe_load_watched_status(path: str) -> dict:
    # ✅ Series watched V2: hashed keys, auto-migration on load
    with _WATCHED_STATUS_LOCK:
        try:
            data = common.load_watched_status_series(path)
        except Exception:
            data = _read_json_resilient(path, default={})
        return data if isinstance(data, dict) else {}


def _safe_save_watched_status(path: str, watched_status: dict) -> bool:
    # ✅ Series watched V2: compact save (hashed keys, no lastplayed)
    if not isinstance(watched_status, dict):
        watched_status = {}
    with _WATCHED_STATUS_LOCK:
        # Snapshot gegen "dict changed size during iteration" in Threads
        try:
            snap = json.loads(json.dumps(watched_status, ensure_ascii=False))
            if not isinstance(snap, dict):
                snap = dict(watched_status)
        except Exception:
            snap = dict(watched_status)

        try:
            return bool(common.save_watched_status_series(path, snap))
        except Exception:
            return bool(_atomic_write_json_vfs(path, snap, keep_bak=True))





# --- Payload Cache (ersetzt große data= JSON in URLs durch pid:HASH) ---
PAYLOAD_CACHE_DIR = os.path.join(common.addon_data_dir, "payload_cache")
try:
    if not xbmcvfs.exists(PAYLOAD_CACHE_DIR):
        xbmcvfs.mkdirs(PAYLOAD_CACHE_DIR)
except Exception:
    pass
PAYLOAD_CACHE_MAX_AGE_S = 14 * 24 * 60 * 60   # 14 Tage
PAYLOAD_CACHE_MAX_FILES = 2000                # hartes Limit
_PAYLOAD_GC_LAST_TS = 0
_PAYLOAD_GC_MIN_INTERVAL_S = 10 * 60          # GC max alle 10 Min

_CONTAINER_RELOAD_DEFER_LOCK = threading.Lock()
_CONTAINER_RELOAD_DEFER_ACTIVE = False

LAST_SERIES_CACHE_KEY = ""

BG_PREFETCH_RUNNING = threading.Event()
BG_IMAGE_PREFETCH_RUNNING = threading.Event()
PROGRESS_WORKER_RUNNING = threading.Event()

BG_PREFETCH_STOP = threading.Event()

CURRENT_SERIES_LIST = []
CURRENT_WATCH_SIG = ""

_IMAGE_MISS_TTL_S = 300  # 5 Minuten: Misses werden neu geprüft
_IMAGE_CACHE_LOCK = threading.Lock()
_WATCHED_STATUS_LOCK = threading.Lock()
_PROGRESS_CACHE_LOCK = threading.Lock()


# --- Progress-Cache & Prefetch (NEU) ---
MAX_IMAGE_PREFETCH = 30  # wie viele Serienposter aktiv vorgeladen werden dürfen

SERIES_PROGRESS_CACHE_FILE = os.path.join(common.addon_data_dir, "series_progress_cache.json")
PROGRESS_CACHE_VERSION = 1

# --- Background Prefetch (NEU) ---
BACKGROUND_REFRESH_INTERVAL_S = 1 * 60 * 60   # alle 1h
IMAGE_PREFETCH_LIMIT_PER_CYCLE = 80          # pro Zyklus (Poster/Backcover)
DETAILS_MAX_FETCH_BG = 60                   # wie viele Serien-Details pro Hintergrundlauf

# --- Throttled Container Reload (gegen doppeltes Laden / zwischenrein Refresh) ---
_CONTAINER_RELOAD_LOCK = threading.Lock()
_CONTAINER_RELOAD_LAST_TS = 0.0

def _exec_builtin_gui(cmd: str, delay: str = "00:01", alarm_id: str = "glotzbox_gui"):
    """
    GUI-sicheres xbmc.executebuiltin:
      - im MainThread: direkt ausführen
      - in Background-Threads: via AlarmClock(...) in den GUI-Thread schieben
    AlarmClock Syntax siehe Kodi Builtins. :contentReference[oaicite:1]{index=1}
    """
    try:
        if threading.current_thread() is threading.main_thread():
            xbmc.executebuiltin(cmd)
            return True
    except Exception:
        pass

    try:
        # cmd in AlarmClock-String quoten/escapen
        esc = (cmd or "").replace("\\", "\\\\").replace('"', '\\"')
        xbmc.executebuiltin(f'AlarmClock({alarm_id},"{esc}",{delay},silent)')
        return True
    except Exception as e:
        # letzter Fallback (kann in manchen Kodi-Builds trotzdem funktionieren)
        try:
            xbmc.log(f"[{common.addon_id}] _exec_builtin_gui AlarmClock failed: {e}", xbmc.LOGDEBUG)
        except Exception:
            pass
        try:
            xbmc.executebuiltin(cmd)
            return True
        except Exception:
            return False


def _is_series_root_folderpath(fp: str) -> bool:
    """
    Erkennung deiner Serien-Hauptliste.
    Du nutzt 'action=show_serien' (siehe monitor_playback_series).
    """
    try:
        fp_low = (fp or "").lower()
        return ("action=show_serien" in fp_low) or ("action=check_series_json" in fp_low)
    except Exception:
        return False



def _verify_series_and_update_progress_cache(details_json, details_url_val, original_serie_key, watched_status):
    """
    Wird beim Öffnen einer Serie (show_seasons) aufgerufen:
    - berechnet Serien-Completed sofort aus details_json + watched_status
    - schreibt/aktualisiert series_progress_cache.json für genau diese Serie
    """
    try:
        if not watched_status or not isinstance(details_json, dict) or not details_json:
            return False

        cache_key = (details_url_val or original_serie_key or "").strip()
        if not cache_key:
            return False

        w, t, completed = _series_progress_from_details(details_json, watched_status)

        progress_cache = _load_progress_cache()
        old = progress_cache.get(cache_key) if isinstance(progress_cache, dict) else None
        old_completed = bool(old.get("completed")) if isinstance(old, dict) else None

        watch_sig_now = _watch_signature(watched_status)
        progress_cache[cache_key] = {
            "watched": int(w),
            "total": int(t),
            "completed": bool(completed),
            "watch_sig": watch_sig_now,
            "ts": time.time()
        }
        _save_progress_cache(progress_cache)

        # True wenn sich completed geändert hat (nützlich fürs Logging/optional Refresh)
        return (old_completed is None) or (old_completed != bool(completed))

    except Exception as e:
        xbmc.log(f"[{common.addon_id} series.py] _verify_series_and_update_progress_cache: {e}", xbmc.LOGDEBUG)
        return False



def _container_reload_throttled(reason="", min_interval_s=1.8, prefer_update=True):
    """
    Throttled Container Reload:
      - verhindert mehrfaches Refresh/Update kurz hintereinander
      - wenn SKIP wegen Throttle: optional deferred Refresh (damit async_progress nicht "verhungert")

    WICHTIG (Crash-Fix):
      - Container.Update/Refresh wird GUI-sicher ausgeführt:
        -> im Thread via AlarmClock(...) statt direkt.
    """
    global _CONTAINER_RELOAD_LAST_TS, _CONTAINER_RELOAD_DEFER_ACTIVE

    def _schedule_deferred(delay_s: float):
        global _CONTAINER_RELOAD_DEFER_ACTIVE
        try:
            with _CONTAINER_RELOAD_DEFER_LOCK:
                if _CONTAINER_RELOAD_DEFER_ACTIVE:
                    return
                _CONTAINER_RELOAD_DEFER_ACTIVE = True

            def _run():
                global _CONTAINER_RELOAD_DEFER_ACTIVE
                try:
                    time.sleep(max(0.05, float(delay_s)))
                    # min_interval_s=0 => wirklich ausführen (keine Endlosskips)
                    _container_reload_throttled(
                        reason=f"{reason}_deferred",
                        min_interval_s=0.0,
                        prefer_update=prefer_update
                    )
                finally:
                    with _CONTAINER_RELOAD_DEFER_LOCK:
                        _CONTAINER_RELOAD_DEFER_ACTIVE = False

            threading.Thread(target=_run, daemon=True).start()
        except Exception:
            pass

    try:
        now = time.time()
        with _CONTAINER_RELOAD_LOCK:
            last = float(_CONTAINER_RELOAD_LAST_TS or 0.0)
            dt = now - last
            if dt < float(min_interval_s):
                # wenn async_* skippt: deferred Refresh planen
                if str(reason).startswith("async_") or reason in ("async_progress", "series_verify_open", "playback_return", "series_root_after_playback"):
                    _schedule_deferred(float(min_interval_s) - dt + 0.10)

                try:
                    xbmc.log(f"[{common.addon_id}] container_reload_throttled: SKIP ({reason}) dt={dt:.2f}s", xbmc.LOGDEBUG)
                except Exception:
                    pass
                return False

            _CONTAINER_RELOAD_LAST_TS = now

        folder_path = ""
        try:
            folder_path = xbmc.getInfoLabel("Container.FolderPath") or ""
        except Exception:
            folder_path = ""

        # Quotes entfernen (Kodi liefert manchmal "...")
        folder_path = (folder_path or "").replace('"', '').strip()

        # Build cmd
        cmd = "Container.Refresh"
        if prefer_update and folder_path:
            # Für deine Serien-Hauptliste willst du replace (damit History sauber bleibt)
            if _is_series_root_folderpath(folder_path):
                cmd = f'Container.Update("{folder_path}",replace)'
            else:
                cmd = f'Container.Update("{folder_path}")'

        # ✅ GUI-safe ausführen (Crash-Fix)
        _exec_builtin_gui(cmd)
        return True

    except Exception as e:
        try:
            xbmc.log(f"[{common.addon_id}] _container_reload_throttled Fehler ({reason}): {e}", xbmc.LOGDEBUG)
        except Exception:
            pass
        return False



def _payload_cache_file(pid_hash: str) -> str:
    return os.path.join(PAYLOAD_CACHE_DIR, f"{pid_hash}.json")


def _vfs_mtime(path: str) -> float:
    """
    VFS-sicheres mtime:
    - xbmcvfs.Stat liefert je nach Kodi-Version st_mtime als property ODER als Methode.
    """
    try:
        st = xbmcvfs.Stat(path)
        mt = getattr(st, "st_mtime", None)
        if callable(mt):
            mt = mt()
        if mt is not None:
            return float(mt)
    except Exception:
        pass

    try:
        return float(os.path.getmtime(path))
    except Exception:
        return 0.0



def payload_cache_gc(force=False):
    """
    Garbage-Collector für payload_cache:
      - löscht alte Dateien (TTL)
      - begrenzt Anzahl Dateien (MAX_FILES)
    """
    global _PAYLOAD_GC_LAST_TS

    if not PAYLOAD_CACHE_DIR or not xbmcvfs.exists(PAYLOAD_CACHE_DIR):
        return

    now = time.time()
    if not force:
        if (now - float(_PAYLOAD_GC_LAST_TS or 0.0)) < float(_PAYLOAD_GC_MIN_INTERVAL_S):
            return

    _PAYLOAD_GC_LAST_TS = now

    try:
        dirs, files = xbmcvfs.listdir(PAYLOAD_CACHE_DIR)
        files = [f for f in (files or []) if f.lower().endswith(".json")]
    except Exception:
        try:
            files = [f for f in os.listdir(PAYLOAD_CACHE_DIR) if f.lower().endswith(".json")]
        except Exception:
            files = []

    if not files:
        return

    # 1) TTL löschen
    kept = []
    for fn in files:
        p = os.path.join(PAYLOAD_CACHE_DIR, fn)
        mt = _vfs_mtime(p)
        if mt > 0 and (now - mt) > float(PAYLOAD_CACHE_MAX_AGE_S):
            try:
                xbmcvfs.delete(p)
            except Exception:
                pass
        else:
            kept.append((p, mt))

    # 2) MAX_FILES Limit
    if len(kept) > int(PAYLOAD_CACHE_MAX_FILES):
        kept.sort(key=lambda x: x[1] or 0.0)  # älteste zuerst
        to_delete = kept[: (len(kept) - int(PAYLOAD_CACHE_MAX_FILES))]
        for p, _mt in to_delete:
            try:
                xbmcvfs.delete(p)
            except Exception:
                pass



def pack_payload(obj) -> str:
    """
    Speichert obj als JSON im payload_cache und gibt 'pid:<hash>' zurück.
    Hard-reset safe: write tmp -> rename.
    """
    if isinstance(obj, str) and obj.startswith("pid:"):
        return obj

    # GC throttled
    try:
        payload_cache_gc(force=False)
    except Exception:
        pass

    if not PAYLOAD_CACHE_DIR:
        try:
            return json.dumps(obj, ensure_ascii=False)
        except Exception:
            return "{}"

    try:
        if not xbmcvfs.exists(PAYLOAD_CACHE_DIR):
            xbmcvfs.mkdirs(PAYLOAD_CACHE_DIR)
    except Exception:
        pass

    try:
        raw = json.dumps(obj, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
        h = hashlib.md5(raw.encode("utf-8")).hexdigest()
        path = _payload_cache_file(h)

        if not xbmcvfs.exists(path):
            # ✅ atomic write
            ok = _atomic_write_text_vfs(path, raw, keep_bak=False)
            if not ok:
                # fallback (not ideal, but better than crash)
                with xbmcvfs.File(path, "w") as f:
                    f.write(raw)

        return f"pid:{h}"

    except Exception as e:
        _log_series(f"pack_payload Fehler: {e}", xbmc.LOGDEBUG)
        try:
            return json.dumps(obj, ensure_ascii=False)
        except Exception:
            return "{}"





def unpack_payload(data_str: str, default=None):
    """
    Akzeptiert:
      - 'pid:<hash>'  -> lädt JSON aus payload_cache
      - JSON String   -> json.loads
    """
    if default is None:
        default = {}

    if not data_str:
        return default

    if isinstance(data_str, (dict, list)):
        return data_str

    try:
        s = data_str.strip()
    except Exception:
        return default

    # pid:... ?
    if s.startswith("pid:"):
        pid_hash = s[4:].strip()
        # minimal sanity
        pid_hash = "".join(ch for ch in pid_hash if ch.isalnum())
        if not pid_hash or not PAYLOAD_CACHE_DIR:
            return default
        path = _payload_cache_file(pid_hash)
        if not xbmcvfs.exists(path):
            # Fallback: manche PIDs wurden ggf. von common.py gepackt (anderes Cache-Dir)
            alt_dir = None
            try:
                alt_dir = getattr(common, 'PAYLOAD_CACHE_DIR', None)
            except Exception:
                alt_dir = None

            alt_path = None
            if alt_dir and alt_dir != PAYLOAD_CACHE_DIR:
                try:
                    alt_path = common._payload_cache_file(pid_hash)
                except Exception:
                    alt_path = None

            if alt_path and xbmcvfs.exists(alt_path):
                path = alt_path
                _log_series(f"unpack_payload: PID file via common gefunden: {alt_path}", xbmc.LOGINFO)
            else:
                _log_series(f"unpack_payload: PID file fehlt: {path}", xbmc.LOGINFO)
                return default

        try:
            with xbmcvfs.File(path, "r") as f:
                raw = f.read()
            obj = json.loads(raw) if raw else default
            return obj if isinstance(obj, (dict, list)) else default
        except Exception as e:
            _log_series(f"unpack_payload: read/parse Fehler: {e}", xbmc.LOGDEBUG)
            return default

    # normales JSON
    try:
        obj = json.loads(s)
        return obj if isinstance(obj, (dict, list)) else default
    except Exception:
        # Fallback: URL-encoding
        try:
            obj = json.loads(urllib.parse.unquote(s))
            return obj if isinstance(obj, (dict, list)) else default
        except Exception:
            return default


def _get_query_params():
    """
    Liest aktuelle Plugin-Query-Params aus sys.argv[2]
    (nützlich als Fallback, falls pid-cache mal fehlt)
    """
    try:
        if len(sys.argv) > 2 and sys.argv[2].startswith("?"):
            return dict(urllib.parse.parse_qsl(sys.argv[2][1:], keep_blank_values=True))
    except Exception:
        pass
    return {}



def _log_series(msg, level=xbmc.LOGINFO):
    """
    Kleiner Log-Wrapper nur für series.py, damit wir überall konsistent loggen.
    """
    try:
        xbmc.log(f"[{common.addon_id} series.py] {msg}", level)
    except Exception:
        pass


def _norm_base_path(p, default="/user/downloads/kodiAddon/"):
    p = (p or "").strip()
    if not p or p == "/":
        return default
    if not p.startswith("/"):
        p = "/" + p
    return p.rstrip("/") + "/"


def _normalize_remote_base():
    """
    Normalisiert ftp_host + scheme + base_path.
    Unterstützt ftp_host mit http/https Prefix & user:pass@.
    """
    ftp_host_raw = (common.addon.getSetting("ftp_host") or "").strip()

    scheme = "https"
    host = ftp_host_raw

    if host.startswith(("http://", "https://")):
        scheme = host.split("://", 1)[0].lower()
        host = host.split("://", 1)[1]

    if "@" in host:
        host = host.split("@", 1)[1]

    host = host.split("/")[0].split("?")[0].split("#")[0].rstrip(":/ ")

    base_path_setting = common.addon.getSetting("ftp_base_path")
    base_path = _norm_base_path(base_path_setting)

    return scheme, host, base_path




def _apply_fanart_to_listitem(
    li: xbmcgui.ListItem,
    *,
    poster: str = None,
    fanart: str = None,
    tvshow_poster: str = None,
    tvshow_fanart: str = None,
    still: str = None
) -> None:
    """
    Einheitliche Artwork-Setzung für Aeon MQ & Co.
    WICHTIG: Setzt IMMER fanart1..fanart5, damit das Skin nie auf Fallback-Bilder schaltet.
    Priorität:
      1) still (wenn vorhanden) -> thumb/icon/poster/landscape + fanart + fanart1..5
      2) sonst: poster -> thumb/icon/poster/landscape
         und fanart -> fanart + fanart1..5
      3) tvshow.* werden zusätzlich gesetzt (Kontext für Episodenansichten)
    """
    art = {}

    def _dup_fanart(dst_url: str):
        if not dst_url:
            return
        art["fanart"] = dst_url
        for i in range(1, 6):
            art[f"fanart{i}"] = dst_url

    # 1) Episode-Still priorisieren (gleichzeitig als Fanart verwenden)
    if still:
        art["thumb"] = still
        art["icon"] = still
        art["poster"] = still
        art["landscape"] = still
        _dup_fanart(still)
    else:
        # 2) Poster/Thumbs
        if poster:
            art["thumb"] = poster
            art["icon"] = poster
            art["poster"] = poster
            art["landscape"] = poster
        # 3) Fanart + Duplikate (fanart1..5)
        if fanart:
            _dup_fanart(fanart)

    # 4) tvshow.* immer ergänzen (Skin nutzt die oft zusätzlich)
    if tvshow_poster:
        art["tvshow.poster"] = tvshow_poster
        # Falls es bisher kein normales Poster gab, nutze tvshow.poster als Fallback
        art.setdefault("poster", tvshow_poster)
        art.setdefault("thumb", tvshow_poster)
        art.setdefault("icon", tvshow_poster)
        art.setdefault("landscape", tvshow_poster)

    if tvshow_fanart:
        art["tvshow.fanart"] = tvshow_fanart
        # Wenn noch KEINE reguläre Fanart gesetzt ist, dupliziere tvshow_fanart
        if "fanart" not in art:
            _dup_fanart(tvshow_fanart)

    # 5) Letzter Fallback (Addon-Icon), damit nie leere Felder bleiben
    if not art.get("icon"):
        fallback = os.path.join(common.addon_path, "series.png")
        if not xbmcvfs.exists(fallback):
            fallback = common.addon.getAddonInfo('icon')
        art["icon"] = art["thumb"] = art.get("poster", fallback) or fallback
        art.setdefault("poster", art["icon"])
        art.setdefault("landscape", art["icon"])
        # auch hier sicherstellen, dass fanart1..5 belegt sind
        if "fanart" not in art:
            _dup_fanart(art["icon"])

    # Anwenden
    li.setArt({k: v for k, v in art.items() if v})

    # Extra: Container-Fanart setzen (einige MQ-Views nutzen das)
    try:
        if art.get("fanart"):
            xbmcplugin.setProperty(common.addon_handle, "fanart_image", art["fanart"])
    except Exception:
        pass

    # Debug (optional): kurz loggen, ob fanart1 gesetzt wurde
    try:
        xbmc.log(f"[{common.addon_id}] _apply_fanart_to_listitem: fanart1 set -> {art.get('fanart1')}", xbmc.LOGDEBUG)
    except Exception:
        pass


def _split_studios(studios_str):
    """
    Normalisiert Studio-Werte zu einer String-Liste.
    Akzeptiert str, list/tuple/set, None, Zahlen, etc.
    Trennt bei Strings an , / | ;  und trimmt Leerzeichen.
    """
    import re
    if not studios_str:
        return []
    if isinstance(studios_str, (list, tuple, set)):
        return [str(s).strip() for s in studios_str if str(s).strip()]
    s = str(studios_str)
    return [p.strip() for p in re.split(r'[,/|;]+', s) if p.strip()]


def _progress_cache_due():
    """Ist der Fortschritts-Cache fällig (älter als Intervall oder fehlt)? VFS-sicher."""
    try:
        if not SERIES_PROGRESS_CACHE_FILE or not xbmcvfs.exists(SERIES_PROGRESS_CACHE_FILE):
            return True

        mtime = None

        # VFS-Stat zuerst (robuster bei special://)
        try:
            st = xbmcvfs.Stat(SERIES_PROGRESS_CACHE_FILE)
            mt = getattr(st, "st_mtime", None)
            if callable(mt):
                mt = mt()
            if mt is not None:
                mtime = float(mt)
        except Exception:
            mtime = None

        # Fallback auf os.path.getmtime
        if mtime is None:
            try:
                mtime = float(os.path.getmtime(SERIES_PROGRESS_CACHE_FILE))
            except Exception:
                mtime = None

        if mtime is None:
            return True

        return (time.time() - mtime) >= BACKGROUND_REFRESH_INTERVAL_S
    except Exception:
        return True





def _compute_due_progress_entries(series_list, progress_cache, watch_sig):
    """Liste der Serien, deren ✓-Fortschritt fehlt/veraltet ist."""
    due = []
    now = time.time()
    for s in (series_list or []):
        details_url_val = s.get("details_url")
        if not details_url_val:
            continue
        cache_key = (details_url_val or s.get("original_online_key") or "")
        entry = progress_cache.get(cache_key)
        outdated = False
        if not entry:
            outdated = True
        else:
            try:
                ts = float(entry.get("ts", 0.0))
            except:
                ts = 0.0
            outdated = (entry.get("watch_sig") != watch_sig) or ((now - ts) >= BACKGROUND_REFRESH_INTERVAL_S)
        if outdated:
            due.append((s.get("name", "Unbekannt"), details_url_val, cache_key))
    return due


def _compute_missing_series_images(series_list, limit):
    """Fehlende Poster/Backcover sammeln (nur HTTP-URLs ohne Cache)."""
    out = []
    for s in (series_list or []):
        for key in ("poster", "backcover"):
            u = (s.get(key) or "").strip()
            if u and isinstance(u, str) and u.startswith("http") and not get_cached_image(u):
                out.append((s, u, key))
                if len(out) >= int(limit):
                    return out
    return out


def _image_prefetch_worker(items):
    """Lädt Bilder still im Hintergrund vor (keine UI-Blockade). Thread-safe via Event."""
    if BG_IMAGE_PREFETCH_RUNNING.is_set():
        return
    BG_IMAGE_PREFETCH_RUNNING.set()
    try:
        for (_serie, url, _k) in (items or []):
            try:
                download_and_cache_image(url)
            except Exception as e:
                xbmc.log(f"[{common.addon_id}] Image prefetch fail: {e}", xbmc.LOGDEBUG)
    finally:
        BG_IMAGE_PREFETCH_RUNNING.clear()



def _start_background_prefetch(series_list, watch_sig):
    """
    Startet einen Hintergrund-Manager:
     - lädt due ✓-Fortschritte
     - lädt fehlende Poster/Fanarts
     - wiederholt das im Intervall

    Fixes:
     - Thread nutzt NICHT dauerhaft die beim Start übergebene series_list/watch_sig,
       sondern liest pro Zyklus CURRENT_* Globals.
     - STOP Mechanik via BG_PREFETCH_STOP, damit bei JSON-Änderung neu gestartet werden kann.
    """
    global CURRENT_SERIES_LIST, CURRENT_WATCH_SIG

    # immer die aktuellen Daten setzen (auch wenn Thread schon läuft)
    try:
        CURRENT_SERIES_LIST = list(series_list or [])
    except Exception:
        CURRENT_SERIES_LIST = series_list or []
    CURRENT_WATCH_SIG = watch_sig or ""

    # wenn schon läuft, nur Globals aktualisiert -> fertig
    if BG_PREFETCH_RUNNING.is_set():
        return

    # neuen Thread starten
    BG_PREFETCH_STOP.clear()
    BG_PREFETCH_RUNNING.set()

    def _runner():
        monitor = xbmc.Monitor()
        try:
            while not monitor.abortRequested():
                if BG_PREFETCH_STOP.is_set():
                    break

                # SNAPSHOT der aktuellen Werte pro Zyklus
                try:
                    series_now = CURRENT_SERIES_LIST or []
                    watch_sig_now = CURRENT_WATCH_SIG or ""
                except Exception:
                    series_now = []
                    watch_sig_now = ""

                # Fortschritt fällig?
                try:
                    progress_cache = _load_progress_cache()
                    if _progress_cache_due():
                        due_entries = _compute_due_progress_entries(series_now, progress_cache, watch_sig_now)
                        if due_entries:
                            _async_fill_progress(due_entries, watch_sig_now, max_to_fetch=DETAILS_MAX_FETCH_BG)
                except Exception as e:
                    xbmc.log(f"[{common.addon_id}] BG progress cycle error: {e}", xbmc.LOGDEBUG)

                # Bilder still vorladen
                try:
                    miss = _compute_missing_series_images(series_now, IMAGE_PREFETCH_LIMIT_PER_CYCLE)
                    if miss:
                        th_img = threading.Thread(target=_image_prefetch_worker, args=(miss,), daemon=True)
                        th_img.start()
                except Exception as e:
                    xbmc.log(f"[{common.addon_id}] BG image cycle error: {e}", xbmc.LOGDEBUG)

                # Sleep im Intervall, aber in kleinen Schritten, damit STOP schnell greift
                sleep_total = int(BACKGROUND_REFRESH_INTERVAL_S)
                step = 10  # Sekunden
                slept = 0
                while slept < sleep_total and not monitor.abortRequested():
                    if BG_PREFETCH_STOP.is_set():
                        return
                    if monitor.waitForAbort(step):
                        return
                    slept += step

        finally:
            BG_PREFETCH_RUNNING.clear()
            BG_PREFETCH_STOP.clear()

    threading.Thread(target=_runner, daemon=True).start()



def _load_progress_cache():
    """
    Robust + hard-reset safe:
      - liest final/bak/tmp
      - verhindert Race mit Save via Lock
      - akzeptiert altes & neues Format
    """
    try:
        if not SERIES_PROGRESS_CACHE_FILE:
            return {}

        with _PROGRESS_CACHE_LOCK:
            data = _read_json_resilient(SERIES_PROGRESS_CACHE_FILE, default={})

        if not isinstance(data, dict) or not data:
            return {}

        # Neues Format mit "entries"
        if "entries" in data:
            entries = data.get("entries") or {}
            return entries if isinstance(entries, dict) else {}

        # Altes Format
        return data if isinstance(data, dict) else {}

    except Exception as e:
        _log_series(f"_load_progress_cache: {e}", xbmc.LOGDEBUG)
        return {}




def invalidate_series_progress_cache():
    """
    Service-Hook bei Änderung von kodiSeries.json:
      - stoppt BG Prefetch
      - löscht progress cache (final/tmp/bak) unter Lock
    """
    try:
        try:
            BG_PREFETCH_STOP.set()
        except Exception:
            pass

        if not SERIES_PROGRESS_CACHE_FILE:
            return

        with _PROGRESS_CACHE_LOCK:
            _vfs_delete_quiet(SERIES_PROGRESS_CACHE_FILE + ".tmp")
            _vfs_delete_quiet(SERIES_PROGRESS_CACHE_FILE + ".bak")
            if xbmcvfs.exists(SERIES_PROGRESS_CACHE_FILE):
                xbmcvfs.delete(SERIES_PROGRESS_CACHE_FILE)
                _log_series("Series: Progress-Cache per invalidate_series_progress_cache() gelöscht.", xbmc.LOGINFO)
            else:
                _log_series("Series: invalidate_series_progress_cache() – kein Progress-Cache vorhanden.", xbmc.LOGDEBUG)

    except Exception as e:
        _log_series(f"Series: Fehler beim Löschen des Progress-Caches: {e}", xbmc.LOGWARNING)




def load_progress_cache_entries():
    """
    Lädt den kompletten Progress-Cache inkl. Meta-Infos (robust + restore aus .bak/.tmp).

    Rückgabe: entries dict
    """
    t0 = time.time()

    if not SERIES_PROGRESS_CACHE_FILE:
        _log_series("Series: Progress-Cache Pfad leer.", xbmc.LOGDEBUG)
        return {}

    try:
        with _PROGRESS_CACHE_LOCK:
            data = _read_json_resilient(SERIES_PROGRESS_CACHE_FILE, default={})

        if not isinstance(data, dict) or not data:
            _log_series("Series: Progress-Cache leer/ungültig, wird ignoriert.", xbmc.LOGDEBUG)
            return {}

        if data.get("version") != PROGRESS_CACHE_VERSION:
            _log_series(
                "Series: Progress-Cache Version mismatch (%r != %r), wird verworfen."
                % (data.get("version"), PROGRESS_CACHE_VERSION),
                xbmc.LOGINFO,
            )
            return {}

        entries = data.get("entries") or {}
        if not isinstance(entries, dict):
            _log_series("Series: Progress-Cache Einträge sind kein dict, wird verworfen.", xbmc.LOGWARNING)
            return {}

        dt = time.time() - t0
        _log_series("TIMING load_progress_cache_entries=%d: %.3fs" % (len(entries), dt), xbmc.LOGINFO)
        return entries

    except Exception as e:
        _log_series(f"Series: Fehler beim Lesen des Progress-Caches: {e}", xbmc.LOGWARNING)
        return {}



def save_progress_cache(entries):
    """
    Speichert den Progress-Cache im 'neuen' Format, hard-reset safe (tmp+bak) + Lock.

    {
      "version": PROGRESS_CACHE_VERSION,
      "saved": <timestamp>,
      "entries": { ... }
    }
    """
    if not SERIES_PROGRESS_CACHE_FILE:
        _log_series("Series: save_progress_cache – SERIES_PROGRESS_CACHE_FILE nicht gesetzt.", xbmc.LOGERROR)
        return False

    with _PROGRESS_CACHE_LOCK:
        try:
            # Snapshot (Thread-sicher)
            if not isinstance(entries, dict):
                entries = {}
            try:
                entries_snap = json.loads(json.dumps(entries, ensure_ascii=False))
                if not isinstance(entries_snap, dict):
                    entries_snap = dict(entries)
            except Exception:
                entries_snap = dict(entries)

            payload = {
                "version": PROGRESS_CACHE_VERSION,
                "saved": int(time.time()),
                "entries": entries_snap,
            }

            ok = _atomic_write_json_vfs(SERIES_PROGRESS_CACHE_FILE, payload)
            if ok:
                _log_series("Series: Progress-Cache gespeichert (%d Einträge)." % len(entries_snap), xbmc.LOGINFO)
            else:
                _log_series("Series: Progress-Cache speichern fehlgeschlagen.", xbmc.LOGWARNING)
            return ok

        except Exception as e:
            _log_series(f"Series: Fehler beim Speichern des Progress-Caches: {e}", xbmc.LOGWARNING)
            return False




def _save_progress_cache(entries):
    """
    Kompatibilitäts-Wrapper, weil im Code an mehreren Stellen
    _save_progress_cache(...) aufgerufen wird.
    """
    return save_progress_cache(entries)



def _watch_signature(watched_status):
    """
    Stabiler Fingerabdruck des 'gesehen'-Standes:
    - normalisiert Keys (damit raw+norm nicht doppelt zählt)
    - nur playcount>0
    - sortiert, md5
    """
    try:
        keys = []
        for k, v in (watched_status or {}).items():
            try:
                if int((v or {}).get("playcount", 0)) <= 0:
                    continue
            except Exception:
                continue
            kn = _normalize_watch_key(k) or (k or "")
            if kn:
                keys.append(kn)

        keys = sorted(set(keys))
        return hashlib.md5(("|".join(keys)).encode("utf-8")).hexdigest()
    except Exception:
        return "nosig"




def _async_fill_progress(entries, watch_sig, max_to_fetch=20, save_every=5, timeout_s=8):
    """
    Lädt für fehlende Serien deren details.json im Hintergrund, berechnet watched/total
    und legt das Ergebnis im Cache ab. Danach Container-Reload (throttled), damit ✓ erscheint.

    FIX (optional):
      - watched_status über _safe_load_watched_status statt common.load_watched_status
    """
    if PROGRESS_WORKER_RUNNING.is_set():
        return
    PROGRESS_WORKER_RUNNING.set()

    try:
        if not entries:
            return

        progress_cache = _load_progress_cache()
        context = ssl.create_default_context()

        # watched_status EINMAL laden (nicht pro Serie)
        ws = {}
        try:
            if WATCHED_STATUS_FILE:
                ws = _safe_load_watched_status(WATCHED_STATUS_FILE)
        except Exception as e_ws:
            xbmc.log(f"[{common.addon_id}] async ws load fail: {e_ws}", xbmc.LOGDEBUG)
            ws = {}

        count = 0
        changed = False

        for (name, durl, cache_key) in entries[:int(max_to_fetch)]:
            try:
                req = get_authenticated_request(durl)
                if not req:
                    continue

                with urllib.request.urlopen(req, context=context, timeout=timeout_s) as r:
                    if r.getcode() != 200:
                        continue
                    dj = json.loads(r.read().decode("utf-8")) or {}

                if not isinstance(dj, dict):
                    continue

                w, t, completed = _series_progress_from_details(dj, ws)

                old = progress_cache.get(cache_key)
                old_completed = None
                if isinstance(old, dict):
                    old_completed = bool(old.get("completed"))

                if old_completed is None or old_completed != bool(completed):
                    changed = True

                progress_cache[cache_key] = {
                    "watched": int(w),
                    "total": int(t),
                    "completed": bool(completed),
                    "watch_sig": watch_sig,
                    "ts": time.time()
                }

                count += 1
                if count % int(save_every) == 0:
                    _save_progress_cache(progress_cache)

            except Exception as e:
                xbmc.log(f"[{common.addon_id}] Async progress fail '{name}': {e}", xbmc.LOGDEBUG)

        _save_progress_cache(progress_cache)

        if changed:
            _container_reload_throttled(reason="async_progress", min_interval_s=1.8)

    except Exception as e:
        xbmc.log(f"[{common.addon_id}] _async_fill_progress: {e}", xbmc.LOGERROR)
    finally:
        PROGRESS_WORKER_RUNNING.clear()


# ---- Helpers: robustes Parsen & Votes setzen ----
def _to_int(val, default=0):
    try:
        if val is None:
            return default
        if isinstance(val, int):
            return val
        s = str(val).replace(",", "").strip()  # "114,431" -> "114431"
        return int(float(s))
    except:
        return default


def _to_float(val, default=0.0):
    try:
        if val is None:
            return default
        if isinstance(val, (int, float)):
            return float(val)
        s = str(val).replace(",", ".").strip()
        return float(s)
    except:
        return default


def _safe_set_votes(info_tag, votes):
    v = _to_int(votes, 0)
    if v > 0:
        try:
            info_tag.setVotes(v)
        except Exception:
            # ältere Kodi-Versionen ohne setVotes -> ignorieren
            pass


def safe_json_loads(data, default=None):
    """Robustes JSON-Parsing für Parameter-Strings."""
    default = {} if default is None else default
    try:
        if not data or not isinstance(data, str):
            return default
        x = json.loads(data)
        return x if isinstance(x, (dict, list)) else default
    except Exception:
        return default




def _is_episode_watched(ep_dict, watched_status):
    """
    Prüft, ob eine Episode als 'gesehen' markiert ist (V2 hashed keys).
    Identifier wird gehasht über common.series_watched_id().
    """
    try:
        if not isinstance(ep_dict, dict) or not isinstance(watched_status, dict):
            return False

        ident = (ep_dict.get("file_ort")
                 or ep_dict.get("stream_url")
                 or ep_dict.get("_local_playback_path")
                 or ep_dict.get("local_path")
                 or "").strip()
        if not ident:
            return False

        try:
            wid = common.series_watched_id(ident)
        except Exception:
            wid = ""

        if not wid:
            return False

        try:
            return int((watched_status.get(wid) or {}).get("playcount", 0) or 0) > 0
        except Exception:
            return False

    except Exception:
        return False



def _season_progress_from_details(season_data, watched_status):
    """
    Ermittelt (watched, total, completed) für eine Season-Struktur aus details.json.
    Zählt nur Episoden, die irgendeinen gültigen Identifier haben.
    """
    episodes = (season_data or {}).get("episodes", []) or []
    total = 0
    watched = 0

    if not episodes:
        xbmc.log(f'[{common.addon_id} series.py] show_episodes: Keine Episoden gefunden (serie={serie_name}, season={season_number}, key={series_cache_key[:50] if isinstance(series_cache_key,str) else series_cache_key})', xbmc.LOGWARNING)

    for ep in episodes:
        if not isinstance(ep, dict):
            continue

        key = (ep.get("file_ort")
               or ep.get("stream_url")
               or ep.get("_local_playback_path")
               or ep.get("local_path")
               or "")

        if not key:
            # ohne Identifier nicht zählbar
            continue

        total += 1
        if _is_episode_watched(ep, watched_status):
            watched += 1

    return watched, total, (total > 0 and watched >= total)




def _series_progress_from_details(details_json, watched_status):
    """
    Ermittelt (watched, total, completed) über alle Seasons in details.json.
    """
    if not isinstance(details_json, dict):
        return 0, 0, False
    total = 0
    watched = 0
    for k, v in details_json.items():
        if isinstance(k, str) and k.lower().startswith("season") and isinstance(v, dict):
            w, t, _ = _season_progress_from_details(v, watched_status)
            watched += w
            total += t
    return watched, total, (total > 0 and watched >= total)


# --- Konstanten und Pfade ---
# Definiere Standard-Dateinamen zuerst
WATCHED_STATUS_FILENAME = 'watched_status_series.json'
LOCAL_JSON_FILENAME = "downloaded_series.json"
LOCAL_IMAGE_CACHE_SUBDIR = "cache_series_images"
KODI_SERIES_JSON_FILENAME = "kodiSeries.json"  # Dateiname für die Hauptserienliste

# Initialisiere Pfadvariablen erstmal leer oder mit Standardwerten
WATCHED_STATUS_FILE = ""
LOCAL_JSON_PATH = ""
LOCAL_IMAGE_CACHE_DIR = ""

# NEU: Dateien für Serien-Hauptliste (analog zu Movies)
SERIES_REMOTE_META_FILE = ""   # z.B. series_remote_meta.json
SERIES_LOCAL_CACHE_FILE = ""   # z.B. series.cache.json

# Globale Cache für details.json Inhalte (um wiederholte Abfragen zu reduzieren)
SERIES_DETAILS_CACHE = {}

# Signal from service.py when details.json files were refreshed on disk (same Kodi session)
_DETAILS_BUMP_PROP = f"{common.addon_id}.series.details.bump"
_DETAILS_BUMP_SEEN = 0.0

def _maybe_invalidate_details_memory_cache() -> None:
    """Invalidate in-memory details cache when the service refreshed details.json on disk."""
    global _DETAILS_BUMP_SEEN, SERIES_DETAILS_CACHE
    try:
        v = xbmcgui.Window(10000).getProperty(_DETAILS_BUMP_PROP)
        ts = float(v) if v else 0.0
        if ts and ts > float(_DETAILS_BUMP_SEEN):
            try:
                SERIES_DETAILS_CACHE.clear()
            except Exception:
                pass
            # also reset inflight marker so prefetch can refill cleanly
            try:
                with _details_prefetch_lock:
                    _details_prefetch_inflight.clear()
            except Exception:
                pass
            _DETAILS_BUMP_SEEN = ts
            try:
                xbmc.log(f"[{common.addon_id} series.py] [details-cache] Memory invalidated (bump={ts})", xbmc.LOGDEBUG)
            except Exception:
                pass
    except Exception:
        pass


# -----------------------------------------------------------------------------
# details.json Disk-Cache + On-Demand Prefetch (neue Serien)
# -----------------------------------------------------------------------------
SERIES_DETAILS_JSON_CACHE_DIR = ""
SERIES_SEEN_URLS_FILE = ""


def _path_exists(path: str) -> bool:
    """Robust existence check for local addon paths.

    On some Linux/Kodi setups, `xbmcvfs.exists()` can return False for
    regular absolute filesystem paths (e.g. /home/.../.kodi/userdata/...),
    even though the path exists. We therefore fall back to `os.path.exists()`.
    """
    try:
        if path and xbmcvfs.exists(path):
            return True
    except Exception:
        pass
    try:
        return bool(path) and os.path.exists(path)
    except Exception:
        return False

_details_prefetch_lock = threading.Lock()
_details_prefetch_thread = None  # type: Optional[threading.Thread]
_details_prefetch_inflight = set()  # type: set[str]

_details_paths_logged = False

def _init_details_cache_paths(force: bool = False):
    """Init (or re-init) cache paths for details.json.

    Hintergrund: je nach Import-Reihenfolge kann `common.addon_data_dir` beim
    ersten Import noch leer sein. Dann blieben die globalen Pfade hier dauerhaft
    leer und `seen` wuerde bei jedem Start wieder als leer erkannt.

    Diese Funktion ist deshalb idempotent und kann spaeter erneut aufgerufen werden.
    """
    global SERIES_DETAILS_JSON_CACHE_DIR, SERIES_SEEN_URLS_FILE
    try:
        base = getattr(common, 'addon_data_dir', '')
        if not base or not isinstance(base, str):
            return

        if (force or not SERIES_DETAILS_JSON_CACHE_DIR or not SERIES_SEEN_URLS_FILE
                or not SERIES_DETAILS_JSON_CACHE_DIR.startswith(base)
                or not SERIES_SEEN_URLS_FILE.startswith(base)):
            SERIES_DETAILS_JSON_CACHE_DIR = os.path.join(base, 'cache_series_details_json')
            SERIES_SEEN_URLS_FILE = os.path.join(base, 'series_seen_urls.json')

        # Cache-Verzeichnis sicherstellen
        if SERIES_DETAILS_JSON_CACHE_DIR and not _path_exists(SERIES_DETAILS_JSON_CACHE_DIR):
            try:
                xbmcvfs.mkdirs(SERIES_DETAILS_JSON_CACHE_DIR)
            except Exception:
                try:
                    os.makedirs(SERIES_DETAILS_JSON_CACHE_DIR, exist_ok=True)
                except Exception:
                    pass

        # --- Schema-Migration: invalidiere alten Details-Cache automatisch (kein manuelles Loeschen)
        try:
            schema_file = os.path.join(base, 'details_cache_schema.txt')
            old = None
            try:
                if os.path.exists(schema_file):
                    old_txt = open(schema_file, 'r', encoding='utf-8', errors='replace').read().strip()
                    old = int(old_txt) if old_txt.isdigit() else None
            except Exception:
                old = None
            if old != DETAILS_CACHE_SCHEMA_VERSION:
                # Cache-Ordner und seen-file best-effort entfernen, um Kollisionen/Leeranzeigen zu vermeiden
                try:
                    if SERIES_DETAILS_JSON_CACHE_DIR and os.path.isdir(SERIES_DETAILS_JSON_CACHE_DIR):
                        shutil.rmtree(SERIES_DETAILS_JSON_CACHE_DIR, ignore_errors=True)
                except Exception:
                    pass
                try:
                    if SERIES_SEEN_URLS_FILE and os.path.exists(SERIES_SEEN_URLS_FILE):
                        os.remove(SERIES_SEEN_URLS_FILE)
                except Exception:
                    pass
                # Ordner wieder anlegen
                try:
                    if SERIES_DETAILS_JSON_CACHE_DIR:
                        os.makedirs(SERIES_DETAILS_JSON_CACHE_DIR, exist_ok=True)
                except Exception:
                    pass
                try:
                    open(schema_file, 'w', encoding='utf-8').write(str(DETAILS_CACHE_SCHEMA_VERSION))
                except Exception:
                    pass
                xbmc.log(f"[{common.addon_id} series.py] Details-Cache Schema geaendert -> Cache neu aufgebaut (v{DETAILS_CACHE_SCHEMA_VERSION}).", xbmc.LOGINFO)
        except Exception:
            pass

        # Cache-Schema sicherstellen / ggf. automatisch invalidieren
        try:
            _ensure_details_cache_schema()
        except Exception:
            pass
    except Exception:
        SERIES_DETAILS_JSON_CACHE_DIR = ''
        SERIES_SEEN_URLS_FILE = ''

_init_details_cache_paths(force=True)

def _details_url_key(details_url: str) -> str:
    """Return a *stable but unique* key for a details URL.

    Many backends append volatile query params (token/ts/expires/sig/...) to
    the details.json URL. For caching we must **not** drop *all* query params,
    otherwise different series that share the same endpoint path would collide.

    Strategy:
      - keep scheme + netloc + path
      - keep query params except a small blacklist of volatile/auth params
      - sort remaining params to make the key deterministic
      - drop fragment
    """
    try:
        u = (details_url or '').strip()
        if not u:
            return ''
        p = urllib.parse.urlsplit(u)
        if not p.scheme or not p.netloc:
            return u

        # Filter query params: remove volatile/auth params, keep the rest.
        volatile = {
            'token','ts','timestamp','t','time','expires','exp',
            'signature','sig','hash','hmac','nonce',
            'auth','key','apikey','api_key','access_token','session','sid',
            'rand','random','_','cb','cachebust','cachebuster'
        }
        q = []
        try:
            for k, v in urllib.parse.parse_qsl(p.query, keep_blank_values=True):
                if (k or '').strip().lower() in volatile:
                    continue
                q.append((k, v))
        except Exception:
            q = []

        # Sort for determinism
        try:
            q.sort(key=lambda kv: (kv[0], kv[1]))
        except Exception:
            pass

        query = urllib.parse.urlencode(q, doseq=True) if q else ''
        return urllib.parse.urlunsplit((p.scheme, p.netloc, p.path, query, ''))
    except Exception:
        return (details_url or '').strip()



def _details_cache_path(details_url: str) -> str:
    # stabiler Dateiname: sha1(key).json (key = URL ohne volatile Query-Parameter)
    key = _details_url_key(details_url)
    try:
        h = hashlib.sha1(key.encode("utf-8", "ignore")).hexdigest()
    except Exception:
        h = hashlib.sha1(str(key).encode("utf-8", "ignore")).hexdigest()
    if not SERIES_DETAILS_JSON_CACHE_DIR:
        return ""
    return os.path.join(SERIES_DETAILS_JSON_CACHE_DIR, f"{h}.json")

def _load_seen_urls() -> set:
    _init_details_cache_paths()
    if not SERIES_SEEN_URLS_FILE or not _path_exists(SERIES_SEEN_URLS_FILE):
        return set()
    try:
        data = _safe_json_read_vfs(SERIES_SEEN_URLS_FILE, default=[])
        # akzeptiere: Liste[...] oder {"urls": [...]} aus alten/anderen Versionen
        if isinstance(data, dict) and 'urls' in data:
            data = data.get('urls')
        if isinstance(data, list):
            out = set()
            for x in data:
                try:
                    u = _details_url_key(str(x).strip())
                    if u:
                        out.add(u)
                except Exception:
                    pass
            return out
    except Exception:
        pass
    return set()
def _save_seen_urls(urls_set: set):
    _init_details_cache_paths()
    if not SERIES_SEEN_URLS_FILE:
        return
    try:
        urls = sorted([_details_url_key(u) for u in urls_set if isinstance(u, str) and u.strip()])
        ok = _atomic_write_json_vfs(SERIES_SEEN_URLS_FILE, urls, indent=2, keep_bak=True)
        if not ok:
            xbmc.log(f"[{common.addon_id} series.py] WARN: Konnte seen-urls nicht schreiben: {SERIES_SEEN_URLS_FILE}", xbmc.LOGWARNING)
    except Exception as e:
        try:
            xbmc.log(f"[{common.addon_id} series.py] WARN: seen-urls write exception: {e}", xbmc.LOGWARNING)
        except Exception:
            pass
def load_series_details_json(details_url: str, timeout_s: int = 20) -> dict:
    _init_details_cache_paths()
    _maybe_invalidate_details_memory_cache()
    """Lädt details.json mit 3-stufigem Cache: Memory -> Disk -> Network.
    WICHTIG: Fehlversuche werden NICHT dauerhaft als False gecached (sonst bleibt eine Staffel 'leer',
    wenn der erste Fetch nur kurz fehlschlägt). Stattdessen erlauben wir jederzeit Retry.
    """
    global SERIES_DETAILS_CACHE

    details_url = (details_url or "").strip()
    details_key = _details_url_key(details_url)
    if not details_url or not details_key:
        return {}

    # 1) Memory (nur wenn es ein nicht-leeres dict ist)
    try:
        v = SERIES_DETAILS_CACHE.get(details_key)
        if isinstance(v, dict) and v:
            return v
        # wenn zuvor ein Fehl-Sentinel drin war: weg damit, damit wir erneut laden können
        if v is False:
            try:
                SERIES_DETAILS_CACHE.pop(details_key, None)
            except Exception:
                pass
    except Exception:
        pass

    # 2) Disk
    cpath = _details_cache_path(details_key)
    if cpath and _path_exists(cpath):
        try:
            cached = _safe_json_read_vfs(cpath, default={})
            if isinstance(cached, dict) and cached:
                try:
                    SERIES_DETAILS_CACHE[details_key] = cached
                except Exception:
                    pass
                return cached
        except Exception:
            pass

    # 3) Network
    try:
        req = get_authenticated_request(details_url)
        if not req:
            raise ValueError("Auth Request fehlgeschlagen.")
        with urllib.request.urlopen(req, context=ssl.create_default_context(), timeout=timeout_s) as r:
            if r.getcode() == 200:
                tmp = json.loads(r.read().decode("utf-8"))
                if isinstance(tmp, dict) and tmp:
                    try:
                        SERIES_DETAILS_CACHE[details_key] = tmp
                    except Exception:
                        pass
                    # disk write best-effort
                    try:
                        if cpath:
                            _atomic_write_json_vfs(cpath, tmp, indent=None, keep_bak=True)
                    except Exception:
                        pass
                    return tmp
    except Exception as e:
        xbmc.log(f"[{common.addon_id} series.py] Details laden fehlgeschlagen: {details_url} -> {e}", xbmc.LOGDEBUG)

    # kein permanentes Failure-Caching
    return {}

    # 1) Memory
    try:
        if details_key in SERIES_DETAILS_CACHE and SERIES_DETAILS_CACHE[details_key] is not False:
            v = SERIES_DETAILS_CACHE.get(details_key) or {}
            return v if isinstance(v, dict) else {}
    except Exception:
        pass

    # 2) Disk
    cpath = _details_cache_path(details_key)
    if cpath and _path_exists(cpath):
        try:
            cached = _safe_json_read_vfs(cpath, default={})
            if isinstance(cached, dict) and cached:
                try:
                    SERIES_DETAILS_CACHE[details_key] = cached
                except Exception:
                    pass
                return cached
        except Exception:
            pass

    # 3) Network
    try:
        req = get_authenticated_request(details_url)
        if not req:
            raise ValueError("Auth Request fehlgeschlagen.")
        with urllib.request.urlopen(req, context=ssl.create_default_context(), timeout=timeout_s) as r:
            if r.getcode() == 200:
                tmp = json.loads(r.read().decode("utf-8"))
                if isinstance(tmp, dict):
                    try:
                        SERIES_DETAILS_CACHE[details_key] = tmp
                    except Exception:
                        pass
                    # disk write best-effort
                    try:
                        if cpath:
                            _atomic_write_json_vfs(cpath, tmp, indent=None, keep_bak=True)
                    except Exception:
                        pass
                    return tmp
    except Exception as e:
        xbmc.log(f"[{common.addon_id} series.py] Details laden fehlgeschlagen: {details_url} -> {e}", xbmc.LOGDEBUG)

    try:
        SERIES_DETAILS_CACHE[details_key] = False
    except Exception:
        pass
    return {}

def _prefetch_details_worker(items: list):
    """items: list of dicts: {details_url, poster, backcover}"""
    try:
        for it in items:
            if not isinstance(it, dict):
                continue
            details_url = (it.get("details_url") or "").strip()
            details_key = (it.get("details_key") or '').strip() or _details_url_key(details_url)
            if not details_url or not details_key:
                continue

            # Inflight markieren (damit wir nicht doppelt starten)
            with _details_prefetch_lock:
                if details_key in _details_prefetch_inflight:
                    continue
                _details_prefetch_inflight.add(details_key)

            try:
                # details.json holen + disk cachen
                load_series_details_json(details_url, timeout_s=20)

                # Artwork best-effort in bestehenden Image-Cache
                poster = (it.get("poster") or "").strip()
                backcover = (it.get("backcover") or "").strip()
                if poster:
                    try: download_and_cache_image(poster, timeout_s=20)
                    except Exception: pass
                if backcover:
                    try: download_and_cache_image(backcover, timeout_s=20)
                    except Exception: pass
            finally:
                with _details_prefetch_lock:
                    _details_prefetch_inflight.discard(details_key)

    except Exception as e:
        xbmc.log(f"[{common.addon_id} series.py] Details-Prefetch Worker Fehler: {e}", xbmc.LOGWARNING)

def kickoff_new_series_details_prefetch(series_list: list):
    _init_details_cache_paths()
    """Beim Öffnen der Serien-Liste: neue Serien erkennen und details.json im Hintergrund ziehen."""
    try:
        if not isinstance(series_list, list) or not series_list:
            return

        current_urls = set()
        items = []
        for s in series_list:
            if not isinstance(s, dict):
                continue
            u = (s.get("details_url") or "").strip()
            if not u or not u.startswith("http"):
                continue
            current_urls.add(_details_url_key(u))

        global _details_paths_logged
        if not _details_paths_logged:
            try:
                xbmc.log(f"[{common.addon_id} series.py] [details-cache] dir='{SERIES_DETAILS_JSON_CACHE_DIR}' seen_file='{SERIES_SEEN_URLS_FILE}' exists={_path_exists(SERIES_SEEN_URLS_FILE)}", xbmc.LOGINFO)
            except Exception:
                pass
            _details_paths_logged = True

        seen = _load_seen_urls()
        new_urls = [u for u in current_urls if u and u not in seen]

        # Auch wenn seen leer (erster Run): NICHT alles prefetch'en.
        # Wir prefetch'en nur, was "added" sehr frisch ist oder wirklich neu erkannt wurde.
        # Heuristik: wenn seen leer -> maximal 5 Einträge (neueste nach 'added').
        if not seen and current_urls:
            # sort nach added desc (falls vorhanden)
            tmp = []
            for s in series_list:
                if not isinstance(s, dict):
                    continue
                u = (s.get("details_url") or "").strip()
                if u and u.startswith("http"):
                    tmp.append(s)
            def _padded(x):
                a = (x.get("added") or "").strip()
                # best-effort parse
                for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d", "%Y-%m-%d %H:%M:%S.%f"):
                    try:
                        return datetime.strptime(a, fmt)
                    except Exception:
                        pass
                return datetime(1970,1,1)
            tmp.sort(key=_padded, reverse=True)
            new_urls = [_details_url_key((t.get("details_url") or "").strip()) for t in tmp[:5] if (t.get("details_url") or "").strip().startswith("http")]
            new_urls = [u for u in new_urls if u]

        if not new_urls:
            # trotzdem seen aktualisieren, damit nächste Änderungen korrekt erkannt werden
            _save_seen_urls(current_urls)
            return

        # items für worker bauen
        for s in series_list:
            if not isinstance(s, dict):
                continue
            full = (s.get("details_url") or "").strip()
            u = _details_url_key(full)
            if u in new_urls:
                items.append({
                    "details_url": full,
                    "details_key": u,
                    "poster": s.get("poster") or "",
                    "backcover": s.get("backcover") or s.get("fanart") or ""
                })

        # seen aktualisieren (current snapshot)
        _save_seen_urls(current_urls)

        if not items:
            return

        # WICHTIG: Kodi beendet den Plugin-Python-Prozess direkt nach endOfDirectory.
        # Daemon-Threads werden dabei gekillt -> Prefetch wuerde nie fertig.
        # Deshalb: bei kleinen Mengen (<=5) synchron prefetch'en, damit der Disk-Cache
        # sicher befuellt ist und die Meldung beim naechsten Oeffnen weg ist.
        if len(items) <= 5:
            xbmc.log(f"[{common.addon_id} series.py] Fehlende Details erkannt: {len(items)} -> details.json Prefetch (sync)", xbmc.LOGINFO)
            _prefetch_details_worker(items)
            return

        xbmc.log(f"[{common.addon_id} series.py] Fehlende Details erkannt: {len(items)} -> details.json Prefetch im Hintergrund", xbmc.LOGINFO)

        global _details_prefetch_thread
        # Nur ein Worker gleichzeitig (reicht; arbeitet Liste ab)
        with _details_prefetch_lock:
            if _details_prefetch_thread and _details_prefetch_thread.is_alive():
                return
            _details_prefetch_thread = threading.Thread(target=_prefetch_details_worker, args=(items,))
            _details_prefetch_thread.daemon = True
            _details_prefetch_thread.start()

    except Exception as e:
        xbmc.log(f"[{common.addon_id} series.py] kickoff_new_series_details_prefetch Fehler: {e}", xbmc.LOGDEBUG)

try:
    # Stelle sicher, dass common.addon_data_dir initialisiert und nicht leer ist
    if not common.addon_data_dir or not isinstance(common.addon_data_dir, str):
        raise ValueError("common.addon_data_dir ist ungültig oder nicht initialisiert.")

    # Baue jetzt die vollen Pfade sicher zusammen
    WATCHED_STATUS_FILE = os.path.join(common.addon_data_dir, WATCHED_STATUS_FILENAME)
    LOCAL_JSON_PATH = os.path.join(common.addon_data_dir, LOCAL_JSON_FILENAME)
    LOCAL_IMAGE_CACHE_DIR = os.path.join(common.addon_data_dir, LOCAL_IMAGE_CACHE_SUBDIR)

    # NEU: Serien-Cache-Dateien
    SERIES_REMOTE_META_FILE = os.path.join(common.addon_data_dir, "series_remote_meta.json")
    SERIES_LOCAL_CACHE_FILE = os.path.join(common.addon_data_dir, "series.cache.json")

    # Erstelle Cache-Verzeichnis nur, wenn der Pfad gültig ist
    if LOCAL_IMAGE_CACHE_DIR:
        if not xbmcvfs.exists(LOCAL_IMAGE_CACHE_DIR):
            xbmcvfs.mkdirs(LOCAL_IMAGE_CACHE_DIR)
            xbmc.log(f"[{common.addon_id}] Cache-Verzeichnis erstellt: {LOCAL_IMAGE_CACHE_DIR}", xbmc.LOGINFO)
        else:
            xbmc.log(f"[{common.addon_id}] Cache-Verzeichnis vorhanden: {LOCAL_IMAGE_CACHE_DIR}", xbmc.LOGDEBUG)
    else:
        xbmc.log(f"[{common.addon_id}] WARNUNG: Cache-Verzeichnis-Pfad konnte nicht erstellt werden (LOCAL_IMAGE_CACHE_DIR ist leer).", xbmc.LOGWARNING)

    xbmc.log(f"[{common.addon_id}] Pfade erfolgreich initialisiert.", xbmc.LOGDEBUG)
    xbmc.log(f"[{common.addon_id}] WATCHED_STATUS_FILE: {WATCHED_STATUS_FILE}", xbmc.LOGDEBUG)
    xbmc.log(f"[{common.addon_id}] LOCAL_JSON_PATH: {LOCAL_JSON_PATH}", xbmc.LOGDEBUG)
    xbmc.log(f"[{common.addon_id}] LOCAL_IMAGE_CACHE_DIR: {LOCAL_IMAGE_CACHE_DIR}", xbmc.LOGDEBUG)
    xbmc.log(f"[{common.addon_id}] SERIES_REMOTE_META_FILE: {SERIES_REMOTE_META_FILE}", xbmc.LOGDEBUG)
    xbmc.log(f"[{common.addon_id}] SERIES_LOCAL_CACHE_FILE: {SERIES_LOCAL_CACHE_FILE}", xbmc.LOGDEBUG)

except Exception as e:
    xbmc.log(f"[{common.addon_id}] FATALER FEHLER beim Initialisieren der Pfade: {e}", xbmc.LOGERROR)
    xbmcgui.Dialog().notification("Addon Fehler", "Datenpfade konnten nicht initialisiert werden. Lokale Funktionen sind deaktiviert.", common.addon.getAddonInfo('icon'), 8000)
    WATCHED_STATUS_FILE = ""
    LOCAL_JSON_PATH = ""
    LOCAL_IMAGE_CACHE_DIR = ""
    SERIES_REMOTE_META_FILE = ""
    SERIES_LOCAL_CACHE_FILE = ""


# ----------------------------
# Funktionen für den Bild-Cache
# -------------------------------------------------------------------
# Image Cache (delegiert auf common.py)
# -------------------------------------------------------------------
# Wichtig: keine Duplizierung mehr in series.py – alle Cache-Details liegen zentral in common.py.

def get_cache_filename(url):
    return common.get_cache_filename(url)

def get_cached_image(url):
    return common.get_cached_image(url, cache_dir=LOCAL_IMAGE_CACHE_DIR)

def download_and_cache_image(url, timeout_s=15):
    return common.download_and_cache_image(url, cache_dir=LOCAL_IMAGE_CACHE_DIR, timeout_s=timeout_s)

# ----------------------------
def _load_series_remote_meta():
    """Liest Meta-Infos zur Online-Serienliste (mit .bak fallback)."""
    if not SERIES_REMOTE_META_FILE or not xbmcvfs.exists(SERIES_REMOTE_META_FILE):
        return {}
    try:
        data = _safe_json_read_vfs(SERIES_REMOTE_META_FILE, default={})
        return data if isinstance(data, dict) else {}
    except Exception as e:
        _log_series(f"Series-Remote-Meta konnte nicht gelesen werden: {e}", xbmc.LOGDEBUG)
        return {}


def _save_series_remote_meta(meta):
    """Speichert Meta-Infos zur Online-Serienliste (hard-reset safe)."""
    if not SERIES_REMOTE_META_FILE:
        return
    try:
        _atomic_write_json_vfs(SERIES_REMOTE_META_FILE, meta or {}, indent=2, keep_bak=True)
    except Exception as e:
        _log_series(f"Series-Remote-Meta konnte nicht gespeichert werden: {e}", xbmc.LOGDEBUG)




def _load_series_cache():
    """Lädt den lokalen Serien-Cache (mit .bak fallback)."""
    if not SERIES_LOCAL_CACHE_FILE or not xbmcvfs.exists(SERIES_LOCAL_CACHE_FILE):
        return []
    try:
        data = _safe_json_read_vfs(SERIES_LOCAL_CACHE_FILE, default=[])
        return data if isinstance(data, list) else []
    except Exception as e:
        _log_series(f"Series-Cache konnte nicht gelesen werden: {e}", xbmc.LOGDEBUG)
        return []



def _save_series_cache(series_list):
    """Speichert die Serienliste lokal (series.cache.json) hard-reset safe."""
    if not SERIES_LOCAL_CACHE_FILE:
        return
    try:
        # keep_bak=True, weil ein kaputter Cache nervt (und groß ist es nicht)
        _atomic_write_json_vfs(SERIES_LOCAL_CACHE_FILE, series_list or [], indent=None, keep_bak=True)
    except Exception as e:
        _log_series(f"Series-Cache konnte nicht gespeichert werden: {e}", xbmc.LOGDEBUG)




def _get_series_list_cached(url):
    """
    Holt die Serienliste mit Conditional GET (ETag/Last-Modified) + SHA1-Fallback
    und lokalem Cache.

    Damit entscheiden Service und UI identisch, und es gibt kein HEAD/Content-Length-Drift mehr.
    """
    # meta file aligned with service.py
    meta_path = os.path.join(common.addon_data_dir, f"{KODI_SERIES_JSON_FILENAME}.meta.json")
    # NOTE: common.conditional_fetch_bytes uses the parameter name "timeout_s".
    status, data_bytes, _hdrs = common.conditional_fetch_bytes(
        url, meta_path, timeout_s=12
    )

    if status == 'not_modified':
        cached = _load_series_cache()
        if cached:
            _log_series('Online-Serienliste unverändert (conditional) – verwende lokalen Cache.', xbmc.LOGINFO)
            return cached
        # No cache -> force a full fetch
        status, data_bytes, _hdrs = common.conditional_fetch_bytes(
            url, meta_path, timeout_s=12
        )

    if status == 'changed' and data_bytes is not None:
        try:
            series_list = json.loads(data_bytes.decode('utf-8'))
        except Exception:
            series_list = []
        if not isinstance(series_list, list):
            series_list = []

        _save_series_cache(series_list)
        _log_series('Online-Serienliste geändert (conditional) – Cache aktualisiert.', xbmc.LOGINFO)
        return series_list

    # error or still no data: fallback cache
    cached = _load_series_cache()
    if cached:
        _log_series('Fehler beim Laden der Online-Serienliste – verwende lokalen Cache.', xbmc.LOGWARNING)
        return cached
    return []


# ----------------------------
# Funktionen zum Aktualisieren der lokalen JSON
# ----------------------------
def update_local_series_json(serie_key_online, title, series_folder_local, local_assets, downloaded_content):
    if not LOCAL_JSON_PATH:
        xbmc.log(f"[{common.addon_id} series.py] FEHLER: LOCAL_JSON_PATH nicht verfügbar in update_local_series_json.", xbmc.LOGERROR)
        return

    try:
        # ✅ robust lesen (main + .bak)
        local_series = _safe_json_read_vfs(LOCAL_JSON_PATH, default={})
        if not isinstance(local_series, dict):
            local_series = {}
    except Exception as e:
        xbmc.log(f"[{common.addon_id} series.py] Fehler beim Lesen von {LOCAL_JSON_PATH}: {e}", xbmc.LOGERROR)
        local_series = {}

    local_series[serie_key_online] = {
        "title": title,
        "local_path": series_folder_local,
        "download_date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
        "assets": local_assets,
        "staffels": downloaded_content
    }

    try:
        ok = _atomic_write_json_vfs(LOCAL_JSON_PATH, local_series, indent=4, keep_bak=True)
        if ok:
            xbmc.log(f"[{common.addon_id} series.py] Lokale JSON aktualisiert für Key '{serie_key_online}' (atomic).", xbmc.LOGINFO)
        else:
            xbmc.log(f"[{common.addon_id} series.py] Fehler: Lokale JSON konnte nicht atomisch gespeichert werden.", xbmc.LOGERROR)
    except Exception as e:
        xbmc.log(f"[{common.addon_id} series.py] Fehler beim Schreiben von {LOCAL_JSON_PATH}: {e}", xbmc.LOGERROR)




def update_local_series_json_for_season(serie_key_online, title, series_folder_local, local_assets, season_number, season_content):
    if not LOCAL_JSON_PATH:
        return

    try:
        local_series = _safe_json_read_vfs(LOCAL_JSON_PATH, default={})
        if not isinstance(local_series, dict):
            local_series = {}
    except Exception as e:
        local_series = {}
        xbmc.log(f"[{common.addon_id} series.py] Fehler Lesen local JSON: {e}", xbmc.LOGERROR)

    if serie_key_online not in local_series:
        local_series[serie_key_online] = {
            "title": title,
            "local_path": series_folder_local,
            "download_date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
            "assets": local_assets,
            "staffels": {}
        }

    if "staffels" not in local_series[serie_key_online] or not isinstance(local_series[serie_key_online].get("staffels"), dict):
        local_series[serie_key_online]["staffels"] = {}

    local_series[serie_key_online]["staffels"][str(season_number)] = season_content

    try:
        _atomic_write_json_vfs(LOCAL_JSON_PATH, local_series, indent=4, keep_bak=True)
    except Exception as e:
        xbmc.log(f"[{common.addon_id} series.py] Fehler Schreiben local JSON: {e}", xbmc.LOGERROR)



def update_local_series_json_for_episode(serie_key_online, title, series_folder_local, local_assets, season_number, episode_number, episode_download_info):
    if not LOCAL_JSON_PATH:
        return

    try:
        local_series = _safe_json_read_vfs(LOCAL_JSON_PATH, default={})
        if not isinstance(local_series, dict):
            local_series = {}
    except Exception as e:
        local_series = {}
        xbmc.log(f"[{common.addon_id} series.py] Fehler Lesen local JSON: {e}", xbmc.LOGERROR)

    s_season_number = str(season_number)
    s_episode_number = str(episode_number)

    if serie_key_online not in local_series:
        local_series[serie_key_online] = {
            "title": title,
            "local_path": series_folder_local,
            "download_date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
            "assets": local_assets,
            "staffels": {}
        }

    if "staffels" not in local_series[serie_key_online] or not isinstance(local_series[serie_key_online].get("staffels"), dict):
        local_series[serie_key_online]["staffels"] = {}

    if s_season_number not in local_series[serie_key_online]["staffels"] or not isinstance(local_series[serie_key_online]["staffels"].get(s_season_number), dict):
        local_series[serie_key_online]["staffels"][s_season_number] = {}

    local_series[serie_key_online]["staffels"][s_season_number][s_episode_number] = episode_download_info

    try:
        _atomic_write_json_vfs(LOCAL_JSON_PATH, local_series, indent=4, keep_bak=True)
    except Exception as e:
        xbmc.log(f"[{common.addon_id} series.py] Fehler Schreiben local JSON: {e}", xbmc.LOGERROR)




# ----------------------------
# Download-Funktion für einzelne Episode
# ----------------------------
def download_episode(episode_data, series_folder_local, season_number):
    episode_number = episode_data.get("episode_number", '')
    title = episode_data.get("name", f"Episode {episode_number}")
    file_url = episode_data.get("file_ort", "")

    if not file_url:
        xbmc.log(f"[{common.addon_id} series.py] Fehler: Keine 'file_ort' URL für Episode '{title}' (S{season_number}E{episode_number}).", xbmc.LOGERROR)
        return None

    safe_episode_title = "".join(c for c in title if c.isalnum() or c in (" ", "_", "-")).rstrip()
    try:
        path = urllib.parse.urlparse(file_url).path
        ext = os.path.splitext(path)[1] if path else ".mp4"
    except Exception:
        ext = ".mp4"

    episode_filename = f"S{str(season_number).zfill(2)}E{str(episode_number).zfill(2)} - {safe_episode_title}{ext}"
    episode_dest = os.path.join(series_folder_local, episode_filename)

    DownloadCancelledError = getattr(common, "DownloadCancelledError", None)

    downloaded_video_path = None
    try:
        xbmc.log(f"[{common.addon_id} series.py] Starte Download Episode: {file_url} -> {episode_dest}", xbmc.LOGDEBUG)
        downloaded_video_path = common.download_file(file_url, episode_dest, title=f"S{season_number}E{episode_number} - {title}")

    except Exception as e:
        # ✅ Cancel NICHT schlucken
        if DownloadCancelledError and isinstance(e, DownloadCancelledError):
            raise

        xbmc.log(f"[{common.addon_id} series.py] Download Fehler für Episode '{title}': {e}", xbmc.LOGERROR)
        if xbmcvfs.exists(episode_dest):
            xbmcvfs.delete(episode_dest)
        return None

    local_assets = {}
    still_url = episode_data.get("still", "")
    if still_url and still_url.startswith("http"):
        try:
            still_filename = f"S{str(season_number).zfill(2)}E{str(episode_number).zfill(2)}-thumb.jpg"
            still_dest = os.path.join(series_folder_local, still_filename)
            local_still_path = download_and_cache_image(still_url)
            if local_still_path:
                xbmcvfs.copy(local_still_path, still_dest)
                local_assets["still"] = still_dest
            else:
                xbmc.log(f"[{common.addon_id} series.py] Fehler beim Caching/Download des Still-Bildes für S{season_number}E{episode_number}", xbmc.LOGWARNING)
        except Exception as e:
            xbmc.log(f"[{common.addon_id} series.py] Download Fehler für Episode-Still '{title}': {e}", xbmc.LOGERROR)

    return {
        "local_path": downloaded_video_path,
        "title": title,
        "download_date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
        "assets": local_assets,
        "overview": episode_data.get("overview", ""),
        "air_date": episode_data.get("air_date", ""),
        "directors": episode_data.get("directors", ""),
        "runtime": episode_data.get("runtime"),
        "original_online_url": episode_data.get("file_ort", "")
    }


# ----------------------------
# Kontextmenü-Hilfsfunktion
# ----------------------------
def get_context_menu_items(serie):
    context_menu_items = []
    name = (serie.get("name", "Unbekannt") or "").strip()

    details_url_val = (serie.get("details_url") or "").strip()
    file_ort_val = (serie.get("file_ort") or "").strip()

    # ✅ Download nur anbieten, wenn details_url wirklich eine URL ist
    if details_url_val.startswith("http") and serie.get("source") != "local_only":
        data_for_download = serie.copy()
        data_for_download["details_url"] = details_url_val  # sauber: echte Details-URL

        download_params = {
            "action": "download_series",
            "serie_key": details_url_val,
            "title": name.replace(" (lokal)", "").strip(),
            # ✅ FIX: statt json.dumps(...) -> pack_payload(...)
            "data": pack_payload(data_for_download),
        }
        plugin_url_download = common.build_url(download_params)
        context_menu_items.append(("Herunterladen", f"RunPlugin({plugin_url_download})"))

    # Lokal löschen (wie vorher)
    if file_ort_val and (not file_ort_val.startswith("http")) and serie.get("source") in ["local", "local_only"]:
        delete_params = {
            "action": "delete_local_series",
            "serie_key": file_ort_val,
            "title": name,
        }
        plugin_url_delete = common.build_url(delete_params)
        context_menu_items.append(("Lokale Kopie löschen", f"RunPlugin({plugin_url_delete})"))

    return context_menu_items




# ------------------------------------------------------------------------------------
# NEUE FUNKTIONEN FÜR ZUFALLSWIEDERGABE
# ------------------------------------------------------------------------------------

# ----------------------------
# Hilfsfunktion: Zufällige Episode aus ALLEN Serien finden
# ----------------------------
def find_random_episode_info_globally(count=1, max_retries=20):
    xbmc.log(f"[{common.addon_id} series.py] Suche zufällige globale Episode(n) (Anzahl: {count})...", xbmc.LOGINFO)

    ftp_username = (common.addon.getSetting("ftp_username") or "").strip()
    ftp_password = (common.addon.getSetting("ftp_password") or "").strip()
    if not ftp_username or not ftp_password:
        xbmc.log(f"[{common.addon_id} series.py] FTP-Zugangsdaten fehlen für globale Zufallssuche.", xbmc.LOGERROR)
        return None

    scheme, ftp_host, base_path = _normalize_remote_base()
    if not ftp_host:
        xbmc.log(f"[{common.addon_id} series.py] Host fehlt für globale Zufallssuche.", xbmc.LOGERROR)
        return None

    url = f"{scheme}://{ftp_host}{base_path}{KODI_SERIES_JSON_FILENAME}"

    all_online_series = []
    try:
        # ✅ FIX: Wrapper verwenden
        req = get_authenticated_request(url)
        if not req:
            raise ValueError(f"Auth Request fehlgeschlagen für {KODI_SERIES_JSON_FILENAME}.")
        with urllib.request.urlopen(req, context=ssl.create_default_context(), timeout=20) as response:
            if response.getcode() == 200:
                all_online_series = json.loads(response.read().decode('utf-8'))
                if not isinstance(all_online_series, list):
                    all_online_series = []
            else:
                raise ValueError(f"HTTP Error {response.getcode()} beim Laden von {KODI_SERIES_JSON_FILENAME}")
    except Exception as e:
        xbmc.log(f"[{common.addon_id} series.py] Fehler Laden/Parsen von {KODI_SERIES_JSON_FILENAME} für Zufallssuche: {e}", xbmc.LOGERROR)
        return None

    if not all_online_series:
        xbmc.log(f"[{common.addon_id} series.py] Keine Serien in {KODI_SERIES_JSON_FILENAME} für Zufallssuche gefunden.", xbmc.LOGWARNING)
        return None

    results = []
    attempts = 0
    max_total_attempts = count * max_retries
    available_series_indices = list(range(len(all_online_series)))

    global SERIES_DETAILS_CACHE

    while len(results) < count and attempts < max_total_attempts and available_series_indices:
        attempts += 1
        try:
            chosen_series_index_in_list = random.choice(available_series_indices)
            random_serie_from_list = all_online_series[chosen_series_index_in_list]

            series_title = random_serie_from_list.get("name", "Unbekannte Serie")
            details_url = (random_serie_from_list.get("details_url") or "").strip()

            series_fsk_global_rnd = random_serie_from_list.get("fsk")
            series_vote_avg_global_rnd = random_serie_from_list.get("tmdbRating") or random_serie_from_list.get("imdbRating")

            if not details_url.startswith("http"):
                available_series_indices.remove(chosen_series_index_in_list)
                continue

            details_json = SERIES_DETAILS_CACHE.get(details_url)
            if details_json is None:
                xbmc.log(f"[{common.addon_id} series.py] Cache miss für details_url: {details_url}. Lade...", xbmc.LOGDEBUG)

                # ✅ FIX: Wrapper verwenden
                req_details = get_authenticated_request(details_url)
                if not req_details:
                    SERIES_DETAILS_CACHE[details_url] = False
                    available_series_indices.remove(chosen_series_index_in_list)
                    continue

                with urllib.request.urlopen(req_details, context=ssl.create_default_context(), timeout=20) as response_details:
                    if response_details.getcode() == 200:
                        details_json_data = json.loads(response_details.read().decode('utf-8'))
                        SERIES_DETAILS_CACHE[details_url] = details_json_data if isinstance(details_json_data, dict) else {}
                        details_json = SERIES_DETAILS_CACHE[details_url]
                    else:
                        SERIES_DETAILS_CACHE[details_url] = False
                        available_series_indices.remove(chosen_series_index_in_list)
                        continue
            elif details_json is False:
                available_series_indices.remove(chosen_series_index_in_list)
                continue

            all_episodes_from_details = []
            for season_key, season_data_val in details_json.items():
                if isinstance(season_data_val, dict) and isinstance(season_key, str) and season_key.lower().startswith("season"):
                    s_num_str = season_key.lower().replace("season", "").strip()
                    s_num = int(s_num_str) if s_num_str.isdigit() else 0

                    season_vote_avg_for_this_s_rnd = season_data_val.get("vote_average")

                    for ep_data_item in season_data_val.get("episodes", []):
                        if isinstance(ep_data_item, dict):
                            ep_copy = ep_data_item.copy()
                            ep_copy['parsed_season_number'] = s_num
                            ep_copy['current_season_vote_average_rnd'] = season_vote_avg_for_this_s_rnd
                            all_episodes_from_details.append(ep_copy)

            if not all_episodes_from_details:
                available_series_indices.remove(chosen_series_index_in_list)
                continue

            random_episode_data = random.choice(all_episodes_from_details)
            info = {
                "playable_path": random_episode_data.get("file_ort"),
                "episode_title": random_episode_data.get("name"),
                "overview": random_episode_data.get("overview", ""),
                "air_date": random_episode_data.get("air_date", ""),
                "still": random_episode_data.get("still", ""),
                "series_title": series_title,
                "series_poster": random_serie_from_list.get("poster", ""),
                "series_fanart": random_serie_from_list.get("backcover", ""),
                "season_number": random_episode_data.get('parsed_season_number', 0),
                "episode_number": random_episode_data.get("episode_number", 0),
                "genre": random_serie_from_list.get("genre", ""),
                "studio": random_serie_from_list.get("studio", ""),
                "directors": random_episode_data.get("directors", ""),
                "runtime": random_episode_data.get("runtime"),
                "tmdbid_status": random_episode_data.get("file_ort"),
                "fsk": series_fsk_global_rnd,
                "episode_rating": random_episode_data.get("rating"),
                "season_vote_average": random_episode_data.get("current_season_vote_average_rnd"),
                "series_vote_average": series_vote_avg_global_rnd,
                "series_imdb_votes": _to_int(random_serie_from_list.get("imdbVotes")),
                "episode_vote_count": _to_int(random_episode_data.get("vote_count")),
            }

            if info["playable_path"] and info["episode_title"] and not any(item.get("playable_path") == info["playable_path"] for item in results):
                results.append(info)

        except Exception as e:
            xbmc.log(f"[{common.addon_id} series.py] Fehler im globalen Zufallsversuch ({attempts}/{max_total_attempts}): {e}", xbmc.LOGWARNING)
            if 'chosen_series_index_in_list' in locals() and chosen_series_index_in_list in available_series_indices:
                available_series_indices.remove(chosen_series_index_in_list)
            continue

    return results[0] if count == 1 and results else results if results else None




# ----------------------------
# Globale Zufallswiedergabe (aus allen Serien)
# ----------------------------
def play_random_series_globally():
    """
    Startet eine zufällige Episode aus *allen* Serien (globaler Button in check_series_json).
    """

    # --- Basis/URL ---
    try:
        scheme, ftp_host, base_path = _normalize_remote_base()
    except Exception:
        scheme, ftp_host, base_path = ("https", "", "/")

    if not ftp_host:
        xbmcgui.Dialog().notification("Fehler", "Random global: FTP Host fehlt!", common.addon.getAddonInfo('icon'))
        return

    url = f"{scheme}://{ftp_host}{base_path}{KODI_SERIES_JSON_FILENAME}"

    # --- Serienliste laden (aus Cache) ---
    try:
        series_list = _get_series_list_cached(url)
    except Exception as e:
        xbmcgui.Dialog().notification("Fehler", f"Random global: Serienliste nicht ladbar: {e}", common.addon.getAddonInfo('icon'))
        return

    if not series_list:
        xbmcgui.Dialog().notification("Info", "Random global: Keine Serien gefunden.", common.addon.getAddonInfo('icon'))
        return

    # --- lokale Serien (optional) dazu ---
    local_db = {}
    if LOCAL_JSON_PATH and xbmcvfs.exists(LOCAL_JSON_PATH):
        try:
            with xbmcvfs.File(LOCAL_JSON_PATH, "r") as f:
                local_db = json.loads(f.read() or "{}")
        except Exception:
            local_db = {}

    # Kandidaten bauen
    candidates = []
    for s in series_list:
        if not isinstance(s, dict):
            continue
        s2 = dict(s)
        s2.setdefault("source", "online")
        s2.setdefault("original_online_key", s2.get("details_url") or s2.get("file_ort") or s2.get("name"))
        candidates.append(s2)

    # local_only ergänzen (alles was nicht in online ist)
    for k, info in (local_db or {}).items():
        # nur wenn staffels existiert oder local_path existiert -> sonst bringt random nichts
        staff = info.get("staffels")
        if not isinstance(staff, dict) and not info.get("local_path"):
            continue
        candidates.append({
            "name": (info.get("title") or "Unbekannt") + " (lokal)",
            "details_url": None,
            "file_ort": info.get("local_path", ""),
            "original_online_key": k,
            "source": "local_only"
        })

    if not candidates:
        xbmcgui.Dialog().notification("Info", "Random global: Keine geeigneten Serien.", common.addon.getAddonInfo('icon'))
        return

    # watched_status laden (für "prefer unwatched")
    watched_status = {}
    if WATCHED_STATUS_FILE:
        try:
            watched_status = _safe_load_watched_status(WATCHED_STATUS_FILE)
        except Exception:
            watched_status = {}

    prefer_unwatched = True
    try:
        prefer_unwatched = common.addon.getSettingBool("random_prefer_unwatched")
    except Exception:
        pass

    # --- Versuche (falls einzelne Serien keine Episoden liefern) ---
    for _attempt in range(20):
        serie = random.choice(candidates)

        # Wir nutzen direkt 3.5, indem wir die Serie als payload "reinreichen".
        # ✅ pack_payload ist kompatibel (JSON+pid), aber wir brauchen hier nur einen String.
        try:
            play_random_episode_from_this_series(pack_payload(serie))
            return
        except Exception as e:
            xbmc.log(f"[{common.addon_id} series.py] Random global attempt failed: {e}", xbmc.LOGDEBUG)
            continue

    xbmcgui.Dialog().notification("Info", "Random global: Keine abspielbare Episode gefunden.", common.addon.getAddonInfo('icon'))


# ----------------------------
# Zufallswiedergabe: Episode aus DIESER SERIE
# ----------------------------
def play_random_episode_from_this_series(data_json_str):
    """
    Startet eine zufällige Episode aus *dieser* Serie.
    data_json_str kann JSON oder "pid:..." sein.

    FIX:
      - setzt last_series_cache_key (damit monitor_playback_series Progress-Cache invalidieren kann)
      - setzt last_played_tvshow_title_for_player (damit Player-InfoTag TVShowTitle hat)
      - watched_status über _safe_load_watched_status (hard-reset safe)
    """

    # ✅ json.loads -> unpack_payload
    try:
        serie = unpack_payload(data_json_str, default={})
        if not isinstance(serie, dict) or not serie:
            raise ValueError("Leere/ungültige Seriendaten.")
    except Exception as e:
        xbmcgui.Dialog().notification("Fehler", f"Random-Serie: Daten fehlerhaft: {e}", common.addon.getAddonInfo('icon'))
        return

    serie_name = (serie.get("name") or "Unbekannte Serie").replace(" (lokal)", "").strip()
    source_type = serie.get("source", "online")

    # ✅ Für InfoTag (play_series setzt TvShowTitle aus Setting)
    try:
        if serie_name:
            common.addon.setSetting("last_played_tvshow_title_for_player", serie_name)
    except Exception:
        pass

    # watched_status laden (hard-reset safe)
    watched_status = {}
    if WATCHED_STATUS_FILE:
        try:
            watched_status = _safe_load_watched_status(WATCHED_STATUS_FILE)
        except Exception:
            watched_status = {}

    prefer_unwatched = True
    try:
        prefer_unwatched = common.addon.getSettingBool("random_prefer_unwatched")
    except Exception:
        pass

    details_json = {}
    details_url_val = (serie.get("details_url") or "").strip()
    original_serie_key = serie.get("original_online_key") or details_url_val or serie.get("file_ort") or serie.get("name") or ""

    # ✅ Für Progress-Cache invalidate nach Playback
    try:
        cache_key = (details_url_val or original_serie_key).strip()
        if cache_key:
            common.addon.setSetting("last_series_cache_key", cache_key)
    except Exception:
        pass

    # --- Details laden (online) oder lokal rekonstruieren ---
    global SERIES_DETAILS_CACHE

    if details_url_val and source_type not in ["local", "local_only"]:
        if details_url_val in SERIES_DETAILS_CACHE and SERIES_DETAILS_CACHE[details_url_val] is not False:
            details_json = SERIES_DETAILS_CACHE[details_url_val]
        else:
            try:
                req = get_authenticated_request(details_url_val)
                with urllib.request.urlopen(req, context=ssl.create_default_context(), timeout=20) as r:
                    if r.getcode() == 200:
                        tmp = json.loads(r.read().decode("utf-8"))
                        if isinstance(tmp, dict):
                            details_json = tmp
                            SERIES_DETAILS_CACHE[details_url_val] = tmp
                        else:
                            SERIES_DETAILS_CACHE[details_url_val] = False
                    else:
                        SERIES_DETAILS_CACHE[details_url_val] = False
            except Exception as e:
                xbmc.log(f"[{common.addon_id} series.py] Random-Serie: Details online laden fehlgeschlagen: {e}", xbmc.LOGWARNING)

    if not details_json and source_type in ["local", "local_only"]:
        # lokal rekonstruieren aus LOCAL_JSON_PATH
        if LOCAL_JSON_PATH and xbmcvfs.exists(LOCAL_JSON_PATH):
            try:
                with xbmcvfs.File(LOCAL_JSON_PATH, "r") as f:
                    local_db = json.loads(f.read() or "{}")
                loc_info = local_db.get(original_serie_key, {})
                staffels = loc_info.get("staffels")
                if isinstance(staffels, dict):
                    reconstructed = {"series_info": {"fsk": loc_info.get("fsk")}}
                    for s_num, s_content in staffels.items():
                        eps = []
                        for ep_num, ep_info in (s_content or {}).items():
                            eps.append({
                                "episode_number": int(ep_num) if str(ep_num).isdigit() else ep_num,
                                "name": ep_info.get("title", f"Episode {ep_num}"),
                                "overview": ep_info.get("overview", ""),
                                "air_date": ep_info.get("air_date", ""),
                                "still": ep_info.get("assets", {}).get("still", ""),
                                "runtime": ep_info.get("runtime"),
                                "rating": ep_info.get("rating"),
                                "file_ort": ep_info.get("original_online_url", ""),
                                "_local_playback_path": ep_info.get("local_path", "")
                            })
                        reconstructed[f"Season {s_num}"] = {"episodes": eps}
                    details_json = reconstructed
            except Exception as e:
                xbmc.log(f"[{common.addon_id} series.py] Random-Serie: Lokal rekonstruieren fehlgeschlagen: {e}", xbmc.LOGERROR)

    if not isinstance(details_json, dict) or not details_json:
        xbmcgui.Dialog().notification("Fehler", f"Random-Serie: Keine Details für '{serie_name}'", common.addon.getAddonInfo('icon'))
        return

    # --- Episoden sammeln ---
    all_eps = []
    for k, season_data in details_json.items():
        if not (isinstance(k, str) and k.lower().startswith("season")):
            continue
        if not isinstance(season_data, dict):
            continue

        s_num_str = k.lower().replace("season", "").strip()
        season_number = int(s_num_str) if s_num_str.isdigit() else -1

        for ep in (season_data.get("episodes") or []):
            if isinstance(ep, dict):
                all_eps.append((season_number, ep))

    if not all_eps:
        xbmcgui.Dialog().notification("Info", f"Random-Serie: Keine Episoden gefunden ({serie_name})", common.addon.getAddonInfo('icon'))
        return

    def _watched(ep_dict):
        try:
            return _is_episode_watched(ep_dict, watched_status)
        except Exception:
            ident = ep_dict.get("file_ort") or ep_dict.get("_local_playback_path") or ""
            return bool(ident and common.is_series_watched(watched_status, ident))

    candidates = all_eps
    if prefer_unwatched and watched_status:
        unwatched = [(s, e) for (s, e) in all_eps if not _watched(e)]
        if unwatched:
            candidates = unwatched

    season_number, ep = random.choice(candidates)

    ep_num = _to_int(ep.get("episode_number"), 0)
    ep_title = ep.get("name") or f"Episode {ep_num or '?'}"

    # --- Playback-Pfad bestimmen (lokal bevorzugen, falls vorhanden) ---
    play_path = ""
    if source_type in ["local", "local_only"]:
        play_path = (ep.get("_local_playback_path") or ep.get("local_path") or "").strip()
    play_path = play_path or (ep.get("file_ort") or ep.get("stream_url") or "").strip()

    if not play_path:
        xbmcgui.Dialog().notification("Fehler", f"Random-Serie: Keine Abspiel-URL ({serie_name})", common.addon.getAddonInfo('icon'))
        return

    status_identifier = (ep.get("file_ort") or ep.get("stream_url") or ep.get("_local_playback_path") or ep.get("local_path") or play_path)

    pretty = f"{serie_name} - S{season_number}E{ep_num:02d} {ep_title}" if (season_number != -1 and ep_num) else f"{serie_name} - {ep_title}"
    xbmcgui.Dialog().notification("Random", pretty, common.addon.getAddonInfo('icon'), 2500)

    try:
        play_series(play_path, pretty, status_identifier)
    except Exception:
        try:
            xbmc.executebuiltin(f'PlayMedia("{play_path}")')
        except Exception as e:
            xbmc.log(f"[{common.addon_id} series.py] Random-Serie: PlayMedia failed: {e}", xbmc.LOGERROR)




# ----------------------------
# Zufallswiedergabe: Episode aus DIESER STAFFEL
# ----------------------------
def play_random_episode_from_this_season(data_json_str):
    """
    Startet eine zufällige Episode aus *dieser* Staffel.
    data_json_str kann JSON oder "pid:..." sein.

    FIX:
      - setzt last_series_cache_key (aus payload["online_serie_key"])
      - watched_status über _safe_load_watched_status (hard-reset safe)
    """

    try:
        payload = unpack_payload(data_json_str, default={})
        if not isinstance(payload, dict) or not payload:
            raise ValueError("Leere/ungültige Staffel-Payload.")
    except Exception as e:
        xbmcgui.Dialog().notification("Fehler", f"Random-Staffel: Daten fehlerhaft: {e}", common.addon.getAddonInfo('icon'))
        return

    # ✅ Für Progress-Cache invalidate nach Playback
    try:
        cache_key = (payload.get("online_serie_key") or "").strip()
        if cache_key:
            common.addon.setSetting("last_series_cache_key", cache_key)
    except Exception:
        pass

    serie_name = (payload.get("serie_name") or "Unbekannte Serie").replace(" (lokal)", "").strip()
    season_number = _to_int(payload.get("season_number"), -1)
    episodes = payload.get("episodes") or []
    source_type = payload.get("source", "online")
    local_path_serie = payload.get("local_path_serie")

    # ✅ Für InfoTag
    try:
        if serie_name:
            common.addon.setSetting("last_played_tvshow_title_for_player", serie_name)
    except Exception:
        pass

    if not episodes:
        xbmcgui.Dialog().notification("Info", f"Random-Staffel: Keine Episoden ({serie_name})", common.addon.getAddonInfo('icon'))
        return

    watched_status = {}
    if WATCHED_STATUS_FILE:
        try:
            watched_status = _safe_load_watched_status(WATCHED_STATUS_FILE)
        except Exception:
            watched_status = {}

    prefer_unwatched = True
    try:
        prefer_unwatched = common.addon.getSettingBool("random_prefer_unwatched")
    except Exception:
        pass

    def _watched(ep_dict):
        try:
            return _is_episode_watched(ep_dict, watched_status)
        except Exception:
            ident = ep_dict.get("file_ort") or ep_dict.get("_local_playback_path") or ""
            return bool(ident and common.is_series_watched(watched_status, ident))

    candidates = [e for e in episodes if isinstance(e, dict)]
    if not candidates:
        xbmcgui.Dialog().notification("Info", f"Random-Staffel: Keine validen Episoden ({serie_name})", common.addon.getAddonInfo('icon'))
        return

    if prefer_unwatched and watched_status:
        unwatched = [e for e in candidates if not _watched(e)]
        if unwatched:
            candidates = unwatched

    ep = random.choice(candidates)

    ep_num = _to_int(ep.get("episode_number"), 0)
    ep_title = ep.get("name") or f"Episode {ep_num or '?'}"

    play_path = ""
    if source_type in ["local", "local_only"]:
        play_path = (ep.get("_local_playback_path") or ep.get("local_path") or local_path_serie or "").strip()
    play_path = play_path or (ep.get("file_ort") or ep.get("stream_url") or "").strip()

    if not play_path:
        xbmcgui.Dialog().notification("Fehler", f"Random-Staffel: Keine Abspiel-URL ({serie_name})", common.addon.getAddonInfo('icon'))
        return

    status_identifier = (ep.get("file_ort") or ep.get("stream_url") or ep.get("_local_playback_path") or ep.get("local_path") or play_path)
    pretty = f"{serie_name} - S{season_number}E{ep_num:02d} {ep_title}" if (season_number != -1 and ep_num) else f"{serie_name} - {ep_title}"

    xbmcgui.Dialog().notification("Random", pretty, common.addon.getAddonInfo('icon'), 2500)

    try:
        play_series(play_path, pretty, status_identifier)
    except Exception:
        try:
            xbmc.executebuiltin(f'PlayMedia("{play_path}")')
        except Exception as e:
            xbmc.log(f"[{common.addon_id} series.py] Random-Staffel: PlayMedia failed: {e}", xbmc.LOGERROR)


# ------------------------------------------------------------------------------------
# ENDE NEUE FUNKTIONEN FÜR ZUFALLSWIEDERGABE
# ------------------------------------------------------------------------------------



# ----------------------------
# Anzeige der Serienliste
# ----------------------------
# --------------------------------------------------------------
#  check_series_json()  –  VERSION NUR MIT SKIN-HAKEN, KEIN TEXTPRÄFIX
def check_series_json():
    """
    Lädt kodiSeries.json, merged lokale Daten und rendert das Serienverzeichnis.

    PERFORMANCE:
      * HEAD-Request + Content-Length-Check
      * Lokaler Cache series.cache.json
      * Keine synchronen Online-details.json-Loads für Fortschritt.
      * Fortschritt kommt aus kleinem Progress-Cache; fehlende Einträge
        werden asynchron nachgezogen und via Refresh angezeigt.
      * Stillen Image-Prefetch im Hintergrund.
      * Hintergrund-Manager aktualisiert Fortschritt + Bilder alle 1h.

    ✅ FIX:
      * LOCAL_JSON wird hard-reset-safe via _safe_json_read_vfs() geladen (inkl .bak fallback).
      * watched_status optional ebenfalls robust via _safe_load_watched_status().
    """

    t_total0 = time.time()

    def _tlog(tag, t0):
        dt = time.time() - t0
        try:
            xbmc.log(f"[{common.addon_id}] TIMING {tag}: {dt:.3f}s", xbmc.LOGINFO)
        except Exception:
            pass
        return time.time()

    try:
        xbmc.log(f"[{common.addon_id}] TIMING check_series_json START", xbmc.LOGINFO)
    except Exception:
        pass

    show_random = common.addon.getSettingBool("show_random_buttons")

    # --- Credentials prüfen ---
    t0 = time.time()
    ftp_username = (common.addon.getSetting("ftp_username") or "").strip()
    ftp_password = (common.addon.getSetting("ftp_password") or "").strip()
    if not ftp_username or not ftp_password:
        xbmcgui.Dialog().notification("Fehler", "FTP Benutzername/Passwort fehlt!", common.addon.getAddonInfo('icon'))
        xbmcplugin.endOfDirectory(common.addon_handle, succeeded=False)
        _tlog("early_exit_missing_credentials_total", t_total0)
        return

    # Host + Scheme + BasePath robust normalisieren
    scheme, ftp_host, base_path = _normalize_remote_base()
    if not ftp_host:
        xbmcgui.Dialog().notification("Fehler", "FTP Host fehlt!", common.addon.getAddonInfo('icon'))
        xbmcplugin.endOfDirectory(common.addon_handle, succeeded=False)
        _tlog("early_exit_missing_host_total", t_total0)
        return

    t1 = _tlog("settings_and_basepath", t0)

    url = f"{scheme}://{ftp_host}{base_path}{KODI_SERIES_JSON_FILENAME}"
    xbmc.log(f"[{common.addon_id} series.py] Lade Serien-JSON: {url}", xbmc.LOGDEBUG)

    # --- kodiSeries.json mit HEAD/Content-Length & lokalem Cache ---
    t0 = time.time()
    try:
        series_list = _get_series_list_cached(url)
    except Exception as e:
        xbmcgui.Dialog().notification(
            "Fehler",
            f"Laden/Parsen von {KODI_SERIES_JSON_FILENAME} fehlgeschlagen: {e}",
            common.addon.getAddonInfo('icon')
        )
        xbmcplugin.endOfDirectory(common.addon_handle, succeeded=False)
        _tlog("fetch_series_json_failed_total", t_total0)
        return
    t2 = _tlog(f"fetch_series_json_count={len(series_list)}", t0)

    # --- On-Demand: neue Serien erkennen & details.json im Hintergrund vorladen ---
    try:
        kickoff_new_series_details_prefetch(series_list)
    except Exception:
        pass

    # --- watched_status laden (robust) ---
    t0 = time.time()
    watched_status = {}
    if WATCHED_STATUS_FILE:
        try:
            watched_status = _safe_load_watched_status(WATCHED_STATUS_FILE)
        except Exception as e:
            xbmc.log(f"[{common.addon_id}] watched_status Laden: {e}", xbmc.LOGWARNING)
            watched_status = {}
    t3 = _tlog(f"load_watched_status_keys={len(watched_status)}", t0)

    # --- lokale JSON (einmal) laden (✅ hard-reset safe + .bak fallback) ---
    t0 = time.time()
    local_series_db = {}
    try:
        if LOCAL_JSON_PATH:
            local_series_db = _safe_json_read_vfs(LOCAL_JSON_PATH, default={})
        if not isinstance(local_series_db, dict):
            local_series_db = {}
    except Exception as e:
        xbmc.log(f"[{common.addon_id}] local JSON lesen (safe): {e}", xbmc.LOGERROR)
        local_series_db = {}
    t4 = _tlog(f"load_local_json_entries={len(local_series_db)}", t0)

    # --- Online & Lokal mergen ---
    t0 = time.time()
    merged_series = {}

    for s in (series_list or []):
        if not isinstance(s, dict):
            continue
        key = (s.get("details_url") or
               (s.get("file_ort") if str(s.get("file_ort")).startswith("http") else None) or
               s.get("name"))
        if not key:
            continue
        s['source'] = 'online'
        s['original_online_key'] = key
        merged_series[key] = s

    for k_local, info_local in (local_series_db or {}).items():
        if not isinstance(info_local, dict):
            continue

        if k_local in merged_series:
            merged_series[k_local]['file_ort'] = info_local.get('local_path', merged_series[k_local].get('file_ort'))
            if " (lokal)" not in (merged_series[k_local].get('name') or ""):
                merged_series[k_local]['name'] = (merged_series[k_local].get('name') or "Unbekannt") + " (lokal)"
            merged_series[k_local]['source'] = 'local'

            for fld in ["poster", "backcover", "overview", "genre", "studio",
                        "imdbRating", "tmdbRating", "fsk", "start_year",
                        "added", "tmdbId", "imdbVotes"]:
                if not merged_series[k_local].get(fld) and info_local.get(fld):
                    merged_series[k_local][fld] = info_local[fld]

            if isinstance(info_local.get('assets'), dict) and info_local.get('assets', {}).get('poster'):
                merged_series[k_local]['poster'] = info_local['assets']['poster']
            if isinstance(info_local.get('assets'), dict) and info_local.get('assets', {}).get('backcover'):
                merged_series[k_local]['backcover'] = info_local['assets']['backcover']
            if info_local.get("download_date"):
                merged_series[k_local]['added'] = info_local["download_date"]

        else:
            merged_series[k_local] = {
                "name": (info_local.get("title") or "Unbekannt") + " (lokal)",
                "file_ort": info_local.get("local_path", k_local),
                "details_url": None,
                "original_online_key": k_local,
                "source": "local_only",
                "start_year": info_local.get("start_year"),
                "overview": info_local.get("overview", ""),
                "genre": info_local.get("genre", ""),
                "studio": info_local.get("studio", ""),
                "poster": (info_local.get("assets") or {}).get("poster", ""),
                "backcover": (info_local.get("assets") or {}).get("backcover", ""),
                "imdbRating": info_local.get("imdbRating", ""),
                "tmdbRating": info_local.get("tmdbRating", ""),
                "fsk": info_local.get("fsk", ""),
                "tmdbId": info_local.get("tmdbId", ""),
                "added": info_local.get("download_date", ""),
                "imdbVotes": info_local.get("imdbVotes", 0)
            }

    final_series_list = list(merged_series.values())
    t5 = _tlog(f"merge_series_final_count={len(final_series_list)}", t0)

    # --- Bilder still im Hintergrund vorladen (kein Dialog) ---
    t0 = time.time()
    missing_images = []
    if LOCAL_IMAGE_CACHE_DIR:
        missing_images = _compute_missing_series_images(final_series_list, MAX_IMAGE_PREFETCH)
        if missing_images:
            th_img = threading.Thread(target=_image_prefetch_worker, args=(missing_images,))
            th_img.daemon = True
            th_img.start()
    t6 = _tlog(f"kickoff_image_prefetch_missing={len(missing_images)}", t0)

    # --- Sortierung ---
    t0 = time.time()
    sort_param = (common.addon.getSetting("default_series_sort") or "alpha").lower()
    if len(sys.argv) > 2 and sys.argv[2].startswith('?'):
        q = dict(urllib.parse.parse_qsl(sys.argv[2][1:]))
        sort_param = (q.get("sort", sort_param) or sort_param).lower()

    if sort_param in ["date", "added"]:
        def pdate(s):
            for fmt in ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
                try:
                    return datetime.strptime(s.get("added", ""), fmt)
                except Exception:
                    continue
            return datetime(1970, 1, 1)
        final_series_list.sort(key=pdate, reverse=True)

    elif sort_param == "year":
        final_series_list.sort(key=lambda s: int(str(s.get("start_year", "0"))[:4] or 0), reverse=True)

    else:
        final_series_list.sort(key=lambda s: (s.get("name", "") or "").lower())

    t7 = _tlog(f"sort_param={sort_param}", t0)

    # --- Progress-Cache vorbereiten ---
    t0 = time.time()
    progress_cache = _load_progress_cache()
    watch_sig = _watch_signature(watched_status)
    to_compute_async = []
    t8 = _tlog(f"load_progress_cache_entries={len(progress_cache)}", t0)

    # --- Hintergrund-Manager starten ---
    t0 = time.time()
    try:
        _start_background_prefetch(final_series_list, watch_sig)
    except Exception as e_bg:
        xbmc.log(f"[{common.addon_id}] BG prefetch start error: {e_bg}", xbmc.LOGDEBUG)
    t9 = _tlog("start_background_prefetch", t0)

    # --- Globaler Zufall-Button ---
    t0 = time.time()
    if final_series_list and show_random:
        li_rand = xbmcgui.ListItem(label="Zufällige Serie starten")
        li_rand.setProperty("specialsort", "top")
        vi = li_rand.getVideoInfoTag()
        vi.setPlot("Startet eine zufällige Episode aus allen verfügbaren Serien.")
        vi.setMediaType('video')
        vi.setStudios([])

        rand_icon = os.path.join(common.addon_path, "random.png")
        if xbmcvfs.exists(rand_icon):
            li_rand.setArt({'icon': rand_icon, 'thumb': rand_icon})
        else:
            li_rand.setArt({'icon': common.addon.getAddonInfo('icon')})

        rand_url = common.build_url({'action': 'play_random_series_globally'})
        xbmcplugin.addDirectoryItem(common.addon_handle, rand_url, li_rand, isFolder=False)
    t10 = _tlog("add_random_button", t0)

    # --- Serien rendern ---
    t_render0 = time.time()
    rendered = 0

    for serie in (final_series_list or []):
        if not isinstance(serie, dict):
            continue

        name = (serie.get("name", "Unbekannt") or "").strip()
        poster = serie.get("poster", "")
        backcover = serie.get("backcover", "") or serie.get("fanart", "")
        overview = serie.get("overview", "")
        source_type = serie.get("source", "online")
        original_key = serie.get("original_online_key")
        details_url_val = serie.get("details_url")

        completed_show = False

        try:
            details_json_for_progress = None

            if (source_type in ["local", "local_only"]
                    and original_key
                    and isinstance((local_series_db.get(original_key) or {}).get("staffels"), dict)):
                loc_info = local_series_db.get(original_key, {})
                details_json_for_progress = {}
                for s_num, s_content in (loc_info.get("staffels") or {}).items():
                    eps = []
                    for ep_num, ep_info in (s_content or {}).items():
                        if isinstance(ep_info, dict):
                            eps.append({
                                "episode_number": int(ep_num) if str(ep_num).isdigit() else ep_num,
                                "file_ort": ep_info.get("original_online_url", "")
                            })
                    details_json_for_progress[f"Season {s_num}"] = {"episodes": eps}

            if details_json_for_progress is None:
                cache_key = (details_url_val or original_key or "")
                entry = progress_cache.get(cache_key)

                if entry:
                    try:
                        completed_show = bool(entry.get("completed"))
                        w = int(entry.get("watched", 0))
                        t = int(entry.get("total", 0))
                        if t > 0 and w >= t:
                            completed_show = True
                    except Exception:
                        pass

                    try:
                        ts = float(entry.get("ts", 0.0))
                    except Exception:
                        ts = 0.0

                    if details_url_val and (
                        entry.get("watch_sig") != watch_sig or
                        (time.time() - ts) >= BACKGROUND_REFRESH_INTERVAL_S
                    ):
                        to_compute_async.append((name, details_url_val, cache_key))
                else:
                    if details_url_val and watched_status:
                        to_compute_async.append((name, details_url_val, cache_key))
            else:
                if watched_status:
                    _w_show, _t_show, completed_show = _series_progress_from_details(details_json_for_progress, watched_status)
                else:
                    completed_show = False

        except Exception as e_prog:
            xbmc.log(f"[{common.addon_id}] Serienfortschritt (lazy) Fehler: {e_prog}", xbmc.LOGDEBUG)

        li = xbmcgui.ListItem(label=name)

        try:
            li.setProperty("glotzbox.series_completed", "true" if completed_show else "false")
        except Exception:
            pass

        poster_final = None
        if isinstance(poster, str) and poster:
            poster_final = (get_cached_image(poster) if poster.startswith("http") else poster) or poster

        fanart_final = None
        if isinstance(backcover, str) and backcover:
            fanart_final = (get_cached_image(backcover) if backcover.startswith("http") else backcover) or backcover

        _apply_fanart_to_listitem(
            li,
            poster=poster_final,
            fanart=fanart_final,
            tvshow_poster=poster_final,
            tvshow_fanart=fanart_final
        )

        info = li.getVideoInfoTag()
        info.setTitle(name)
        info.setPlot(overview)
        info.setMediaType("tvshow")
        info.setTvShowTitle(name)

        try:
            info.setPlaycount(1 if completed_show else 0)
        except Exception:
            pass

        y_str = str(serie.get("start_year", ""))
        year = int(y_str[:4]) if y_str[:4].isdigit() else 0
        premiered = ""
        if len(y_str) == 10 and y_str[4] == '-' and y_str[7] == '-':
            premiered = y_str
        elif year:
            premiered = f"{year}-01-01"
        if premiered:
            info.setPremiered(premiered)
        if year:
            info.setYear(year)

        rating = 0.0
        for key in ("tmdbRating", "imdbRating"):
            r = serie.get(key)
            if r:
                try:
                    rating = float(str(r).replace(",", "."))
                    if rating > 0:
                        break
                except Exception:
                    pass
        if rating > 0:
            info.setRating(rating)

        imdb_votes = _to_int(serie.get("imdbVotes"))
        if imdb_votes > 0:
            _safe_set_votes(info, imdb_votes)

        fsk = str(serie.get("fsk", "")).strip()
        if fsk:
            info.setMpaa(f"FSK {fsk}")

        if serie.get("added"):
            info.setDateAdded(serie["added"])

        if serie.get("genre"):
            info.setGenres([g.strip() for g in str(serie["genre"]).split(',') if g.strip()])
        if serie.get("studio"):
            info.setStudios(_split_studios(serie.get("studio", "")))

        target_url = ""
        isFolder = False

        data_for_nav = serie.copy()
        if serie.get("details_url") or serie.get("file_ort"):
            target_url = common.build_url({
                "action": "show_seasons",
                "data": pack_payload(data_for_nav),
                "title": name.replace(" (lokal)", "").strip()
            })
            isFolder = True

        li.setProperty('IsPlayable', 'false')

        cm = get_context_menu_items(serie)
        if cm:
            li.addContextMenuItems(cm)

        xbmcplugin.addDirectoryItem(common.addon_handle, target_url, li, isFolder=isFolder)
        rendered += 1

    t_render1 = time.time()
    try:
        xbmc.log(f"[{common.addon_id}] TIMING render_loop_count={rendered}: {(t_render1 - t_render0):.3f}s", xbmc.LOGINFO)
    except Exception:
        pass

    t0 = time.time()
    xbmcplugin.addSortMethod(common.addon_handle, xbmcplugin.SORT_METHOD_LABEL)
    xbmcplugin.addSortMethod(common.addon_handle, xbmcplugin.SORT_METHOD_LABEL_IGNORE_THE)
    xbmcplugin.addSortMethod(common.addon_handle, xbmcplugin.SORT_METHOD_DATEADDED)
    xbmcplugin.addSortMethod(common.addon_handle, xbmcplugin.SORT_METHOD_VIDEO_YEAR)
    xbmcplugin.addSortMethod(common.addon_handle, xbmcplugin.SORT_METHOD_VIDEO_RATING)
    xbmcplugin.setContent(common.addon_handle, "tvshows")
    xbmcplugin.endOfDirectory(common.addon_handle, succeeded=True, updateListing=False, cacheToDisc=True)
    _tlog("endOfDirectory_and_sortmethods", t0)

    xbmc.log(f"[{common.addon_id} series.py] Serien-Verzeichnis fertig.", xbmc.LOGINFO)

    t0 = time.time()
    if watched_status and to_compute_async:
        try:
            th = threading.Thread(target=_async_fill_progress, args=(to_compute_async, watch_sig))
            th.daemon = True
            th.start()
        except Exception as e_th:
            xbmc.log(f"[{common.addon_id}] Async-Thread Startfehler: {e_th}", xbmc.LOGDEBUG)
    _tlog(f"async_progress_start_queued={len(to_compute_async)}", t0)

    _tlog("check_series_json TOTAL", t_total0)





# ----------------------------
# Anzeige der Staffeln
# ----------------------------$
def show_seasons(data_json_str):
    """
    Zeigt alle Staffeln einer Serie. Wenn show_random_buttons == True ist,
    wird oben zusätzlich der Button „Zufällige Episode starten“ eingeblendet.

    FIX:
      - Season-Key Sortierung robust (auch bei "Specials", "Season X Part 1", etc.)
      - Auth-Requests nur noch über get_authenticated_request()
      - ✅ NEU: Beim Öffnen sofort Serie verifizieren + Progress-Cache updaten
    """

    show_random = common.addon.getSettingBool("show_random_buttons")

    # ✅ json.loads -> unpack_payload
    try:
        serie = unpack_payload(data_json_str, default={})
        if not isinstance(serie, dict) or not serie:
            raise ValueError("Leere/ungültige Seriendaten.")
    except Exception as e:
        xbmcgui.Dialog().notification(
            "Fehler",
            f"Serien-Daten fehlerhaft: {e}",
            common.addon.getAddonInfo('icon')
        )
        xbmcplugin.endOfDirectory(common.addon_handle, succeeded=False)
        return

    details_url_val = (serie.get("details_url") or "").strip()
    original_serie_key = serie.get(
        "original_online_key",
        details_url_val or serie.get("file_ort") or serie.get("name")
    )
    serie_name_display = serie.get("name", "Unbekannte Serie")

    # --- Fix: wenn show_seasons nur eine PID bekommt (data=pid:...), details_url aus der Serienliste aufloesen ---
    try:
        pid_val = None
        if isinstance(data_json_str, str) and data_json_str.startswith('pid:'):
            pid_val = data_json_str
        else:
            pid_val = serie.get('pid')

        # Falls original_serie_key kein URL ist, versuchen wir die details_url ueber die aktuelle Serienliste zu finden.
        if pid_val and (not isinstance(original_serie_key, str) or not original_serie_key.startswith(('http://', 'https://'))):
            # CURRENT_SERIES_LIST wird in check_series_json aufgebaut (Serienuebersicht)
            entries = globals().get('CURRENT_SERIES_LIST') or []
            match = next((e for e in entries if isinstance(e, dict) and e.get('pid') == pid_val), None)
            if match:
                resolved = (match.get('details_url') or match.get('details') or '').strip()
                if resolved.startswith(('http://', 'https://')):
                    original_serie_key = resolved
                    # optional auch im serie-dict spiegeln, damit spaetere payloads sauber sind
                    serie['details_url'] = resolved
                    serie['original_online_key'] = resolved
    except Exception as _e_resolve:
        xbmc.log(f"[{common.addon_id} series.py] show_seasons: pid->details_url resolve failed: {_e_resolve}", xbmc.LOGDEBUG)

    current_series_fsk_raw = serie.get("fsk")
    current_series_overall_vote_average = (serie.get("tmdbRating") or serie.get("imdbRating"))
    series_imdb_votes_val = _to_int(serie.get("imdbVotes"), 0)

    # --- watched_status laden ---
    watched_status = {}
    if WATCHED_STATUS_FILE:
        try:
            watched_status = _safe_load_watched_status(WATCHED_STATUS_FILE)
        except Exception as e_ws:
            xbmc.log(
                f"[{common.addon_id} series.py] show_seasons: watched_status konnte nicht geladen werden: {e_ws}",
                xbmc.LOGWARNING
            )

    # --- Random Button (optional) ---
    if show_random:
        random_item = xbmcgui.ListItem(label="Zufällige Episode starten")
        random_item.setProperty("specialsort", "top")
        plot_txt = (
            "Startet die Wiedergabe einer zufälligen Episode aus der "
            f"Serie '{serie_name_display}'."
        )
        vi = random_item.getVideoInfoTag()
        vi.setPlot(plot_txt)
        vi.setMediaType('video')
        vi.setStudios([])

        icon_random = os.path.join(common.addon_path, "random.png")
        if xbmcvfs.exists(icon_random):
            random_item.setArt({'icon': icon_random, 'thumb': icon_random})
        else:
            random_item.setArt({'icon': common.addon.getAddonInfo('icon')})

        random_url = common.build_url({
            "action": "play_random_episode_from_this_series",
            "data": data_json_str
        })
        xbmcplugin.addDirectoryItem(common.addon_handle, random_url, random_item, isFolder=False)

    details_json = {}
    global SERIES_DETAILS_CACHE
    source_type = serie.get("source", "online")
    # --- Online Details laden (Memory/Disk/Network Cache) ---
    if details_url_val and source_type not in ["local", "local_only"]:
        try:
            details_json = load_series_details_json(details_url_val, timeout_s=20) or {}
        except Exception as e:
            xbmc.log(f"[{common.addon_id} series.py] Fehler Laden Online-Details (cached): {e}", xbmc.LOGWARNING)

    # --- Lokal rekonstruieren ---
    if not details_json and source_type in ["local", "local_only"]:
        if LOCAL_JSON_PATH and xbmcvfs.exists(LOCAL_JSON_PATH):
            try:
                with xbmcvfs.File(LOCAL_JSON_PATH, 'r') as f:
                    local_db = json.loads(f.read() or "{}")
                loc_info = local_db.get(original_serie_key, {})
                if isinstance(loc_info.get("staffels"), dict):
                    reconstructed = {}
                    reconstructed["series_info"] = {"fsk": loc_info.get("fsk", current_series_fsk_raw)}
                    for s_num, s_content in loc_info["staffels"].items():
                        eps = []
                        for ep_num, ep_info in (s_content or {}).items():
                            eps.append({
                                "episode_number": int(ep_num) if str(ep_num).isdigit() else ep_num,
                                "name": ep_info.get("title", f"Episode {ep_num}"),
                                "overview": ep_info.get("overview", ""),
                                "air_date": ep_info.get("air_date", ""),
                                "directors": ep_info.get("directors", ""),
                                "still": ep_info.get("assets", {}).get("still", ""),
                                "runtime": ep_info.get("runtime"),
                                "rating": ep_info.get("rating"),
                                "file_ort": ep_info.get("original_online_url", ""),
                                "_local_playback_path": ep_info.get("local_path", "")
                            })
                        reconstructed[f"Season {s_num}"] = {
                            "poster": loc_info.get("assets", {}).get("poster", serie.get("poster")),
                            "overview": f"Staffel {s_num} (Lokal)",
                            "air_date": "",
                            "episodes": eps,
                            "tmdb_season_details": {
                                "name": f"Staffel {s_num} (Lokal)",
                                "overview": f"Staffel {s_num} von {serie_name_display} (Lokal)",
                                "vote_average": None
                            }
                        }
                    details_json = reconstructed
            except Exception as e:
                xbmc.log(f"[{common.addon_id} series.py] Lokale Staffeldaten nicht rekonstruierbar: {e}", xbmc.LOGERROR)

    # FSK aus details_json übernehmen, falls Serie selbst keins hat
    if not str(current_series_fsk_raw).strip() and details_json:
        fsk_tmp = str((details_json.get("series_info") or {}).get("fsk", "")).strip()
        if fsk_tmp:
            current_series_fsk_raw = fsk_tmp

    if not details_json:
        xbmcgui.Dialog().notification(
            "Fehler",
            f"Staffeldetails für '{serie_name_display}' konnten nicht geladen werden.",
            common.addon.getAddonInfo('icon')
        )
        xbmcplugin.endOfDirectory(common.addon_handle, succeeded=False)
        return

    # ✅ NEU: Beim Öffnen sofort Serie verifizieren + Progress-Cache updaten
    # (damit die Serie beim nächsten Öffnen/Refresh als "fertig" markiert ist)
    try:
        changed = _verify_series_and_update_progress_cache(
            details_json,
            details_url_val,
            original_serie_key,
            watched_status
        )
        if changed:
            # optional (hilft, wenn du sofort zurück in die Serienliste gehst)
            _container_reload_throttled(reason="series_verify_open", min_interval_s=1.2, prefer_update=True)
    except Exception:
        pass

    # ✅ robuste Sortierung
    def _season_sort_key(k: str):
        try:
            if not isinstance(k, str):
                return (1, 10**9, str(k))
            m = re.search(r'(\d+)', k)
            if m:
                return (0, int(m.group(1)), k.lower())
            return (1, 10**9, k.lower())  # Specials/sonstige ans Ende
        except Exception:
            return (1, 10**9, str(k))


    def _normalize_eps(eps):
        """Normalisiert Episoden-Container (list/dict) zu sortierter List[dict]."""
        if isinstance(eps, list):
            return [e for e in eps if isinstance(e, dict)]
        if isinstance(eps, dict):
            out = []
            for k, v in eps.items():
                if not isinstance(v, dict):
                    continue
                vv = dict(v)
                if vv.get('episode_number') in (None, ''):
                    try:
                        vv['episode_number'] = int(k) if str(k).isdigit() else k
                    except Exception:
                        vv['episode_number'] = k
                out.append(vv)
            out.sort(key=lambda x: _to_int(x.get('episode_number'), 0))
            return out
        return []

    season_keys = sorted(
        [k for k in details_json.keys() if isinstance(k, str) and k.lower().startswith("season")],
        key=_season_sort_key
    )

    if not season_keys:
        xbmcgui.Dialog().notification("Info", "Keine Staffeln gefunden.", common.addon.getAddonInfo('icon'))
        xbmcplugin.endOfDirectory(common.addon_handle, succeeded=True)
        return

    for s_key in season_keys:
        s_data = details_json.get(s_key, {})
        if not isinstance(s_data, dict):
            continue

        tmdb_s = s_data.get("tmdb_season_details", {})

        # Episoden können je nach Source als dict oder list kommen -> normalisieren
        try:
            s_data = dict(s_data)
            s_data['episodes'] = _normalize_eps(s_data.get('episodes', []))
        except Exception:
            pass

        # ✅ Season Nummer robust
        season_number = -1
        try:
            m = re.search(r'(\d+)', s_key)
            season_number = int(m.group(1)) if m else -1
        except Exception:
            season_number = -1

        season_label = tmdb_s.get("name", f"Staffel {season_number}" if season_number != -1 else s_key)

        ep_count_tmdb = _to_int(tmdb_s.get("episode_count"))
        ep_count_fallback = len(s_data.get("episodes", []))

        w_seen, t_total, completed = _season_progress_from_details(s_data, watched_status)

        if t_total > 0:
            progress_txt = f"{w_seen}/{t_total}"
            label = f"{season_label} ({progress_txt})"
        else:
            progress_txt = str(ep_count_tmdb or ep_count_fallback)
            label = f"{season_label} ({progress_txt} Episoden)"

        li = xbmcgui.ListItem(label=label)

        season_poster = s_data.get("poster", serie.get("poster"))
        series_poster_raw = serie.get("poster")
        fanart = (serie.get("backcover", "") or serie.get("fanart", ""))

        if season_poster and isinstance(season_poster, str) and season_poster.startswith("http"):
            season_poster = get_cached_image(season_poster) or season_poster

        if series_poster_raw and isinstance(series_poster_raw, str) and series_poster_raw.startswith("http"):
            series_poster_final = get_cached_image(series_poster_raw) or series_poster_raw
        else:
            series_poster_final = series_poster_raw

        if fanart and isinstance(fanart, str) and fanart.startswith("http"):
            fanart = get_cached_image(fanart) or fanart

        _apply_fanart_to_listitem(
            li,
            poster=season_poster,
            fanart=fanart,
            tvshow_poster=series_poster_final,
            tvshow_fanart=fanart
        )

        info = li.getVideoInfoTag()
        info.setTitle(season_label)
        info.setTvShowTitle(serie_name_display)
        info.setMediaType("season")
        if season_number != -1:
            info.setSeason(season_number)

        plot_s = (tmdb_s.get("overview") or s_data.get("overview") or f"Staffel {season_number} von {serie_name_display}")
        info.setPlot(plot_s)

        air_date = tmdb_s.get("air_date") or s_data.get("air_date", "")
        premiere = ""
        year = 0
        if len(str(air_date)) == 10 and air_date[4] == '-' and air_date[7] == '-':
            premiere = air_date
            year = int(air_date[:4])
        elif len(str(air_date)) >= 4 and str(air_date)[:4].isdigit():
            year = int(str(air_date)[:4])
            premiere = f"{year}-01-01"
        if premiere:
            info.setPremiered(premiere)
        if year:
            info.setYear(year)

        rating_val = None
        try:
            rating_val = float(str(tmdb_s.get("vote_average")).replace(",", "."))
        except Exception:
            pass
        if not rating_val or rating_val <= 0:
            try:
                rating_val = float(str(current_series_overall_vote_average).replace(",", "."))
            except Exception:
                rating_val = None
        if rating_val and rating_val > 0:
            info.setRating(rating_val)

        episodes_list = s_data.get("episodes", [])
        season_vote_count = _to_int(tmdb_s.get("vote_count"), 0)
        if season_vote_count <= 0 and episodes_list:
            season_vote_count = sum(
                (_to_int(e.get("vote_count"), 0) or _to_int(e.get("imdbVotes"), 0))
                for e in episodes_list if isinstance(e, dict)
            )
        if season_vote_count > 0:
            _safe_set_votes(info, season_vote_count)

        fsk_proc = str(current_series_fsk_raw).strip()
        if fsk_proc:
            info.setMpaa(f"FSK {fsk_proc}")

        if serie.get("genre"):
            info.setGenres([g.strip() for g in str(serie["genre"]).split(',') if g.strip()])
        if serie.get("studio"):
            info.setStudios(_split_studios(serie.get("studio", "")))

        try:
            info.setPlaycount(1 if completed else 0)
        except Exception:
            pass

        data_for_episodes = {
            "serie_name": serie_name_display,
            "series_tmdbid": serie.get("tmdbId"),
            "season_label": season_label,
            "season_number": season_number,
            "episodes": episodes_list,
            "series_fanart": fanart,
            "series_poster": season_poster or series_poster_final,
            "series_genre": serie.get("genre", ""),
            "series_studio": serie.get("studio", ""),
            "online_serie_key": original_serie_key,
            "source": source_type,
            "local_path_serie": (serie.get("file_ort") if source_type in ["local", "local_only"] else None),

            "fsk_from_series_or_details": current_series_fsk_raw,
            "fsk_from_series": current_series_fsk_raw,
            "series_vote_average_from_series": current_series_overall_vote_average,
            "season_specific_vote_average": tmdb_s.get("vote_average"),
            "season_vote_average_from_season": tmdb_s.get("vote_average"),

            "series_imdb_votes": series_imdb_votes_val,
            "season_vote_count_from_season": season_vote_count
        }

        ep_url = common.build_url({
            "action": "show_episodes",
            "data": pack_payload(data_for_episodes)
        })
        xbmcplugin.addDirectoryItem(common.addon_handle, ep_url, li, isFolder=True)

    fanart_container = serie.get("backcover", "") or serie.get("fanart", "")
    if fanart_container:
        if isinstance(fanart_container, str) and fanart_container.startswith("http"):
            fanart_container = (get_cached_image(fanart_container) or fanart_container)
        try:
            xbmcplugin.setProperty(common.addon_handle, 'fanart_image', fanart_container)
        except Exception:
            pass

    xbmcplugin.setContent(common.addon_handle, "seasons")
    xbmcplugin.addSortMethod(common.addon_handle, xbmcplugin.SORT_METHOD_LABEL)
    xbmcplugin.addSortMethod(common.addon_handle, xbmcplugin.SORT_METHOD_VIDEO_YEAR)
    xbmcplugin.endOfDirectory(common.addon_handle, succeeded=True, cacheToDisc=False)




# ----------------------------
# Anzeige der Episoden
# ----------------------------
def show_episodes(data_json_str):
    """
    Listet die Episoden einer Staffel auf.
    (Komplette Funktion – inkl. Setzen von last_series_cache_key für den Playback-Fix)
    """

    # ✅ HIER: json.loads -> unpack_payload
    try:
        payload = unpack_payload(data_json_str, default={})
        if not isinstance(payload, dict):
            payload = {}
    except Exception as e:
        xbmcgui.Dialog().notification("Fehler", f"Episoden-Daten fehlerhaft: {e}", common.addon.getAddonInfo('icon'))
        xbmcplugin.endOfDirectory(common.addon_handle, succeeded=False)
        return

    # --- watched_status laden ---
    watched_status = {}
    if WATCHED_STATUS_FILE:
        try:
            watched_status = _safe_load_watched_status(WATCHED_STATUS_FILE)
        except Exception as e_ws:
            xbmc.log(f"[{common.addon_id} series.py] show_episodes: watched_status konnte nicht geladen werden: {e_ws}", xbmc.LOGWARNING)

    # ✅ Serie-Key merken (für monitor_playback_series -> Progress-Cache Update)
    # Erwartet: payload["online_serie_key"] == details_url / original_online_key (aus show_seasons)
    try:
        series_cache_key = (payload.get("online_serie_key") or "").strip()
        if series_cache_key:
            common.addon.setSetting("last_series_cache_key", series_cache_key)
    except Exception:
        pass

    serie_name = payload.get("serie_name", "")
    season_label = payload.get("season_label", "")
    season_number = payload.get("season_number", -1)
    episodes = payload.get("episodes", []) or []

    def _normalize_eps(eps):
        if isinstance(eps, list):
            return [e for e in eps if isinstance(e, dict)]
        if isinstance(eps, dict):
            out = []
            for k, v in eps.items():
                if not isinstance(v, dict):
                    continue
                vv = dict(v)
                if vv.get('episode_number') in (None, ''):
                    try:
                        vv['episode_number'] = int(k) if str(k).isdigit() else k
                    except Exception:
                        vv['episode_number'] = k
                out.append(vv)
            out.sort(key=lambda x: _to_int(x.get('episode_number'), 0))
            return out
        return []

    episodes = _normalize_eps(episodes)

    # Mini-Logging (Test): wenn Episoden bereits im Payload sind, kein Reload notwendig
    try:
        if episodes:
            xbmc.log(f'[{common.addon_id} series.py] show_episodes: payload episodes={len(episodes)} season={season_number} (no reload)', xbmc.LOGINFO)
    except Exception:
        pass

    # Fallback: wenn keine Episoden im Payload sind, versuche Details neu zu laden
    if not episodes and isinstance(series_cache_key, str) and series_cache_key.startswith('http') and season_number != -1:
        try:
            # Mini-Logging: Woher wuerden die Details kommen (Memory/Disk/Network)?
            src_hint = "network"
            before_mtime = None
            try:
                durl = (series_cache_key or "").strip()
                dkey = _details_url_key(durl)
                if dkey:
                    try:
                        v = SERIES_DETAILS_CACHE.get(dkey)
                        if isinstance(v, dict) and v:
                            src_hint = "memory"
                    except Exception:
                        pass
                    if src_hint == "network":
                        cpath0 = _details_cache_path(dkey)
                        if cpath0 and _path_exists(cpath0):
                            src_hint = "disk"
                        # fuer spaeteren Vergleich merken (nur best-effort)
                        try:
                            if cpath0 and os.path.exists(xbmcvfs.translatePath(cpath0)):
                                before_mtime = os.path.getmtime(xbmcvfs.translatePath(cpath0))
                        except Exception:
                            pass
            except Exception:
                pass

            xbmc.log(f'[{common.addon_id} series.py] show_episodes: empty payload -> reload details (hint={src_hint}) season={season_number}', xbmc.LOGINFO)

            dj = load_series_details_json(series_cache_key, timeout_s=20) or {}
            # finde passenden Season-Key
            skey = None
            for k in dj.keys():
                if isinstance(k, str) and k.lower().startswith('season'):
                    import re as _re
                    m = _re.search(r'(\d+)', k)
                    if m and int(m.group(1)) == int(season_number):
                        skey = k
                        break
            if skey and isinstance(dj.get(skey), dict):
                episodes = _normalize_eps(dj[skey].get('episodes', []))
                # Quelle fuer Log bestimmen: wenn vorher kein Cache vorhanden war oder wir neu geschrieben haben -> network
                src_used = src_hint
                try:
                    if src_hint == "network":
                        src_used = "network"
                    else:
                        # wenn wir nicht aus network starteten, bleibt es memory/disk
                        src_used = src_hint
                except Exception:
                    src_used = src_hint

                if episodes:
                    xbmc.log(f'[{common.addon_id} series.py] show_episodes: empty payload -> reload details -> episodes={len(episodes)} season={season_number} source={src_used}', xbmc.LOGINFO)
                else:
                    xbmc.log(f'[{common.addon_id} series.py] show_episodes: empty payload -> reload details -> episodes=0 season={season_number} source={src_used}', xbmc.LOGINFO)
        except Exception as e_fb:
            xbmc.log(f'[{common.addon_id} series.py] show_episodes: Fallback-Reload fehlgeschlagen: {e_fb}', xbmc.LOGWARNING)
    series_fanart_raw = payload.get("series_fanart", "")
    series_poster_raw = payload.get("series_poster", "")
    series_genre = payload.get("series_genre", "")
    series_studio = payload.get("series_studio", "")
    source_type = payload.get("source", "online")
    local_path_serie = payload.get("local_path_serie")

    fsk_from_series = payload.get("fsk_from_series") or payload.get("fsk_from_series_or_details")
    series_vote_average_from_series = payload.get("series_vote_average_from_series")
    season_vote_average_from_season = payload.get("season_vote_average_from_season")
    series_imdb_votes = _to_int(payload.get("series_imdb_votes"), 0)
    season_vote_count_from_season = _to_int(payload.get("season_vote_count_from_season"), 0)

    def _maybe_cache(url_or_path):
        if isinstance(url_or_path, str) and url_or_path and url_or_path.startswith("http"):
            return get_cached_image(url_or_path) or url_or_path
        return url_or_path

    series_fanart_final = _maybe_cache(series_fanart_raw)
    series_poster_final = _maybe_cache(series_poster_raw)

    # Skin/Wallpaper-Mode
    skin_id = (xbmc.getSkinDir() or "").lower()
    mode = (common.addon.getSetting("episode_wallpaper_mode") or "auto").lower()
    prefer_still_for_wallpaper = (mode == "still") or (mode == "auto" and "aeonmq" in skin_id)

    # Container-Fanart setzen
    if series_fanart_final:
        try:
            xbmcplugin.setProperty(common.addon_handle, 'fanart_image', series_fanart_final)
        except Exception:
            pass

    # Optional: Staffelname als Header/Plot in der Ansicht (nicht zwingend)
    # (Kodi Views nutzen oft ListItem-Info)
    # Du kannst hier auch einen Dummy-Item als "Header" einfügen – lassen wir weg.

    for ep in episodes:
        if not isinstance(ep, dict):
            continue

        ep_num = _to_int(ep.get("episode_number"), 0)
        ep_title = ep.get("name") or f"Episode {ep_num}"
        ep_plot = ep.get("overview") or ""
        ep_air = ep.get("air_date") or ""
        ep_dir = ep.get("directors") or ""
        ep_rt = _to_int(ep.get("runtime"), 0)  # Minuten
        ep_rating = ep.get("rating")

        prefix = f"{season_number}x{ep_num:02d}. " if season_number != -1 and ep_num > 0 else ""
        li = xbmcgui.ListItem(label=prefix + ep_title)

        vi = li.getVideoInfoTag()
        vi.setTitle(ep_title)
        vi.setTvShowTitle(serie_name)
        vi.setMediaType("episode")
        if season_number != -1:
            try:
                vi.setSeason(int(season_number))
            except Exception:
                pass
        if ep_num:
            try:
                vi.setEpisode(int(ep_num))
            except Exception:
                pass
        if ep_plot:
            vi.setPlot(ep_plot)

        if ep_rt:
            try:
                vi.setDuration(ep_rt * 60)
            except Exception:
                pass

        # Premiered/Year
        try:
            if isinstance(ep_air, str) and len(ep_air) >= 4 and ep_air[:4].isdigit():
                if len(ep_air) == 10 and ep_air[4] == '-' and ep_air[7] == '-':
                    vi.setPremiered(ep_air)
                    try:
                        vi.setYear(int(ep_air[:4]))
                    except Exception:
                        pass
                else:
                    try:
                        vi.setYear(int(ep_air[:4]))
                    except Exception:
                        pass
        except Exception:
            pass

        # Rating
        imdb_ep_rating = ep.get("imdbRating")
        rating_val = None
        for rsrc in (imdb_ep_rating, ep_rating, season_vote_average_from_season, series_vote_average_from_series):
            if rsrc is None or rsrc == "":
                continue
            try:
                rating_val = float(str(rsrc).replace(",", "."))
                if rating_val > 0:
                    break
            except Exception:
                pass
        if rating_val and rating_val > 0:
            try:
                vi.setRating(rating_val)
            except Exception:
                pass

        # Votes
        ep_votes = _to_int(ep.get("vote_count"), 0) or _to_int(ep.get("imdbVotes"), 0)
        votes = ep_votes or season_vote_count_from_season or series_imdb_votes
        if votes > 0:
            _safe_set_votes(vi, votes)

        # Genre/Studio/FSK/Directors
        if series_genre:
            try:
                vi.setGenres([g.strip() for g in str(series_genre).split(',') if g.strip()])
            except Exception:
                pass
        if series_studio:
            try:
                vi.setStudios(_split_studios(series_studio))
            except Exception:
                pass
        if fsk_from_series:
            try:
                vi.setMpaa(f"FSK {fsk_from_series}")
            except Exception:
                pass
        if ep_dir:
            try:
                vi.setDirectors([d.strip() for d in str(ep_dir).split(',') if d.strip()])
            except Exception:
                pass

        # Watched
        try:
            is_watched = _is_episode_watched(ep, watched_status)
            try:
                vi.setPlaycount(1 if is_watched else 0)
            except Exception:
                pass
        except Exception:
            pass

        # Artwork: Still bevorzugt (optional)
        still_raw = ep.get("still") or ep.get("still_path") or ""
        still_final = _maybe_cache(still_raw)
        poster_fallback = series_poster_final

        wallpaper_fanart = (still_final if (prefer_still_for_wallpaper and still_final)
                            else series_fanart_final)

        _apply_fanart_to_listitem(
            li,
            poster=still_final or poster_fallback,
            fanart=wallpaper_fanart,
            tvshow_poster=series_poster_final,
            tvshow_fanart=series_fanart_final
        )

        # Kleine Art-Felder (Thumb/Icon/Landscape)
        small_art = {}
        base_thumb = still_final or poster_fallback
        if base_thumb:
            small_art["thumb"] = base_thumb
            small_art["icon"] = base_thumb
            small_art["landscape"] = base_thumb
        if small_art:
            try:
                li.setArt(small_art)
            except Exception:
                pass

        # --- Playback-Pfad bestimmen (lokal bevorzugen, falls vorhanden) ---
        play_path = None
        if source_type in ["local", "local_only"]:
            play_path = ep.get("_local_playback_path") or ep.get("local_path") or local_path_serie
        play_path = play_path or ep.get("file_ort") or ep.get("stream_url") or ""

        li.setProperty("IsPlayable", "true")

        # ✅ Status-Identifier IMMER sinnvoll füllen (sonst kein Monitoring/kein watched)
        status_identifier = (ep.get("file_ort")
                             or ep.get("stream_url")
                             or ep.get("_local_playback_path")
                             or ep.get("local_path")
                             or play_path
                             or "")

        if play_path:
            play_url = common.build_url({
                "action": "play_series",
                "serie_key": play_path,
                "title": f"S{season_number}E{ep_num:02d}. {ep_title}",
                "tmdbid": status_identifier
            })
        else:
            play_url = ""

        # Kontextmenü (optional)
        try:
            cm = get_episode_context_menu_items(ep, serie_name, season_number, ep_num)
            if cm:
                li.addContextMenuItems(cm)
        except Exception:
            pass

        xbmcplugin.addDirectoryItem(common.addon_handle, play_url, li, isFolder=False)

    xbmcplugin.setContent(common.addon_handle, "episodes")
    xbmcplugin.addSortMethod(common.addon_handle, xbmcplugin.SORT_METHOD_EPISODE)
    xbmcplugin.addSortMethod(common.addon_handle, xbmcplugin.SORT_METHOD_LABEL)
    xbmcplugin.addSortMethod(common.addon_handle, xbmcplugin.SORT_METHOD_DATE)
    xbmcplugin.addSortMethod(common.addon_handle, xbmcplugin.SORT_METHOD_VIDEO_RATING)
    xbmcplugin.endOfDirectory(common.addon_handle, succeeded=True, cacheToDisc=False)





# ----------------------------
# Playback Monitoring
# ----------------------------
def monitor_playback_series(
    serie_key: str,
    status_identifier: str,
    *,
    min_percent: float = 0.85,
    end_slack_s: float = 60.0,
    min_watch_s_if_unknown: float = 300.0,
    poll_s: float = 0.5,
):
    """
    Monitor für EIN Episode-Playback.

    Markiert watched NUR wenn Episode als "fertig" gilt:
      - pos/total >= min_percent, ODER
      - Restzeit <= end_slack_s, ODER
      - duration unbekannt und pos >= min_watch_s_if_unknown

    Dann:
      - watched_status_series.json wird sofort geschrieben (atomic + fsync über _safe_save_watched_status)
      - Progress-Cache der Serie invalidiert
      - UI Refresh (GUI-safe) angestoßen
    """
    try:
        if not status_identifier:
            xbmc.log(f"[{common.addon_id} series.py] monitor_playback_series: No status_identifier -> exit", xbmc.LOGDEBUG)
            return

        if not WATCHED_STATUS_FILE:
            xbmc.log(f"[{common.addon_id} series.py] monitor_playback_series: WATCHED_STATUS_FILE empty -> exit", xbmc.LOGERROR)
            return

        mon = xbmc.Monitor()
        player = xbmc.Player()

        started = False
        last_pos = 0.0
        last_total = 0.0

        # --- 1) Warten bis Playback startet und dann bis es endet ---
        while not mon.abortRequested():
            try:
                playing = player.isPlayingVideo()
            except Exception:
                playing = xbmc.getCondVisibility("Player.Playing")

            if playing:
                started = True
                try:
                    last_pos = float(player.getTime() or 0.0)
                except Exception:
                    pass
                try:
                    t = float(player.getTotalTime() or 0.0)
                    if t > 0:
                        last_total = t
                except Exception:
                    pass
            else:
                # Playback ist zu Ende/abgebrochen -> raus, wenn es überhaupt gestartet hatte
                if started:
                    break

            if mon.waitForAbort(float(poll_s)):
                return

        # Wenn nie gestartet -> nix tun
        if not started:
            xbmc.log(f"[{common.addon_id} series.py] monitor_playback_series: never started -> exit", xbmc.LOGDEBUG)
            return

        # --- 2) watched Entscheidung NUR am Ende ---
        watched = False
        if last_total > 0:
            try:
                # (a) prozentual fertig
                if (last_pos / last_total) >= float(min_percent):
                    watched = True
                # (b) oder Restzeit <= Slack
                elif (last_total - last_pos) <= float(end_slack_s):
                    watched = True
            except Exception:
                watched = False
        else:
            # duration unbekannt -> Mindestzeit
            watched = (last_pos >= float(min_watch_s_if_unknown))

        if not watched:
            xbmc.log(
                f"[{common.addon_id} series.py] monitor_playback_series: NOT watched "
                f"(pos={last_pos:.1f}s total={last_total:.1f}s) id={status_identifier}",
                xbmc.LOGDEBUG
            )
            return

        # --- 3) Identifier -> hashed watched-id (V2) ---
        ident = (status_identifier or "").strip()
        wid = ""
        try:
            wid = common.series_watched_id(ident)
        except Exception:
            wid = ""

        if not wid:
            xbmc.log(f"[{common.addon_id} series.py] monitor_playback_series: empty watched-id -> exit", xbmc.LOGDEBUG)
            return

        # --- 4) watched_status laden + EINMAL speichern (atomic+fsync) ---
        ws = _safe_load_watched_status(WATCHED_STATUS_FILE)

        # minimal speichern: nur playcount (kein lastplayed)
        ws[wid] = {"playcount": 1}

        ok = _safe_save_watched_status(WATCHED_STATUS_FILE, ws)

        if not ok:
            xbmc.log(f"[{common.addon_id} series.py] monitor_playback_series: save watched_status FAILED", xbmc.LOGERROR)
            return

        xbmc.log(f"[{common.addon_id} series.py] monitor_playback_series: MARK WATCHED -> {wid}", xbmc.LOGINFO)

        # --- 5) Progress-Cache der Serie invalidieren (damit ✓ neu berechnet wird) ---
        try:
            cache_key = (common.addon.getSetting("last_series_cache_key") or "").strip()
            if cache_key:
                pc = _load_progress_cache()
                if isinstance(pc, dict) and cache_key in pc:
                    pc.pop(cache_key, None)
                    _save_progress_cache(pc)
        except Exception as e_pc:
            xbmc.log(f"[{common.addon_id} series.py] monitor_playback_series: progress-cache invalidate fail: {e_pc}", xbmc.LOGDEBUG)

        # --- 6) UI Refresh (GUI-safe, throttled) ---
        try:
            _safe_refresh_container(max_wait_s=15)
        except Exception:
            _container_reload_throttled(reason="playback_return", min_interval_s=0.9, prefer_update=True)

    except Exception as e:
        xbmc.log(
            f"[{common.addon_id} series.py] monitor_playback_series ERROR: {e}\n{traceback.format_exc()}",
            xbmc.LOGERROR
        )





def _safe_refresh_container(max_wait_s=40):
    """
    Robust: wartet bis Player wirklich weg ist UND ein Container-FolderPath existiert,
    dann EIN Reload (throttled).
    Läuft sicher aus JEDEM Thread, weil _container_reload_throttled intern GUI-safe ist.
    """
    try:
        monitor = xbmc.Monitor()

        folder_path = ""
        steps = int((max_wait_s * 1000) / 250)

        # 1) Warten bis Kodi wirklich aus der Wiedergabe raus ist
        for _ in range(steps):
            if monitor.abortRequested():
                return

            # Player.HasMedia ist ein guter Indikator (keine Player-Exceptions)
            if xbmc.getCondVisibility("Player.HasMedia") or xbmc.getCondVisibility("Player.Playing"):
                xbmc.sleep(250)
                continue

            folder_path = xbmc.getInfoLabel("Container.FolderPath") or ""
            if folder_path:
                break

            xbmc.sleep(250)

        # 2) UI kurz settlen lassen
        xbmc.sleep(600)

        # 3) EINMAL reload (throttled + GUI-safe)
        _container_reload_throttled(reason="playback_return", min_interval_s=0.9, prefer_update=True)

    except Exception as e:
        try:
            xbmc.log(f"[{common.addon_id} series.py] _safe_refresh_container Fehler: {e}", xbmc.LOGDEBUG)
        except Exception:
            pass




# ----------------------------
# Wiedergabe einer Episode
# ----------------------------
def play_series(serie_key, title, tmdbid_str):
    status_identifier = tmdbid_str
    xbmc.log(f"[{common.addon_id} series.py] play_series: Aufgerufen. Key='{serie_key}', Title='{title}', StatusID='{status_identifier}'", xbmc.LOGDEBUG)

    if not serie_key:
        xbmcgui.Dialog().notification("Fehler", "Keine Datei/URL zum Abspielen angegeben.", common.addon.getAddonInfo('icon'))
        if common.addon_handle is not None and isinstance(common.addon_handle, int) and common.addon_handle != -1:
            xbmcplugin.setResolvedUrl(common.addon_handle, False, xbmcgui.ListItem())
        return

    play_item = xbmcgui.ListItem(path=serie_key)
    play_item.setProperty("IsPlayable", "true")

    info_tag = play_item.getVideoInfoTag()
    info_tag.setMediaType('episode')

    match = re.match(r"S(\d+)E(\d+)\.\s*(.*)", title, re.IGNORECASE)
    if match:
        try:
            season_no = int(match.group(1))
            episode_no = int(match.group(2))
            ep_title_only = (match.group(3) or "").strip() or title

            info_tag.setSeason(season_no)
            info_tag.setEpisode(episode_no)

            # ✅ WICHTIG: Title OHNE SxxEyy setzen
            info_tag.setTitle(ep_title_only)
            info_tag.setOriginalTitle(ep_title_only)

            # Optional (nice): SortTitle mit Prefix, falls Skin das nutzt
            try:
                info_tag.setSortTitle(f"S{season_no:02d}E{episode_no:02d}. {ep_title_only}")
            except Exception:
                pass

            tvshow_title_setting = common.addon.getSetting("last_played_tvshow_title_for_player")
            if tvshow_title_setting:
                info_tag.setTvShowTitle(tvshow_title_setting)

        except ValueError:
            xbmc.log(f"[{common.addon_id} series.py] play_series: Fehler beim Parsen von S/E aus Titel '{title}' für InfoTag.", xbmc.LOGWARNING)
            info_tag.setTitle(title)
            info_tag.setOriginalTitle(title)
    else:
        info_tag.setTitle(title)
        info_tag.setOriginalTitle(title)

    xbmcplugin.setResolvedUrl(common.addon_handle, True, listitem=play_item)
    xbmc.log(f"[{common.addon_id} series.py] play_series: setResolvedUrl aufgerufen.", xbmc.LOGINFO)

    if status_identifier:
        try:
            if 'monitor_playback_series' in globals() and callable(globals()['monitor_playback_series']):
                monitor_thread = threading.Thread(target=monitor_playback_series, args=(serie_key, status_identifier))
                monitor_thread.daemon = True
                monitor_thread.start()
                xbmc.log(f"[{common.addon_id} series.py] play_series: Monitoring Thread gestartet für '{status_identifier}'.", xbmc.LOGINFO)
            else:
                xbmc.log(f"[{common.addon_id} series.py] play_series: Funktion 'monitor_playback_series' nicht gefunden oder nicht aufrufbar.", xbmc.LOGERROR)
        except Exception as e_thread_start:
            xbmc.log(f"[{common.addon_id} series.py] play_series: FEHLER Start Monitoring Thread: {e_thread_start}", xbmc.LOGERROR)
            xbmc.log(traceback.format_exc(), xbmc.LOGERROR)
    else:
        xbmc.log(f"[{common.addon_id} series.py] play_series: Kein Status-Identifier vorhanden, Monitoring Thread nicht gestartet.", xbmc.LOGWARNING)


# ----------------------------
# Umschalten des Gesehen-Status
# ----------------------------
def toggle_watched_status(serie_or_episode_key):
    """
    Umschalten gesehen/ungesehen (Series watched V2).
    - Keys werden gehasht gespeichert (common.series_watched_id)
    - lastplayed wird NICHT mehr gespeichert
    """
    if not serie_or_episode_key:
        xbmcgui.Dialog().notification("Fehler", "Kein Schlüssel zum Umschalten gefunden.", common.addon.getAddonInfo('icon'))
        return

    try:
        if not WATCHED_STATUS_FILE:
            xbmc.log(f"[{common.addon_id} series.py] toggle_watched_status: WATCHED_STATUS_FILE nicht gesetzt.", xbmc.LOGERROR)
            xbmcgui.Dialog().notification("Fehler", "Statusdatei-Pfad nicht gesetzt.", common.addon.getAddonInfo('icon'))
            return

        ident = (serie_or_episode_key or "").strip()
        wid = ""
        try:
            wid = common.series_watched_id(ident)
        except Exception:
            wid = ""

        if not wid:
            xbmcgui.Dialog().notification("Fehler", "Ungültiger Schlüssel.", common.addon.getAddonInfo('icon'))
            return

        watched_status = _safe_load_watched_status(WATCHED_STATUS_FILE)

        # aktueller Status
        playcount = 0
        try:
            playcount = int((watched_status.get(wid) or {}).get("playcount", 0) or 0)
        except Exception:
            playcount = 0

        if playcount > 0:
            watched_status.pop(wid, None)
            new_status_text = "ungesehen"
        else:
            watched_status[wid] = {"playcount": 1}
            new_status_text = "gesehen"

        if _safe_save_watched_status(WATCHED_STATUS_FILE, watched_status):
            xbmc.log(f"[{common.addon_id} series.py] toggle_watched_status: {wid} -> '{new_status_text}'", xbmc.LOGINFO)
            try:
                xbmcgui.Dialog().notification("Status geändert", f"Als {new_status_text} markiert.", common.addon.getAddonInfo('icon'), 2000)
            except Exception:
                pass

            try:
                _container_reload_throttled(reason="toggle_watched_status", min_interval_s=0.6, prefer_update=True)
            except Exception:
                try:
                    xbmc.executebuiltin('Container.Refresh')
                except Exception:
                    pass
        else:
            xbmcgui.Dialog().notification("Fehler", "Status konnte nicht gespeichert werden.", common.addon.getAddonInfo('icon'))

    except Exception as e:
        xbmcgui.Dialog().notification("Fehler", f"Status konnte nicht umgeschaltet werden: {e}", common.addon.getAddonInfo('icon'))
        xbmc.log(f"[{common.addon_id} series.py] toggle_watched_status ERROR für {serie_or_episode_key}: {e}", xbmc.LOGERROR)

# ----------------------------
# Download-Funktion
# ----------------------------
def download_series(serie_key, title, data_json_str, mode='serie', season_param=None, episode_param=None):
    """
    Lädt eine komplette Serie / Staffel / Episode herunter und schreibt/aktualisiert die lokale DB.

    FIXES integriert:
      - data_json_str kann JSON ODER pid:... sein -> unpack_payload()
      - Auth Requests konsistent über get_authenticated_request() (Wrapper)
      - Robustere Default-/None-Behandlung für mode/season/episode
    """
    addon_id_log = getattr(common, 'addon_id', 'plugin.video.glotzbox')
    xbmc.log(f"[{addon_id_log} series.py] download_series: key='{serie_key}', title='{title}', mode='{mode}'", xbmc.LOGDEBUG)

    # --- Parameter normalisieren ---
    mode = (mode or "serie").strip().lower()
    season_param = str(season_param).strip() if season_param is not None else None
    episode_param = str(episode_param).strip() if episode_param is not None else None

    # --- Downloadpfad prüfen ---
    try:
        download_path_setting = common.addon.getSetting("download_path")
        default_value = "Drück mich"
        current_path_stripped = download_path_setting.strip() if download_path_setting else ""
        if not current_path_stripped or current_path_stripped == default_value:
            xbmcgui.Dialog().notification(
                "Download nicht möglich",
                "Downloadpfad zuerst anpassen!",
                common.addon.getAddonInfo('icon'),
                5000
            )
            return
    except Exception as e_path_check:
        xbmc.log(f"[{addon_id_log} series.py] ERROR: Fehler Download-Pfad Prüfung: {e_path_check}", xbmc.LOGERROR)
        xbmcgui.Dialog().notification("Fehler", "Downloadpfad konnte nicht geprüft werden.", common.addon.getAddonInfo('icon'), 5000)
        return

    cancelled = False
    download_successful = False
    progress_overall = None
    series_folder_local = None

    try:
        # ✅ data kann JSON oder pid:... sein
        series_data_from_param = unpack_payload(data_json_str, default={})
        if not isinstance(series_data_from_param, dict):
            series_data_from_param = {}

        if not serie_key:
            xbmcgui.Dialog().notification("Fehler", "Keine Serien-URL zum Download.", common.addon.getAddonInfo('icon'))
            return

        # --- Zielordner vorbereiten ---
        download_path = current_path_stripped
        if not xbmcvfs.exists(download_path):
            xbmcvfs.mkdirs(download_path)

        clean_title_for_folder = (title or "").replace(" (lokal)", "").strip() or "Unbenannte_Serie"
        safe_title = (
            common.sanitize_filename(clean_title_for_folder)
            if hasattr(common, 'sanitize_filename')
            else re.sub(r'[\\/*?:"<>|]', "", clean_title_for_folder).strip()
        ) or "Unbenannte_Serie"

        series_folder_local = os.path.join(download_path, safe_title)
        if not xbmcvfs.exists(series_folder_local):
            xbmcvfs.mkdirs(series_folder_local)

        # --- Assets (Poster/Backcover) runterladen oder aus Cache kopieren ---
        asset_keys_mapping = {"poster": "poster", "backcover": "backcover"}
        local_assets = {}

        for kodi_key, json_key_asset in asset_keys_mapping.items():
            url_asset = (series_data_from_param.get(json_key_asset, "") or "").strip()
            if not (isinstance(url_asset, str) and url_asset.startswith("http")):
                continue
            try:
                # Dateiname bestimmen
                if hasattr(common, 'get_filename_from_url'):
                    asset_filename = common.get_filename_from_url(
                        url_asset,
                        fallback_name=kodi_key,
                        use_extension_only=True
                    )
                else:
                    asset_filename = f"{kodi_key}.jpg"

                asset_dest = os.path.join(series_folder_local, asset_filename)

                # zuerst Cache
                cached_asset_path = get_cached_image(url_asset)
                if cached_asset_path and xbmcvfs.exists(cached_asset_path):
                    xbmcvfs.copy(cached_asset_path, asset_dest)
                    local_assets[kodi_key] = asset_dest
                    xbmc.log(f"[{addon_id_log} series.py] Asset '{kodi_key}' aus Cache nach '{asset_dest}' kopiert.", xbmc.LOGDEBUG)
                else:
                    # dann Download
                    downloaded_asset_path = common.download_file(
                        url_asset,
                        asset_dest,
                        title=f"{clean_title_for_folder} - {kodi_key}"
                    )
                    if downloaded_asset_path and xbmcvfs.exists(downloaded_asset_path):
                        local_assets[kodi_key] = downloaded_asset_path

                if kodi_key == "backcover":
                    local_assets["fanart"] = local_assets.get("backcover")

            except Exception as e_asset_dl:
                xbmc.log(f"[{addon_id_log} series.py] Download Fehler Asset '{kodi_key}': {e_asset_dl}", xbmc.LOGWARNING)

        # --- Details-URL ermitteln (SerieKey sollte meist details_url sein) ---
        details_url_for_download = (serie_key or "").strip()
        if not details_url_for_download.startswith("http"):
            details_url_for_download = (
                (series_data_from_param.get("details_url") or "").strip()
                or (series_data_from_param.get("original_online_key") or "").strip()
            )

        if not (isinstance(details_url_for_download, str) and details_url_for_download.startswith("http")):
            xbmcgui.Dialog().notification("Download Fehler", "Konnte Details-URL für Download nicht ermitteln.", common.addon.getAddonInfo('icon'))
            return

        # --- details.json laden (Cache + Wrapper) ---
        details_json_content = {}
        global SERIES_DETAILS_CACHE

        if details_url_for_download in SERIES_DETAILS_CACHE and SERIES_DETAILS_CACHE[details_url_for_download] is not False:
            details_json_content = SERIES_DETAILS_CACHE[details_url_for_download]
            xbmc.log(f"[{addon_id_log} series.py] Nutze gecachte Details für Download: {details_url_for_download}", xbmc.LOGDEBUG)
        else:
            try:
                # ✅ FIX: konsistent Wrapper nutzen
                req = get_authenticated_request(details_url_for_download)
                if not req:
                    raise ValueError("Auth Request fehlgeschlagen.")
                with urllib.request.urlopen(req, context=ssl.create_default_context(), timeout=20) as response:
                    if response.getcode() == 200:
                        tmp = json.loads(response.read().decode('utf-8'))
                        if isinstance(tmp, dict):
                            details_json_content = tmp
                            SERIES_DETAILS_CACHE[details_url_for_download] = tmp
                        else:
                            details_json_content = {}
                            SERIES_DETAILS_CACHE[details_url_for_download] = False
                    else:
                        SERIES_DETAILS_CACHE[details_url_for_download] = False
                        raise ValueError(f"HTTP {response.getcode()}")
            except Exception as e_details_dl:
                xbmcgui.Dialog().notification("Download Fehler", f"Laden details.json fehlgeschlagen: {e_details_dl}", common.addon.getAddonInfo('icon'))
                return

        if not isinstance(details_json_content, dict) or not details_json_content:
            xbmcgui.Dialog().notification("Download Fehler", "details.json ist leer oder ungültig.", common.addon.getAddonInfo('icon'))
            return

        # Cancel-Exception aus common (falls vorhanden)
        DownloadCancelledError = getattr(common, 'DownloadCancelledError', None)

        # --- Download-Liste bauen ---
        items_to_download = []

        if mode == "serie":
            for sk_dl, s_data_dl in details_json_content.items():
                if isinstance(s_data_dl, dict) and isinstance(sk_dl, str) and sk_dl.lower().startswith("season"):
                    s_num_str_dl = sk_dl.lower().replace("season", "").strip()
                    for ep_d_dl in (s_data_dl.get("episodes", []) or []):
                        if isinstance(ep_d_dl, dict):
                            items_to_download.append((s_num_str_dl, ep_d_dl))

        elif mode == "staffel" and season_param:
            s_data_dl_staffel = details_json_content.get(f"Season {season_param}")
            if isinstance(s_data_dl_staffel, dict):
                for ep_d_dl_s in (s_data_dl_staffel.get("episodes", []) or []):
                    if isinstance(ep_d_dl_s, dict):
                        items_to_download.append((str(season_param), ep_d_dl_s))

        elif mode == "episode" and season_param and episode_param:
            s_data_dl_ep = details_json_content.get(f"Season {season_param}")
            if isinstance(s_data_dl_ep, dict):
                for ep_d_dl_e in (s_data_dl_ep.get("episodes", []) or []):
                    if isinstance(ep_d_dl_e, dict) and str(ep_d_dl_e.get("episode_number", "")).strip() == str(episode_param):
                        items_to_download.append((str(season_param), ep_d_dl_e))
                        break

        # Für Staffel/Episode: wenn nix gefunden -> Info
        if not items_to_download and mode != "serie":
            xbmcgui.Dialog().notification("Info", "Keine Episoden zum Download gefunden.", common.addon.getAddonInfo('icon'))
            return

        downloaded_content_for_json = {}
        some_ep_download_failed = False

        # --- Download durchführen ---
        if items_to_download:
            progress_overall = xbmcgui.DialogProgress()
            progress_overall.create(f"{common.addon_name} - Download", f"{clean_title_for_folder}\nStarte Download...")
            total_items = len(items_to_download)

            for i_dl, (s_num_str_dl_loop, ep_data_dl_loop) in enumerate(items_to_download):
                ep_num_dl = ep_data_dl_loop.get("episode_number", "?")
                ep_name_dl = ep_data_dl_loop.get("name", f"Episode {ep_num_dl}")
                percent_dl = int((i_dl + 1) * 100 / max(1, total_items))

                progress_overall.update(
                    percent_dl,
                    f"{clean_title_for_folder}\n({i_dl+1}/{total_items}) Lade S{s_num_str_dl_loop}E{ep_num_dl}: {ep_name_dl}"
                )

                if progress_overall.iscanceled():
                    cancelled = True
                    break

                try:
                    result_dl = download_episode(ep_data_dl_loop, series_folder_local, s_num_str_dl_loop)
                    if result_dl:
                        downloaded_content_for_json.setdefault(str(s_num_str_dl_loop), {})[str(ep_num_dl)] = result_dl
                    else:
                        some_ep_download_failed = True

                except Exception as e:
                    # ✅ Cancel NICHT schlucken
                    if DownloadCancelledError and isinstance(e, DownloadCancelledError):
                        cancelled = True
                        break
                    if str(e) == "Download vom Benutzer abgebrochen.":
                        cancelled = True
                        break

                    xbmc.log(f"[{addon_id_log} series.py] Fehler download_episode S{s_num_str_dl_loop}E{ep_num_dl}: {e}", xbmc.LOGERROR)
                    some_ep_download_failed = True

            try:
                progress_overall.close()
            except Exception:
                pass
            progress_overall = None

            download_successful = (not cancelled) and (not some_ep_download_failed)
        else:
            # mode=serie, aber keine Episoden vorhanden -> gilt als "erfolgreich" (Assets+DB)
            download_successful = True

        # --- Lokale DB schreiben ---
        if not cancelled and (downloaded_content_for_json or (mode == "serie" and not items_to_download)):
            json_update_title = clean_title_for_folder

            if mode == "serie":
                update_local_series_json(
                    details_url_for_download,
                    json_update_title,
                    series_folder_local,
                    local_assets,
                    downloaded_content_for_json
                )

            elif mode == "staffel" and season_param:
                update_local_series_json_for_season(
                    details_url_for_download,
                    json_update_title,
                    series_folder_local,
                    local_assets,
                    season_param,
                    downloaded_content_for_json.get(str(season_param), {})
                )

            elif mode == "episode" and season_param and episode_param and downloaded_content_for_json:
                ep_result_dl = downloaded_content_for_json.get(str(season_param), {}).get(str(episode_param))
                if ep_result_dl:
                    update_local_series_json_for_episode(
                        details_url_for_download,
                        json_update_title,
                        series_folder_local,
                        local_assets,
                        season_param,
                        episode_param,
                        ep_result_dl
                    )
                else:
                    download_successful = False

    except Exception as e_dl_main:
        DownloadCancelledError = getattr(common, 'DownloadCancelledError', None)
        if (DownloadCancelledError and isinstance(e_dl_main, DownloadCancelledError)) or str(e_dl_main) == "Download vom Benutzer abgebrochen.":
            cancelled = True
        else:
            xbmc.log(f"[{addon_id_log} series.py] Schwerer Fehler in download_series: {e_dl_main}\n{traceback.format_exc()}", xbmc.LOGERROR)
            xbmcgui.Dialog().notification("Download Fehler", f"Unerwarteter Fehler: {e_dl_main}", common.addon.getAddonInfo('icon'), 6000)
        download_successful = False

    finally:
        if progress_overall:
            try:
                progress_overall.close()
            except Exception:
                pass

    # --- Final Notifications + Refresh ---
    final_title_notification = (title or "").replace(" (lokal)", "").strip() or "Serie"
    if cancelled:
        xbmcgui.Dialog().notification(
            "Download Abgebrochen",
            f"Download für '{final_title_notification}' wurde abgebrochen.",
            common.addon.getAddonInfo('icon'),
            5000
        )
    elif download_successful:
        if mode == 'serie':
            msg_text = f"Serie '{final_title_notification}' heruntergeladen."
        elif mode == 'staffel':
            msg_text = f"Staffel {season_param} von '{final_title_notification}' heruntergeladen."
        else:
            msg_text = f"Episode S{season_param}E{episode_param} von '{final_title_notification}' heruntergeladen."

        xbmcgui.Dialog().notification("Download Abgeschlossen", msg_text, common.addon.getAddonInfo('icon'), 5000)
        xbmc.executebuiltin('Container.Refresh')
    else:
        xbmcgui.Dialog().notification(
            "Download Fehlgeschlagen",
            f"Mindestens eine Datei für '{final_title_notification}' konnte nicht geladen werden.",
            common.addon.getAddonInfo('icon'),
            5000
        )




# ----------------------------
# Löschen lokaler Serien
# ----------------------------
def delete_local_series(serie_key_local_path, title):
    addon_id_log = getattr(common, 'addon_id', 'plugin.video.glotzbox')
    if not serie_key_local_path or not isinstance(serie_key_local_path, str):
        xbmcgui.Dialog().notification("Fehler", "Kein gültiger Serien-Pfad zum Löschen.", common.addon.getAddonInfo('icon'))
        return

    local_series_db = {}
    original_online_key_del = None
    local_serie_info_del = None

    if LOCAL_JSON_PATH and xbmcvfs.exists(LOCAL_JSON_PATH):
        try:
            # ✅ robust lesen (main + .bak)
            local_series_db = _safe_json_read_vfs(LOCAL_JSON_PATH, default={})
            if not isinstance(local_series_db, dict):
                local_series_db = {}

            for online_key_iter, data_iter in local_series_db.items():
                if isinstance(data_iter, dict) and data_iter.get("local_path", "").strip() == serie_key_local_path.strip():
                    original_online_key_del = online_key_iter
                    local_serie_info_del = data_iter
                    break

            if not original_online_key_del:
                xbmcgui.Dialog().notification("Fehler", "Serien-Eintrag in lokaler DB nicht gefunden.", common.addon.getAddonInfo('icon'))
                return

        except Exception as e_del_read:
            xbmc.log(f"[{addon_id_log} series.py] Fehler Lesen/Finden in local JSON: {e_del_read}", xbmc.LOGERROR)
            xbmcgui.Dialog().notification("Fehler", "Fehler Lesen lokale DB.", common.addon.getAddonInfo('icon'))
            return
    else:
        xbmcgui.Dialog().notification("Fehler", "Pfad zur lokalen DB fehlt.", common.addon.getAddonInfo('icon'))
        return

    if not xbmcgui.Dialog().yesno("Lokale Serie löschen", f"'{title}' wirklich lokal löschen?\nOrdner: {serie_key_local_path}", yeslabel="Löschen", nolabel="Abbrechen"):
        return

    files_deleted_successfully = True
    if local_serie_info_del and isinstance(local_serie_info_del.get("staffels"), dict):
        for s_content_del in local_serie_info_del["staffels"].values():
            if isinstance(s_content_del, dict):
                for ep_data_del in s_content_del.values():
                    if isinstance(ep_data_del, dict):
                        ep_video_path = ep_data_del.get("local_path")
                        if ep_video_path and xbmcvfs.exists(ep_video_path):
                            try:
                                if not xbmcvfs.delete(ep_video_path):
                                    if xbmcvfs.exists(ep_video_path):
                                        files_deleted_successfully = False
                            except Exception:
                                files_deleted_successfully = False
                        for asset_val_del in ep_data_del.get("assets", {}).values():
                            if asset_val_del and xbmcvfs.exists(asset_val_del):
                                try:
                                    if not xbmcvfs.delete(asset_val_del):
                                        if xbmcvfs.exists(asset_val_del):
                                            files_deleted_successfully = False
                                except Exception:
                                    files_deleted_successfully = False

    if local_serie_info_del and isinstance(local_serie_info_del.get("assets"), dict):
        for asset_path_main_del in local_serie_info_del["assets"].values():
            if asset_path_main_del and xbmcvfs.exists(asset_path_main_del):
                try:
                    if not xbmcvfs.delete(asset_path_main_del):
                        if xbmcvfs.exists(asset_path_main_del):
                            files_deleted_successfully = False
                except Exception:
                    files_deleted_successfully = False

    if not files_deleted_successfully:
        xbmcgui.Dialog().notification("Fehler", "Nicht alle Dateien konnten gelöscht werden. Ordner wird nicht entfernt.", common.addon.getAddonInfo('icon'))
        return

    folder_deleted = False
    try:
        if xbmcvfs.exists(serie_key_local_path):
            if xbmcvfs.rmdir(serie_key_local_path):
                folder_deleted = True
                xbmc.log(f"[{addon_id_log} series.py] Ordner '{serie_key_local_path}' erfolgreich gelöscht.", xbmc.LOGINFO)
            else:
                if xbmcvfs.exists(serie_key_local_path):
                    xbmc.log(f"[{addon_id_log} series.py] xbmcvfs.rmdir für '{serie_key_local_path}' gab False zurück, Ordner existiert aber noch.", xbmc.LOGWARNING)
                else:
                    folder_deleted = True
                    xbmc.log(f"[{addon_id_log} series.py] xbmcvfs.rmdir gab False zurück, aber Ordner ist weg.", xbmc.LOGINFO)
        else:
            xbmc.log(f"[{addon_id_log} series.py] Ordner '{serie_key_local_path}' existierte nicht mehr vor dem Löschversuch.", xbmc.LOGWARNING)
            folder_deleted = True

    except Exception as e_rmdir_main:
        xbmc.log(f"[{addon_id_log} series.py] Fehler bei rmdir für '{serie_key_local_path}': {e_rmdir_main}", xbmc.LOGERROR)

    if folder_deleted:
        if original_online_key_del in local_series_db:
            del local_series_db[original_online_key_del]
            try:
                # ✅ atomic DB write
                ok = _atomic_write_json_vfs(LOCAL_JSON_PATH, local_series_db, indent=4, keep_bak=True)
                if ok:
                    xbmcgui.Dialog().notification("Gelöscht", f"'{title}' wurde lokal entfernt.", common.addon.getAddonInfo('icon'))
                    xbmc.executebuiltin('Container.Refresh')
                else:
                    xbmcgui.Dialog().notification("Fehler", "Lokale DB konnte nicht gespeichert werden (atomic fehlgeschlagen).", common.addon.getAddonInfo('icon'))
            except Exception as e_write_db_del:
                xbmcgui.Dialog().notification("Fehler", "Lokale DB konnte nicht gespeichert werden.", common.addon.getAddonInfo('icon'))
        else:
            xbmcgui.Dialog().notification("Gelöscht", f"Ordner für '{title}' entfernt, DB-Eintrag nicht gefunden/aktualisiert.", common.addon.getAddonInfo('icon'))
            xbmc.executebuiltin('Container.Refresh')
    else:
        xbmcgui.Dialog().notification("Löschfehler", f"Konnte Ordner nicht entfernen: {serie_key_local_path}", common.addon.getAddonInfo('icon'))


# ----------------------------
# Authentifizierter Request
# ----------------------------
def get_authenticated_request(url_param):
    """Zentraler Wrapper für Auth-Requests. Nutzt common.get_authenticated_request, wenn vorhanden."""
    try:
        common_func = getattr(common, "get_authenticated_request", None)
        if callable(common_func):
            # ✅ WICHTIG: common-Funktion aufrufen, NICHT sich selbst
            return common_func(url_param)

        # --- Fallback: selber Request bauen ---
        parsed = urllib.parse.urlsplit(url_param)
        netloc = parsed.hostname or ""
        if parsed.port:
            netloc += f":{parsed.port}"

        clean_url = urllib.parse.urlunsplit((parsed.scheme, netloc, parsed.path, parsed.query, parsed.fragment))
        req = urllib.request.Request(clean_url)

        req.add_header(
            "User-Agent",
            f"Kodi/{xbmc.getInfoLabel('System.BuildVersion')} "
            f"Addon/{getattr(common, 'addon_id', 'plugin.video.glotzbox')}/"
            f"{getattr(common, 'addon_version', '0.0.0')}"
        )

        user = (common.addon.getSetting("ftp_username") or "").strip()
        pwd  = (common.addon.getSetting("ftp_password") or "").strip()
        if user and pwd:
            token = base64.b64encode(f"{user}:{pwd}".encode("utf-8")).decode("ascii")
            req.add_header("Authorization", "Basic " + token)

        return req

    except Exception as e:
        xbmc.log(
            f"[{getattr(common,'addon_id','plugin.video.glotzbox')}] get_authenticated_request ERROR: {e}",
            xbmc.LOGERROR
        )
        return None


# ----------------------------
# (Fehlende) Kontext-Menü-Funktion für Episoden (Platzhalter)
# ----------------------------
def get_episode_context_menu_items(ep, serie_name, season_number, ep_num):
    """
    Platzhalter – falls du später noch Kontextmenü-Optionen für einzelne Episoden brauchst.
    Aktuell bleibt das leer.
    """
    return []


# --- Haupt-Router ---
# --- Haupt-Router ---
if __name__ == '__main__':
    params = {}
    if len(sys.argv) > 2 and sys.argv[2].startswith('?'):
        params = dict(urllib.parse.parse_qsl(sys.argv[2][1:]))

    action = params.get('action')
    serie_key = params.get('serie_key')
    title = params.get('title')
    tmdbid = params.get('tmdbid')
    data = params.get('data')
    mode = params.get('mode')
    season = params.get('season')
    episode = params.get('episode')

    if title and action == "show_seasons":
        common.addon.setSetting("last_played_tvshow_title_for_player", title.replace(" (lokal)", "").strip())

    if action is None:
        check_series_json()

    elif action == 'show_seasons':
        if data:
            show_seasons(data)
        else:
            xbmcgui.Dialog().notification("Fehler", "Keine Seriendaten für Staffelansicht.", common.addon.getAddonInfo('icon'))

    elif action == 'show_episodes':
        if data:
            show_episodes(data)
        else:
            xbmcgui.Dialog().notification("Fehler", "Keine Staffeldaten für Episodenansicht.", common.addon.getAddonInfo('icon'))

    elif action == 'play_series':
        if serie_key and title:
            play_series(serie_key, title, tmdbid)
        else:
            xbmcgui.Dialog().notification("Fehler", "Fehlende Infos zum Abspielen.", common.addon.getAddonInfo('icon'))

    elif action == 'toggle_watched_status_series':
        if serie_key:
            toggle_watched_status(serie_key)
        else:
            xbmcgui.Dialog().notification("Fehler", "Kein Key für Statusumschaltung.", common.addon.getAddonInfo('icon'))

    elif action == 'download_series':
        if serie_key and title and data:
            download_series(serie_key, title, data, mode=mode, season_param=season, episode_param=episode)
        else:
            xbmcgui.Dialog().notification("Fehler", "Fehlende Infos für Download.", common.addon.getAddonInfo('icon'))

    elif action == 'delete_local_series':
        if serie_key and title:
            delete_local_series(serie_key, title)
        else:
            xbmcgui.Dialog().notification("Fehler", "Fehlende Infos zum Löschen.", common.addon.getAddonInfo('icon'))

    elif action == 'play_random_series_globally':
        play_random_series_globally()

    elif action == 'play_random_episode_from_this_series':
        if data:
            play_random_episode_from_this_series(data)
        else:
            xbmcgui.Dialog().notification("Fehler", "Keine Seriendaten für Zufallsepisode.", common.addon.getAddonInfo('icon'))

    elif action == 'play_random_episode_from_this_season':
        if data:
            play_random_episode_from_this_season(data)
        else:
            xbmcgui.Dialog().notification("Fehler", "Keine Staffeldaten für Zufallsepisode.", common.addon.getAddonInfo('icon'))

    else:
        xbmc.log(f"[{common.addon_id} series.py] Unbekannte Aktion: {action}", xbmc.LOGWARNING)
        check_series_json()