
# coding: utf-8
from __future__ import annotations
import logging
from typing import Any, Optional, Callable
import gb_common as 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


# ---------------------------------------------------------------------------
# GlotzBox series.py – cache hardening
# ---------------------------------------------------------------------------

SERIES_PY_VERSION = "cache-hardening+details-cache-lock+recover-hardening+remote-artwork-ui-fix+cec-refresh-guard"

def _force_series_container_dateadded_desc():
    """Delegiert an gemeinsamen Helper in gb_common."""
    common.force_container_sort_dateadded_desc("show_serien")




_VERSION_LOGGED = False


def _log_version_once():
    global _VERSION_LOGGED
    if _VERSION_LOGGED:
        return
    _VERSION_LOGGED = True
    try:
        _log_series(f"series.py geladen: {SERIES_PY_VERSION}", xbmc.LOGINFO)
    except Exception:
        pass

import xbmcvfs
import ssl  # Wichtig für HTTPS-Requests
import hashlib
import time
from datetime import datetime, timezone  # <--- Wichtiger Import ganz oben
import threading
import re
import traceback
import random  # <-- NEUER IMPORT für Zufallsfunktionen
import errno
import shutil

from collections import OrderedDict
# --- 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 _safe_json_read_vfs(path: str, default: Any = None) -> Any:
    """Liest JSON aus einer Datei mit .bak/.tmp Recovery (via gb_common).
    Gibt default zurueck, wenn Datei fehlt/leer/kaputt ist.
    """
    try:
        if not path:
            return default
        result = common.safe_load_json_file(path, default)
        return result
    except Exception:
        return default


def _set_container_fanart(handle: int, fanart: Optional[str]) -> None:
    """Setzt Fanart fuer das Plugin-Directory (Container).
    Kodi-API: bevorzugt setPluginFanart, Fallback ueber Window-Property.
    """
    if not fanart:
        return
    try:
        if hasattr(xbmcplugin, "setPluginFanart"):
            xbmcplugin.setPluginFanart(handle, fanart)
            return
    except Exception:
        pass

    # Fallback: manche Skins lesen Fanart_Image / fanart_image aus Window-Properties
    try:
        wid = xbmcgui.getCurrentWindowId()
        w = xbmcgui.Window(wid)
        w.setProperty("Fanart_Image", fanart)
        w.setProperty("fanart_image", fanart)
    except Exception:
        pass
    try:
        w = xbmcgui.Window(10000)  # Home
        w.setProperty("Fanart_Image", fanart)
        w.setProperty("fanart_image", fanart)
    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 = {}
_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: bool = True, durable: bool = True, **_ignored_kwargs) -> bool:
    """
    Atomisches Schreiben (so gut wie möglich) für Text.

    Hintergrund:
    Kodi/xbmcvfs-Schreibvorgänge können in seltenen Fällen 0-Byte-Dateien hinterlassen, z.B. wenn
    Kodi oder der Python-Thread mitten im Write abbricht. Genau das triggert dann:
      unpack_payload: leere Cache-Datei -> .../payload_cache/<pid>.json

    Deshalb:
    - Für lokale FS-Pfade bevorzugen wir OS-level temp+fsync+os.replace (siehe _atomic_write_bytes_os via _atomic_write_bytes_vfs)
    - Für VFS-Pfade (special:// etc.) fallen wir auf common.atomic_write_bytes zurück.
    """
    try:
        data = (text or "").encode("utf-8")
    except Exception:
        data = b""

    # Prefer robust byte-writer (OS atomic for local FS; best-effort VFS fallback otherwise)
    try:
        ok = _atomic_write_bytes_vfs(path, data, keep_bak=keep_bak)
        if ok:
            return True
    except Exception:
        pass

    # Fallbacks: common.atomic_write_bytes had different kwargs over time
    try:
        return bool(common.atomic_write_bytes(path, data, keep_backup=bool(keep_bak), durable=bool(durable)))
    except TypeError:
        pass
    try:
        return bool(common.atomic_write_bytes(path, data, backup=bool(keep_bak), durable=bool(durable)))
    except TypeError:
        pass
    try:
        return bool(common.atomic_write_bytes(path, data, keep_backup=bool(keep_bak)))
    except TypeError:
        pass
    try:
        return bool(common.atomic_write_bytes(path, data, backup=bool(keep_bak)))
    except TypeError:
        pass
    try:
        return bool(common.atomic_write_bytes(path, data))
    except Exception:
        # Last resort: plain write ONLY for local paths
        if _is_local_fs_path(path):
            try:
                os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
                with open(path, "wb") as f:
                    f.write(data)
                return True
            except Exception:
                return False
        return False

def _is_local_fs_path(path: str) -> bool:
    """Best-effort: our addon caches live on local FS on Linux/Android boxes."""
    try:
        return bool(path) and path.startswith("/") and "://" not in path and not path.startswith("special://")
    except Exception:
        return False

def _atomic_write_bytes_os(path: str, data: bytes, keep_bak: bool = False) -> bool:
    """Atomic write for local filesystem paths (tmp + fsync + os.replace).

    This avoids rare cases where xbmcvfs writes can leave 0-byte files on some boxes.
    """
    tmp = None  # ensure cleanup in except even if assignment never ran
    try:
        d = os.path.dirname(path) or "."
        os.makedirs(d, exist_ok=True)

        # Optional .bak snapshot (best-effort)
        if keep_bak:
            try:
                if os.path.exists(path) and os.path.getsize(path) > 0:
                    shutil.copy2(path, path + ".bak")
            except Exception:
                pass

        tmp = f"{path}.tmp_{os.getpid()}_{threading.get_ident()}_{random.randint(0, 999999):06d}"
        with open(tmp, "wb") as f:
            f.write(data)
            f.flush()
            try:
                os.fsync(f.fileno())
            except Exception:
                pass

        os.replace(tmp, path)

        # Verify non-empty (and ideally exact length)
        try:
            if os.path.getsize(path) <= 0:
                raise IOError("written file is empty")
        except Exception:
            raise

        # Best-effort durability on ext4 etc.
        try:
            dfd = os.open(d, os.O_DIRECTORY)
            try:
                os.fsync(dfd)
            finally:
                os.close(dfd)
        except Exception:
            pass

        return True
    except Exception as e:
        try:
            if tmp is not None and os.path.exists(tmp):
                os.remove(tmp)
        except Exception:
            pass
        xbmc.log(f"[{common.addon_id} series.py] atomic_write_os failed: {path} ({type(e).__name__}: {e})", xbmc.LOGWARNING)
        return False

def _atomic_write_bytes_vfs(path: str, data: bytes, keep_bak: bool = False) -> bool:
    with _lock_for_path(path):
        # Prefer OS-level atomic writes for local FS paths (Linux/RPi/Odroid/PC).
        if _is_local_fs_path(path):
            ok = _atomic_write_bytes_os(path, data, keep_bak=keep_bak)
            if ok:
                return True

        # Fallback to common VFS-safe writer (can handle special:// etc.)
        try:
            return bool(common.atomic_write_bytes(path, data))
        except Exception as e:
            xbmc.log(f"[{common.addon_id} series.py] atomic_write_vfs failed: {path} ({type(e).__name__}: {e})", xbmc.LOGWARNING)
            return False

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

    Wichtig: viele Call-Sites übergeben `indent=`.
    Mit Fallback für ältere common.py Versionen ohne `durable`.
    """
    try:
        try:
            return bool(common.atomic_write_json(path, obj, indent=indent, durable=bool(durable), backup=bool(keep_bak)))
        except TypeError:
            return bool(common.atomic_write_json(path, obj, indent=indent, 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 _delete_targets(p: str, with_sidecars: bool = False):
    path = (p or "").strip()
    if not path:
        return []

    out = [path]
    if not with_sidecars:
        return out

    extras = [path + ".bak", path + ".seed", path + ".seed.bak"]
    lp = path.lower()
    if lp.endswith(".bak") or lp.endswith(".seed"):
        base = path.rsplit(".", 1)[0]
        extras.extend([base, base + ".bak", base + ".seed", base + ".seed.bak"])

    for cand in extras:
        if cand and cand not in out:
            out.append(cand)
    return out


def _vfs_delete_quiet(p: str, with_sidecars: bool = False):
    for cand in _delete_targets(p, with_sidecars=with_sidecars):
        if not cand:
            continue
        # OS fallback
        try:
            import os
            if os.path.isfile(cand):
                os.remove(cand)
                continue
        except Exception:
            pass
        try:
            # Only call VFS delete if VFS actually sees the file.
            if xbmcvfs.exists(cand):
                try:
                    xbmcvfs.delete(cand)
                except Exception:
                    pass
        except Exception:
            pass

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

    If `max_bytes` is set, we cap the returned data (best-effort).
    """
    # Prefer shared helper with local-FS fallback, because some Kodi builds
    # return false negatives for xbmcvfs on normal absolute paths.
    data = ""
    try:
        data = _safe_read_text_vfs(p) or ""
    except Exception:
        data = ""

    if max_bytes is not None and data:
        try:
            mb = int(max_bytes)
            if mb > 0:
                data = data[:mb]
        except Exception:
            pass

    return data or None

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 = []

# --- Payload-Memo (RAM-Fallback) ---
# Manchmal kann eine Cache-Datei im payload_cache kurzzeitig leer (0 Bytes) sein
# (z.B. Race beim Schreiben/GC/FS). Dann fehlen Episoden erst beim 2. Öffnen.
# Wir merken uns die zuletzt "gepackten" Payloads zusätzlich im RAM, damit
# unpack_payload in diesem Fall sofort weiterarbeiten kann.
_PAYLOAD_MEMO_MAX = 2048
_PAYLOAD_MEMO = OrderedDict()  # pid_str -> (ts, obj)
_PAYLOAD_MEMO_LOCK = threading.Lock()

def _payload_memo_put(pid_str: str, obj: Any) -> None:
    try:
        if not pid_str:
            return
        with _PAYLOAD_MEMO_LOCK:
            _PAYLOAD_MEMO[pid_str] = (time.time(), obj)
            try:
                _PAYLOAD_MEMO.move_to_end(pid_str, last=True)
            except Exception:
                pass
            while len(_PAYLOAD_MEMO) > _PAYLOAD_MEMO_MAX:
                try:
                    _PAYLOAD_MEMO.popitem(last=False)
                except Exception:
                    break
    except Exception:
        pass

def _payload_memo_get(pid_str: str) -> Any:
    try:
        if not pid_str:
            return None
        with _PAYLOAD_MEMO_LOCK:
            item = _PAYLOAD_MEMO.get(pid_str)
            if not item:
                return None
            try:
                _PAYLOAD_MEMO.move_to_end(pid_str, last=True)
            except Exception:
                pass
            return item[1]
    except Exception:
        return None
CURRENT_WATCH_SIG = ""

_IMAGE_MISS_TTL_S = 300  # 5 Minuten: Misses werden neu geprüft
_IMAGE_CACHE_LOCK = threading.Lock()
_WATCHED_STATUS_LOCK = threading.RLock()
_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

# Progress strategy for series list rendering:
# True  -> fill due progress entries synchronously before rendering.
# False -> keep async post-render progress fill + follow-up refresh.
SYNC_PROGRESS_ON_LIST_LOAD = True
SYNC_PROGRESS_TIMEOUT_S = 8
# Avoid long UI stalls on slow devices/networks.
SYNC_PROGRESS_MAX_BLOCK_S = 1.2

# --- 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.
    """
    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.
    Unterstützt Legacy + frische Root-Pfade (show_serien, v2, v3, v4, v5).
    """
    try:
        fp_low = (fp or "").lower()
        return (
            ("action=show_serien" in fp_low)
            or ("action=show_serien_v2" in fp_low)
            or ("action=show_serien_v3" in fp_low)
            or ("action=show_serien_v4" in fp_low)
            or ("action=show_serien_v5" in fp_low)
            or ("action=check_series_json" in fp_low)
        )
    except Exception:
        return False



def _progress_cache_key(details_url_val: str = "", fallback_key: str = "") -> str:
    """Canonical progress-cache key (credential-free when URL-based)."""
    durl = (details_url_val or "").strip()
    if durl:
        try:
            key = (_details_url_key(durl) or "").strip()
            if key:
                return key
        except Exception:
            pass
        return durl
    return (fallback_key or "").strip()


def _progress_cache_aliases(details_url_val: str = "", fallback_key: str = "") -> list:
    aliases = []
    durl = (details_url_val or "").strip()
    if durl:
        aliases.append(durl)
        try:
            credless = (common.strip_url_credentials(durl) or "").strip()
            if credless and credless not in aliases:
                aliases.append(credless)
        except Exception:
            pass
    fb = (fallback_key or "").strip()
    if fb and fb not in aliases:
        aliases.append(fb)
    return aliases


def _progress_cache_entry(progress_cache: dict, details_url_val: str = "", fallback_key: str = ""):
    """Lookup progress entry with legacy-key fallback.

    Returns: (canonical_key, entry_or_none, migrated_bool)
    """
    key = _progress_cache_key(details_url_val, fallback_key)
    if not key or not isinstance(progress_cache, dict):
        return key, None, False

    entry = progress_cache.get(key)
    if isinstance(entry, dict):
        return key, entry, False

    for alias in _progress_cache_aliases(details_url_val, fallback_key):
        if not alias or alias == key:
            continue
        legacy = progress_cache.get(alias)
        if isinstance(legacy, dict):
            progress_cache[key] = legacy
            try:
                progress_cache.pop(alias, None)
            except Exception:
                pass
            return key, legacy, True

    return key, None, 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 = _progress_cache_key(details_url_val, original_serie_key)
        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_entry(progress_cache, details_url_val, original_serie_key)
        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:
                    mon = None
                    try:
                        mon = xbmc.Monitor()
                    except Exception:
                        mon = None
                    if common.wait_or_abort(mon, max(0.05, float(delay_s))):
                        return
                    # 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 as e:
            xbmc.log(f"[{common.addon_id}] _container_reload_throttled schedule_deferred failed: {e}", xbmc.LOGDEBUG)

    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()
        folder_path_l = folder_path.lower()
        addon_base = f"plugin://{common.addon_id}".lower()

        if not folder_path_l.startswith(addon_base):
            try:
                xbmc.log(
                    f"[{common.addon_id}] container_reload_throttled: SKIP ({reason}) foreign container -> {folder_path}",
                    xbmc.LOGDEBUG,
                )
            except Exception:
                pass
            return False

        # Background / async refreshes sollen die GUI nur anfassen, wenn Kodi aktiv benutzt wird.
        # Das verhindert eher HDMI-CEC-Wakeups, wenn der User den TV schon auf einen anderen
        # Eingang umgeschaltet hat und Kodi nur noch im Hintergrund auf der Serienliste steht.
        try:
            if xbmc.getCondVisibility("System.ScreenSaverActive"):
                xbmc.log(f"[{common.addon_id}] container_reload_throttled: SKIP ({reason}) screensaver active", xbmc.LOGDEBUG)
                return False
        except Exception:
            pass

        is_background_reason = (
            str(reason).startswith("async_")
            or str(reason).endswith("_deferred")
            or reason in ("series_verify_open", "series_root_after_playback")
        )

        if is_background_reason:
            try:
                if xbmc.getCondVisibility("Window.IsVisible(fullscreenvideo)") or xbmc.getCondVisibility("Player.HasMedia"):
                    xbmc.log(f"[{common.addon_id}] container_reload_throttled: SKIP ({reason}) playback active", xbmc.LOGDEBUG)
                    return False
            except Exception:
                pass
            try:
                if xbmc.getCondVisibility("System.IdleTime(180)"):
                    xbmc.log(f"[{common.addon_id}] container_reload_throttled: SKIP ({reason}) idle >= 180s", xbmc.LOGDEBUG)
                    return False
            except Exception:
                pass
            if not _is_series_root_folderpath(folder_path):
                try:
                    xbmc.log(
                        f"[{common.addon_id}] container_reload_throttled: SKIP ({reason}) not series root -> {folder_path}",
                        xbmc.LOGDEBUG,
                    )
                except Exception:
                    pass
                return False

        # 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 _vfs_size(path: str) -> int:
    """Robust size getter for xbmcvfs paths."""
    try:
        st = xbmcvfs.Stat(path)
        val = getattr(st, 'st_size', None)
        if callable(val):
            val = val()
        if val is not None:
            return int(val)
    except Exception:
        pass
    try:
        if os.path.exists(path):
            return int(os.path.getsize(path))
    except Exception:
        pass
    return 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):
            _vfs_delete_quiet(p, with_sidecars=True)
        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:
            _vfs_delete_quiet(p, with_sidecars=True)




# =====================================================================================
# SECTION: PAYLOAD-CACHE SYSTEM
# =====================================================================================
def pack_payload(obj) -> str:
    """Pack a (dict/list) payload into payload_cache and return a short PID token.

    Why: Kodi URLs have length limits. We store the full JSON payload in
    addon_data/payload_cache/<md5>.json and pass around only "pid:<md5>".
    """
    try:
        raw = json.dumps(obj, ensure_ascii=False, separators=(',', ':'))
        h = hashlib.md5(raw.encode('utf-8')).hexdigest()
        path = _payload_cache_file(h)
        seed_path = path + ".seed"

        def _is_valid_json_file(p: str) -> bool:
            if not _vfs_exists(p):
                return False
            txt = _vfs_read_text(p, max_bytes=512 * 1024)
            if not txt:
                return False
            try:
                json.loads(txt)
                return True
            except Exception:
                return False

        main_ok = _is_valid_json_file(path)
        seed_ok = _is_valid_json_file(seed_path)

        # Main file: rewrite if missing/empty/corrupt
        if not main_ok:
            _atomic_write_text_vfs(path, raw, keep_bak=False, durable=True)

        # Seed copy: helps recover from rare empty/corrupt main files (power loss / vfs lag / abrupt kills)
        if not seed_ok:
            _atomic_write_text_vfs(seed_path, raw, keep_bak=False, durable=True)

        pid_str = f"pid:{h}"
        _payload_memo_put(pid_str, obj)
        return pid_str
    except Exception as e:
        _log_series(
            f"pack_payload failed: {e} -- kein Inline-Fallback fuer Serien-Payload (URL-Leak-Schutz)",
            xbmc.LOGERROR
        )
        return ""
def unpack_payload(data_json_str: str, default=None):
    """
    Akzeptiert:
      - "pid:<hash>"  → lädt JSON aus payload_cache/<hash>.json (mit Retry + .bak/.seed Fallback)
      - JSON-String   → json.loads
    """
    if default is None:
        default = {}

    if not data_json_str:
        return default

    s = str(data_json_str).strip()

    # pid:<hash>
    if s.startswith("pid:"):
        pid = s[4:].strip()
        if not pid:
            return default

        base_paths = []
        try:
            base_paths.append(_payload_cache_file(pid))
        except Exception:
            pass

        # Alt-/Fallback-Pfad (falls common.PAYLOAD_CACHE_DIR anders ist)
        try:
            alt_dir = getattr(common, "PAYLOAD_CACHE_DIR", None)
            if alt_dir and alt_dir != PAYLOAD_CACHE_DIR:
                base_paths.append(os.path.join(alt_dir, f"{pid}.json"))
        except Exception:
            pass

        # Dedup
        seen = set()
        base_paths = [p for p in base_paths if p and not (p in seen or seen.add(p))]

        # In-flight Writes: kurz warten + retry (vor allem auf Pi3/4/Odroid)
        delays_ms = (0, 80, 120, 180, 260, 400)

        def _candidates_for(base_path: str):
            return (
                base_path,
                base_path + ".bak",
                base_path + ".seed",
                base_path + ".seed.bak",
            )

        last_empty = None
        poll_mon = None
        try:
            poll_mon = xbmc.Monitor()
        except Exception:
            poll_mon = None

        for ms in delays_ms:
            if ms:
                if common.wait_or_abort(poll_mon, float(ms) / 1000.0):
                    return default

            for base in base_paths:
                for cand in _candidates_for(base):
                    if not _vfs_exists(cand):
                        continue
                    raw = _vfs_read_text(cand)
                    if not raw:
                        if cand == base:
                            last_empty = base
                        continue

                    raw_try = raw.strip()
                    if not (raw_try.startswith("{") or raw_try.startswith("[")):
                        continue

                    try:
                        payload = json.loads(raw_try)
                    except Exception:
                        continue

                    # Memoize
                    try:
                        _payload_memo_put(s, payload)
                    except Exception:
                        pass

                    # Heal: falls wir aus .bak/.seed gelesen haben, Hauptdatei wiederherstellen (best effort)
                    if cand != base:
                        try:
                            _log_series(f"unpack_payload: recovered via {os.path.basename(cand)} -> {base}", xbmc.LOGINFO)
                        except Exception:
                            pass
                        try:
                            _atomic_write_text_vfs(base, raw_try, keep_bak=True, durable=True)
                        except Exception:
                            pass

                    return payload

        # RAM memo fallback
        try:
            memo = _payload_memo_get(s)
            if memo is not None:
                return memo
        except Exception:
            pass
        # Cleanup / Race-Fix: auf RasPi/ODROID kann direkt nach dem Schreiben kurzzeitig '' gelesen werden.
        # Daher: erst ein paar Mal retry-en, erst dann als "leer" werten (und löschen).
        if last_empty and _vfs_exists(last_empty):
            raw = ""
            try:
                raw = _vfs_read_text(last_empty)
            except Exception:
                raw = ""

            if not raw:
                for _i in range(5):
                    if common.wait_or_abort(poll_mon, 0.08):
                        return default
                    try:
                        raw = _vfs_read_text(last_empty)
                    except Exception:
                        raw = ""
                    if raw:
                        break

            if raw:
                # Falls nach Retry doch Daten da sind, nochmal JSON parsen versuchen.
                try:
                    payload = json.loads(raw)
                    return payload
                except Exception:
                    pass

            # Immer noch leer -> als defekt behandeln
            _log_series(f"unpack_payload: leere Cache-Datei -> {last_empty}", xbmc.LOGDEBUG)
            try:
                age = time.time() - _vfs_mtime(last_empty)
            except Exception:
                age = 9999.0
            if age < 10.0:
                _log_series(f"unpack_payload: leere Cache-Datei aber sehr jung ({age:.1f}s) -> skip delete: {last_empty}", xbmc.LOGDEBUG)
            else:
                _vfs_delete_quiet(last_empty)
# Nur einmal loggen (sonst Spam)
        if base_paths:
            _log_series(f"unpack_payload: PID file fehlt/kaputt: {base_paths[0]}", xbmc.LOGDEBUG)
        return default

    # Inline JSON
    try:
        payload = json.loads(s)
        return payload
    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 {}



# ------------------------------------------------------------
# Hardening helpers (PID-Cache Recovery)
# ------------------------------------------------------------

def _norm_title(s):
    try:
        txt = (s or "").strip()
        txt = re.sub(r"\s*\(lokal\)\s*$", "", txt, flags=re.IGNORECASE)
        return txt.casefold()
    except Exception:
        txt = (s or "").strip()
        try:
            txt = re.sub(r"\s*\(lokal\)\s*$", "", txt, flags=re.IGNORECASE)
        except Exception:
            pass
        return txt.lower()


def _write_payload_pid_file(pid_hash: str, payload_obj) -> bool:
    """Write a payload object into payload_cache as <pid_hash>.json (+ .seed)."""
    try:
        if PAYLOAD_CACHE_DIR:
            try:
                common.fs_mkdirs(PAYLOAD_CACHE_DIR)
            except Exception:
                os.makedirs(xbmcvfs.translatePath(PAYLOAD_CACHE_DIR), exist_ok=True)
        path = _payload_cache_file(pid_hash)
        seed_path = path + ".seed"
        raw = json.dumps(payload_obj, ensure_ascii=False, separators=(',', ':'))

        ok_main = _atomic_write_text_vfs(path, raw, keep_bak=False, durable=True)
        ok_seed = _atomic_write_text_vfs(seed_path, raw, keep_bak=False, durable=True)

        return bool(ok_main and ok_seed)
    except Exception as e:
        _log_series(f"_write_payload_pid_file failed: {e}", xbmc.LOGERROR)
        return False
def _recover_serie_nav_payload(pid_hash=None, title_hint=None):
    """Try to recover a minimal 'serie' dict for navigation when PID cache is missing.

    Strategy:
    1) Use CURRENT_SERIES_LIST (if present) by title match
    2) Load cached series list (series.cache.json) and match by title
    3) Fallback: match by pid_hash (pack_payload-kompatibles MD5 über compact JSON)
    """
    title_hint = (title_hint or "").strip()
    title_key = _norm_title(title_hint)

    def _entry_title(entry):
        if not isinstance(entry, dict):
            return ""
        return _norm_title(entry.get("title") or entry.get("name") or "")

    def _payload_pid_matches(entry_obj, wanted_pid):
        if not wanted_pid or not isinstance(entry_obj, dict):
            return False
        try:
            raw = json.dumps(entry_obj, ensure_ascii=False, separators=(',', ':'))
            md5_now = hashlib.md5(raw.encode("utf-8")).hexdigest()
            if md5_now == wanted_pid:
                return True
        except Exception:
            pass
        return False

    def _build_online_nav_candidate(entry_obj):
        """Recreate the dict shape used for pack_payload(data_for_nav) in check_series_json."""
        if not isinstance(entry_obj, dict):
            return None
        try:
            s2 = dict(entry_obj)
            key = (
                s2.get("details_url")
                or (s2.get("file_ort") if str(s2.get("file_ort")).startswith("http") else None)
                or s2.get("name")
            )
            if not key:
                return s2
            s2["source"] = "online"
            s2["original_online_key"] = key
            return s2
        except Exception:
            return None

    # 1) In-memory list (same Kodi invocation)
    try:
        if title_key and isinstance(CURRENT_SERIES_LIST, list) and CURRENT_SERIES_LIST:
            for s in CURRENT_SERIES_LIST:
                if isinstance(s, dict) and _entry_title(s) == title_key:
                    return s
        if pid_hash and isinstance(CURRENT_SERIES_LIST, list) and CURRENT_SERIES_LIST:
            for s in CURRENT_SERIES_LIST:
                if not isinstance(s, dict):
                    continue
                if _payload_pid_matches(s, pid_hash):
                    return s
    except Exception:
        pass

    # 2) Local cached list (works across invocations)
    series_list = []
    remote_url = ""
    try:
        scheme, ftp_host, base_path = _normalize_remote_base()
        if ftp_host:
            remote_url = f"{scheme}://{ftp_host}{base_path}{KODI_SERIES_JSON_FILENAME}"
            series_list = _get_series_list_cached(remote_url) or []
        if not series_list:
            series_list = _load_series_cache() or []
        # Last resort: force a fresh GET (no-cache), so recovery also works without Kodi restart.
        if not series_list and remote_url:
            try:
                req = common.get_authenticated_request(remote_url)
                if req:
                    try:
                        req.add_header("Cache-Control", "no-cache")
                        req.add_header("Pragma", "no-cache")
                    except Exception:
                        pass
                    with common.retry_urlopen(req, context=common.get_ssl_context(), timeout=12, retries=1, retry_delay=2.0) as r:
                        if int(r.getcode() or 0) == 200:
                            raw = r.read() or b""
                            fresh = json.loads(raw.decode("utf-8-sig", errors="replace"))
                            if isinstance(fresh, list):
                                series_list = fresh
                                try:
                                    _save_series_cache(series_list)
                                except Exception:
                                    pass
                                xbmc.log(
                                    f"[plugin.video.glotzbox series.py] _recover_serie_nav_payload: forced remote list fetch OK ({len(series_list)} entries)",
                                    xbmc.LOGINFO
                                )
            except Exception as e_force:
                xbmc.log(
                    f"[plugin.video.glotzbox series.py] _recover_serie_nav_payload: forced remote fetch failed: {e_force}",
                    xbmc.LOGWARNING
                )
    except Exception as e:
        xbmc.log(f"[plugin.video.glotzbox series.py] _recover_serie_nav_payload: series list load failed: {e}", xbmc.LOGWARNING)
        series_list = []

    if title_key and series_list:
        for s in series_list:
            if isinstance(s, dict) and _entry_title(s) == title_key:
                return s

    # 3) pid hash fallback (best effort)
    if pid_hash and series_list:
        try:
            for s in series_list:
                if not isinstance(s, dict):
                    continue
                # pass 1: raw entry (online json)
                if _payload_pid_matches(s, pid_hash):
                    return s
                # pass 2: entry as rendered in check_series_json (source + original_online_key)
                candidate = _build_online_nav_candidate(s)
                if candidate and _payload_pid_matches(candidate, pid_hash):
                    return candidate
        except Exception:
            pass

    return None



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: einige Skins erwarten zusätzlich Properties statt Art()
    try:
        fa_prop = art.get("fanart") or art.get("tvshow.fanart")
        if fa_prop:
            li.setProperty("fanart_image", fa_prop)
            li.setProperty("Fanart_Image", fa_prop)
    except Exception:
        pass

    # HINWEIS: Container-Fanart wird NICHT hier gesetzt.
    # Das soll einmalig pro View (check_series_json / show_seasons / show_episodes)
    # geschehen, nicht fuer jedes einzelne ListItem – sonst ueberflüssige Aufrufe.


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 _parse_added_datetime(value):
    """Robustes Parsing für serverseitige added-Werte (ISO-8601, mit/ohne Z/Offset)."""
    raw = str(value or "").strip()
    if not raw:
        return datetime(1970, 1, 1)

    # 1) Bevorzugt echte ISO-Verarbeitung inklusive Zeitzonenoffset
    iso_raw = raw
    if iso_raw.endswith("Z"):
        iso_raw = iso_raw[:-1] + "+00:00"
    try:
        dt = datetime.fromisoformat(iso_raw)
        if dt.tzinfo is not None:
            try:
                dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
            except Exception:
                dt = dt.replace(tzinfo=None)
        return dt
    except Exception:
        pass

    # 2) Fallback: Kodi-/Legacy-Format ohne Offset normalisieren
    raw = raw.replace("T", " ").strip()
    if raw.endswith("Z"):
        raw = raw[:-1].strip()
    raw = re.sub(r"([+-]\d{2}:?\d{2})$", "", raw).strip()

    for fmt in ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
        try:
            return datetime.strptime(raw, fmt)
        except Exception:
            continue
    return datetime(1970, 1, 1)


def _normalize_added_for_kodi(value):
    """Kodi bekommt immer ein sauberes 'YYYY-MM-DD HH:MM:SS'."""
    raw = str(value or "").strip()
    if not raw:
        return ""
    try:
        return _parse_added_datetime(raw).strftime("%Y-%m-%d %H:%M:%S")
    except Exception:
        pass

    raw = raw.replace("T", " ").strip()
    if raw.endswith("Z"):
        raw = raw[:-1].strip()
    raw = re.sub(r"([+-]\d{2}:?\d{2})$", "", raw).strip()
    return raw


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 _vfs_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(xbmcvfs.translatePath(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()
    migrated_any = False
    for s in (series_list or []):
        details_url_val = s.get("details_url")
        if not details_url_val:
            continue
        cache_key, entry, migrated = _progress_cache_entry(
            progress_cache,
            details_url_val,
            s.get("original_online_key") or "",
        )
        if migrated:
            migrated_any = True
        outdated = False
        if not entry:
            outdated = True
        else:
            try:
                ts = float(entry.get("ts", 0.0))
            except Exception:
                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))
    if migrated_any:
        try:
            _save_progress_cache(progress_cache)
        except Exception:
            pass
    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):
    """Stub – kein Addon-eigener Image-Prefetch mehr. Kodi Texture-Cache wird genutzt."""
    pass



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() or BG_PREFETCH_STOP.is_set()):

                # 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)

                # (Image-Prefetch entfernt – Kodi Texture-Cache wird genutzt)

                # Sleep im Intervall, aber in kurzen Schritten, damit STOP/Abort sofort greift
                sleep_total = int(BACKGROUND_REFRESH_INTERVAL_S)
                slept = 0
                while slept < sleep_total:
                    if monitor.abortRequested() or BG_PREFETCH_STOP.is_set():
                        return
                    # BG_PREFETCH_STOP.wait() reagiert sofort wenn das Event gesetzt wird
                    if BG_PREFETCH_STOP.wait(timeout=1.0):
                        return
                    if monitor.abortRequested():
                        return
                    slept += 1

        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 _vfs_exists(SERIES_PROGRESS_CACHE_FILE):
                _vfs_delete_quiet(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 = common.get_ssl_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 = common.get_authenticated_request(durl)
                if not req:
                    continue

                with common.retry_urlopen(req, context=context, timeout=timeout_s, retries=1, retry_delay=2.0) 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_entry(progress_cache, durl, cache_key)
                old_completed = None
                if isinstance(old, dict):
                    old_completed = bool(old.get("completed"))

                # UI-default without cache entry is "not completed".
                # Trigger reload only for visible completion changes.
                new_completed = bool(completed)
                if old_completed is None:
                    if new_completed:
                        changed = True
                elif old_completed != new_completed:
                    changed = True

                progress_cache[cache_key] = {
                    "watched": int(w),
                    "total": int(t),
                    "completed": new_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()


def _fill_progress_sync(entries, watch_sig, watched_status, save_every=10, timeout_s=8):
    """Compute progress cache synchronously for all passed entries.

    This is used when opening the series root list so completed/watched status
    is available on first render without a follow-up Container refresh.
    """
    if not entries or not watched_status:
        return _load_progress_cache(), 0, 0, 0

    progress_cache = _load_progress_cache()
    done = 0
    failed = 0
    skipped = 0
    t_start = time.monotonic()
    max_block_s = max(0.1, float(SYNC_PROGRESS_MAX_BLOCK_S or 0.0))

    for idx, (name, durl, cache_key) in enumerate(entries or []):
        if (time.monotonic() - t_start) >= max_block_s:
            skipped = max(0, len(entries) - idx)
            break
        try:
            if not durl or not cache_key:
                continue

            # Reuse existing details cache logic (memory/disk/network fallback).
            dj = load_series_details_json(durl, timeout_s=timeout_s) or {}
            if not isinstance(dj, dict) or not dj:
                failed += 1
                continue

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

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

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

    if done:
        _save_progress_cache(progress_cache)

    return progress_cache, done, failed, skipped


# ---- 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 Exception:
        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 Exception:
        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] _season_progress_from_details: Keine Episoden gefunden (season_data leer/ohne episodes)', xbmc.LOGDEBUG)

    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", "staffel")) 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'
KODI_SERIES_JSON_FILENAME = "kodiSeries.json"  # Dateiname für die Hauptserienliste

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

# 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 = {}
_SERIES_DETAILS_CACHE_LOCK = threading.RLock()


def _series_details_cache_get(key, default=None):
    try:
        with _SERIES_DETAILS_CACHE_LOCK:
            return SERIES_DETAILS_CACHE.get(key, default)
    except Exception:
        return default


def _series_details_cache_set(key, value):
    try:
        with _SERIES_DETAILS_CACHE_LOCK:
            SERIES_DETAILS_CACHE[key] = value
    except Exception:
        pass


def _series_details_cache_pop(key, default=None):
    try:
        with _SERIES_DETAILS_CACHE_LOCK:
            return SERIES_DETAILS_CACHE.pop(key, default)
    except Exception:
        return default


def _series_details_cache_clear():
    try:
        with _SERIES_DETAILS_CACHE_LOCK:
            SERIES_DETAILS_CACHE.clear()
    except Exception:
        pass

# Tracks last 'open-time' validation per details_url to avoid repeated IO in quick succession
_DETAILS_OPEN_STATE = {}  # details_key -> {'ts': float, 'sha1': str|None}


# 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_PREFETCH_TRIGGER_PROP = f"{common.addon_id}.series.details.prefetch.trigger"
_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):
            _series_details_cache_clear()
            # 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 = ""
SERIES_DETAILS_META_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, SERIES_DETAILS_META_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_META_FILE
                or not SERIES_DETAILS_JSON_CACHE_DIR.startswith(base)
                or not SERIES_SEEN_URLS_FILE.startswith(base)
                or not SERIES_DETAILS_META_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')

            SERIES_DETAILS_META_FILE = os.path.join(base, 'series_details_meta.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

        # Single source of truth for schema invalidation: _ensure_details_cache_schema()
        # uses .schema_version inside SERIES_DETAILS_JSON_CACHE_DIR.
        # Remove legacy schema file (details_cache_schema.txt in base dir) to avoid
        # double-invalidation on upgrades.
        try:
            legacy_schema = os.path.join(base, 'details_cache_schema.txt')
            if os.path.exists(legacy_schema):
                os.remove(legacy_schema)
        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.

    Implementation lives in common.details_url_key() so service.py, favorites.py
    and series.py always agree on cache keys.
    """
    try:
        return common.details_url_key(details_url)
    except Exception:
        return (details_url or '').strip()


# =====================================================================================
# SECTION: DETAILS-CACHE SYSTEM
# =====================================================================================
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 _prune_stale_details_cache(current_keys: set, max_delete: int = 600):
    """Entfernt stale details-Cache/index Eintraege fuer nicht mehr gelistete Serien."""
    _init_details_cache_paths()
    if not current_keys or not isinstance(current_keys, set):
        return

    try:
        keep_hashes = set()
        for key in current_keys:
            if not isinstance(key, str):
                continue
            k = key.strip()
            if not k:
                continue
            try:
                keep_hashes.add(hashlib.sha1(k.encode("utf-8", "ignore")).hexdigest())
            except Exception:
                pass

        if not keep_hashes:
            return

        removed_cache = 0
        if SERIES_DETAILS_JSON_CACHE_DIR and _path_exists(SERIES_DETAILS_JSON_CACHE_DIR):
            try:
                _dirs, files = xbmcvfs.listdir(SERIES_DETAILS_JSON_CACHE_DIR)
            except Exception:
                try:
                    files = os.listdir(SERIES_DETAILS_JSON_CACHE_DIR)
                except Exception:
                    files = []

            for fn in (files or []):
                if not isinstance(fn, str):
                    continue
                lfn = fn.lower().strip()
                if not lfn.endswith('.json'):
                    continue
                stem = os.path.splitext(lfn)[0]
                if len(stem) != 40 or stem in keep_hashes:
                    continue
                _vfs_delete_quiet(os.path.join(SERIES_DETAILS_JSON_CACHE_DIR, fn), with_sidecars=True)
                removed_cache += 1
                if removed_cache >= int(max_delete):
                    break

        removed_meta = 0
        try:
            if SERIES_DETAILS_META_FILE and _path_exists(SERIES_DETAILS_META_FILE):
                mm = _safe_json_read_vfs(SERIES_DETAILS_META_FILE, default={})
                if isinstance(mm, dict):
                    new_mm = {k: v for k, v in mm.items() if isinstance(k, str) and k in current_keys}
                    removed_meta = max(0, len(mm) - len(new_mm))
                    if removed_meta:
                        _atomic_write_json_vfs(SERIES_DETAILS_META_FILE, new_mm, indent=None, keep_bak=True)
        except Exception:
            pass

        removed_sha = 0
        try:
            sha_map = common.load_details_sha1_map() or {}
            if isinstance(sha_map, dict):
                new_sha = {}
                for k, v in sha_map.items():
                    if not isinstance(k, str):
                        continue
                    if k in current_keys:
                        new_sha[k] = v
                        continue
                    if not (k.startswith("http://") or k.startswith("https://")):
                        new_sha[k] = v
                removed_sha = max(0, len(sha_map) - len(new_sha))
                if removed_sha:
                    common.save_details_sha1_map(new_sha)
        except Exception:
            pass

        removed_mem = 0
        try:
            stale = [k for k in list(SERIES_DETAILS_CACHE.keys()) if isinstance(k, str) and k not in current_keys]
            for k in stale:
                SERIES_DETAILS_CACHE.pop(k, None)
            removed_mem = len(stale)
        except Exception:
            pass

        try:
            stale_open = [k for k in list(_DETAILS_OPEN_STATE.keys()) if isinstance(k, str) and k not in current_keys]
            for k in stale_open:
                _DETAILS_OPEN_STATE.pop(k, None)
        except Exception:
            pass

        if removed_cache or removed_meta or removed_sha or removed_mem:
            xbmc.log(
                f"[{common.addon_id} series.py] [details-cache] stale prune: cache={removed_cache} meta={removed_meta} sha={removed_sha} mem={removed_mem}",
                xbmc.LOGINFO,
            )

    except Exception as e:
        xbmc.log(f"[{common.addon_id} series.py] [details-cache] stale prune failed: {e}", xbmc.LOGDEBUG)

# --- Details-Meta: optional revalidation on open (fix: new episodes without series list change) ---
_details_meta_lock = threading.Lock()

def _load_details_meta_map() -> dict:
    """Map: details_key -> {checked_ts, etag, last_modified, content_length, ...}"""
    _init_details_cache_paths()
    if not SERIES_DETAILS_META_FILE:
        return {}
    with _details_meta_lock:
        meta = _safe_json_read_vfs(SERIES_DETAILS_META_FILE, default={})
        return meta if isinstance(meta, dict) else {}

def _save_details_meta_map(meta: dict) -> None:
    _init_details_cache_paths()
    if not SERIES_DETAILS_META_FILE or not isinstance(meta, dict):
        return
    with _details_meta_lock:
        try:
            _atomic_write_json_vfs(SERIES_DETAILS_META_FILE, meta, indent=None, keep_bak=True)
        except Exception:
            pass

def _head_details_meta(details_url: str, timeout_s: int = 6) -> dict:
    """Return remote headers for details.json via HEAD (best-effort)."""
    try:
        req = common.get_authenticated_request(details_url)
        if not req:
            raise ValueError("Auth request failed")
        try:
            req.add_header("Cache-Control", "no-cache")
            req.add_header("Pragma", "no-cache")
        except Exception:
            pass
        # Force method to HEAD (urllib compat)
        try:
            req.method = "HEAD"  # type: ignore[attr-defined]
        except Exception:
            pass
        try:
            req.get_method = lambda: "HEAD"
        except Exception:
            pass
        with common.retry_urlopen(req, context=common.get_ssl_context(), timeout=timeout_s, retries=1, retry_delay=2.0) as r:
            h = r.headers
            return {
                "etag": (h.get("ETag") or "").strip(),
                "last_modified": (h.get("Last-Modified") or "").strip(),
                "content_length": (h.get("Content-Length") or "").strip(),
                "code": int(r.getcode() or 0),
            }
    except Exception as e:
        return {"error": str(e)}

def _details_open_needs_wait(details_url: str, ttl_s: int = 60) -> bool:
    """Heuristic: show a short 'busy' dialog when we might revalidate/download."""
    try:
        _init_details_cache_paths()
        details_key = _details_url_key(details_url)
        cpath = _details_cache_path(details_key)
        if not _path_exists(cpath):
            return True
        meta = _load_details_meta_map()
        ent = meta.get(details_key, {}) if isinstance(meta, dict) else {}
        last_check = int(ent.get("checked_ts") or 0)
        return (int(time.time()) - last_check) >= int(ttl_s)
    except Exception:
        return True

def load_series_details_json_open(details_url, timeout_s=20, expected_sha1=None, serie_name="", revalidate_ttl_s=60):
    """
    UI-safe wrapper around load_series_details_json().

    - Uses `expected_sha1` (from series list `details_sha1`) to detect changes.
    - Avoids repeated work when user enters/exits the same series quickly (TTL).
    - Shows a busy dialog while potential IO happens (network/disk).
    """
    if not details_url:
        return {}

    details_key = _details_url_key(details_url)
    now = time.time()

    # Fast-path: if we validated recently and the in-memory cache exists, return it.
    try:
        st = _DETAILS_OPEN_STATE.get(details_key) or {}
        last_ts = float(st.get("ts") or 0.0)
        last_sha = st.get("sha1")
        if (now - last_ts) < float(revalidate_ttl_s):
            cached = _series_details_cache_get(details_key)
            if isinstance(cached, dict) and cached:
                if expected_sha1:
                    if last_sha == expected_sha1:
                        return cached
                else:
                    return cached
    except Exception:
        pass

    busy_shown = False
    try:
        xbmc.executebuiltin("ActivateWindow(busydialognocancel)")
        busy_shown = True
    except Exception:
        busy_shown = False

    try:
        # Attempt to load (and refresh if needed). Small retry to tolerate concurrent writes.
        result = {}
        mon = None
        try:
            mon = xbmc.Monitor()
        except Exception:
            mon = None
        effective_timeout_s = max(3, min(int(timeout_s or 8), 8))
        for attempt in range(2):
            result = load_series_details_json(details_url, timeout_s=effective_timeout_s, expected_sha1=expected_sha1) or {}
            if isinstance(result, dict) and result:
                break
            if attempt == 0:
                if common.wait_or_abort(mon, 0.2):
                    return {}

        # Mark as validated (even if empty; avoids UI loops on intermittent network).
        try:
            _DETAILS_OPEN_STATE[details_key] = {"ts": now, "sha1": expected_sha1}
        except Exception:
            pass

        return result if isinstance(result, dict) else {}
    finally:
        if busy_shown:
            try:
                xbmc.executebuiltin("Dialog.Close(busydialognocancel)")
            except Exception:
                try:
                    xbmc.executebuiltin("Dialog.Close(busydialog)")
                except Exception:
                    pass

def load_series_details_json(details_url: str, timeout_s: int = 20, expected_sha1: Optional[str] = None, revalidate: bool = False, revalidate_ttl_s: int = 60) -> dict:
    """Lädt details.json mit Memory/Disk/Network Cache.

    Neu:
      - Wenn expected_sha1 (aus kodiSeries.json/details_sha1) gesetzt ist und NICHT zur lokal
        gespeicherten Hash-Map passt, wird der Disk/Mem-Cache bewusst ignoriert und die Datei
        sofort neu vom Server geholt.

    Wichtig:
      - Fehlversuche werden NICHT dauerhaft als False gecached (damit ein kurzer HTTP/Netz-Fehler
        nicht dazu führt, dass Staffeln dauerhaft "leer" bleiben).
    """
    _init_details_cache_paths()
    _maybe_invalidate_details_memory_cache()
    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 {}

    exp = (expected_sha1 or "").strip().lower()
    if exp and not (len(exp) == 40 and all(c in "0123456789abcdef" for c in exp)):
        exp = ""

    # Erwartete Hashes (von kodiSeries/seriesFav) vs lokal
    force_refresh = False
    try:
        m = common.load_details_sha1_map() or {}
        local_h = (m.get(details_key) or "").strip().lower()
        if (not local_h) and details_url:
            try:
                legacy_k = common.details_url_key_legacy_sha1(details_url)
                legacy_h = (m.get(legacy_k) or "").strip().lower() if legacy_k else ""
                if legacy_h:
                    local_h = legacy_h
                    # migrate -> neuer Key
                    try:
                        m[details_key] = legacy_h
                        if legacy_k:
                            m.pop(legacy_k, None)
                        common.save_details_sha1_map(m)
                    except Exception:
                        pass
            except Exception:
                pass
        if exp and local_h and local_h != exp:
            force_refresh = True
        elif exp and not local_h:
            # unknown locally -> treat as refresh if disk not present
            force_refresh = False
    except Exception:
        force_refresh = False

    if force_refresh:
        _series_details_cache_pop(details_key, None)

    # 1) Memory (nur wenn es ein nicht-leeres dict ist)
    if not force_refresh:
        try:
            v = _series_details_cache_get(details_key)
            if isinstance(v, dict) and v:
                return v
            if v is False:
                _series_details_cache_pop(details_key, None)
        except Exception:
            pass

    # 2) Disk
    cpath = _details_cache_path(details_key)
    if not force_refresh and cpath and _path_exists(cpath):
        try:
            cached = _safe_json_read_vfs(cpath, default={})
            if isinstance(cached, dict) and cached:
                # Optional: revalidate details.json on open (fix: new episodes without series list change)
                if revalidate:
                    try:
                        now = int(time.time())
                        ttl = int(revalidate_ttl_s or 0) if revalidate_ttl_s is not None else 0
                        if ttl <= 0:
                            ttl = 60

                        meta = _load_details_meta_map()
                        ent = meta.get(details_key, {}) if isinstance(meta, dict) else {}
                        last_check = int(ent.get("checked_ts") or 0)

                        # Avoid re-checking too often (per series)
                        if (now - last_check) >= ttl:
                            # If we have no prior meta markers, refresh once for older caches
                            had_prev = bool(
                                (ent.get("etag") or "").strip()
                                or (ent.get("last_modified") or "").strip()
                                or (ent.get("content_length") or "").strip()
                            )
                            cache_age = now - int(_vfs_mtime(cpath) or 0)
                            changed = True if (not had_prev and cache_age > 30) else False

                            head = _head_details_meta(details_url, timeout_s=max(3, min(8, int(timeout_s // 4) or 6)))
                            if isinstance(head, dict) and "error" not in head:
                                new_etag = (head.get("etag") or "").strip()
                                new_lm = (head.get("last_modified") or "").strip()
                                new_cl = (head.get("content_length") or "").strip()
                                old_etag = (ent.get("etag") or "").strip()
                                old_lm = (ent.get("last_modified") or "").strip()
                                old_cl = (ent.get("content_length") or "").strip()

                                if had_prev:
                                    same = True
                                    if new_etag and old_etag and new_etag != old_etag:
                                        same = False
                                    if new_lm and old_lm and new_lm != old_lm:
                                        same = False
                                    if new_cl and old_cl and new_cl != old_cl:
                                        same = False
                                    changed = not same

                                meta[details_key] = {
                                    **(ent if isinstance(ent, dict) else {}),
                                    "checked_ts": now,
                                    "etag": new_etag or old_etag,
                                    "last_modified": new_lm or old_lm,
                                    "content_length": new_cl or old_cl,
                                    "head_code": int(head.get("code") or 0),
                                }
                                _save_details_meta_map(meta)
                            else:
                                # HEAD failed -> still mark checked_ts, then try refresh best-effort
                                try:
                                    meta[details_key] = {
                                        **(ent if isinstance(ent, dict) else {}),
                                        "checked_ts": now,
                                        "head_error": str(head.get("error")) if isinstance(head, dict) else "head_error",
                                    }
                                    _save_details_meta_map(meta)
                                except Exception:
                                    pass
                                changed = True

                            if changed:
                                xbmc.log(f"[plugin.video.glotzbox series.py] details revalidate: remote changed or unknown -> refresh now. key={details_key}", xbmc.LOGINFO)
                                force_refresh = True
                    except Exception:
                        pass

                if not force_refresh:
                    _series_details_cache_set(details_key, cached)
                    return cached
        except Exception:
            pass
    # 3) Network
    #
    # Fallback-Regel: Wenn das Netzwerk zickt (Timeout, DNS-Fehler, 5xx) und
    # wir dadurch KEINE frischen Daten bekommen, geben wir trotzdem den alten
    # Disk-Cache zurück - sofern vorhanden. Das ist wichtig bei force_refresh:
    # sonst bekommt der User "leere Staffelliste" nur weil das Netz kurz weg war,
    # obwohl auf der Disk noch eine zwar veraltete, aber funktionierende
    # details.json liegt. Der nächste erfolgreiche Fetch aktualisiert sie dann.
    def _load_disk_fallback():
        try:
            if cpath and _path_exists(cpath):
                cached_fb = _safe_json_read_vfs(cpath, default={})
                if isinstance(cached_fb, dict) and cached_fb:
                    # Memory-Cache wieder füllen, damit die nächste Abfrage schnell ist
                    try:
                        _series_details_cache_set(details_key, cached_fb)
                    except Exception:
                        pass
                    xbmc.log(
                        f"[plugin.video.glotzbox series.py] Details: Netzwerk-Fetch fehlgeschlagen, "
                        f"liefere veralteten Disk-Cache als Fallback (key={details_key}).",
                        xbmc.LOGWARNING,
                    )
                    return cached_fb
        except Exception:
            pass
        return None

    try:
        req = common.get_authenticated_request(details_url)
        if not req:
            raise ValueError("Auth Request fehlgeschlagen.")
        # Bei sha1-Divergenz bewusst "no-cache" mitsenden
        try:
            req.add_header("Cache-Control", "no-cache")
            req.add_header("Pragma", "no-cache")
        except Exception:
            pass

        with common.retry_urlopen(req, context=common.get_ssl_context(), timeout=timeout_s, retries=1, retry_delay=2.0) as r:
            if r.getcode() == 200:
                raw = r.read() or b""
                # Meta aktualisieren (für Revalidate)
                try:
                    now = int(time.time())
                    meta = _load_details_meta_map()
                    ent = meta.get(details_key, {}) if isinstance(meta, dict) else {}
                    meta[details_key] = {
                        **(ent if isinstance(ent, dict) else {}),
                        "checked_ts": now,
                        "fetched_ts": now,
                        "etag": (r.headers.get("ETag") or "").strip() or (ent.get("etag") or ""),
                        "last_modified": (r.headers.get("Last-Modified") or "").strip() or (ent.get("last_modified") or ""),
                        "content_length": (r.headers.get("Content-Length") or "").strip() or (ent.get("content_length") or ""),
                        "head_code": int(r.getcode() or 0),
                    }
                    _save_details_meta_map(meta)
                except Exception:
                    pass

                body_sha = common.sha1_hex(raw)
                tmp = json.loads(raw.decode("utf-8-sig", errors="replace"))
                if isinstance(tmp, dict) and tmp:
                    _series_details_cache_set(details_key, tmp)
                    # disk write best-effort
                    try:
                        if cpath:
                            _atomic_write_json_vfs(cpath, tmp, indent=None, keep_bak=True)
                    except Exception:
                        pass

                    # Hash-Map aktualisieren (exp bevorzugen, sonst body_sha)
                    try:
                        m = common.load_details_sha1_map() or {}
                        m[details_key] = (exp or body_sha or "")
                        common.save_details_sha1_map(m)
                    except Exception:
                        pass

                    return tmp

        # Response war nicht 200 oder der Body war leer/kein valides dict.
        # Statt {} zurückzugeben versuchen wir den Disk-Cache als Fallback.
        fb = _load_disk_fallback()
        if fb is not None:
            return fb
        return {}
    except Exception as e:
        xbmc.log(f"[plugin.video.glotzbox series.py] Details Download Error: {e}", xbmc.LOGERROR)
        # Meta merken (damit nicht bei jedem Klick sofort wieder versucht wird)
        try:
            now = int(time.time())
            meta = _load_details_meta_map()
            ent = meta.get(details_key, {}) if isinstance(meta, dict) else {}
            meta[details_key] = {**(ent if isinstance(ent, dict) else {}), "checked_ts": now, "last_error": str(e)}
            _save_details_meta_map(meta)
        except Exception:
            pass

        # Netzwerk-Fehler -> Disk-Cache als Fallback versuchen, damit der User
        # wenigstens die veralteten Staffeln/Episoden sieht statt einer leeren Liste.
        fb = _load_disk_fallback()
        if fb is not None:
            return fb
        return {}
def _prefetch_details_worker(items: list):
    """items: list of dicts: {details_url, poster, backcover, expected_sha1}"""
    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, expected_sha1=(it.get('expected_sha1') or None))

                # (Artwork-Prefetch entfernt – Kodi Texture-Cache wird genutzt)
            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

        try:
            _prune_stale_details_cache(current_urls)
        except Exception:
            pass

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

        # Zusätzlich: details_sha1-Updates erkennen (wenn kodiSeries.json aktualisiert wurde).
        # Wenn local sha1 != expected sha1 => diese Serie zuerst prefetch'en.
        changed_urls = []
        try:
            sha1_map = common.load_details_sha1_map() or {}
            if not isinstance(sha1_map, dict):
                sha1_map = {}
            for s in series_list:
                if not isinstance(s, dict):
                    continue
                full = (s.get("details_url") or "").strip()
                if not full or not full.startswith("http"):
                    continue
                key = _details_url_key(full)
                exp = (s.get("details_sha1") or "").strip().lower()
                local = (sha1_map.get(key) or "").strip().lower()
                if exp and local and local != exp:
                    changed_urls.append(key)
        except Exception:
            pass

        if changed_urls:
            # Priorität: Updates zuerst, dann echte neue URLs; maximal 5 insgesamt
            cand = []
            seen2 = set()
            for k in (changed_urls + new_urls):
                if k and k not in seen2:
                    cand.append(k)
                    seen2.add(k)
            new_urls = cand[:5]
            xbmc.log(f"[{common.addon_id} series.py] Details-Prefetch: updates={len(changed_urls)} new={len([u for u in current_urls if u and u not in seen])} run={len(new_urls)}", xbmc.LOGDEBUG)

        # 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):
                return _parse_added_datetime((x or {}).get("added"))
            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 "",
                    "expected_sha1": (s.get("details_sha1") or "")
                })

        # seen aktualisieren (current snapshot)
        _save_seen_urls(current_urls)

        if not items:
            return

        # Nicht mehr synchron im UI-Thread laden.
        # Stattdessen Service triggern (reagiert jetzt schnell per Window-Property).
        try:
            xbmcgui.Window(10000).setProperty(_DETAILS_PREFETCH_TRIGGER_PROP, str(int(time.time())))
            xbmc.log(
                f"[{common.addon_id} series.py] Fehlende Details erkannt: {len(items)} -> details.json Prefetch via Service (async)",
                xbmc.LOGINFO,
            )
            return
        except Exception as e:
            xbmc.log(f"[{common.addon_id} series.py] Service-Prefetch trigger fehlgeschlagen: {e}", xbmc.LOGDEBUG)

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

        # Nicht parallel zum Service-Prefetch laufen: wenn der Service gerade
        # seinen Details-Prefetch ausführt, würden wir sonst doppelt dieselben
        # details.json laden. Property-Name muss synchron mit service.py bleiben
        # (dort: SERIES_PREFETCH_PROP = f"{common.addon_id}.series.details.prefetch.running").
        try:
            _svc_prop = f"{common.addon_id}.series.details.prefetch.running"
            if xbmcgui.Window(10000).getProperty(_svc_prop) == '1':
                xbmc.log(f"[{common.addon_id} series.py] Fallback-Prefetch skip: Service-Prefetch läuft bereits.", xbmc.LOGDEBUG)
                return
        except Exception as e:
            xbmc.log(f"[{common.addon_id} series.py] Fallback-Prefetch service-prop check failed: {e}", xbmc.LOGDEBUG)

        global _details_prefetch_thread
        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,), 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)

    # 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")

    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}] 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.", common.addon.getAddonInfo('icon'), 8000)
    WATCHED_STATUS_FILE = ""
    SERIES_REMOTE_META_FILE = ""
    SERIES_LOCAL_CACHE_FILE = ""


# ----------------------------
# Funktionen für den Bild-Cache (entfernt – Kodi Texture-Cache wird genutzt)
# -------------------------------------------------------------------

def get_cache_filename(url):
    """Stub – kein Addon-eigener Image-Cache mehr."""
    return ""

def get_cached_image(url):
    """Stub – kein Addon-eigener Image-Cache mehr."""
    return ""

def download_and_cache_image(url, timeout_s=15):
    """Stub – kein Addon-eigener Image-Cache mehr."""
    return ""

# ----------------------------
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 or not isinstance(series_list, list):
        return False
    try:
        return bool(_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)
        return False




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")

    cached = _load_series_cache()
    try:
        meta = common.safe_load_json_file(meta_path, default={})
        if not isinstance(meta, dict):
            meta = {}
    except Exception:
        meta = {}
    local_sha = str(meta.get("sha1") or "").strip()

    # NEW: prefer tiny .sha1 sidecar to avoid downloading the full list when unchanged
    remote_sha = None
    try:
        remote_sha = common.fetch_remote_sha1(url, timeout_s=8)
    except Exception:
        remote_sha = None

    if remote_sha and local_sha and remote_sha == local_sha and isinstance(cached, list):
        _log_series('Online-Serienliste unverändert (304/meta/sha1) – verwende lokalen Cache.', xbmc.LOGINFO)
        return cached

    status = "error"
    data_bytes = None
    hdrs = {}
    fetch_info = None

    # If sha says "changed", force a non-conditional GET (no accidental 304)
    if remote_sha:
        try:
            import urllib.request
            import ssl

            req = common.get_authenticated_request(url, headers={
                "Cache-Control": "no-cache",
                "Pragma": "no-cache",
            })
            with common.retry_urlopen(req, context=common.get_ssl_context(), timeout=12, retries=1, retry_delay=2.0) as resp:
                data_bytes = resp.read()
                try:
                    hdrs = dict(resp.headers.items())
                except Exception:
                    hdrs = {}
            status = "changed" if data_bytes is not None else "error"
        except Exception as e:
            _log_series(f"Series list fetch failed: {e}", xbmc.LOGWARNING)
            status = "error"
            data_bytes = None
    else:
        # Fallback: classic conditional fetch
        # Bei "changed" gibt conditional_fetch_bytes jetzt ein Meta-Dict statt eines
        # Debug-Strings zurück (Meta wird erst nach Schema-Prüfung gespeichert).
        status, data_bytes, fetch_info = common.conditional_fetch_bytes(url, meta_path, timeout_s=12)

    if status in ("not_modified", "unchanged"):
        if isinstance(cached, list):
            _log_series('Online-Serienliste unverändert (conditional) – verwende lokalen Cache.', xbmc.LOGINFO)
            return cached
        # No cache: we must fetch a full copy (non-conditional)
        try:
            import urllib.request
            import ssl
            req = common.get_authenticated_request(url, headers={
                "Cache-Control": "no-cache",
                "Pragma": "no-cache",
            })
            with common.retry_urlopen(req, context=common.get_ssl_context(), timeout=12, retries=1, retry_delay=2.0) as resp:
                data_bytes = resp.read()
                fetch_info = None
                try:
                    hdrs = dict(resp.headers.items())
                except Exception:
                    hdrs = {}
            status = "changed" if data_bytes is not None else "error"
        except Exception:
            data_bytes = None
            fetch_info = None
            status = "error"

    if status == 'changed' and data_bytes is not None:
        try:
            # tolerate UTF-8 BOM
            series_list = json.loads(data_bytes.decode('utf-8-sig'))
        except Exception:
            series_list = None
        if not isinstance(series_list, list):
            _log_series(f"Serienliste ist kein Array (Typ: {type(series_list).__name__}) – Cache bleibt erhalten.", xbmc.LOGWARNING)
            if isinstance(cached, list):
                return cached
            # Kein Cache vorhanden: leere Liste zurückgeben, aber NICHT cachen
            # und Meta NICHT aktualisieren (invalider Remote-Body).
            return []

        cache_ok = _save_series_cache(series_list)

        # Update meta nur wenn Cache-Write erfolgreich war
        if cache_ok:
            try:
                # Wenn conditional_fetch_bytes das Meta-Dict geliefert hat -> direkt speichern
                if isinstance(fetch_info, dict) and "sha1" in fetch_info:
                    common.save_conditional_meta(meta_path, fetch_info)
                else:
                    # Full-GET Pfad: Meta selbst zusammenbauen
                    body_sha = common.sha1_hex(data_bytes)
                    if remote_sha and body_sha and remote_sha != body_sha:
                        _log_series(f"WARN: .sha1 mismatch remote={remote_sha[:8]} local={body_sha[:8]}", xbmc.LOGWARNING)
                    _hdrs = hdrs if isinstance(hdrs, dict) else {}
                    meta_out = {
                        "url": url,
                        "fetched_at": time.time(),
                        "sha1": (remote_sha or body_sha),
                        "etag": _hdrs.get("ETag", meta.get("etag")),
                        "last_modified": _hdrs.get("Last-Modified", meta.get("last_modified")),
                        "content_length": _hdrs.get("Content-Length", meta.get("content_length")),
                    }
                    common.atomic_write_json(meta_path, meta_out, indent=2)
            except Exception:
                pass

            _log_series('Online-Serienliste geändert – Cache aktualisiert.', xbmc.LOGINFO)
        else:
            _log_series('Online-Serienliste geändert, aber Cache-Write fehlgeschlagen – Meta bleibt unverändert.', xbmc.LOGWARNING)
        return series_list

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


# ----------------------------
# Kontextmenü-Hilfsfunktion
# ----------------------------
def get_context_menu_items(serie):
    """
    Platzhalter – Kontextmenü-Optionen für Serien.
    Aktuell bleibt das leer (Download/Delete wurde mit dem Download-Feature entfernt).
    """
    return []




# ------------------------------------------------------------------------------------
# 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 = common.get_authenticated_request(url)
        if not req:
            raise ValueError(f"Auth Request fehlgeschlagen für {KODI_SERIES_JSON_FILENAME}.")
        with common.retry_urlopen(req, context=common.get_ssl_context(), timeout=20, retries=1, retry_delay=2.0) 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_cache_key = _details_url_key(details_url)
            details_json = _series_details_cache_get(details_cache_key) if details_cache_key else None
            if details_json is None:
                xbmc.log(
                    f"[{common.addon_id} series.py] Cache miss für details_url: {common.mask_url(details_url)}. Lade...",
                    xbmc.LOGDEBUG,
                )
                details_json = load_series_details_json(
                    details_url,
                    timeout_s=8,
                    expected_sha1=(random_serie_from_list.get("details_sha1") or None),
                    revalidate=False,
                ) or {}
                if not (isinstance(details_json, dict) and details_json):
                    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




# ----------------------------
# Codec-Hint Helper
# ----------------------------
def _series_codec_hint(explicit_codec=None, path_hint=""):
    """Ermittelt einen stabilen Codec-Hint für Routing/Playback.

    Delegiert an common.detect_video_codec() (erkennt x265/h265/hevc/hev1/hvc1/h.265).
    Priorität:
      1. expliziter video_codec-Wert aus Metadaten
      2. Fallback-Erkennung aus dem Dateipfad/Namen
    """
    try:
        return common.detect_video_codec(codec_hint=explicit_codec, path=path_hint or "") or ""
    except Exception:
        pass

    # Minimaler Fallback falls common nicht verfügbar
    try:
        s = str(path_hint or "").lower()
        if any(tok in s for tok in ("h265", "x265", "hevc", "hev1", "hvc1", "h.265")):
            return "h265"
        if any(tok in s for tok in ("h264", "x264", "avc")):
            return "h264"
    except Exception:
        pass
    return ""


def _play_random_series_with_metadata(
    *,
    play_path: str,
    serie_name: str,
    season_number: int,
    ep: dict,
    status_identifier: str,
    series_poster_raw: str = "",
    series_fanart_raw: str = "",
    series_genre: str = "",
    series_studio: str = "",
    fsk_value=None,
    series_vote_average=None,
    season_vote_average=None,
    series_imdb_votes: int = 0,
    series_tmdbid=None,
) -> bool:
    """Start random series playback with a fully populated ListItem.

    This keeps Plot/Artwork/Season/Episode/TVShowTitle visible in OSD/skins,
    similar to the random movie / favorite playback paths.
    """
    try:
        ep = ep if isinstance(ep, dict) else {}
        play_path = (play_path or "").strip()
        if not play_path:
            return False

        ep_num = _to_int(ep.get("episode_number"), 0)
        ep_title = ep.get("name") or f"Episode {ep_num or '?'}"
        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}"
        codec_hint = _series_codec_hint(ep.get("video_codec"), play_path)
        tmdb_id_norm = _normalize_series_tmdb_id(series_tmdbid)

        plugin_play_url = common.build_url({
            'action': 'play_series',
            'serie_key': play_path,
            'title': pretty,
            'status_id': status_identifier,
            'video_codec': codec_hint,
            'series_tmdbid': tmdb_id_norm,
            'tmdbid': tmdb_id_norm,
        })

        li = xbmcgui.ListItem(label=pretty, path=plugin_play_url, offscreen=True)
        li.setProperty("IsPlayable", "true")
        if codec_hint:
            li.setProperty("video_codec", str(codec_hint))
        if tmdb_id_norm:
            li.setProperty("TMDbId", str(tmdb_id_norm))

        info_tag = li.getVideoInfoTag()
        info_tag.setMediaType('episode')
        info_tag.setTitle(ep_title)
        info_tag.setOriginalTitle(ep_title)
        if serie_name:
            info_tag.setTvShowTitle(serie_name)
        if season_number != -1:
            try:
                info_tag.setSeason(int(season_number))
            except Exception:
                pass
        if ep_num:
            try:
                info_tag.setEpisode(int(ep_num))
            except Exception:
                pass

        ep_plot = ep.get("overview") or ""
        if ep_plot:
            try:
                info_tag.setPlot(ep_plot)
            except Exception:
                pass

        ep_air = ep.get("air_date") or ""
        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] == '-':
                    info_tag.setPremiered(ep_air)
                try:
                    info_tag.setYear(int(ep_air[:4]))
                except Exception:
                    pass
        except Exception:
            pass

        ep_rt = _to_int(ep.get("runtime"), 0)
        if ep_rt > 0:
            try:
                info_tag.setDuration(ep_rt * 60)
            except Exception:
                pass

        rating_val = None
        for rsrc in (ep.get("imdbRating"), ep.get("rating"), season_vote_average, series_vote_average):
            if rsrc in (None, ""):
                continue
            try:
                rating_val = float(str(rsrc).replace(",", "."))
                if rating_val > 0:
                    break
            except Exception:
                pass
        if rating_val and rating_val > 0:
            try:
                info_tag.setRating(rating_val)
            except Exception:
                pass

        votes = _to_int(ep.get("vote_count"), 0) or _to_int(ep.get("imdbVotes"), 0) or _to_int(series_imdb_votes, 0)
        if votes > 0:
            try:
                _safe_set_votes(info_tag, votes)
            except Exception:
                pass

        if series_genre:
            try:
                info_tag.setGenres([g.strip() for g in str(series_genre).split(',') if g.strip()])
            except Exception:
                pass
        if series_studio:
            try:
                info_tag.setStudios(_split_studios(series_studio))
            except Exception:
                pass
        if ep.get("directors"):
            try:
                info_tag.setDirectors([d.strip() for d in str(ep.get("directors")).split(',') if d.strip()])
            except Exception:
                pass
        if fsk_value is not None:
            try:
                fsk_s = str(fsk_value).strip()
                if fsk_s:
                    info_tag.setMpaa(f"FSK {fsk_s}")
            except Exception:
                pass
        if tmdb_id_norm:
            try:
                info_tag.setUniqueIDs({'tmdb': str(tmdb_id_norm)})
            except Exception:
                pass

        def _resolve_art(url_or_path):
            try:
                return common.resolve_artwork_for_listing(url_or_path, prefer_cache=False)
            except Exception:
                return url_or_path or ""

        still_final = _resolve_art(ep.get("still") or ep.get("still_path") or "")
        series_poster_final = _resolve_art(series_poster_raw)
        series_fanart_final = _resolve_art(series_fanart_raw)
        wallpaper_fanart = still_final or series_fanart_final or series_poster_final

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

        xbmc.Player().play(item=plugin_play_url, listitem=li)
        return True
    except Exception as e:
        xbmc.log(f"[{common.addon_id} series.py] random metadata prefill failed: {e}", xbmc.LOGDEBUG)
        return False



# ----------------------------
# Globale Zufallswiedergabe (aus allen Serien)
# ----------------------------

# =====================================================================================
# SECTION: RANDOM-PLAY
# =====================================================================================
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

    # Kandidaten bauen (nur Online-Serien; Local-Feature wurde entfernt)
    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)

    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)
    """


    # Hardening: params/title Fallback (nützlich falls PID-Cache mal fehlt)
    params = _get_query_params()
    title_hint = (params.get("title") or "").strip()

    pid_hash = None
    if isinstance(data_json_str, str) and data_json_str.startswith("pid:"):
        pid_hash = data_json_str.split(":", 1)[1].strip()

    # ✅ json.loads -> unpack_payload (mit Recovery)
    try:
        serie = unpack_payload(data_json_str, default={})
    except Exception:
        serie = {}

    if not isinstance(serie, dict) or not serie:
        recovered = _recover_serie_nav_payload(pid_hash=pid_hash, title_hint=title_hint)
        if isinstance(recovered, dict) and recovered:
            serie = recovered
            if pid_hash:
                _write_payload_pid_file(pid_hash, serie)
            xbmc.log(
                f"[plugin.video.glotzbox series.py] show_seasons: recovered missing PID cache pid={pid_hash} title='{title_hint}'",
                xbmc.LOGINFO
            )
        else:
            xbmcgui.Dialog().notification(
                "Glotzbox",
                "Seriendaten fehlen – bitte zurück und die Serie erneut öffnen.",
                common.addon.getAddonInfo('icon')
            )
            xbmcplugin.endOfDirectory(common.addon_handle, succeeded=True)
            return

    source_type = (str(serie.get("source", "online") or "online")).strip().lower()
    serie_name = (serie.get("title") or serie.get("name") or title_hint or "Unbekannte Serie").replace(" (lokal)", "").strip()
    details_json = {}

    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

    # Für Player-InfoTag/Skins den Seriennamen mitschreiben.
    try:
        if serie_name:
            xbmcgui.Window(10000).setProperty("glotzbox.last_played_tvshow_title", serie_name)
    except Exception:
        pass

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

    # Extra hardening: details_url auflösen, falls im Payload nicht enthalten
    if not details_url_val:
        recovered = _recover_serie_nav_payload(pid_hash=None, title_hint=title_hint or serie.get("title"))
        if isinstance(recovered, dict) and recovered and recovered.get("details_url"):
            serie.setdefault("title", recovered.get("title") or title_hint)
            serie["details_url"] = recovered.get("details_url")
            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 = _progress_cache_key(details_url_val, original_serie_key)
        if cache_key:
            xbmcgui.Window(10000).setProperty("glotzbox.last_series_cache_key", cache_key)
    except Exception:
        pass

    # --- Details laden (online) oder lokal rekonstruieren ---
    details_cache_key = _details_url_key(details_url_val) if details_url_val else ""

    if details_url_val and source_type not in ["local", "local_only"]:
        try:
            cached_details = _series_details_cache_get(details_cache_key) if details_cache_key else None
            if isinstance(cached_details, dict) and cached_details:
                details_json = cached_details
            elif cached_details is not False:
                details_json = load_series_details_json_open(
                    details_url_val,
                    timeout_s=8,
                    expected_sha1=(serie.get("details_sha1") or None),
                    serie_name=serie_name,
                    revalidate_ttl_s=60,
                ) or {}
        except Exception as e:
            xbmc.log(f"[{common.addon_id} series.py] Random-Serie: Details online laden fehlgeschlagen: {e}", xbmc.LOGWARNING)

    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", "staffel"))):
            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}"
    series_info_node = details_json.get("series_info") if isinstance(details_json.get("series_info"), dict) else {}
    details_art = details_json.get("art") if isinstance(details_json.get("art"), dict) else {}
    series_poster_raw = (
        serie.get("poster")
        or serie.get("series_poster")
        or series_info_node.get("poster")
        or series_info_node.get("tvshow_poster")
        or details_json.get("series_poster")
        or details_json.get("tvshow_poster")
        or details_json.get("poster")
        or details_art.get("poster")
        or details_art.get("tvshow.poster")
        or ""
    )
    series_fanart_raw = (
        serie.get("backcover")
        or serie.get("fanart")
        or serie.get("series_fanart")
        or series_info_node.get("fanart")
        or series_info_node.get("backcover")
        or series_info_node.get("tvshow_fanart")
        or details_json.get("series_fanart")
        or details_json.get("tvshow_fanart")
        or details_json.get("fanart")
        or details_json.get("backcover")
        or details_art.get("fanart")
        or details_art.get("tvshow.fanart")
        or ""
    )
    series_genre = serie.get("genre") or series_info_node.get("genre") or ""
    series_studio = serie.get("studio") or series_info_node.get("studio") or ""
    fsk_value = serie.get("fsk") or series_info_node.get("fsk")
    series_vote_average = serie.get("tmdbRating") or serie.get("imdbRating") or series_info_node.get("vote_average") or series_info_node.get("rating")
    season_vote_average = None
    try:
        skey = f"season{season_number}" if season_number != -1 else ""
        season_node = details_json.get(skey) if skey and isinstance(details_json.get(skey), dict) else {}
        season_vote_average = season_node.get("vote_average") or season_node.get("rating")
    except Exception:
        season_vote_average = None
    series_imdb_votes = _to_int(serie.get("imdbVotes"))
    series_tmdbid = _normalize_series_tmdb_id(serie.get("series_tmdbid") or serie.get("tmdbid") or serie.get("tmdb_id") or series_info_node.get("tmdbid") or series_info_node.get("tmdb_id"))

    try:
        if _play_random_series_with_metadata(
            play_path=play_path,
            serie_name=serie_name,
            season_number=season_number,
            ep=ep,
            status_identifier=status_identifier,
            series_poster_raw=series_poster_raw,
            series_fanart_raw=series_fanart_raw,
            series_genre=series_genre,
            series_studio=series_studio,
            fsk_value=fsk_value,
            series_vote_average=series_vote_average,
            season_vote_average=season_vote_average,
            series_imdb_votes=series_imdb_votes,
            series_tmdbid=series_tmdbid,
        ):
            return
        play_series(play_path, pretty, status_identifier, _series_codec_hint(ep.get("video_codec"), play_path), series_tmdbid=series_tmdbid)
    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 = _progress_cache_key(
            payload.get("details_url") or "",
            payload.get("online_serie_key") or "",
        )
        if cache_key:
            xbmcgui.Window(10000).setProperty("glotzbox.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:
            xbmcgui.Window(10000).setProperty("glotzbox.last_played_tvshow_title", 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}"

    try:
        if _play_random_series_with_metadata(
            play_path=play_path,
            serie_name=serie_name,
            season_number=season_number,
            ep=ep,
            status_identifier=status_identifier,
            series_poster_raw=payload.get("series_poster") or "",
            series_fanart_raw=payload.get("series_fanart") or "",
            series_genre=payload.get("series_genre") or "",
            series_studio=payload.get("series_studio") or "",
            fsk_value=payload.get("fsk_from_series"),
            series_vote_average=payload.get("series_vote_average_from_series"),
            season_vote_average=payload.get("season_vote_average_from_season"),
            series_imdb_votes=_to_int(payload.get("series_imdb_votes"), 0),
            series_tmdbid=payload.get("series_tmdbid"),
        ):
            return
        play_series(
            play_path,
            pretty,
            status_identifier,
            _series_codec_hint(ep.get("video_codec"), play_path),
            series_tmdbid=payload.get("series_tmdbid"),
        )
    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

# =====================================================================================
# SECTION: HAUPTNAVIGATION: Serienliste
# =====================================================================================
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

    # Verbindungscheck: bei fehlender Server-/Internet-Verbindung kein
    # Cache-Listing zeigen (Abspielen würde eh fehlschlagen).
    if not common.check_connectivity_or_notify():
        try:
            li = xbmcgui.ListItem(label="Keine Verbindung zum Server", offscreen=True)
            li.setProperty("IsPlayable", "false")
            icon = common.addon.getAddonInfo('icon') or ""
            if icon:
                li.setArt({'thumb': icon, 'icon': icon, 'poster': icon})
            xbmcplugin.addDirectoryItem(
                handle=common.addon_handle,
                url="",
                listitem=li,
                isFolder=False,
            )
        except Exception as e:
            xbmc.log(f"[{common.addon_id} series.py] offline hint-item failed: {e}", xbmc.LOGDEBUG)
        xbmcplugin.endOfDirectory(common.addon_handle, succeeded=True, updateListing=False, cacheToDisc=False)
        _tlog("early_exit_offline_total", t_total0)
        return

    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: {common.mask_url(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)

    # --- Online-Serien mergen (Local-Merge wurde mit dem Download-Feature entfernt) ---
    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

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

    # (Image-Prefetch entfernt – Kodi Texture-Cache wird genutzt)
    t0 = time.time()
    t6 = _tlog("image_prefetch_skipped_kodi_cache", t0)

    # --- Sortierung ---
    t0 = time.time()
    # Hart verdrahtet: Serien immer nach "Hinzugefügt" sortieren, neueste zuerst.
    sort_param = "added"

    def pdate(s):
        return _parse_added_datetime((s or {}).get("added", ""))

    final_series_list.sort(key=pdate, reverse=True)

    t7 = _tlog(f"sort_param={sort_param} order=desc (source=forced)", 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)

    # --- Progress-Cache vor dem Rendern komplett auffüllen (synchron) ---
    t0 = time.time()
    if SYNC_PROGRESS_ON_LIST_LOAD and watched_status:
        try:
            due_entries = _compute_due_progress_entries(final_series_list, progress_cache, watch_sig)
            sync_done = 0
            sync_failed = 0
            sync_skipped = 0
            if due_entries:
                progress_cache, sync_done, sync_failed, sync_skipped = _fill_progress_sync(
                    due_entries,
                    watch_sig,
                    watched_status,
                    save_every=10,
                    timeout_s=SYNC_PROGRESS_TIMEOUT_S,
                )
            _tlog(
                f"sync_progress_prefill_due={len(due_entries)} done={sync_done} failed={sync_failed} skipped={sync_skipped}",
                t0,
            )
        except Exception as e_sync:
            xbmc.log(f"[{common.addon_id}] sync progress prefill error: {e_sync}", xbmc.LOGDEBUG)
            _tlog("sync_progress_prefill_error", t0)
    else:
        _tlog("sync_progress_prefill_skipped", 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", offscreen=True)
        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

    # WICHTIG:
    # Für UI/Listings niemals aktiv auf lokale Cache-Dateien umbiegen.
    # Kodi rendert Remote-URLs hier stabiler und nutzt dafür seinen eigenen Texture-Cache.
    # Der Addon-Cache bleibt nur für stilles Prefetching im Hintergrund aktiv.
    def _cs(url):
        return common.resolve_artwork_for_listing(
            url,
            # cache_dir entfernt (Kodi Texture-Cache)
            prefer_cache=False
        )

    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 details_json_for_progress is None:
                cache_key, entry, migrated = _progress_cache_entry(
                    progress_cache,
                    details_url_val,
                    original_key or "",
                )
                if migrated:
                    try:
                        _save_progress_cache(progress_cache)
                    except Exception:
                        pass

                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
                    ):
                        if not SYNC_PROGRESS_ON_LIST_LOAD:
                            to_compute_async.append((name, details_url_val, cache_key))
                else:
                    if details_url_val and watched_status:
                        if not SYNC_PROGRESS_ON_LIST_LOAD:
                            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, offscreen=True)

        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 = _cs(poster)

        fanart_final = None
        if isinstance(backcover, str) and backcover:
            fanart_final = _cs(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"):
            added_norm = _normalize_added_for_kodi(serie.get("added"))
            if added_norm:
                info.setDateAdded(added_norm)

        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.setContent(common.addon_handle, "tvshows")

    # Im Kodi-Menü sollen NUR diese zwei Sortierungen erscheinen:
    #  - Hinzugefügt am
    #  - Bewertung
    try:
        xbmcplugin.addSortMethod(common.addon_handle, xbmcplugin.SORT_METHOD_DATEADDED)
    except Exception as e_dateadded:
        xbmc.log(f"[{common.addon_id} series.py] addSortMethod(DATEADDED) nicht verfügbar: {e_dateadded}", xbmc.LOGDEBUG)
    try:
        xbmcplugin.addSortMethod(common.addon_handle, xbmcplugin.SORT_METHOD_VIDEO_RATING)
    except Exception as e_rating:
        xbmc.log(f"[{common.addon_id} series.py] addSortMethod(VIDEO_RATING) nicht verfügbar: {e_rating}", xbmc.LOGDEBUG)

    xbmcplugin.endOfDirectory(common.addon_handle, succeeded=True, updateListing=False, cacheToDisc=False)
    _force_series_container_dateadded_desc()

    xbmc.log(
        f"[{common.addon_id} series.py] Kodi-native Sortmethoden registriert: DATEADDED, VIDEO_RATING. "
        f"Standard-Default aktiv erzwungen: DATEADDED descending (neueste zuerst). "
        f"Sichtbar im Kodi-Menü: Hinzugefügt am, Bewertung.",
        xbmc.LOGINFO
    )
    _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), 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
# ----------------------------$

# =====================================================================================
# SECTION: HAUPTNAVIGATION: Staffeln
# =====================================================================================
def show_seasons(data_json_str):
    """
    Öffentlicher Entry-Point. Fängt unerwartete Fehler ab, damit Kodi nicht mit
    GetDirectory-Fehlern aus der Ansicht springt.
    """
    try:
        return _show_seasons_impl(data_json_str)
    except Exception as e:
        try:
            xbmc.log(
                f"[{common.addon_id} series.py] show_seasons: UNHANDLED exception: {e}\n{traceback.format_exc()}",
                xbmc.LOGERROR
            )
        except Exception:
            pass
        try:
            xbmcgui.Dialog().notification(
                "Fehler",
                f"Serienansicht fehlgeschlagen: {e}",
                common.addon.getAddonInfo('icon')
            )
        except Exception:
            pass
        try:
            xbmcplugin.endOfDirectory(common.addon_handle, succeeded=True)
        except Exception:
            pass
        return




def _looks_like_eps(val):
    try:
        if isinstance(val, list) and val:
            sample = val[0]
            if isinstance(sample, dict):
                keys = set(sample.keys())
                if keys & {"file_ort", "stream_url", "file", "path", "url"}:
                    return True
                if keys & {"episode_number", "episode", "ep", "e"}:
                    return True
            if isinstance(sample, str):
                low = sample.lower()
                if any(low.endswith(ext) for ext in ('.mkv', '.mp4', '.avi', '.m4v', '.ts')):
                    return True
                if '://' in low:
                    return True
        if isinstance(val, dict) and val:
            keys = list(val.keys())
            if all(str(k).isdigit() or str(k).lower().startswith('e') for k in keys[: min(8, len(keys))]):
                return True
            for vv in list(val.values())[: min(8, len(val))]:
                if isinstance(vv, str):
                    low = vv.lower()
                    if any(low.endswith(ext) for ext in ('.mkv', '.mp4', '.avi', '.m4v', '.ts')):
                        return True
                if isinstance(vv, dict):
                    kset = set(vv.keys())
                    if kset & {"file_ort", "stream_url", "file", "path", "url"}:
                        return True
        return False
    except Exception:
        return False


def _extract_eps_container(season_obj):
    if not isinstance(season_obj, dict):
        return []
    for k in ("episodes", "episoden", "episode_list", "episode", "files", "file_list", "items", "streams"):
        if k in season_obj:
            return season_obj.get(k)

    # nested containers (z.B. tmdb_season_details -> episodes)
    for nk in ("tmdb_season_details", "season_details", "details", "tmdb"):
        nested = season_obj.get(nk) if isinstance(season_obj, dict) else None
        if isinstance(nested, dict):
            for k in ("episodes", "episoden", "episode_list", "items", "streams", "files", "file_list"):
                if k in nested and nested.get(k) is not None:
                    return nested.get(k)

    # heuristic scan
    for _, v in season_obj.items():
        if isinstance(v, (list, dict)) and _looks_like_eps(v):
            return v

    # fallback: manche details.json haben Episoden direkt als Keys im Season-Objekt (gemischt mit Meta-Feldern)
    try:
        episode_map = {}
        for k, v in season_obj.items():
            ks = str(k).strip()
            if re.match(r"(?i)^(?:e|ep|episode)[ _-]*\d{1,3}$", ks) or re.match(r"(?i)^s\d{1,2}e\d{1,3}$", ks) or ks.isdigit():
                if isinstance(v, dict):
                    episode_map[k] = v
                elif isinstance(v, str) and v.strip():
                    episode_map[k] = v
        if len(episode_map) >= 2:
            return episode_map
    except Exception:
        pass
    return season_obj.get("episodes", [])


def _normalize_eps(eps):
    """Normalisiert Episoden-Container (list/dict) zu sortierter List[dict].

    Unterstuetzt auch aeltere Formate, bei denen Episoden nur als String-Pfade
    (z.B. "/media/Serie/S01E01.mkv") oder als dict{episode_key: pfad} geliefert werden.
    """

    def _guess_ep_number(s):
        if s is None:
            return 0
        s = str(s)
        m = re.search(r'(?i)\bS\d{1,2}E(\d{1,3})\b', s)
        if m:
            try:
                return int(m.group(1))
            except Exception:
                return 0
        m = re.search(r'(?i)\bE(\d{1,3})\b', s)
        if m:
            try:
                return int(m.group(1))
            except Exception:
                return 0
        m = re.search(r'(?i)\b(?:ep|episode)\s*0*(\d{1,3})\b', s)
        if m:
            try:
                return int(m.group(1))
            except Exception:
                return 0
        return 0

    def _mk_from_str(key, path):
        pth = (path or "").strip()
        epn = _guess_ep_number(key) or _guess_ep_number(pth)
        if (not epn) and str(key).isdigit():
            try:
                epn = int(key)
            except Exception:
                epn = 0
        name = ""
        try:
            import os
            base = os.path.basename(pth)
            name = os.path.splitext(base)[0]
        except Exception:
            name = str(key)
        return {"episode_number": epn or 0, "name": name, "file_ort": pth}

    out = []
    if isinstance(eps, list):
        for idx, item in enumerate(eps, start=1):
            if isinstance(item, dict):
                dd = dict(item)
                if "file_ort" not in dd:
                    if dd.get("file"):
                        dd["file_ort"] = dd.get("file")
                    elif dd.get("path"):
                        dd["file_ort"] = dd.get("path")
                if "stream_url" not in dd and dd.get("url"):
                    dd["stream_url"] = dd.get("url")
                out.append(dd)
            elif isinstance(item, str) and item.strip():
                out.append(_mk_from_str(idx, item))
    elif isinstance(eps, dict):
        for k, v in eps.items():
            if isinstance(v, dict):
                vv = dict(v)
                if "file_ort" not in vv:
                    if vv.get("file"):
                        vv["file_ort"] = vv.get("file")
                    elif vv.get("path"):
                        vv["file_ort"] = vv.get("path")
                if "stream_url" not in vv and vv.get("url"):
                    vv["stream_url"] = vv.get("url")
                if vv.get("episode_number") in (None, ""):
                    try:
                        vv["episode_number"] = int(k) if str(k).isdigit() else _guess_ep_number(k)
                    except Exception:
                        vv["episode_number"] = _guess_ep_number(k)
                out.append(vv)
            elif isinstance(v, str) and v.strip():
                out.append(_mk_from_str(k, v))
    else:
        return []

    for idx, ep in enumerate(out, start=1):
        if isinstance(ep, dict) and ep.get("episode_number") in (None, "", 0):
            ep["episode_number"] = idx

    out.sort(key=lambda x: _to_int(x.get("episode_number"), 0) if isinstance(x, dict) else 0)
    return out

def _show_seasons_impl(data_json_str):
    _log_version_once()
    """
    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
    params = _get_query_params()
    title_hint = (params.get("title") or "").strip()
    pid_hash = ""
    if isinstance(data_json_str, str) and data_json_str.startswith("pid:"):
        try:
            pid_hash = data_json_str.split(":", 1)[1].strip()
        except Exception:
            pid_hash = ""

    try:
        serie = unpack_payload(data_json_str, default={})
        if not isinstance(serie, dict) or not serie:
            recovered = _recover_serie_nav_payload(
                pid_hash=(pid_hash or None),
                title_hint=(title_hint or None)
            )
            if isinstance(recovered, dict) and recovered:
                serie = recovered
                if pid_hash:
                    _write_payload_pid_file(pid_hash, serie)
                xbmc.log(
                    f"[{common.addon_id} series.py] show_seasons: recovered missing PID cache pid={pid_hash or '-'} title='{title_hint or '-'}'",
                    xbmc.LOGINFO
                )
            else:
                raise ValueError("Leere/ungültige Seriendaten (PID cache missing/empty).")
    except Exception as e:
        xbmc.log(f"[{common.addon_id} series.py] show_seasons: payload decode failed: {e}", xbmc.LOGWARNING)
        xbmcgui.Dialog().notification(
            "Fehler",
            f"Serien-Daten fehlerhaft: {e}",
            common.addon.getAddonInfo('icon')
        )
        xbmcplugin.endOfDirectory(common.addon_handle, succeeded=True)
        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 = 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://')):
                    details_url_val = resolved
                    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)

    if (not details_url_val) and isinstance(original_serie_key, str) and original_serie_key.startswith(("http://", "https://")):
        details_url_val = original_serie_key
        try:
            serie["details_url"] = original_serie_key
        except Exception:
            pass

    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", offscreen=True)
        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_open(
                details_url_val,
                timeout_s=20,
                expected_sha1=(serie.get("details_sha1") or None),
                serie_name=serie_name_display,
                revalidate_ttl_s=60,
            ) or {}
        except Exception as e:
            xbmc.log(f"[{common.addon_id} series.py] Fehler Laden Online-Details (cached): {e}", xbmc.LOGWARNING)

    # 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=True)
        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
        )
        # Kein Container-Reload hier: das kann beim Öffnen einer Serie ein
        # unerwartetes Zurückspringen/Refresh der Serienliste auslösen.
        # Der Progress-Cache ist trotzdem aktualisiert.
    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))

    season_keys = sorted(
        [k for k in details_json.keys() if isinstance(k, str) and (k.lower().startswith("season") or k.lower().startswith("staffel"))],
        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

    # Seasons/Staffeln ebenfalls immer mit Remote-URLs an Kodi geben.
    # Lokale Cache-Dateien bleiben reine Hintergrund-Optimierung.
    def _cs_season(url):
        return common.resolve_artwork_for_listing(
            url,
            # cache_dir entfernt (Kodi Texture-Cache)
            prefer_cache=False
        )

    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", {})
        if not isinstance(tmdb_s, dict):
            tmdb_s = {}

        # Episoden können je nach Source als dict oder list kommen -> normalisieren
        try:
            s_data = dict(s_data)
            s_data['episodes'] = _normalize_eps(_extract_eps_container(s_data))
        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, offscreen=True)

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

        season_poster = _cs_season(season_poster) if season_poster else season_poster
        series_poster_final = _cs_season(series_poster_raw) if series_poster_raw else series_poster_raw
        fanart = _cs_season(fanart) if fanart else 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": _progress_cache_key(details_url_val, original_serie_key),
            "details_url": details_url_val,
            "details_sha1": (serie.get("details_sha1") or None),
            "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
        }

        # Episodes: statt komplettes Episoden-Payload zu "packen" (kann auf RasPi/ODROID race-condition -> 0-Byte Cache ergeben),
        # schicken wir nur ein kleines Pointer-Payload (details_url + season_number). show_episodes lädt dann sauber aus dem Details-Cache nach.
        data_for_episodes_light = {
            # Wichtig: show_episodes erwartet diese Keys (sonst fehlt Fanart / Labels)
            "serie_name": data_for_episodes.get("serie_name"),
            "season_label": data_for_episodes.get("season_label"),
            "season_number": data_for_episodes.get("season_number"),

            # Pointer/Ident
            "details_url": data_for_episodes.get("details_url"),
            "details_sha1": data_for_episodes.get("details_sha1"),
            "online_serie_key": data_for_episodes.get("online_serie_key"),
            "source": data_for_episodes.get("source"),
            "local_path_serie": data_for_episodes.get("local_path_serie"),

            # Artwork (FIX: Fanart korrekt durchreichen!)
            "series_poster": data_for_episodes.get("series_poster"),
            "season_poster": season_poster or data_for_episodes.get("series_poster"),
            "series_fanart": data_for_episodes.get("series_fanart"),
            "fanart": data_for_episodes.get("series_fanart"),  # extra Kompatibilität
            "backcover": (serie.get("backcover") or data_for_episodes.get("series_fanart")),

            # Metadaten (optional, hilft in show_episodes als Fallback)
            "series_tmdbid": data_for_episodes.get("series_tmdbid"),
            "series_genre": data_for_episodes.get("series_genre"),
            "series_studio": data_for_episodes.get("series_studio"),
            "fsk_from_series_or_details": data_for_episodes.get("fsk_from_series_or_details"),
            "series_vote_average_from_series": data_for_episodes.get("series_vote_average_from_series"),
            "season_specific_vote_average": data_for_episodes.get("season_specific_vote_average"),
            "series_imdb_votes": data_for_episodes.get("series_imdb_votes"),
        }
        ep_url = common.build_url({
            "action": "show_episodes",
            "data": json.dumps(data_for_episodes_light, ensure_ascii=False, separators=(",", ":"))
        })
        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 = _cs_season(fanart_container)
        try:
            _set_container_fanart(common.addon_handle, 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
# ----------------------------

# =====================================================================================
# SECTION: HAUPTNAVIGATION: Episoden
# =====================================================================================
def show_episodes(data_json_str):
    _log_version_once()
    """
    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 = _progress_cache_key(
            payload.get("details_url") or "",
            payload.get("online_serie_key") or "",
        )
        if series_cache_key:
            xbmcgui.Window(10000).setProperty("glotzbox.last_series_cache_key", series_cache_key)
    except Exception:
        pass

    # Optional: explizite Details-URL/Hash aus Payload (hilft bei Revalidation ohne Wartezeit)
    details_url_payload = (payload.get("details_url") or series_cache_key or "").strip()
    details_sha1_payload = payload.get("details_sha1")

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

    episodes = _normalize_eps(_extract_eps_container(payload))

    # 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(details_url_payload, str) and details_url_payload.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 = (details_url_payload 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_open(
                details_url_payload,
                timeout_s=20,
                expected_sha1=(details_sha1_payload or None),
                serie_name=serie_name,
                revalidate_ttl_s=60,
            ) or {}
            details_json = dj if isinstance(dj, dict) else None
            # finde passenden Season-Key
            skey = None
            for k in dj.keys():
                if isinstance(k, str) and (k.lower().startswith('season') or k.lower().startswith('staffel')):
                    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(_extract_eps_container(dj[skey]))
                # 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")
        or payload.get("backcover")
        or payload.get("fanart")
        or payload.get("tvshow_fanart")
        or ""
    )
    series_poster_raw = (
        payload.get("series_poster")
        or payload.get("poster")
        or payload.get("cover")
        or ""
    )

    # Falls der Router-Payload keine Art-Infos enthaelt (kommt bei show_episodes oft vor),
    # nehmen wir Fanart/Poster aus details.json als Fallback.
    if (not series_fanart_raw) and isinstance(details_json, dict):
        art = details_json.get("art") if isinstance(details_json.get("art"), dict) else {}
        series_fanart_raw = (
            details_json.get("tvshow_fanart")
            or details_json.get("series_fanart")
            or details_json.get("fanart")
            or details_json.get("backcover")
            or details_json.get("backdrop")
            or details_json.get("wallpaper")
            or art.get("fanart")
            or art.get("tvshow_fanart")
            or art.get("backdrop")
            or art.get("wallpaper")
            or ""
        )

    if (not series_poster_raw) and isinstance(details_json, dict):
        art = details_json.get("art") if isinstance(details_json.get("art"), dict) else {}
        series_poster_raw = (
            details_json.get("series_poster")
            or details_json.get("tvshow_poster")
            or details_json.get("poster")
            or details_json.get("cover")
            or art.get("poster")
            or art.get("tvshow_poster")
            or ""
        )
    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)
    series_tmdbid = _normalize_series_tmdb_id(payload.get("series_tmdbid"))

    # Episodenbilder/Stills niemals für die Ansicht auf lokale Cache-Dateien umbiegen.
    # Sonst sieht man oft erst kurz Artwork und danach blanke Einträge.
    def _maybe_cache(url_or_path):
        return common.resolve_artwork_for_listing(
            url_or_path,
            # cache_dir entfernt (Kodi Texture-Cache)
            prefer_cache=False
        )

    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:
            _set_container_fanart(common.addon_handle, 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, offscreen=True)

        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

        if series_tmdbid:
            try:
                vi.setUniqueIDs({'tmdb': series_tmdbid})
            except Exception:
                pass
            try:
                li.setProperty("TMDbId", series_tmdbid)
            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
        )

        # (FIX) Kein zweites li.setArt(...) ohne Fanart, sonst überschreibt Kodi die Fanart-Felder.
        # --- 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_params = {
                "action": "play_series",
                "serie_key": play_path,
                "title": f"S{season_number}E{ep_num:02d}. {ep_title}",
                "status_id": status_identifier,
                "video_codec": _series_codec_hint(ep.get("video_codec"), play_path)
            }
            if series_tmdbid:
                play_params["tmdbid"] = series_tmdbid
                play_params["series_tmdbid"] = series_tmdbid
            play_url = common.build_url(play_params)
        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)
    # SORT_METHOD_DATE bewusst entfernt, damit im Kodi-Menü kein zusätzliches "Datum" auftaucht.
    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

        safe_ident = common.mask_url(status_identifier)

        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={safe_ident}",
                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) ---
        with _WATCHED_STATUS_LOCK:
            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 = (xbmcgui.Window(10000).getProperty("glotzbox.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.

    Fix: Wenn nach dem Playback bereits show_episodes als aktueller Container sichtbar ist,
    hat Kodi die Navigation selbst erledigt – kein zusätzlicher Container.Refresh nötig.
    Ohne diesen Check kommt es zu einem doppelten show_episodes-Aufruf (einmal von Kodi,
    einmal von uns), was sich als kurzes Flackern/doppelter Refresh in der Episodenliste zeigt.
    """
    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"):
                if common.wait_or_abort(monitor, 0.25):
                    return
                continue

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

            if common.wait_or_abort(monitor, 0.25):
                return

        # 2) UI kurz settlen lassen
        if common.wait_or_abort(monitor, 0.6):
            return

        # 3) Prüfen ob wir schon auf show_episodes sind – dann hat Kodi die Navigation
        #    selbst erledigt und ein Container.Refresh würde einen doppelten Aufruf erzeugen.
        try:
            current_path = (xbmc.getInfoLabel("Container.FolderPath") or "").lower()
            if "action=show_episodes" in current_path or "action=show_fav_episodes" in current_path:
                xbmc.log(
                    f"[{common.addon_id} series.py] _safe_refresh_container: skip (bereits auf show_episodes, "
                    f"Kodi hat Navigation selbst erledigt)", xbmc.LOGDEBUG
                )
                return
        except Exception:
            pass

        # 4) 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 _normalize_series_tmdb_id(value):
    """Return a clean TMDb series id string or '' when the value is clearly not a TMDb id."""
    try:
        s = str(value or '').strip()
    except Exception:
        s = ''
    if not s:
        return ''
    return s if s.isdigit() else ''


def _resolve_series_status_identifier(serie_key, status_identifier_or_tmdbid=None):
    """Resolve the watched/progress identifier for episode playback.

    Historisch wurde der Identifier ueber den Query-Key ``tmdbid`` transportiert.
    Wir unterstuetzen das weiter fuer bestehende Links, bevorzugen aber kuenftig
    den expliziten Query-Key ``status_id``.
    """
    try:
        raw = str(status_identifier_or_tmdbid or '').strip()
    except Exception:
        raw = ''

    if raw:
        if raw.startswith(('http://', 'https://', 'plugin://', 'special://', '/')):
            return raw
        if ('/' in raw) or ('\\' in raw):
            return raw
        if not raw.isdigit():
            return raw

    try:
        key = str(serie_key or '').strip()
    except Exception:
        key = ''
    return key or raw


def play_series(serie_key, title, status_identifier_or_tmdbid=None, codec_hint=None, series_tmdbid=None):
    status_identifier = _resolve_series_status_identifier(serie_key, status_identifier_or_tmdbid)
    resolved_tmdb_id = _normalize_series_tmdb_id(series_tmdbid or status_identifier_or_tmdbid)
    safe_status = common.mask_url(status_identifier)
    safe_key = common.mask_url(serie_key)
    xbmc.log(
        f"[{common.addon_id} series.py] play_series: Aufgerufen. Key='{safe_key}', Title='{title}', "
        f"StatusID='{safe_status}', TMDbId='{resolved_tmdb_id}', CodecHint='{codec_hint or ''}'",
        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(offscreen=True))
        return

    play_url = common.prepare_playback_path(serie_key, codec_hint=codec_hint)
    if not play_url:
        xbmc.log(
            f"[{common.addon_id} series.py] play_series: Wiedergabe abgebrochen (blockiert/ungueltiger Playback-Pfad). "
            f"Key='{safe_key}', CodecHint='{codec_hint or ''}'",
            xbmc.LOGINFO
        )
        try:
            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(offscreen=True))
        except Exception:
            pass
        return

    play_item = xbmcgui.ListItem(path=play_url, offscreen=True)
    play_item.setProperty("IsPlayable", "true")
    play_item.setProperty("video_codec", str(codec_hint or ""))
    if resolved_tmdb_id:
        try:
            play_item.setProperty("TMDbId", resolved_tmdb_id)
        except Exception:
            pass

    info_tag = play_item.getVideoInfoTag()
    info_tag.setMediaType('episode')
    if resolved_tmdb_id:
        try:
            info_tag.setUniqueIDs({'tmdb': resolved_tmdb_id})
        except Exception:
            pass

    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 = xbmcgui.Window(10000).getProperty("glotzbox.last_played_tvshow_title")
            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), daemon=True)
                monitor_thread.start()
                xbmc.log(f"[{common.addon_id} series.py] play_series: Monitoring Thread gestartet für '{common.mask_url(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

        with _WATCHED_STATUS_LOCK:
            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"

            save_ok = _safe_save_watched_status(WATCHED_STATUS_FILE, watched_status)

        if save_ok:
            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)

# ----------------------------
# (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')
    status_id = params.get('status_id')
    series_tmdbid = params.get('series_tmdbid')
    video_codec = params.get('video_codec')
    data = params.get('data')
    mode = params.get('mode')
    season = params.get('season')
    episode = params.get('episode')

    if title and action == "show_seasons":
        xbmcgui.Window(10000).setProperty("glotzbox.last_played_tvshow_title", 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, status_id or tmdbid, video_codec, series_tmdbid or 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 == '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()