# coding: utf-8

import os
import sys
import gb_common as common
import urllib.request
import urllib.parse
import json
import xbmc
import xbmcgui
import xbmcaddon
import xbmcplugin
import xbmcvfs
import ssl
import hashlib
import datetime as dt_module
import random
import threading
import traceback
import re
from collections import OrderedDict

# ---------------------------------------------------------------------------------------
# Konfiguration / Konstanten
# ---------------------------------------------------------------------------------------

SERIES_FAV_JSON_FILENAME = "seriesFav.json"

# Favoriten: bewusst KEIN watched-status / kein Playback-Monitoring.
# (Random-Play kann optional einen Monitor starten, ist aber default aus.)
ENABLE_FAV_SERIES_MONITOR = False

# Log-Helper
LOGP = f"[{common.addon_id} favorites.py]"

def log(msg, lvl=xbmc.LOGINFO):
    try:
        xbmc.log(f"{LOGP} {msg}", lvl)
    except Exception:
        pass


def _safe_end_of_directory(succeeded=True, update_listing=False, cache_to_disc=True, handle=None):
    try:
        h = common.addon_handle if handle is None else handle
        h = int(h)
    except Exception as e:
        log(f"endOfDirectory: invalid handle ({handle}): {e}", xbmc.LOGDEBUG)
        return False
    if h < 0:
        log(f"endOfDirectory skipped in script mode (handle={h})", xbmc.LOGDEBUG)
        return False
    try:
        xbmcplugin.endOfDirectory(h, succeeded=succeeded, updateListing=update_listing, cacheToDisc=cache_to_disc)
        return True
    except Exception as e:
        log(f"endOfDirectory failed (handle={h}): {e}", xbmc.LOGDEBUG)
        return False


def start_series_monitor(playable_path: str, identifier_for_status: str = None):
    """
    Optional: startet den Playback-Monitor (series.monitor_playback_series) für eine Episode,
    die aus den Favoriten gestartet wird.

    Standard ist AUS (ENABLE_FAV_SERIES_MONITOR=False), damit Favoriten:
      - keinen watched-status setzen/ändern
      - keinerlei Nebenwirkungen im Hintergrund haben
    """
    if not ENABLE_FAV_SERIES_MONITOR:
        return

    try:
        if not playable_path:
            return

        try:
            import series  # lazy import (nur wenn Feature aktiv)
        except Exception as e:
            log(f"Monitor: series.py nicht importierbar -> skip ({e})", xbmc.LOGWARNING)
            return

        ident = (identifier_for_status or playable_path or "").strip() or playable_path

        t = threading.Thread(
            target=series.monitor_playback_series,
            args=(playable_path, ident),
            daemon=True,
        )
        t.start()
        try:
            p_mask = common.mask_url(playable_path)
            i_mask = common.mask_url(ident)
        except Exception:
            p_mask = playable_path
            i_mask = ident
        log(f"Monitor gestartet für Favorit-Playback: {p_mask} (id={i_mask})", xbmc.LOGDEBUG)
    except Exception as e:
        log(f"Monitor Fehler: {e}", xbmc.LOGDEBUG)

# ---------------------------------------------------------------------------------------
# Safe-Getter für Addon-Settings (ohne settings.xml)
# ---------------------------------------------------------------------------------------
def _get_setting_str(key, default=""):
    try:
        val = common.addon.getSetting(key)
        return val if isinstance(val, str) else default
    except Exception:
        return default

def _get_setting_bool(key, default=False):
    try:
        raw = common.addon.getSetting(key)
    except Exception:
        raw = ""
    s = (raw or "").strip().lower()
    if s in ("true", "1", "yes", "on"):
        return True
    if s in ("false", "0", "no", "off"):
        return False
    return default

def _get_setting_int(key, default=0):
    try:
        raw = common.addon.getSetting(key)
        return int(raw)
    except Exception:
        return default


# ---------------------------------------------------------------------------------------
# Netz / Timeouts / SSL
# ---------------------------------------------------------------------------------------
NET_TIMEOUT = max(5, _get_setting_int("net_timeout", 20))
IMG_TIMEOUT = max(5, _get_setting_int("image_timeout", 15))

def _robust_urlopen(req, timeout=NET_TIMEOUT):
    """urllib.request.urlopen mit zentralem SSL-Context und Retry."""
    try:
        return common.retry_urlopen(req, context=common.get_ssl_context(), timeout=timeout, retries=1, retry_delay=2.0)
    except AttributeError:
        return urllib.request.urlopen(req, context=common.get_ssl_context(), timeout=timeout)

def unquote2(s):
    return urllib.parse.unquote(urllib.parse.unquote(s or ""))

def _prepare_playback_path(path, codec_hint=None, notify=False):
    """Prepare HTTP(S) URLs for Kodi playback (strip userinfo + auth via headers)."""
    try:
        p = (path or "").strip()
    except Exception:
        p = ""
    if not p:
        return ""
    if p.startswith(("http://", "https://")):
        try:
            prepare_fn = getattr(common, "prepare_playback_path", None)
            if callable(prepare_fn):
                try:
                    return prepare_fn(p, codec_hint=codec_hint, notify=notify)
                except TypeError:
                    try:
                        return prepare_fn(p, codec_hint=codec_hint)
                    except TypeError:
                        return prepare_fn(p)
            return common.kodi_prepare_play_url(p)
        except Exception as e:
            log(f"playback path hardening fallback (raw URL used): {e}", xbmc.LOGDEBUG)
            return p
    return p

def _playback_compare_key(path):
    """Normalize player paths for robust comparison (raw URL vs Kodi prepared URL)."""
    try:
        p = unquote2(path or "")
    except Exception:
        p = str(path or "")
    if not p:
        return ""
    if "|" in p:
        p = p.split("|", 1)[0]
    try:
        sp = urllib.parse.urlsplit(p)
        if (sp.scheme or "").lower() in ("http", "https"):
            host = sp.hostname or ""
            if sp.port:
                host = f"{host}:{sp.port}"
            p = urllib.parse.urlunsplit((sp.scheme, host, sp.path, sp.query, sp.fragment))
    except Exception:
        pass
    try:
        return unquote2(p)
    except Exception:
        return p


# ---------------------------------------------------------------------------------------
# Payload Cache (pid:...) + Details-Cache
# ---------------------------------------------------------------------------------------

_PAYLOAD_PREFIX = "pid:"
_PID_RE = re.compile(r"^[0-9a-f]{8,64}$")
_PAYLOAD_CACHE_SUBDIR = "payload_cache_fav"
_PAYLOAD_TTL_SECONDS = max(3600, _get_setting_int("payload_ttl_seconds", 7 * 24 * 3600))  # 7 Tage
_PAYLOAD_MAX_BYTES = max(50_000, _get_setting_int("payload_max_bytes", 2_000_000))        # 2MB

# Kleiner RAM-Fallback für Payloads innerhalb derselben Kodi-Python-Session.
_PAYLOAD_MEMO_MAX = 128
_PAYLOAD_MEMO = OrderedDict()  # pid -> obj
_PAYLOAD_MEMO_LOCK = threading.Lock()

_DETAILS_CACHE_SUBDIR = "fav_details_cache"
_DETAILS_TTL_SECONDS = max(3600, _get_setting_int("fav_details_ttl_seconds", 3 * 24 * 3600))  # 3 Tage



def _vfs_mtime(path: str) -> float:
    """mtime für VFS/Local Pfade (best effort)."""
    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(common.fs_local(path)))
    except Exception:
        return 0.0



def _safe_mkdirs(path: str) -> None:
    try:
        if path and not common.fs_exists(path):
            common.fs_mkdirs(path)
    except Exception:
        pass


def _payload_cache_dir() -> str:
    try:
        base = common.addon_data_dir
    except Exception:
        base = xbmcvfs.translatePath("special://temp/")
    p = os.path.join(base, _PAYLOAD_CACHE_SUBDIR)
    _safe_mkdirs(p)
    return p


def _payload_path_from_pid(pid: str) -> str:
    """Liefert den lokalen Payload-Pfad nur für gültige pid-Hashes.

    Wichtig: pid kommt aus Plugin-URLs/Query-Parametern. Darum Whitelist statt
    Blacklist, damit keine Pfad-Traversal- oder Spezialdatei-Pfade entstehen.
    """
    pid_clean = (pid or "").strip().lower()
    if not _PID_RE.match(pid_clean):
        return ""
    return os.path.join(_payload_cache_dir(), f"{pid_clean}.json")


def _payload_memo_put(pid: str, obj) -> None:
    try:
        pid_clean = (pid or "").strip().lower()
        if not _PID_RE.match(pid_clean):
            return
        with _PAYLOAD_MEMO_LOCK:
            _PAYLOAD_MEMO[pid_clean] = obj
            try:
                _PAYLOAD_MEMO.move_to_end(pid_clean, 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):
    try:
        pid_clean = (pid or "").strip().lower()
        if not _PID_RE.match(pid_clean):
            return None
        with _PAYLOAD_MEMO_LOCK:
            obj = _PAYLOAD_MEMO.get(pid_clean)
            if obj is not None:
                try:
                    _PAYLOAD_MEMO.move_to_end(pid_clean, last=True)
                except Exception:
                    pass
            return obj
    except Exception:
        return None

def _payload_file_is_valid(fp: str) -> bool:
    """Best-effort Prüfung: existiert, nicht leer, JSON parsebar.

    Wichtig: zuerst über den lokalen Pfad lesen. Bei lokalen addon_data-Pfaden kann
    xbmcvfs direkt nach einem os.replace() je nach Plattform kurz zickig sein;
    das darf keinen Security-Fallback auf rohe Stream-URLs auslösen.
    """
    try:
        if not fp or not common.fs_exists(fp):
            return False

        raw = None

        # 1) Lokaler Pfad: robust nach atomic os.replace().
        try:
            lp = common.fs_local(fp)
            if lp and os.path.exists(lp):
                if os.path.getsize(lp) < 5:
                    return False
                with open(lp, "rb") as fh:
                    raw = fh.read()
        except Exception:
            raw = None

        # 2) VFS-Fallback.
        if raw is None:
            try:
                st = xbmcvfs.Stat(fp)
                sz_attr = getattr(st, "st_size", None)
                sz = int(sz_attr() if callable(sz_attr) else (sz_attr or 0))
                if sz and sz < 5:
                    return False
            except Exception:
                pass

            with xbmcvfs.File(fp, "rb") as fh:
                raw = fh.read()

        if raw is None:
            return False
        if isinstance(raw, bytes):
            raw = raw.decode("utf-8", errors="replace")
        raw = (raw or "").lstrip("\ufeff").strip()
        if not raw:
            return False

        obj = json.loads(raw)
        return isinstance(obj, (dict, list))
    except Exception:
        return False

def pack_payload(obj) -> str:
    """
    Speichert obj als JSON in addon_data_dir/.../payload_cache_fav/<pid>.json und gibt 'pid:<pid>' zurück.
    Robust:
      - wenn Datei existiert aber leer/kaputt -> wird neu geschrieben
      - wenn Schreiben/Validieren scheitert -> leerer String statt Inline-JSON (keine URL-Leaks)
    (Stable: sort_keys=True => bessere Cache-Hitrate)
    """
    try:
        raw = json.dumps(obj, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
    except Exception:
        raw = json.dumps({"_payload_error": "json_dumps_failed"}, ensure_ascii=False, sort_keys=True, separators=(",", ":"))

    raw_b = raw.encode("utf-8", errors="replace")
    if len(raw_b) > _PAYLOAD_MAX_BYTES:
        log(f"Payload sehr groß ({len(raw_b) / 1024:.1f} KB) – wird trotzdem gecached.", xbmc.LOGWARNING)

    pid = hashlib.sha256(raw_b).hexdigest()[:24]
    fp = _payload_path_from_pid(pid)
    if not fp:
        log("pack_payload: ungültige interne Payload-ID -- kein Inline-Fallback.", xbmc.LOGWARNING)
        return ""

    # RAM-Memo ist nur ein Zusatz-Fallback innerhalb derselben Kodi-Python-Session.
    _payload_memo_put(pid, obj)

    # Wenn schon vorhanden und gültig -> direkt nutzen
    try:
        if _payload_file_is_valid(fp):
            return f"{_PAYLOAD_PREFIX}{pid}"
    except Exception:
        pass

    # Sonst neu schreiben (atomisch, Cache ist nicht kritisch -> ohne heavy fsync)
    try:
        _safe_mkdirs(os.path.dirname(fp))

        try:
            common.atomic_write_bytes(fp, raw_b, backup=False, durable=False)
        except Exception:
            with xbmcvfs.File(fp, "w") as fh:
                fh.write(raw)

        # Optionaler Seed-Fallback: enthält denselben lokalen Payload, aber keine Plugin-URL.
        # Falls die Hauptdatei später fehlt/kaputt ist, kann unpack_payload daraus recovern.
        try:
            common.atomic_write_bytes(fp + ".seed", raw_b, backup=False, durable=False)
        except Exception:
            pass

        # Validieren – wenn immer noch kaputt, kein Inline-Fallback:
        # Favoriten-Payloads koennen Stream-URLs enthalten und duerfen nicht in plugin:// Logs landen.
        if _payload_file_is_valid(fp):
            return f"{_PAYLOAD_PREFIX}{pid}"

        try:
            if xbmcvfs.exists(fp):
                xbmcvfs.delete(fp)
        except Exception:
            pass
        log("pack_payload validation failed: kein Inline-Fallback fuer Favoriten-Payload.", xbmc.LOGWARNING)
        return ""

    except Exception as e:
        log(f"pack_payload write failed: {e} -- kein Inline-Fallback fuer Favoriten-Payload.", xbmc.LOGWARNING)
        return ""



def unpack_payload(data_str):
    """
    Akzeptiert entweder:
      - 'pid:<id>'  -> lädt JSON aus Datei (payload_cache_fav/<pid>.json)
      - JSON String -> json.loads

    Robust gegen seltene '0-Byte' / Race-Cases:
      - kurzer Retry-Loop
      - löscht eine leere/kaputte Cache-Datei nur, wenn sie "alt" ist (>=10s),
        damit ein paralleler Writer nicht torpediert wird.
    """
    if data_str is None:
        return None

    s = str(data_str)

    candidates = [s]
    try:
        uq = unquote2(s)
        if uq != s:
            candidates.append(uq)
    except Exception:
        pass

    def _normalize_payload_raw(raw) -> str:
        try:
            if raw is None:
                return ""
            if isinstance(raw, bytes):
                raw = raw.decode("utf-8", errors="replace")
            return (raw or "").lstrip("\ufeff").strip()
        except Exception:
            return ""

    def _read_payload_file(path: str) -> str:
        # Wichtig für lokale addon_data-Pfade: zuerst direkt über den OS-Pfad lesen.
        # In Kodi kann xbmcvfs.File() direkt nach einem atomic os.replace() kurz leer
        # liefern, obwohl die Datei lokal bereits vollständig geschrieben ist.
        try:
            lp = common.fs_local(path)
            if lp and os.path.exists(lp):
                with open(lp, "rb") as fh:
                    raw = fh.read()
                raw_s = _normalize_payload_raw(raw)
                if raw_s:
                    return raw_s
        except Exception:
            pass

        try:
            with xbmcvfs.File(path, "r") as fh:
                raw = fh.read()
            return _normalize_payload_raw(raw)
        except Exception:
            return ""

    def _vfs_size(path: str) -> int:
        try:
            st = xbmcvfs.Stat(path)
            sz = getattr(st, "st_size", 0)
            if callable(sz):
                sz = sz()
            return int(sz or 0)
        except Exception:
            return 0

    retry_monitor = None
    try:
        retry_monitor = xbmc.Monitor()
    except Exception:
        retry_monitor = None

    for cand in candidates:
        c = (cand or "").strip()
        if not c:
            continue

        # pid:<hash> -> load from cache
        if c.startswith(_PAYLOAD_PREFIX):
            pid = c[len(_PAYLOAD_PREFIX):].strip().lower()
            fp = _payload_path_from_pid(pid)
            if not fp:
                log("unpack_payload: ungültige Payload-ID verworfen.", xbmc.LOGWARNING)
                return None

            try:
                # quick guard: exists but 0 bytes
                if xbmcvfs.exists(fp) and _vfs_size(fp) < 5:
                    # Retry kurz – evtl. wird gerade geschrieben
                    raw = ""
                    for _i in range(3):
                        if common.wait_or_abort(retry_monitor, 0.05):
                            return None
                        raw = _read_payload_file(fp)
                        if raw:
                            break

                    if not raw:
                        # Falls "alt" -> löschen, sonst stehen lassen
                        try:
                            now_ts = dt_module.datetime.utcnow().timestamp()
                            st = xbmcvfs.Stat(fp)
                            mt = getattr(st, "st_mtime", None)
                            if callable(mt):
                                mt = mt()
                            mt = float(mt or 0.0)
                            if mt and (now_ts - mt) >= 10.0:
                                try:
                                    xbmcvfs.delete(fp)
                                except Exception:
                                    pass
                        except Exception:
                            pass

                # Normaler Read (mit kurzem Retry falls leer)
                raw = _read_payload_file(fp) if xbmcvfs.exists(fp) else ""
                if not raw:
                    for _i in range(2):
                        if common.wait_or_abort(retry_monitor, 0.05):
                            return None
                        raw = _read_payload_file(fp)
                        if raw:
                            break

                # Fallback: .bak / .seed (best-effort)
                if not raw:
                    for alt in (fp + ".bak", fp + ".seed"):
                        if xbmcvfs.exists(alt):
                            raw = _read_payload_file(alt)
                            if raw:
                                break

                if not raw:
                    memo = _payload_memo_get(pid)
                    if memo is not None:
                        return memo
                    return None

                obj = json.loads(raw) if raw else None
                if obj is not None:
                    _payload_memo_put(pid, obj)
                return obj

            except Exception as e:
                memo = _payload_memo_get(pid)
                if memo is not None:
                    return memo
                log(f"unpack_payload pid read failed ({pid}): {e}", xbmc.LOGWARNING)
                return None

        # inline JSON
        try:
            c = (c or "").lstrip("\ufeff")
            return json.loads(c)
        except Exception:
            continue

    return None

def _build_favorite_episode_info(playable_path_raw: str, episode_title: str, overview: str = "", air_date: str = "",
                                 still: str = "", series_title: str = "", series_poster: str = "",
                                 series_fanart: str = "", season_number: int = 0, episode_number: int = 0,
                                 genre: str = "", studio: str = "", directors: str = "", runtime=None,
                                 fsk=None, episode_rating=None, season_vote_average=None,
                                 series_vote_average=None, video_codec: str = "") -> dict:
    return {
        "playable_path": playable_path_raw or "",
        "episode_title": episode_title or "Zufällige Episode",
        "overview": overview or "",
        "air_date": air_date or "",
        "still": still or "",
        "series_title": series_title or "",
        "series_poster": series_poster or "",
        "series_fanart": series_fanart or "",
        "season_number": int(season_number or 0),
        "episode_number": int(episode_number or 0),
        "genre": genre or "",
        "studio": studio or "",
        "directors": directors or "",
        "runtime": runtime,
        "fsk": fsk,
        "episode_rating": episode_rating,
        "season_vote_average": season_vote_average,
        "series_vote_average": series_vote_average,
        "video_codec": video_codec or "",
    }


def _build_favorite_episode_plugin_url(episode_info: dict) -> str:
    try:
        payload_token = pack_payload(episode_info or {})
        if not isinstance(payload_token, str):
            return ""
        payload_token = payload_token.strip()
        if not payload_token:
            return ""
        return common.build_url({"action": "play_favorite_episode_secure", "data": payload_token})
    except Exception as e:
        log(f"build favorite secure plugin url failed: {e}", xbmc.LOGDEBUG)
        return ""


def _append_plugin_flag(plugin_url: str, key: str, value: str = "1") -> str:
    """Append/override a query flag on a plugin:// URL (best effort)."""
    try:
        if not plugin_url:
            return ""
        parts = urllib.parse.urlsplit(plugin_url)
        query = dict(urllib.parse.parse_qsl(parts.query, keep_blank_values=True))
        query[str(key)] = str(value)
        new_query = urllib.parse.urlencode(query)
        return urllib.parse.urlunsplit((parts.scheme, parts.netloc, parts.path, new_query, parts.fragment))
    except Exception as e:
        log(f"append plugin flag failed ({key}={value}): {e}", xbmc.LOGDEBUG)
        return plugin_url


def _play_plugin_route(plugin_url: str, fallback_listitem=None, prefer_playmedia_pm: bool = False, log_label: str = "plugin_route") -> bool:
    """Start a playable plugin route robustly in Kodi script/plugin contexts."""
    if not plugin_url:
        return False

    pm_url = _append_plugin_flag(plugin_url, "__pm", "1") if prefer_playmedia_pm else plugin_url

    if prefer_playmedia_pm:
        try:
            xbmc.executebuiltin(f"PlayMedia({pm_url})")
            return True
        except Exception as e:
            log(f"{log_label}: PlayMedia(pm) failed: {e}", xbmc.LOGDEBUG)

    try:
        xbmc.executebuiltin(f"RunPlugin({plugin_url})")
        return True
    except Exception as e:
        log(f"{log_label}: RunPlugin failed: {e}", xbmc.LOGDEBUG)

    try:
        xbmc.executebuiltin(f"PlayMedia({plugin_url})")
        return True
    except Exception as e:
        log(f"{log_label}: PlayMedia(raw-plugin) failed: {e}", xbmc.LOGDEBUG)

    try:
        xbmc.Player().play(item=pm_url, listitem=fallback_listitem)
        return True
    except Exception as e:
        log(f"{log_label}: Player().play(plugin) failed: {e}", xbmc.LOGDEBUG)
        return False


def _build_favorite_episode_listitem(episode_info: dict, path_override: str = None):
    playable_path_raw = unquote2(episode_info.get("playable_path") or "")
    li_path = path_override if path_override is not None else _prepare_playback_path(playable_path_raw, codec_hint=episode_info.get("video_codec"))

    li = xbmcgui.ListItem(path=li_path, offscreen=True)
    li.setProperty("IsPlayable", "true")

    info_tag = li.getVideoInfoTag()
    episode_title = episode_info.get("episode_title", "Zufällige Episode")
    info_tag.setTitle(episode_title)
    info_tag.setTvShowTitle(episode_info.get("series_title", ""))
    info_tag.setMediaType("episode")
    info_tag.setPlot(episode_info.get("overview", ""))

    season_number = episode_info.get("season_number", 0)
    if season_number:
        try:
            info_tag.setSeason(int(season_number))
        except Exception:
            pass

    episode_number = episode_info.get("episode_number", 0)
    if episode_number:
        try:
            info_tag.setEpisode(int(episode_number))
        except Exception:
            pass

    air_date_val = episode_info.get("air_date")
    if air_date_val:
        try:
            info_tag.setPremiered(air_date_val)
        except Exception:
            pass
        try:
            _parse_year_from_air_date_string(air_date_val, info_tag, episode_title)
        except Exception:
            pass

    runtime_min = episode_info.get("runtime")
    if isinstance(runtime_min, int) and runtime_min > 0:
        try:
            info_tag.setDuration(runtime_min * 60)
        except Exception:
            pass

    final_rating = 0.0
    for cand in (episode_info.get("episode_rating"), episode_info.get("season_vote_average"), episode_info.get("series_vote_average")):
        if cand is not None:
            try:
                final_rating = float(str(cand).replace(",", "."))
            except Exception:
                pass
        if final_rating > 0:
            break
    if final_rating > 0:
        try:
            info_tag.setRating(final_rating)
        except Exception:
            pass

    fsk_raw = episode_info.get("fsk")
    if fsk_raw is not None:
        fsk_s = str(fsk_raw).strip()
        if fsk_s:
            try:
                info_tag.setMpaa(f"FSK {fsk_s}")
            except Exception:
                pass

    try:
        _split_and_set_list(info_tag, "setDirectors", episode_info.get("directors", ""))
    except Exception:
        pass
    try:
        _split_and_set_list(info_tag, "setGenres", episode_info.get("genre", ""))
    except Exception:
        pass
    try:
        _split_and_set_list(info_tag, "setStudios", episode_info.get("studio", ""))
    except Exception:
        pass

    still = episode_info.get("still", "")
    still_cached = _cached_or_local_image(still) or still
    poster_cached = _cached_or_local_image(episode_info.get("series_poster")) or episode_info.get("series_poster")
    fanart_cached = _cached_or_local_image(episode_info.get("series_fanart")) or episode_info.get("series_fanart")

    try:
        _set_art_mq8(li, thumb=still_cached, icon=still_cached, poster=poster_cached, fanart=fanart_cached)
    except Exception:
        pass
    return li


def play_favorite_episode_secure(data_json_str):
    try:
        episode_info = unpack_payload(data_json_str)
        if not isinstance(episode_info, dict):
            raise ValueError("Ungültige Episodendaten für Favoriten-Wiedergabe.")

        playable_path_raw = unquote2(episode_info.get("playable_path") or "")
        play_path = _prepare_playback_path(playable_path_raw, codec_hint=episode_info.get("video_codec"))
        if not play_path:
            raise ValueError("Kein Abspielpfad gefunden.")

        play_item = _build_favorite_episode_listitem(episode_info, path_override=play_path)

        try:
            addon_handle = common.resolve_addon_handle(default=getattr(common, "addon_handle", -1))
        except Exception:
            addon_handle = getattr(common, "addon_handle", -1)

        xbmcplugin.setResolvedUrl(addon_handle, True, listitem=play_item)
        log("play_favorite_episode_secure: setResolvedUrl aufgerufen.", xbmc.LOGDEBUG)
        start_series_monitor(playable_path_raw)
    except Exception as e:
        log(f"Fehler in play_favorite_episode_secure: {e}\n{traceback.format_exc()}", xbmc.LOGERROR)
        xbmcgui.Dialog().notification("Fehler", "Favoriten-Episode konnte nicht gestartet werden.", xbmcgui.NOTIFICATION_ERROR)
        try:
            addon_handle = common.resolve_addon_handle(default=getattr(common, "addon_handle", -1))
        except Exception:
            addon_handle = getattr(common, "addon_handle", -1)
        try:
            xbmcplugin.setResolvedUrl(addon_handle, False, xbmcgui.ListItem(offscreen=True))
        except Exception:
            pass


def _details_cache_dir() -> str:
    try:
        base = common.addon_data_dir
    except Exception:
        base = xbmcvfs.translatePath("special://temp/")
    p = os.path.join(base, _DETAILS_CACHE_SUBDIR)
    _safe_mkdirs(p)
    return p


def _details_cache_path(details_url: str) -> str:
    """Stable cache filename based on canonical details_url_key."""
    try:
        key = common.details_url_key(details_url)
    except Exception:
        key = (details_url or "").strip()
    try:
        h = hashlib.sha1((key or '').encode('utf-8', errors='ignore')).hexdigest()
    except Exception:
        h = hashlib.sha1(str(key).encode('utf-8', errors='ignore')).hexdigest()
    return os.path.join(_details_cache_dir(), f"{h}.json")


def _details_cache_path_legacy(details_url: str) -> str:
    """Legacy cache filename (older versions): md5(full_url)."""
    h = hashlib.md5((details_url or '').encode('utf-8', errors='ignore')).hexdigest()
    return os.path.join(_details_cache_dir(), f"{h}.json")
def _load_details_from_cache(details_url: str):
    fp = _details_cache_path(details_url)
    fp_legacy = _details_cache_path_legacy(details_url)
    try:
        if (not common.fs_exists(fp)) and (not common.fs_exists(fp_legacy)):
            return None
        if (not common.fs_exists(fp)) and common.fs_exists(fp_legacy):
            fp = fp_legacy
        try:
            age = dt_module.datetime.utcnow().timestamp() - float(_vfs_mtime(fp))
            if age > _DETAILS_TTL_SECONDS:
                return None
        except Exception:
            pass

        d = common.safe_load_json_file(fp, default=None)
        return d if isinstance(d, dict) else None
    except Exception:
        return None


def _save_details_to_cache(details_url: str, details_json: dict) -> None:
    fp = _details_cache_path(details_url)
    try:
        common.atomic_write_json(fp, details_json, indent=None, durable=True, backup=True)
    except Exception:
        pass


def fetch_details_json(details_url: str, expected_sha1: str = None):
    """
    Lädt details.json:
      - Wenn expected_sha1 vorhanden: disk cache wird genutzt, wenn lokal bekannter SHA passt,
        andernfalls wird sofort neu vom Server geladen.
      - Ohne expected_sha1: Fallback TTL-Cache.
    Rückgabe: (dict_or_empty, err_or_None)
    """
    if not details_url:
        return {}, "details_url fehlt"

    # Hash-map (shared with series.py) -> erlaubt Update ohne "warte 5 min".
    details_key = None
    try:
        details_key = common.details_url_key(details_url)
    except Exception:
        details_key = None

    if expected_sha1 and details_key:
        try:
            m = common.load_details_sha1_map()
            local_sha = (m.get(details_key) or "").strip()

            # Backward-Compat: ältere Versionen nutzten sha1(full_url) als Key
            if (not local_sha) and details_url:
                try:
                    legacy_k = common.details_url_key_legacy_sha1(details_url)
                    legacy_sha = (m.get(legacy_k) or "").strip() if legacy_k else ""
                    if legacy_sha:
                        local_sha = legacy_sha
                        # migrate -> neuer Key
                        try:
                            m[details_key] = legacy_sha
                            if legacy_k:
                                m.pop(legacy_k, None)
                            common.save_details_sha1_map(m)
                        except Exception:
                            pass
                except Exception:
                    pass

            # Wenn Hash passt: Cache darf verwendet werden (TTL ignoriert)
            if local_sha and local_sha == expected_sha1:
                fp = _details_cache_path(details_url)
                fp_legacy = _details_cache_path_legacy(details_url)
                if (not common.fs_exists(fp)) and common.fs_exists(fp_legacy):
                    fp = fp_legacy
                if common.fs_exists(fp):
                    d = common.safe_load_json_file(fp, default=None)
                    if isinstance(d, dict):
                        return d, None
        except Exception:
            pass

    # Fallback: TTL cache (oder kein expected_sha1)
    if not expected_sha1:
        cached = _load_details_from_cache(details_url)
        if isinstance(cached, dict) and cached:
            return cached, None

    try:
        req = common.get_authenticated_request(details_url)
        if not req:
            return {}, "Kein authentifizierter Request."
        with _robust_urlopen(req, timeout=NET_TIMEOUT) as response:
            if response.getcode() != 200:
                return {}, f"HTTP {response.getcode()}"
            raw = response.read()
            body_sha = None
            try:
                body_sha = common.sha1_hex(raw)
            except Exception:
                body_sha = None
            d = json.loads(raw.decode("utf-8-sig", errors="replace"))
            if not isinstance(d, dict):
                d = {}
            if d:
                _save_details_to_cache(details_url, d)
                # persist hash
                if details_key:
                    try:
                        m = common.load_details_sha1_map()
                        m[details_key] = expected_sha1 or body_sha or m.get(details_key)
                        common.save_details_sha1_map(m)
                    except Exception:
                        pass
            return d, None
    except Exception as e:
        return {}, str(e)


# ---------------------------------------------------------------------------------------
# Init: Pfade (Image-Cache entfernt – Kodi Texture-Cache wird genutzt)
# ---------------------------------------------------------------------------------------

# Favoriten: bewusst KEIN watched-status / kein Playback-Monitoring.
# (Random-Play kann optional einen Monitor starten, ist aber default aus.)
# Hinweis: ENABLE_FAV_SERIES_MONITOR wird oben bei den Konstanten gesetzt – nicht nochmal hier.


# ---------------------------------------------------------------------------------------
# Datei I/O
# ---------------------------------------------------------------------------------------
def _serialize_json_to_file(obj, dest_path):
    """Schreibt JSON atomisch über zentrale gb_common Logik."""
    try:
        return bool(common.atomic_write_json(dest_path, obj, indent=None, durable=True, backup=True))
    except Exception as e:
        log(f"Speichern der Cache-Datei fehlgeschlagen: {dest_path} -> {e}", xbmc.LOGWARNING)
        return False

def _load_json_from_file(src_path):
    """Lädt JSON mit .bak/.tmp Recovery-Logik aus gb_common."""
    try:
        return common.safe_load_json_file(src_path, None)
    except Exception as e:
        log(f"Lesen der Cache-Datei fehlgeschlagen: {src_path} -> {e}", xbmc.LOGWARNING)
        return None

def _get_seriesfav_cache_path():
    try:
        return os.path.join(common.addon_data_dir, "seriesFav.cache.json")
    except Exception:
        return os.path.join(xbmcvfs.translatePath("special://temp/"), "seriesFav.cache.json")

def _get_seriesfav_remote_meta_path():
    """Meta-Cache für HEAD/Content-Length der Favoritenliste."""
    try:
        return os.path.join(common.addon_data_dir, "seriesFav.remote_meta.json")
    except Exception:
        return os.path.join(xbmcvfs.translatePath("special://temp/"), "seriesFav.remote_meta.json")


def _load_seriesfav_remote_meta():
    meta_path = _get_seriesfav_remote_meta_path()
    meta = _load_json_from_file(meta_path)
    return meta if isinstance(meta, dict) else {}


def _save_seriesfav_remote_meta(meta: dict) -> None:
    try:
        meta_path = _get_seriesfav_remote_meta_path()
        _serialize_json_to_file(meta or {}, meta_path)
    except Exception as e:
        log(f"save seriesFav remote meta failed: {e}", xbmc.LOGDEBUG)


# ---------------------------------------------------------------------------------------
# Bild-Cache-Funktionen (entfernt – Kodi Texture-Cache wird genutzt)
# ---------------------------------------------------------------------------------------

def _cached_or_local_image(url):
    """Stub – kein Addon-eigener Image-Cache mehr. Gibt None zurueck."""
    return None


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

# ---------------------------------------------------------------------------------------
# Artwork Helfer (MQ8)
# ---------------------------------------------------------------------------------------
def _force_window_fanart(img_path: str):
    def _ok(s): return isinstance(s, str) and s.strip()
    if not _ok(img_path):
        return
    try:
        if common.addon_handle != -1:
            xbmcplugin.setProperty(common.addon_handle, "fanart_image", img_path)
    except Exception as e:
        log(f"setProperty(fanart_image) failed: {e}", xbmc.LOGDEBUG)
    for win_id in (10025, 10028, 10000):
        try:
            win = xbmcgui.Window(win_id)
            for key in ("fanart_image", "Fanart_Image", "background_fanart"):
                win.setProperty(key, img_path)
        except Exception as e:
            log(f"window fanart set failed (win={win_id}): {e}", xbmc.LOGDEBUG)


try:
    _ADDON = xbmcaddon.Addon()
    _ADDON_FANART_PATH = (_ADDON.getAddonInfo("fanart") or "").replace("\\", "/").lower()
except Exception:
    _ADDON = None
    _ADDON_FANART_PATH = ""


def _set_art_mq8(li, *, thumb=None, icon=None, poster=None, fanart=None, tvshow_fanart=None, **extra):
    def _ok(s): return isinstance(s, str) and s.strip()

    _addon_fanart = _ADDON_FANART_PATH

    def _clean(p):
        if not _ok(p):
            return ""
        p2 = p.replace("\\", "/").lower()
        if _addon_fanart and p2 == _addon_fanart:
            return ""
        return p

    thumb = _clean(thumb)
    icon = _clean(icon)
    poster = _clean(poster)
    fanart = _clean(fanart)
    tvshow_fanart = _clean(tvshow_fanart)

    fanart_final = fanart or tvshow_fanart or poster or thumb or icon
    poster_final = poster or thumb or icon

    art = {}

    if _ok(poster_final):
        art["poster"] = poster_final
        art["thumb"] = poster_final
        art["icon"] = poster_final
        art["landscape"] = poster_final
    else:
        fallback = os.path.join(common.addon_path, "series.png")
        if not xbmcvfs.exists(fallback):
            fallback = common.addon.getAddonInfo('icon')
        art["icon"] = art["thumb"] = art["poster"] = art["landscape"] = fallback

    if _ok(fanart_final):
        art["fanart"] = fanart_final
        for i in range(1, 10):
            art[f"fanart{i}"] = fanart_final
        art["tvshow.fanart"] = fanart_final
        for i in range(1, 10):
            art[f"tvshow.fanart{i}"] = fanart_final

    for k, v in (extra or {}).items():
        if _ok(v):
            art[k] = v

    if art:
        li.setArt(art)

    if _ok(fanart_final):
        try:
            li.setProperty("fanart_image", fanart_final)
            li.setProperty("Fanart_Image", fanart_final)
        except Exception:
            pass
        _force_window_fanart(fanart_final)


def _apply_fanart_to_listitem(
    li,
    *,
    poster: str = None,
    fanart: str = None,
    tvshow_poster: str = None,
    tvshow_fanart: str = None,
    still: str = None
) -> None:
    def _ok(s): return isinstance(s, str) and s.strip()

    poster_final = None
    for cand in (still, poster, tvshow_poster):
        if _ok(cand):
            poster_final = cand
            break

    fanart_final = None
    for cand in (fanart, still, poster, tvshow_fanart, tvshow_poster):
        if _ok(cand):
            fanart_final = cand
            break

    _set_art_mq8(
        li,
        thumb=poster_final or "",
        icon=poster_final or "",
        poster=poster_final or "",
        fanart=fanart_final or "",
        tvshow_fanart=tvshow_fanart or ""
    )


# ---------------------------------------------------------------------------------------
# Hilfsfunktionen
# ---------------------------------------------------------------------------------------
def _parse_year_from_air_date_string(air_date_str, info_tag_ref, item_label_for_log="Item"):
    if not (air_date_str and isinstance(air_date_str, str) and air_date_str.strip()):
        return False

    did_set_year = False
    try:
        try:
            parsed_date = dt_module.datetime.strptime(air_date_str, "%Y-%m-%d")
            info_tag_ref.setYear(parsed_date.year)
            return True
        except Exception:
            pass

        if len(air_date_str) >= 4 and air_date_str[:4].isdigit():
            info_tag_ref.setYear(int(air_date_str[:4]))
            did_set_year = True
    except Exception:
        pass

    return did_set_year


def _split_and_set_list(info_tag, setter_name, value):
    if not value:
        return
    try:
        items = [x.strip() for x in str(value).split(',') if x.strip()]
        if not items:
            return
        getattr(info_tag, setter_name)(items)
    except Exception:
        pass


# ---------------------------------------------------------------------------------------
# Laden von seriesFav.json (mit lokalem Fallback-Cache)
# ---------------------------------------------------------------------------------------
def _normalize_host_and_basepath():
    """Einheitliche Normalisierung (delegiert an common.normalize_ftp_host_basepath)."""
    ftp_username = _get_setting_str("ftp_username", "").strip()
    ftp_password = _get_setting_str("ftp_password", "").strip()

    try:
        host_final, base_path, scheme = common.normalize_ftp_host_basepath()
        return scheme, host_final, ftp_username, ftp_password, base_path
    except Exception:
        # Fallback (sollte praktisch nie passieren)
        ftp_host_raw = _get_setting_str("ftp_host", "").strip()
        base_path_setting = _get_setting_str("ftp_base_path", "").strip()

        scheme = "https"
        ftp_host_cleaned = ftp_host_raw

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

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

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

        base_path = "/user/downloads/kodiAddon/"
        try:
            if base_path_setting:
                temp_path = base_path_setting.strip()
                if temp_path and temp_path != "/":
                    base_path = temp_path.rstrip("/") + "/"
        except Exception:
            pass

        if not base_path.startswith("/"):
            base_path = "/" + base_path

        return scheme, ftp_host_cleaned, ftp_username, ftp_password, base_path



def load_favorites_json():
    scheme, ftp_host, ftp_username, ftp_password, base_path = _normalize_host_and_basepath()
    if not ftp_host:
        return None, "FTP/HTTP Host fehlt!"

    if not (ftp_username and ftp_password) and not scheme.startswith("http"):
        return None, "FTP Benutzername/Passwort fehlt!"

    url = f"{scheme}://{ftp_host}{base_path}{SERIES_FAV_JSON_FILENAME}"
    cache_path = _get_seriesfav_cache_path()
    meta = _load_seriesfav_remote_meta()

    # NEW: prefer tiny .sha1 sidecar (seriesFav.json.sha1) for change detection
    remote_sha = None
    try:
        if scheme.startswith("http"):
            remote_sha = common.fetch_remote_sha1(url, timeout_s=8)
    except Exception:
        remote_sha = None

    # Fast path: if sha matches and cache exists -> no HEAD/GET needed
    try:
        prev_sha = (meta.get("sha1") or "").strip()
        if remote_sha and prev_sha and remote_sha == prev_sha and xbmcvfs.exists(cache_path):
            cached = _load_json_from_file(cache_path)
            if isinstance(cached, list):
                log("Favoritenliste laut .sha1 unverändert – verwende lokalen Cache.", xbmc.LOGINFO)
                return cached, None
    except Exception:
        pass

    if not (ftp_username and ftp_password):
        log("Keine FTP-Zugangsdaten gesetzt – versuche ohne Auth.", xbmc.LOGDEBUG)

    # Fallback: HEAD/Content-Length Check (wenn .sha1 fehlt)
    try:
        if scheme.startswith("http") and (not remote_sha) and xbmcvfs.exists(cache_path):
            req_h = common.get_authenticated_request(url)
            if req_h:
                # method override kompatibel mit älterem urllib in Kodi
                try:
                    req_h.get_method = lambda: "HEAD"
                except Exception:
                    pass
                with _robust_urlopen(req_h, timeout=NET_TIMEOUT) as resp_h:
                    cl = None
                    try:
                        cl_raw = resp_h.headers.get("Content-Length")
                        if cl_raw is not None:
                            cl = int(cl_raw)
                    except Exception:
                        cl = None

                    prev_cl = meta.get("content_length")
                    if cl is not None and prev_cl is not None and int(prev_cl) == int(cl):
                        cached = _load_json_from_file(cache_path)
                        if isinstance(cached, list):
                            log("Favoritenliste laut HEAD/Content-Length unverändert – verwende lokalen Cache.", xbmc.LOGINFO)
                            return cached, None
    except Exception as e:
        # HEAD darf niemals die Funktion kaputt machen – dann einfach normal weiter
        log(f"HEAD/Content-Length Check fehlgeschlagen: {e}", xbmc.LOGDEBUG)

    log(f"Lade Favoriten-JSON von: {common.mask_url(url)}", xbmc.LOGDEBUG)
    try:
        req = common.get_authenticated_request(url)
        if not req:
            raise ValueError("Konnte Auth Request nicht erstellen.")
        with _robust_urlopen(req, timeout=NET_TIMEOUT) as response:
            if response.getcode() == 200:
                raw = response.read()
                body_sha = None
                try:
                    body_sha = common.sha1_hex(raw)
                except Exception:
                    body_sha = None
                # BOM robust (utf-8-sig)
                try:
                    text = raw.decode("utf-8-sig")
                except Exception:
                    text = raw.decode("utf-8", errors="replace")
                data = json.loads(text)
                if not isinstance(data, list):
                    log(f"Favoriten-JSON ist kein Array (Typ: {type(data).__name__}) – Cache bleibt erhalten.", xbmc.LOGWARNING)
                    # Nicht cachen! Bestehenden Cache als Fallback nutzen.
                    cached = _load_json_from_file(cache_path)
                    if isinstance(cached, list):
                        return cached, None
                    return [], None

                # Cache + Meta speichern
                cache_ok = _serialize_json_to_file(data, cache_path)
                if cache_ok:
                    try:
                        cl = response.headers.get("Content-Length")
                        meta["content_length"] = int(cl) if cl is not None else meta.get("content_length")
                    except Exception:
                        pass
                    try:
                        body_sha = common.sha1_hex(raw)
                        meta["sha1"] = body_sha or remote_sha or meta.get("sha1")
                    except Exception:
                        if remote_sha:
                            meta["sha1"] = remote_sha
                    meta["last_ok_utc"] = dt_module.datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
                    _save_seriesfav_remote_meta(meta)
                else:
                    log(f"Favoriten-Cache-Write fehlgeschlagen – Meta bleibt unverändert.", xbmc.LOGWARNING)

                return data, None
            else:
                raise ValueError(f"HTTP Error {response.getcode()}")
    except Exception as e:
        log(f"Netzwerkfehler bei {SERIES_FAV_JSON_FILENAME}: {e}", xbmc.LOGWARNING)
        cached = _load_json_from_file(cache_path)
        if isinstance(cached, list):
            log(f"Offline: Verwende lokale {os.path.basename(cache_path)} ({len(cached)} Einträge)", xbmc.LOGINFO)
            return cached, None
        return None, f"Laden/Parsen von {SERIES_FAV_JSON_FILENAME} fehlgeschlagen: {e}"

# ---------------------------------------------------------------------------------------
# Favoriten-Root anzeigen
# ---------------------------------------------------------------------------------------
def show_favorite_series():
    xbmcplugin.setContent(common.addon_handle, "tvshows")

    # 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} favorites.py] offline hint-item failed: {e}", xbmc.LOGDEBUG)
        _safe_end_of_directory(succeeded=True, cache_to_disc=False)
        return

    # Payload-GC läuft zentral im Service; hier kein synchrones Aufräumen im UI-Pfad.

    data, err = load_favorites_json()
    if err:
        xbmcgui.Dialog().notification("Fehler", err, "")
        _safe_end_of_directory(succeeded=False, cache_to_disc=False)
        return

    favorite_series_list = data or []

    # Artwork wird via Kodis eigenem Texture-Cache verwaltet.
    # Remote-URLs bleiben unveraendert – Kodi laedt sie beim ersten Anzeigen.
    for serie in favorite_series_list:
        if isinstance(serie, dict):
            serie.pop("action", None)

    # 3) Sortierung
    sort_method_param = "added"
    if len(sys.argv) > 2 and sys.argv[2].startswith('?'):
        try:
            plugin_params = dict(urllib.parse.parse_qsl(sys.argv[2][1:]))
            sort_method_param = plugin_params.get("sort", sort_method_param).lower()
        except Exception:
            pass

    if sort_method_param in ("date", "added"):
        def parse_date_robust_fav(s_item):
            d_str = s_item.get("added", "")
            for f in ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
                try:
                    return dt_module.datetime.strptime(d_str, f)
                except Exception:
                    continue
            return dt_module.datetime(1970, 1, 1)
        favorite_series_list = sorted(favorite_series_list, key=parse_date_robust_fav, reverse=True)

    elif sort_method_param == "year":
        def get_year_fav(s_item):
            year_str = str(s_item.get("start_year", "0"))[:4]
            return int(year_str) if year_str.isdigit() else 0
        favorite_series_list = sorted(favorite_series_list, key=get_year_fav, reverse=True)

    else:
        favorite_series_list = sorted(favorite_series_list, key=lambda s: s.get("name", "").lower())

    # 4) Erstes Fanart für Container setzen
    first_fanart = ""
    for s in favorite_series_list:
        cand = (s.get("backcover") or s.get("poster") or "").strip()
        if cand:
            first_fanart = cand
            break
    _force_window_fanart(first_fanart or common.addon.getAddonInfo('icon'))

    # 5) Random-Item oben
    random_item = xbmcgui.ListItem(label="Zufällige Serie starten", offscreen=True)
    random_item.setProperty("specialsort", "top")
    vi_r = random_item.getVideoInfoTag()
    vi_r.setPlot("Startet die Wiedergabe einer zufälligen Episode aus deinen Favoriten-Serien.")
    vi_r.setMediaType("video")

    rand_icon = os.path.join(common.addon_path, "random.png")
    series_icon = os.path.join(common.addon_path, "series.png")
    icon_final = rand_icon if xbmcvfs.exists(rand_icon) else (series_icon if xbmcvfs.exists(series_icon) else common.addon.getAddonInfo('icon'))

    _apply_fanart_to_listitem(
        random_item,
        poster=icon_final,
        fanart=first_fanart or icon_final,
        tvshow_poster=icon_final,
        tvshow_fanart=first_fanart or icon_final
    )

    random_url = common.build_url({'action': 'play_random_favorite_series'})
    xbmcplugin.addDirectoryItem(handle=common.addon_handle, url=random_url, listitem=random_item, isFolder=False)

    if not favorite_series_list:
        xbmcgui.Dialog().notification(common.addon_name, "Keine Favoriten gefunden.", "")

    # 6) Serien listen
    for serie_item in favorite_series_list:
        if not isinstance(serie_item, dict):
            continue

        name = (serie_item.get("name") or "Unbekannt").strip()
        li = xbmcgui.ListItem(label=name, offscreen=True)

        final_poster_raw = (serie_item.get("poster") or "").strip()
        final_fanart_raw = (serie_item.get("backcover") or "").strip()
        if not final_fanart_raw:
            final_fanart_raw = final_poster_raw
        # Bevorzugt lokalen Cache-Pfad, Fallback auf Remote-URL
        final_poster = _cached_or_local_image(final_poster_raw) or final_poster_raw
        final_fanart = _cached_or_local_image(final_fanart_raw) or final_fanart_raw

        _apply_fanart_to_listitem(
            li,
            poster=final_poster or final_fanart,
            fanart=final_fanart or final_poster,
            tvshow_poster=final_poster or final_fanart,
            tvshow_fanart=final_fanart or final_poster
        )

        info_tag = li.getVideoInfoTag()
        info_tag.setTitle(name)
        info_tag.setPlot(serie_item.get("overview", ""))
        info_tag.setMediaType("tvshow")
        info_tag.setTvShowTitle(name)
        info_tag.setPlaycount(0)

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

        series_vote_avg_str = serie_item.get("vote_average")
        if series_vote_avg_str is not None:
            try:
                rv = float(str(series_vote_avg_str).replace(",", "."))
                if rv > 0:
                    info_tag.setRating(rv)
            except Exception:
                pass
        else:
            imdb_rating_str = serie_item.get("imdbRating")
            if imdb_rating_str is not None:
                try:
                    rv = float(str(imdb_rating_str).replace(",", "."))
                    if rv > 0:
                        info_tag.setRating(rv)
                except Exception:
                    pass

        # Votes (IMDb bevorzugt, sonst TMDB)
        votes_val = 0
        for imdb_key in ("imdbVotes", "imdb_votes", "imdbvotes", "votes_imdb"):
            raw = serie_item.get(imdb_key)
            if raw:
                try:
                    votes_val = int(re.sub(r"[^\d]", "", str(raw)))
                except Exception:
                    votes_val = 0
                if votes_val > 0:
                    break
        if votes_val <= 0:
            for tmdb_key in ("vote_count", "votes", "tmdb_vote_count"):
                raw = serie_item.get(tmdb_key)
                if raw is not None:
                    try:
                        votes_val = int(str(raw))
                    except Exception:
                        votes_val = 0
                    if votes_val > 0:
                        break
        if votes_val > 0:
            try:
                info_tag.setVotes(votes_val)
            except Exception:
                pass

        fsk_val = serie_item.get("fsk")
        fsk_processed = str(fsk_val).strip() if fsk_val is not None else ""
        if fsk_processed:
            info_tag.setMpaa(f"FSK {fsk_processed}")

        if serie_item.get("added"):
            info_tag.setDateAdded(serie_item.get("added"))

        _split_and_set_list(info_tag, "setGenres", serie_item.get("genre", ""))
        _split_and_set_list(info_tag, "setStudios", serie_item.get("studio", ""))

        details_url_nav = (serie_item.get("details_url") or "").strip()
        if not details_url_nav:
            log(f"Serie ohne details_url ignoriert: {name}", xbmc.LOGDEBUG)
            continue

        nav_data = serie_item.copy()
        nav_data['series_vote_average'] = series_vote_avg_str

        # WICHTIG: data= pack_payload statt json.dumps (kurze URLs)
        target_url = common.build_url({"action": "show_fav_seasons", "data": pack_payload(nav_data)})
        li.setProperty('IsPlayable', 'false')

        xbmcplugin.addDirectoryItem(handle=common.addon_handle, url=target_url, listitem=li, isFolder=True)

    xbmcplugin.addSortMethod(common.addon_handle, xbmcplugin.SORT_METHOD_LABEL)
    xbmcplugin.addSortMethod(common.addon_handle, xbmcplugin.SORT_METHOD_DATEADDED)
    xbmcplugin.addSortMethod(common.addon_handle, xbmcplugin.SORT_METHOD_VIDEO_YEAR)

    _safe_end_of_directory(succeeded=True, cache_to_disc=True)
    log("Favoriten-Verzeichnis erfolgreich aufgebaut.")


# ---------------------------------------------------------------------------------------
# Favoriten-Staffeln anzeigen
# ---------------------------------------------------------------------------------------
def show_fav_seasons(data_json_str):
    xbmcplugin.setContent(common.addon_handle, "seasons")

    try:
        series_info = unpack_payload(data_json_str)
        if not isinstance(series_info, dict):
            raise ValueError("Ungültige Seriendaten.")
        series_name = series_info.get("name", "Unbekannte Serie")
        series_backcover = series_info.get("backcover", "")
        series_poster = series_info.get("poster", "")
        current_series_genre = series_info.get("genre", "")
        current_series_studio = series_info.get("studio", "")
        series_start_year_str = str(series_info.get("start_year", ""))
        current_series_fsk_raw = series_info.get("fsk")
        current_series_overall_vote_average = series_info.get("series_vote_average")
    except Exception as e:
        xbmcgui.Dialog().notification("Fehler", f"Ungültige Seriendaten: {e}", xbmcgui.NOTIFICATION_ERROR)
        log(f"Fehler in show_fav_seasons (Datenparsen): {e}", xbmc.LOGERROR)
        _safe_end_of_directory(succeeded=False, cache_to_disc=False)
        return

    series_fanart_for_win = _cached_or_local_image(series_backcover) or series_backcover or series_poster
    _force_window_fanart(series_fanart_for_win)

    details_url = series_info.get("details_url")
    if not details_url:
        xbmcgui.Dialog().notification("Fehler", "Keine Details-URL gefunden.", xbmcgui.NOTIFICATION_ERROR)
        _safe_end_of_directory(succeeded=False, cache_to_disc=False)
        return

    # Zufall oben
    random_item = xbmcgui.ListItem(label="Zufällige Episode starten", offscreen=True)
    random_item.setProperty("specialsort", "top")
    vi = random_item.getVideoInfoTag()
    vi.setPlot(f"Startet die Wiedergabe einer zufälligen Episode aus der Serie '{series_name}'.")
    vi.setMediaType('video')

    icon_r = os.path.join(common.addon_path, "random.png")
    if not xbmcvfs.exists(icon_r):
        icon_r = os.path.join(common.addon_path, "series.png")
        if not xbmcvfs.exists(icon_r):
            icon_r = common.addon.getAddonInfo('icon')

    _set_art_mq8(random_item, thumb=icon_r, icon=icon_r, fanart=series_fanart_for_win)

    random_series_url = common.build_url({
    "action": "play_random_fav_episode_from_this_series",
    "data": pack_payload(series_info.copy())
})

    xbmcplugin.addDirectoryItem(handle=common.addon_handle, url=random_series_url, listitem=random_item, isFolder=False)

    # Details laden (mit Cache)
    details_json, err = fetch_details_json(details_url, expected_sha1=(series_info.get("details_sha1") or None))
    if err:
        xbmcgui.Dialog().notification("Fehler", f"Details laden fehlgeschlagen: {err}", xbmcgui.NOTIFICATION_ERROR)
        log(f"Fehler beim Laden von details.json für '{series_name}': {err}", xbmc.LOGERROR)
        details_json = {}

    # FSK ggf. aus Details übernehmen
    fsk_from_seriesfav = str(current_series_fsk_raw).strip() if current_series_fsk_raw is not None else ""
    if not fsk_from_seriesfav and details_json:
        details_series_info_node = details_json.get("series_info", {})
        fsk_from_details_node = details_series_info_node.get("fsk")
        fsk_from_details_processed = str(fsk_from_details_node).strip() if fsk_from_details_node is not None else ""
        if fsk_from_details_processed:
            current_series_fsk_raw = fsk_from_details_node

    season_keys = sorted(
        [k for k in details_json if isinstance(k, str) and k.lower().startswith("season")],
        key=lambda s_key: int(s_key.lower().replace("season", "").strip()) if s_key.lower().replace("season", "").strip().isdigit() else float('inf')
    )

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

        tmdb_season_details = season_data.get("tmdb_season_details", {})
        season_num_str = season_key.lower().replace("season", "").strip()
        season_num = int(season_num_str) if season_num_str.isdigit() else -1
        season_label_name = tmdb_season_details.get("name", f"Staffel {season_num}" if season_num != -1 else season_key)

        episode_count_tmdb = tmdb_season_details.get("episode_count")
        label = f"{season_label_name} ({episode_count_tmdb} Episoden)" if isinstance(episode_count_tmdb, int) and episode_count_tmdb > 0 else season_label_name

        li = xbmcgui.ListItem(label=label, offscreen=True)

        season_poster_url = season_data.get("poster", series_poster)
        final_poster_path = _cached_or_local_image(season_poster_url) or season_poster_url

        _set_art_mq8(
            li,
            thumb=final_poster_path,
            icon=final_poster_path,
            poster=final_poster_path,
            fanart=series_fanart_for_win
        )

        info_tag = li.getVideoInfoTag()
        info_tag.setMediaType("season")
        info_tag.setTitle(season_label_name)
        if season_num != -1:
            info_tag.setSeason(season_num)
        info_tag.setTvShowTitle(series_name)
        info_tag.setPlot(tmdb_season_details.get("overview", season_data.get("overview", "")))

        s_air_date_str = str(tmdb_season_details.get("air_date", season_data.get("air_date", "")))
        s_year = 0
        s_premiered = ""
        if s_air_date_str and len(s_air_date_str) >= 4 and s_air_date_str[:4].isdigit():
            s_year = int(s_air_date_str[:4])
        if len(s_air_date_str) == 10:
            s_premiered = s_air_date_str
        elif s_year > 0:
            s_premiered = f"{s_year}-01-01"
        if not s_premiered and series_start_year_str:
            if len(series_start_year_str) == 10:
                s_premiered = series_start_year_str
            elif len(series_start_year_str) >= 4 and series_start_year_str[:4].isdigit():
                s_premiered = f"{int(series_start_year_str[:4])}-01-01"
        if s_premiered:
            info_tag.setPremiered(s_premiered)
        if s_year > 0:
            info_tag.setYear(s_year)
        elif s_air_date_str:
            _parse_year_from_air_date_string(s_air_date_str, info_tag, label)

        determined_season_rating_value = 0.0
        season_specific_vote_average_str = tmdb_season_details.get("vote_average")
        if season_specific_vote_average_str is not None:
            try:
                rating_val = float(str(season_specific_vote_average_str).replace(",", "."))
                if rating_val > 0:
                    determined_season_rating_value = rating_val
            except Exception:
                pass

        if determined_season_rating_value > 0:
            info_tag.setRating(determined_season_rating_value)
        elif current_series_overall_vote_average is not None:
            try:
                series_rating_float = float(str(current_series_overall_vote_average).replace(",", "."))
                if series_rating_float > 0:
                    info_tag.setRating(series_rating_float)
                    determined_season_rating_value = series_rating_float
            except Exception:
                pass

        fsk_processed_value_season = str(current_series_fsk_raw).strip() if current_series_fsk_raw is not None else ""
        if fsk_processed_value_season:
            info_tag.setMpaa(f"FSK {fsk_processed_value_season}")

        _split_and_set_list(info_tag, "setGenres", current_series_genre)
        _split_and_set_list(info_tag, "setStudios", current_series_studio)

        # WICHTIG: NICHT season_data + episodes in data= mitschleppen
        season_nav_data = {
            "details_url": details_url,
            "details_sha1": (series_info.get("details_sha1") or None),
            "season_key": season_key,
            "season_number": season_num,

            "series_fanart": series_backcover,
            "series_poster": series_poster,
            "series_name": series_name,
            "series_genre": current_series_genre,
            "series_studio": current_series_studio,

            "fsk_from_series": current_series_fsk_raw,
            "series_vote_average_from_series": current_series_overall_vote_average,
            "season_vote_average_from_season": determined_season_rating_value if determined_season_rating_value > 0 else None
        }

        folder_url = common.build_url({"action": "show_fav_episodes", "data": pack_payload(season_nav_data)})
        xbmcplugin.addDirectoryItem(handle=common.addon_handle, url=folder_url, listitem=li, isFolder=True)

    xbmcplugin.addSortMethod(common.addon_handle, xbmcplugin.SORT_METHOD_LABEL)
    xbmcplugin.addSortMethod(common.addon_handle, xbmcplugin.SORT_METHOD_VIDEO_YEAR)
    _safe_end_of_directory(succeeded=True, cache_to_disc=True)


# ---------------------------------------------------------------------------------------
# Episoden anzeigen
# ---------------------------------------------------------------------------------------
def show_fav_episodes(data_json_str):
    xbmcplugin.setContent(common.addon_handle, "episodes")

    try:
        season_info = unpack_payload(data_json_str)
        if not isinstance(season_info, dict):
            raise ValueError("Ungültige Staffeldaten.")
    except Exception as e:
        xbmcgui.Dialog().notification("Fehler", f"Ungültige Staffeldaten: {e}", xbmcgui.NOTIFICATION_ERROR)
        _safe_end_of_directory(succeeded=False, cache_to_disc=False)
        return

    # Legacy: falls season_data noch inline mitgegeben wurde, nutze es.
    season_data_from_nav = season_info.get("season_data") if isinstance(season_info, dict) else None
    episodes = []
    if isinstance(season_data_from_nav, dict):
        episodes = season_data_from_nav.get("episodes", []) or []

    # Neu: über details_url + season_key nachladen
    if not episodes:
        details_url = (season_info or {}).get("details_url")
        season_key = (season_info or {}).get("season_key")
        details_json, derr = fetch_details_json(details_url, expected_sha1=(season_info.get("details_sha1") or None))
        if derr:
            log(f"show_fav_episodes: Details laden fehlgeschlagen: {derr}", xbmc.LOGWARNING)
            details_json = {}
        season_data_from_nav = details_json.get(season_key, {}) if isinstance(details_json, dict) else {}
        episodes = (season_data_from_nav.get("episodes", []) or []) if isinstance(season_data_from_nav, dict) else []

    if not episodes or not isinstance(episodes, list):
        xbmcgui.Dialog().notification("Info", "Keine Episoden in dieser Staffel gefunden.", "")
        _safe_end_of_directory(succeeded=True, cache_to_disc=True)
        return

    series_name_for_ep = season_info.get("series_name", "Unbekannte Serie")
    current_season_number = season_info.get("season_number", -1)
    series_genre_str_for_ep = season_info.get("series_genre", "")
    series_studio_str_for_ep = season_info.get("series_studio", "")
    series_fanart_for_ep = season_info.get("series_fanart", "")
    series_poster_for_ep = season_info.get("series_poster", "")

    series_fsk_raw_for_ep = season_info.get("fsk_from_series")
    series_overall_vote_average_for_ep = season_info.get("series_vote_average_from_series")
    season_vote_average_for_ep = season_info.get("season_vote_average_from_season")

    series_fanart_cached = _cached_or_local_image(series_fanart_for_ep) or series_fanart_for_ep
    series_poster_cached = _cached_or_local_image(series_poster_for_ep) or series_poster_for_ep

    _force_window_fanart(series_fanart_cached or series_poster_cached)

    # Zufall oben
    random_item = xbmcgui.ListItem(label="Zufällige Episode starten", offscreen=True)
    random_item.setProperty("specialsort", "top")
    vi = random_item.getVideoInfoTag()
    vi.setPlot(
        f"Startet die Wiedergabe einer zufälligen Episode aus Staffel {current_season_number}."
        if current_season_number != -1 else "Startet die Wiedergabe einer zufälligen Episode aus dieser Staffel."
    )
    vi.setMediaType('video')

    # data_json_str kann pid:... sein -> passt
    random_url = common.build_url({"action": "play_random_favorite_episode_from_season", "data": data_json_str})

    icon_r = os.path.join(common.addon_path, "random.png")
    if not xbmcvfs.exists(icon_r):
        icon_r = os.path.join(common.addon_path, "series.png")
        if not xbmcvfs.exists(icon_r):
            icon_r = common.addon.getAddonInfo('icon')

    _set_art_mq8(random_item, thumb=icon_r, icon=icon_r, fanart=series_fanart_cached or series_poster_cached)
    xbmcplugin.addDirectoryItem(handle=common.addon_handle, url=random_url, listitem=random_item, isFolder=False)

    episodes = sorted(episodes, key=lambda ep_item: int(ep_item.get("episode_number", 0)) if str(ep_item.get("episode_number", "0")).isdigit() else float('inf'))

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

        ep_title = ep.get("name", "Unbekannte Episode")
        ep_number_val = ep.get("episode_number", "")
        label = f"E{ep_number_val}: {ep_title}" if ep_number_val else ep_title
        if current_season_number != -1:
            label = f"S{str(current_season_number).zfill(2)}{label}"

        li = xbmcgui.ListItem(label=label, offscreen=True)
        li.setProperty("IsPlayable", "true")

        playable_path = unquote2(ep.get("file_ort", ""))

        still_url = ep.get("still", "")
        final_still_path = _cached_or_local_image(still_url) or still_url if still_url else ""
        final_thumb = final_still_path or series_poster_cached

        _set_art_mq8(
            li,
            thumb=final_thumb,
            icon=final_thumb,
            poster=final_thumb,
            fanart=series_fanart_cached or final_thumb,
            **{
                "landscape": final_thumb,
                "tvshow.poster": series_poster_cached,
                "tvshow.fanart": series_fanart_cached
            }
        )

        info_tag = li.getVideoInfoTag()
        info_tag.setMediaType("episode")
        info_tag.setTitle(ep_title)
        info_tag.setPlot(ep.get("overview", ""))
        info_tag.setTvShowTitle(series_name_for_ep)

        if current_season_number != -1:
            info_tag.setSeason(current_season_number)
        if ep_number_val:
            try:
                info_tag.setEpisode(int(ep_number_val))
            except Exception:
                info_tag.setEpisode(0)

        air_date_str = ep.get("air_date", "")
        if air_date_str and air_date_str.strip():
            info_tag.setPremiered(air_date_str)
            _parse_year_from_air_date_string(air_date_str, info_tag, label)

        episode_runtime_minutes = ep.get("runtime")
        if isinstance(episode_runtime_minutes, int) and episode_runtime_minutes > 0:
            try:
                info_tag.setDuration(episode_runtime_minutes * 60)
            except Exception:
                pass

        final_rating = 0.0
        for cand in (ep.get("rating"), season_vote_average_for_ep, series_overall_vote_average_for_ep):
            if cand is not None:
                try:
                    final_rating = float(str(cand).replace(",", "."))
                except Exception:
                    pass
            if final_rating > 0:
                break
        if final_rating > 0:
            info_tag.setRating(final_rating)

        fsk_processed_value_ep = str(series_fsk_raw_for_ep).strip() if series_fsk_raw_for_ep is not None else ""
        if fsk_processed_value_ep:
            info_tag.setMpaa(f"FSK {fsk_processed_value_ep}")

        _split_and_set_list(info_tag, "setDirectors", ep.get("directors", ""))
        _split_and_set_list(info_tag, "setGenres", series_genre_str_for_ep)
        _split_and_set_list(info_tag, "setStudios", series_studio_str_for_ep)
        episode_info = _build_favorite_episode_info(
            playable_path_raw=playable_path,
            episode_title=ep_title,
            overview=ep.get("overview", ""),
            air_date=ep.get("air_date", ""),
            still=ep.get("still", ""),
            series_title=series_name_for_ep,
            series_poster=series_poster_cached,
            series_fanart=series_fanart_cached,
            season_number=current_season_number if current_season_number != -1 else 0,
            episode_number=ep.get("episode_number", 0),
            genre=series_genre_str_for_ep,
            studio=series_studio_str_for_ep,
            directors=ep.get("directors", ""),
            runtime=ep.get("runtime"),
            fsk=series_fsk_raw_for_ep,
            episode_rating=ep.get("rating"),
            season_vote_average=season_vote_average_for_ep,
            series_vote_average=series_overall_vote_average_for_ep,
            video_codec=ep.get("video_codec", "")
        )
        ep_play_url = _build_favorite_episode_plugin_url(episode_info)
        if not ep_play_url:
            log(f"show_fav_episodes: sichere Plugin-URL konnte nicht gebaut werden für '{ep_title}'", xbmc.LOGWARNING)
            continue
        xbmcplugin.addDirectoryItem(handle=common.addon_handle, url=ep_play_url, listitem=li, isFolder=False)

    xbmcplugin.addSortMethod(common.addon_handle, xbmcplugin.SORT_METHOD_EPISODE)
    xbmcplugin.addSortMethod(common.addon_handle, xbmcplugin.SORT_METHOD_LABEL)
    xbmcplugin.addSortMethod(common.addon_handle, xbmcplugin.SORT_METHOD_DATE)
    _safe_end_of_directory(succeeded=True, cache_to_disc=True)


# ---------------------------------------------------------------------------------------
# Zufallsfunktionen
# ---------------------------------------------------------------------------------------
def play_random_episode_from_season(data_json_str):
    try:
        season_info = unpack_payload(data_json_str)
        if not isinstance(season_info, dict):
            raise ValueError("Geparste Daten sind kein Dictionary.")
    except Exception as e:
        xbmc.log(f"[{common.addon_id}] Fehler beim Parsen der Staffeldaten: {e}", xbmc.LOGERROR)
        xbmcgui.Dialog().notification("Fehler", "Ungültige Staffeldaten.", xbmcgui.NOTIFICATION_ERROR)
        return

    # Legacy / neu kombinieren
    season_data = (season_info or {}).get("season_data") if isinstance(season_info, dict) else None
    episodes = (season_data.get("episodes", []) or []) if isinstance(season_data, dict) else []

    if not episodes:
        details_url = (season_info or {}).get("details_url")
        season_key = (season_info or {}).get("season_key")
        details_json, derr = fetch_details_json(details_url, expected_sha1=(season_info.get("details_sha1") or None))
        if derr:
            xbmcgui.Dialog().notification("Fehler", f"Details laden fehlgeschlagen: {derr}", xbmcgui.NOTIFICATION_ERROR)
            return
        season_data = details_json.get(season_key, {}) if isinstance(details_json, dict) else {}
        episodes = (season_data.get("episodes", []) or []) if isinstance(season_data, dict) else []

    if not episodes or not isinstance(episodes, list):
        xbmcgui.Dialog().notification("Fehler", "Keine Episoden in dieser Staffel gefunden.", xbmcgui.NOTIFICATION_ERROR)
        return

    season_no = season_info.get("season_number", -1)
    series_name = season_info.get("series_name", "Unbekannte Serie")
    series_poster = season_info.get("series_poster", "")
    series_fanart = season_info.get("series_fanart", "")
    series_genre = season_info.get("series_genre", "")
    series_studio = season_info.get("series_studio", "")
    fsk_raw = season_info.get("fsk_from_series")
    series_vote_avg = season_info.get("series_vote_average_from_series")
    season_vote_avg = season_info.get("season_vote_average_from_season")

    autoplay_random = _get_setting_bool("autoplay_random", False)

    def _apply_common_tags(li, ep):
        info = li.getVideoInfoTag()
        info.setMediaType("episode")
        info.setTitle(ep.get("name", "Zufällige Episode"))
        info.setTvShowTitle(series_name)
        info.setPlot(ep.get("overview", ""))

        if season_no != -1:
            info.setSeason(season_no)
        ep_no = ep.get("episode_number", 0)
        if ep_no:
            try:
                info.setEpisode(int(ep_no))
            except Exception:
                pass

        air_date = ep.get("air_date", "")
        if air_date:
            info.setPremiered(air_date)
            _parse_year_from_air_date_string(air_date, info, ep.get("name", ""))

        runtime_min = ep.get("runtime")
        if isinstance(runtime_min, int) and runtime_min > 0:
            try:
                info.setDuration(runtime_min * 60)
            except Exception:
                pass

        final_rating = 0.0
        for cand in (ep.get("rating"), season_vote_avg, series_vote_avg):
            if cand is not None:
                try:
                    final_rating = float(str(cand).replace(",", "."))
                except Exception:
                    pass
            if final_rating > 0:
                break
        if final_rating > 0:
            info.setRating(final_rating)

        if fsk_raw is not None:
            fsk_s = str(fsk_raw).strip()
            if fsk_s:
                info.setMpaa(f"FSK {fsk_s}")

        _split_and_set_list(info, "setDirectors", ep.get("directors", ""))
        _split_and_set_list(info, "setGenres", series_genre)
        _split_and_set_list(info, "setStudios", series_studio)

    def _apply_art(li, still_url):
        still_cached = _cached_or_local_image(still_url) or still_url
        _set_art_mq8(
            li,
            thumb=still_cached,
            icon=still_cached,
            poster=_cached_or_local_image(series_poster) or series_poster,
            fanart=_cached_or_local_image(series_fanart) or series_fanart
        )

    if autoplay_random:
        playlist = xbmc.PlayList(xbmc.PLAYLIST_VIDEO)
        playlist.clear()

        chosen = random.sample(episodes, min(10, len(episodes)))
        added_paths = []

        for ep in chosen:
            path = unquote2(ep.get("file_ort", ""))
            title = ep.get("name")
            play_path = _prepare_playback_path(path, codec_hint=ep.get("video_codec"))
            if not (play_path and title):
                continue
            episode_info = _build_favorite_episode_info(
                playable_path_raw=path,
                episode_title=title,
                overview=ep.get("overview", ""),
                air_date=ep.get("air_date", ""),
                still=ep.get("still", ""),
                series_title=series_name,
                series_poster=series_poster,
                series_fanart=series_fanart,
                season_number=season_no if season_no != -1 else 0,
                episode_number=ep.get("episode_number", 0),
                genre=series_genre,
                studio=series_studio,
                directors=ep.get("directors", ""),
                runtime=ep.get("runtime"),
                fsk=fsk_raw,
                episode_rating=ep.get("rating"),
                season_vote_average=season_vote_avg,
                series_vote_average=series_vote_avg,
                video_codec=ep.get("video_codec", "")
            )
            plugin_url = _build_favorite_episode_plugin_url(episode_info)
            if not plugin_url:
                continue
            li = _build_favorite_episode_listitem(episode_info, path_override=plugin_url)
            _apply_common_tags(li, ep)
            _apply_art(li, ep.get("still", ""))
            playlist.add(plugin_url, li)
            added_paths.append(path)

        if playlist.size() > 0:
            xbmc.Player().play(playlist)

            def _playlist_watcher(expected_paths):
                try:
                    mon = xbmc.Monitor()
                    player = xbmc.Player()

                    expected_norm = [_playback_compare_key(p) for p in expected_paths if p]
                    started_for = set()

                    while not mon.abortRequested():
                        if player.isPlayingVideo():
                            try:
                                current_file = _playback_compare_key(player.getPlayingFile() or "")
                            except Exception:
                                current_file = ""

                            if current_file:
                                for p_raw, p_norm in zip(expected_paths, expected_norm):
                                    if p_norm == current_file and p_raw not in started_for:
                                        start_series_monitor(p_raw)
                                        started_for.add(p_raw)
                                        break

                        if not player.isPlayingVideo() and not xbmc.getCondVisibility("Player.HasMedia"):
                            break

                        if common.wait_or_abort(mon, 1.0):
                            return
                except Exception as e:
                    log(f"Playlist-Watcher Fehler: {e}", xbmc.LOGDEBUG)

            try:
                t_mon = threading.Thread(target=_playlist_watcher, args=(added_paths,), daemon=True)
                t_mon.start()
            except Exception as e:
                log(f"Playlist-Watcher Startfehler: {e}", xbmc.LOGDEBUG)

        else:
            xbmcgui.Dialog().notification("Zufall", "Keine passende Episode gefunden.", xbmcgui.NOTIFICATION_INFO)

    else:
        ep = random.choice(episodes)
        path = unquote2(ep.get("file_ort", ""))
        title = ep.get("name")
        play_path = _prepare_playback_path(path, codec_hint=ep.get("video_codec"))
        if not (play_path and title):
            xbmcgui.Dialog().notification("Zufall", "Unvollständige Episodendaten.", xbmcgui.NOTIFICATION_WARNING)
            return
        episode_info = _build_favorite_episode_info(
            playable_path_raw=path,
            episode_title=title,
            overview=ep.get("overview", ""),
            air_date=ep.get("air_date", ""),
            still=ep.get("still", ""),
            series_title=series_name,
            series_poster=series_poster,
            series_fanart=series_fanart,
            season_number=season_no if season_no != -1 else 0,
            episode_number=ep.get("episode_number", 0),
            genre=series_genre,
            studio=series_studio,
            directors=ep.get("directors", ""),
            runtime=ep.get("runtime"),
            fsk=fsk_raw,
            episode_rating=ep.get("rating"),
            season_vote_average=season_vote_avg,
            series_vote_average=series_vote_avg,
            video_codec=ep.get("video_codec", "")
        )
        plugin_url = _build_favorite_episode_plugin_url(episode_info)
        if not plugin_url:
            xbmcgui.Dialog().notification("Zufall", "Plugin-Route konnte nicht gebaut werden.", xbmcgui.NOTIFICATION_WARNING)
            return
        li = _build_favorite_episode_listitem(episode_info, path_override=plugin_url)
        _apply_common_tags(li, ep)
        _apply_art(li, ep.get("still", ""))
        _play_plugin_route(plugin_url, li, prefer_playmedia_pm=True)


def play_random_episode_from_specific_series(series_info_json_str):
    try:
        series_info = unpack_payload(series_info_json_str)
        if not isinstance(series_info, dict):
            raise ValueError("Ungültige Seriendaten für spezifische Zufallswiedergabe.")

        series_name = series_info.get("name", "Unbekannte Serie")
        details_url = series_info.get("details_url")
        series_fsk_raw = series_info.get("fsk")

        # kompatibel: manchmal kommt series_vote_average statt vote_average
        series_overall_vote_avg = series_info.get("vote_average")
        if series_overall_vote_avg is None:
            series_overall_vote_avg = series_info.get("series_vote_average")

        series_poster_for_ep = series_info.get("poster")
        series_fanart_for_ep = series_info.get("backcover")
        series_genre_for_ep = series_info.get("genre", "")
        series_studio_for_ep = series_info.get("studio", "")

        if not details_url:
            xbmcgui.Dialog().notification("Fehler", f"Keine Details-URL für Serie '{series_name}' gefunden.", xbmcgui.NOTIFICATION_ERROR)
            return

        details_json, derr = fetch_details_json(details_url, expected_sha1=(series_info.get("details_sha1") or None))
        if derr:
            xbmcgui.Dialog().notification("Fehler", f"Details laden fehlgeschlagen: {derr}", xbmcgui.NOTIFICATION_ERROR)
            return

        all_episodes = []
        for season_key, season_data_val in details_json.items():
            if isinstance(season_data_val, dict) and season_key.lower().startswith("season"):
                s_num = 0
                try:
                    s_num = int(re.search(r'(\d+)', season_key).group(1))
                except Exception:
                    pass
                tmdb_s_details = season_data_val.get("tmdb_season_details", {})
                season_vote_avg = tmdb_s_details.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'] = season_vote_avg
                        all_episodes.append(ep_copy)

        if not all_episodes:
            xbmcgui.Dialog().notification("Info", f"Keine Episoden in Serie '{series_name}' gefunden.", xbmcgui.NOTIFICATION_INFO)
            return

        random_episode = random.choice(all_episodes)
        playable_path_raw = unquote2(random_episode.get("file_ort") or "")
        playable_path = _prepare_playback_path(playable_path_raw, codec_hint=random_episode.get("video_codec"))
        episode_title = random_episode.get("name", "Zufällige Episode")
        if not playable_path:
            xbmcgui.Dialog().notification("Fehler", "Kein Abspielpfad gefunden.", xbmcgui.NOTIFICATION_WARNING)
            return

        episode_info = _build_favorite_episode_info(
            playable_path_raw=playable_path_raw,
            episode_title=episode_title,
            overview=random_episode.get("overview", ""),
            air_date=random_episode.get("air_date", ""),
            still=random_episode.get("still", ""),
            series_title=series_name,
            series_poster=series_poster_for_ep,
            series_fanart=series_fanart_for_ep,
            season_number=random_episode.get('parsed_season_number', 0),
            episode_number=random_episode.get("episode_number", 0),
            genre=series_genre_for_ep,
            studio=series_studio_for_ep,
            directors=random_episode.get("directors", ""),
            runtime=random_episode.get("runtime"),
            fsk=series_fsk_raw,
            episode_rating=random_episode.get("rating"),
            season_vote_average=random_episode.get("current_season_vote_average"),
            series_vote_average=series_overall_vote_avg,
            video_codec=random_episode.get("video_codec", "")
        )
        plugin_url = _build_favorite_episode_plugin_url(episode_info)
        if not plugin_url:
            xbmcgui.Dialog().notification("Hinweis", "Plugin-Route konnte nicht gebaut werden.", xbmcgui.NOTIFICATION_WARNING)
            return

        li = _build_favorite_episode_listitem(episode_info, path_override=plugin_url)
        _play_plugin_route(plugin_url, li, prefer_playmedia_pm=True)

    except Exception as e:
        log(f"Fehler in play_random_episode_from_specific_series: {e}\n{traceback.format_exc()}",
            xbmc.LOGERROR)
        xbmcgui.Dialog().notification("Fehler", "Zufallsepisode konnte nicht gestartet werden.", xbmcgui.NOTIFICATION_ERROR)


def find_random_episode_info(count=1, max_retries=20):
    log(f"BG: Suche zufällige Episode(n) (Anzahl: {count})")
    all_series_data, err = load_favorites_json()
    if err:
        log(f"find_random_episode_info: load_favorites_json Fehler: {err}", xbmc.LOGWARNING)
        return None
    if not all_series_data:
        log("find_random_episode_info: keine Favoriten vorhanden.", xbmc.LOGINFO)
        return None

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

    # In-Memory Details Cache (zusätzlich zum Disk-Cache)
    processed_series_details_cache = {}

    while len(results) < count and attempts < max_total_attempts and available_series_indices:
        attempts += 1
        try:
            chosen_idx = random.choice(available_series_indices)
            random_serie = all_series_data[chosen_idx].copy()
            random_serie.pop("action", None)

            series_title_fav = random_serie.get("name", "Unbekannte Serie")
            details_url = random_serie.get("details_url")
            if not details_url:
                available_series_indices.remove(chosen_idx)
                continue

            details_json = processed_series_details_cache.get(details_url)
            if details_json is None:
                d, derr = fetch_details_json(details_url, expected_sha1=(random_serie.get("details_sha1") or None))
                if derr:
                    processed_series_details_cache[details_url] = False
                    available_series_indices.remove(chosen_idx)
                    continue
                details_json = d if isinstance(d, dict) else {}
                processed_series_details_cache[details_url] = details_json
            elif details_json is False:
                available_series_indices.remove(chosen_idx)
                continue

            all_episodes = []
            for season_key, season_data_val in details_json.items():
                if isinstance(season_data_val, dict) and season_key.lower().startswith("season"):
                    s_num = 0
                    try:
                        s_num = int(re.search(r'(\d+)', season_key).group(1))
                    except Exception:
                        pass

                    tmdb_s_details = season_data_val.get("tmdb_season_details", {})
                    season_vote_avg_for_this_season = tmdb_s_details.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'] = season_vote_avg_for_this_season
                            all_episodes.append(ep_copy)

            if not all_episodes:
                available_series_indices.remove(chosen_idx)
                continue

            random_episode_data = random.choice(all_episodes)

            info = _build_favorite_episode_info(
                playable_path_raw=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_fav,
                series_poster=random_serie.get("poster", ""),
                series_fanart=random_serie.get("backcover", ""),
                season_number=random_episode_data.get("parsed_season_number", 0),
                episode_number=random_episode_data.get("episode_number", 0),
                genre=random_serie.get("genre", ""),
                studio=random_serie.get("studio", ""),
                directors=random_episode_data.get("directors", ""),
                runtime=random_episode_data.get("runtime"),
                fsk=random_serie.get("fsk"),
                episode_rating=random_episode_data.get("rating"),
                season_vote_average=random_episode_data.get("current_season_vote_average"),
                series_vote_average=random_serie.get("vote_average"),
                video_codec=random_episode_data.get("video_codec", ""),
            )
            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:
            log(f"Fehler im Zufallsversuch ({attempts}/{max_total_attempts}): {e}", xbmc.LOGDEBUG)
            try:
                if chosen_idx in available_series_indices:
                    available_series_indices.remove(chosen_idx)
            except Exception:
                pass
            continue

    if not results:
        return None
    return results[0] if count == 1 else results


def play_random_favorite_series():
    autoplay_random = _get_setting_bool("autoplay_random", False)

    if autoplay_random:
        episodes_info_list = find_random_episode_info(count=10)
        if episodes_info_list and isinstance(episodes_info_list, list):
            playlist = xbmc.PlayList(xbmc.PLAYLIST_VIDEO)
            playlist.clear()

            added_paths = []

            for episode_info in episodes_info_list:
                playable_path_raw = unquote2(episode_info.get("playable_path") or "")
                if not playable_path_raw:
                    continue
                playable_path = _prepare_playback_path(playable_path_raw, codec_hint=episode_info.get("video_codec"))
                if not playable_path:
                    continue

                plugin_url = _build_favorite_episode_plugin_url(episode_info)
                if not plugin_url:
                    continue

                plugin_url_pm = _append_plugin_flag(plugin_url, "__pm", "1")
                li = _build_favorite_episode_listitem(episode_info, path_override=plugin_url_pm)
                playlist.add(plugin_url_pm, li)
                added_paths.append(playable_path_raw)

            if playlist.size() > 0:
                xbmc.Player().play(playlist)

                def _playlist_watcher(expected_paths):
                    try:
                        mon = xbmc.Monitor()
                        player = xbmc.Player()

                        expected_norm = [_playback_compare_key(p) for p in expected_paths if p]
                        started_for = set()

                        while not mon.abortRequested():
                            if player.isPlayingVideo():
                                try:
                                    current_file = _playback_compare_key(player.getPlayingFile() or "")
                                except Exception:
                                    current_file = ""

                                if current_file:
                                    for p_raw, p_norm in zip(expected_paths, expected_norm):
                                        if p_norm == current_file and p_raw not in started_for:
                                            start_series_monitor(p_raw)
                                            started_for.add(p_raw)
                                            break

                            if not player.isPlayingVideo() and not xbmc.getCondVisibility("Player.HasMedia"):
                                break

                            if common.wait_or_abort(mon, 1.0):
                                return
                    except Exception as e:
                        log(f"Playlist-Watcher Fehler: {e}", xbmc.LOGDEBUG)

                try:
                    t_mon = threading.Thread(target=_playlist_watcher, args=(added_paths,), daemon=True)
                    t_mon.start()
                except Exception as e:
                    log(f"Playlist-Watcher Startfehler: {e}", xbmc.LOGDEBUG)

            else:
                xbmcgui.Dialog().notification("Zufall", "Keine passende globale Favoriten-Episode gefunden.", xbmcgui.NOTIFICATION_INFO)
        else:
            xbmcgui.Dialog().notification("Zufall", "Keine passende globale Favoriten-Episode gefunden.", xbmcgui.NOTIFICATION_INFO)

    else:
        episode_info = find_random_episode_info(count=1)
        if not (episode_info and isinstance(episode_info, dict)):
            xbmcgui.Dialog().notification("Zufall", "Keine passende globale Favoriten-Episode gefunden.", xbmcgui.NOTIFICATION_INFO)
            return

        playable_path_raw = unquote2(episode_info.get("playable_path") or "")
        playable_path = _prepare_playback_path(playable_path_raw, codec_hint=episode_info.get("video_codec"))
        if not playable_path:
            xbmcgui.Dialog().notification("Zufall", "Globale Episode gefunden, aber kein Pfad.", xbmcgui.NOTIFICATION_WARNING)
            return

        plugin_url = _build_favorite_episode_plugin_url(episode_info)
        if plugin_url:
            plugin_url_pm = _append_plugin_flag(plugin_url, "__pm", "1")
            li = _build_favorite_episode_listitem(episode_info, path_override=plugin_url_pm)
            if _play_plugin_route(plugin_url, li, prefer_playmedia_pm=True, log_label="play_random_favorite_series"):
                return

        # Kein direkter Stream-Fallback: Kodi loggt direkte http(s)-Playback-URLs inkl.
        # Pipe-Headern (z.B. Authorization=Basic). Wenn der sichere pid:-Pfad nicht
        # gebaut werden kann, lieber sauber abbrechen statt Credentials ins kodi.log zu schreiben.
        log("play_random_favorite_series: secure plugin route unavailable; direct stream fallback disabled.", xbmc.LOGWARNING)
        xbmcgui.Dialog().notification("Zufall", "Episode konnte nicht sicher gestartet werden.", xbmcgui.NOTIFICATION_WARNING)
        return

# Hinweis: Routing läuft zentral über default.py.
