#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
resources/resolvers/daddyhd.py
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Resolver per DaddyHD / DLStreams.

Funzioni esposte al dispatcher:
    show_menu()              <- mode=daddy_menu
    show_channels()          <- mode=daddy_channels
    show_categories(date)    <- mode=daddy_categories
    show_matches(cat, date)  <- mode=daddy_matches
    show_favourites()        <- mode=daddy_favourites
    add_favourite(link, t)   <- mode=daddy_add_fav
    remove_favourite(l, t)   <- mode=daddy_remove_fav
    show_search()            <- mode=daddy_search
    play(link, title)        <- mode=daddy_play
    refresh()                <- mode=daddy_refresh
    run_diag()               <- mode=daddy_diagnostics
"""

import os
import sys
import re
import json
import html
import gzip
import base64
import hashlib
import hmac as _hmac
import socket
import struct
import random
import threading
import tempfile
import time
import calendar
from datetime import datetime, timezone, timedelta
import traceback
import concurrent.futures
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlencode, unquote, parse_qsl, quote_plus, urlparse, urljoin, quote
from typing import Optional

import xbmc
import xbmcgui
import xbmcvfs
import xbmcplugin
import xbmcaddon
import requests

import variables as var
import functions as func
from models import Item, Category, Channel

# ── Adattatori: mappa le variabili globali di addon.py a var/func ──────────
_KODI_TEMP = xbmcvfs.translatePath('special://temp/')
os.makedirs(_KODI_TEMP, exist_ok=True)

# addon.py usa 'addon' per settings — usiamo var.addon ma creiamo alias
_addon = xbmcaddon.Addon(id='plugin.video.torproxy')
addon  = _addon   # alias necessario: tutto il codice integrato usa 'addon' non '_addon'

# Variabili che addon.py usa come globali
SEED_BASEURL    = var.base_url if hasattr(var, 'base_url') else 'https://dlstreams.top/'
UA              = ('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 '
                  '(KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36')
FANART          = var.addon_fanart
ICON            = var.addon_icon
_CUSTOM_DNS     = ''
EXTRA_M3U8_URL  = ''

CHEVY_PROXY          = 'https://chevy.soyspace.cyou'
CHEVY_LOOKUP         = 'https://chevy.vovlacosa.sbs'
_CHEVY_PROXY_BUILTIN = 'https://chevy.soyspace.cyou'
_CHEVY_LOOKUP_BUILTIN= 'https://chevy.vovlacosa.sbs'
_KSOHLS_BASE         = 'https://www.ksohls.ru'
PLAYER_REFERER       = _KSOHLS_BASE + '/'
_KSOHLS_FALLBACK_BASES = ['https://www.ksohls.ru', 'https://enviromentalspace.cyou']

M3U8_PROXY_PORT  = 19876
_actual_proxy_port = None
try:
    _PROXY_ADDON_MTIME = str(int(os.path.getmtime(__file__)))
except Exception:
    _PROXY_ADDON_MTIME = '0'

IPTV_ORG_API        = 'https://iptv-org.github.io/api/channels.json'
IPTV_ORG_INDEX_CACHE= os.path.join(_KODI_TEMP, 'dltv_iptv_org_idx2.json')
IPTV_ORG_TVG_CACHE  = os.path.join(_KODI_TEMP, 'dltv_iptv_org_tvg.json')
IPTV_ORG_TTL        = 7 * 24 * 3600
_XMLTV_TTL          = 4 * 3600
_XMLTV_PROG_CACHE   = os.path.join(_KODI_TEMP, 'dltv_xmltv_programs.json')
_XMLTV_PROG_TTL     = 1800
_XMLTV_SOURCES = {
    'epg_fr': ('French',     'https://epgshare01.online/epgshare01/epg_ripper_FR1.xml.gz', 'dltv_xmltv_FR.xml.gz'),
    'epg_uk': ('English',    'https://epgshare01.online/epgshare01/epg_ripper_UK1.xml.gz', 'dltv_xmltv_UK.xml.gz'),
    'epg_ar': ('Arabic',     'https://epgshare01.online/epgshare01/epg_ripper_AR1.xml.gz', 'dltv_xmltv_AR.xml.gz'),
    'epg_es': ('Spanish',    'https://epgshare01.online/epgshare01/epg_ripper_ES1.xml.gz', 'dltv_xmltv_ES.xml.gz'),
    'epg_it': ('Italian',    'https://epgshare01.online/epgshare01/epg_ripper_IT1.xml.gz', 'dltv_xmltv_IT.xml.gz'),
    'epg_de': ('German',     'https://epgshare01.online/epgshare01/epg_ripper_DE1.xml.gz', 'dltv_xmltv_DE.xml.gz'),
    'epg_pt': ('Portuguese', 'https://epgshare01.online/epgshare01/epg_ripper_PT1.xml.gz', 'dltv_xmltv_PT.xml.gz'),
    'epg_nl': ('Dutch',      'https://epgshare01.online/epgshare01/epg_ripper_NL1.xml.gz', 'dltv_xmltv_NL.xml.gz'),
    'epg_tr': ('Turkish',    'https://epgshare01.online/epgshare01/epg_ripper_TR1.xml.gz', 'dltv_xmltv_TR.xml.gz'),
    'epg_pl': ('Polish',     'https://epgshare01.online/epgshare01/epg_ripper_PL1.xml.gz', 'dltv_xmltv_PL.xml.gz'),
    'epg_ro': ('Romanian',   'https://epgshare01.online/epgshare01/epg_ripper_RO1.xml.gz', 'dltv_xmltv_RO.xml.gz'),
    'epg_ru': ('Russian',    'https://epgshare01.online/epgshare01/epg_ripper_RU1.xml.gz', 'dltv_xmltv_RU.xml.gz'),
}
_CDN_CACHE_FILE       = os.path.join(_KODI_TEMP, 'dltv_cdn_map.json')
_DISCOVERY_CACHE_FILE = os.path.join(_KODI_TEMP, 'dltv_discovery.json')
_DISCOVERY_CACHE_TTL  = 12 * 3600
_CDN_CACHE_TTL        = 3600
_CDN_STATUS_CACHE_FILE= os.path.join(_KODI_TEMP, 'dltv_cdn_status.json')
_CDN_STATUS_TTL       = 300
_FAILED_CHANNELS_FILE = os.path.join(_KODI_TEMP, 'dltv_failed_channels.json')
_FAILED_CHANNEL_TTL   = 600
_PREMIUM_KEY_RX = re.compile(r'^premium(\d+)$')
_M3U8_ENDLIST   = b'#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:4\n#EXT-X-ENDLIST\n'
_SEQ_RX         = re.compile(r'#EXT-X-MEDIA-SEQUENCE:(\d+)')
_FAV_PROBE_TS_FILE = os.path.join(_KODI_TEMP, 'dltv_fav_probe_ts')
_PAGE_CACHE_FILE   = os.path.join(_KODI_TEMP, 'dltv_page_cache.json')

# addon.py usa addon_handle per xbmcplugin — usiamo var.handle
addon_handle = var.handle

# addon.py usa build_url con addon_url — usiamo var.plugin_url
addon_url = var.plugin_url

def _aes128_cbc_decrypt(data, key, iv):
    """AES-128-CBC decrypt. Tries pycryptodome, cryptography, Windows CryptoAPI."""
    # Backend 1: pycryptodome
    try:
        from Crypto.Cipher import AES
        c = AES.new(key, AES.MODE_CBC, iv)
        dec = c.decrypt(data)
        log('[StreamProxy] AES: pycryptodome')
        pad = dec[-1] if dec else 0
        if 0 < pad <= 16 and dec[-pad:] == bytes([pad] * pad):
            return dec[:-pad]
        return dec
    except ImportError:
        pass
    except Exception as e:
        log(f'[StreamProxy] AES pycryptodome error: {e}')
    # Backend 2: cryptography package
    try:
        from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
        from cryptography.hazmat.backends import default_backend
        c = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
        d = c.decryptor()
        dec = d.update(data) + d.finalize()
        log('[StreamProxy] AES: cryptography')
        pad = dec[-1] if dec else 0
        if 0 < pad <= 16 and dec[-pad:] == bytes([pad] * pad):
            return dec[:-pad]
        return dec
    except ImportError:
        pass
    except Exception as e:
        log(f'[StreamProxy] AES cryptography error: {e}')
    # Backend 3: Windows CryptoAPI — explicit argtypes to avoid marshaling errors
    try:
        import ctypes
        _adv = ctypes.WinDLL('advapi32', use_last_error=True)
        _H = ctypes.c_size_t  # HCRYPTPROV / HCRYPTKEY = ULONG_PTR (pointer-sized)
        _adv.CryptAcquireContextW.restype = ctypes.c_int
        _adv.CryptAcquireContextW.argtypes = [
            ctypes.POINTER(_H), ctypes.c_wchar_p, ctypes.c_wchar_p,
            ctypes.c_uint, ctypes.c_uint]
        _adv.CryptImportKey.restype = ctypes.c_int
        _adv.CryptImportKey.argtypes = [
            _H, ctypes.c_char_p, ctypes.c_uint, _H, ctypes.c_uint, ctypes.POINTER(_H)]
        _adv.CryptSetKeyParam.restype = ctypes.c_int
        _adv.CryptSetKeyParam.argtypes = [_H, ctypes.c_uint, ctypes.c_char_p, ctypes.c_uint]
        _adv.CryptDecrypt.restype = ctypes.c_int
        _adv.CryptDecrypt.argtypes = [
            _H, _H, ctypes.c_int, ctypes.c_uint,
            ctypes.c_char_p, ctypes.POINTER(ctypes.c_uint)]
        _adv.CryptDestroyKey.restype = ctypes.c_int
        _adv.CryptDestroyKey.argtypes = [_H]
        _adv.CryptReleaseContext.restype = ctypes.c_int
        _adv.CryptReleaseContext.argtypes = [_H, ctypes.c_uint]
        _prov = _H(0)
        _key_h = _H(0)
        # PROV_RSA_AES=24, CRYPT_VERIFYCONTEXT=0xF0000000
        if not _adv.CryptAcquireContextW(ctypes.byref(_prov), None, None, 24, 0xF0000000):
            log(f'[StreamProxy] AES WinCryptoAPI AcquireCtx err={ctypes.get_last_error()}')
        else:
            # PLAINTEXTKEYBLOB: BLOBHEADER(8B) + DWORD cbKeySize(4B) + key(16B) = 28B
            # BLOBHEADER: bType=8, bVersion=2, reserved=0, aiKeyAlg=0x660E (CALG_AES_128)
            # 0x660E = ALG_CLASS_DATA_ENCRYPT|ALG_TYPE_BLOCK|ALG_SID_AES_128 (NOT 0x6610=AES-256)
            _blob = struct.pack('<BBHII', 8, 2, 0, 0x660E, 16) + bytes(key)
            if not _adv.CryptImportKey(_prov.value, _blob, len(_blob), 0, 0, ctypes.byref(_key_h)):
                log(f'[StreamProxy] AES WinCryptoAPI ImportKey err={ctypes.get_last_error()}')
                _adv.CryptReleaseContext(_prov.value, 0)
            else:
                _adv.CryptSetKeyParam(_key_h.value, 1, bytes(iv), 0)  # KP_IV=1
                _buf = ctypes.create_string_buffer(bytes(data), len(data))
                _buf_len = ctypes.c_uint(len(data))
                # fFinal=0: skip PKCS#7 validation (HLS segments may use non-standard padding)
                # strip padding manually, same as pycryptodome/cryptography backends
                if _adv.CryptDecrypt(_key_h.value, 0, 0, 0, _buf, ctypes.byref(_buf_len)):
                    dec = bytes(_buf.raw[:_buf_len.value])
                    _adv.CryptDestroyKey(_key_h.value)
                    _adv.CryptReleaseContext(_prov.value, 0)
                    pad = dec[-1] if dec else 0
                    if 0 < pad <= 16 and dec[-pad:] == bytes([pad] * pad):
                        result = dec[:-pad]
                    else:
                        result = dec
                    log('[StreamProxy] AES: WinCryptoAPI')
                    return result
                log(f'[StreamProxy] AES WinCryptoAPI CryptDecrypt err={ctypes.get_last_error()}')
                _adv.CryptDestroyKey(_key_h.value)
                _adv.CryptReleaseContext(_prov.value, 0)
    except Exception as e:
        log(f'[StreamProxy] AES WinCryptoAPI error: {e}')
    log('[StreamProxy] AES: no backend — streaming encrypted data (will not play)')
    return data

def _fetch_stream_key(key_id, channel_key, state, key_cache, force_refresh=False):
    """Fetch AES-128 decryption key from CDN, caching result per session.

    force_refresh=True: bypass CHEVY's server-side cache (used on stale key retry).
    Adds Cache-Control: no-cache headers + ?_nc=<ts> URL cache-buster so CHEVY
    re-fetches from the upstream CDN key server instead of serving cached bytes.

    Key server is derived from state['m3u8_url'] (same host as CHEVY proxy) so that
    when CHEVY_PROXY != CHEVY_LOOKUP (different server deployments), the correct
    server is always used — avoiding AES key mismatch.
    """
    if key_id in key_cache and not force_refresh:
        return key_cache[key_id]
    try:
        # Derive key host and server_key from m3u8 URL
        # m3u8 URL pattern: https://{host}/proxy/{server_key}/{channel_key}/mono.css
        # Key URL pattern:  https://{host}/key/{server_key}/{key_id}
        m3u8_url = state.get('m3u8_url', '')
        _key_host_m = re.match(r'(https?://[^/]+)', m3u8_url)
        key_host = _key_host_m.group(1) if _key_host_m else CHEVY_LOOKUP
        _srv_key_m = re.search(r'/proxy/([^/]+)/', m3u8_url)
        _srv_key = _srv_key_m.group(1) if _srv_key_m else channel_key
        url = f'{key_host}/key/{_srv_key}/{key_id}'
        ts = int(time.time())
        channel_salt = state.get('channel_salt') or ''
        fp = _compute_fingerprint()
        hdrs = {
            'User-Agent': _AUTH_UA,
            'Referer': m3u8_url or PLAYER_REFERER,  # m3u8 URL is the natural referer for key requests
        }
        if channel_salt and channel_salt != 'noauth':
            nonce = _compute_pow_nonce(channel_key, channel_salt, key_id, ts)
            auth_sig = _compute_auth_sig(channel_key, channel_salt, key_id, ts, fp)
            hdrs['Authorization'] = f'Bearer {state["auth_token"]}'
            hdrs['X-Key-Timestamp'] = str(ts)
            hdrs['X-Key-Nonce'] = str(nonce)
            hdrs['X-Key-Path'] = auth_sig
            hdrs['X-Fingerprint'] = fp
        if force_refresh:
            # Bust CHEVY's URL-based cache and signal it to re-fetch from upstream
            url += f'?_nc={ts}'
            hdrs['Cache-Control'] = 'no-cache'
            hdrs['Pragma'] = 'no-cache'
        r = _get_session().get(url, headers=hdrs, timeout=3)
        key = r.content
        log(f'[StreamProxy] key {key_id}: {len(key)}B from {key_host} force={force_refresh}')
        key_cache[key_id] = key
        return key
    except Exception as e:
        log(f'[StreamProxy] key {key_id} fetch error: {e}')
        return None


def _is_placeholder(url):
    """Return True for CDN domains/paths confirmed to serve AI-generated placeholder images,
    never real HLS video. CHEVY uses these when a channel has no real stream.
    Other image-sounding CDNs (S3, R2, fooocus, visualgpt) are NOT listed: CHEVY stores
    real AES-encrypted TS on those (confirmed by decryption)."""
    _PH_DOMAINS = ('cdn.gptimage15.com', 'x-design-release.stariicloud.com')
    _PH_PATHS = ('/image-to-video/', '/ai_agent/')
    return any(d in url for d in _PH_DOMAINS) or any(p in url for p in _PH_PATHS)


def _apply_m3u8_filter(content):
    """Filter placeholder image segments from an M3U8 playlist.
    Returns (filtered_content, remaining_count, total_count) or (None, 0, total) if all filtered."""
    raw_lines = content.splitlines(keepends=True)
    filtered = []
    prev_removed = found_first = False
    removed_before_first = total = 0
    orig_seq_m = _SEQ_RX.search(content)
    orig_seq = int(orig_seq_m.group(1)) if orig_seq_m else 0
    for line in raw_lines:
        stripped = line.strip()
        if stripped and not stripped.startswith('#'):
            total += 1
            if _is_placeholder(stripped):
                if filtered and filtered[-1].strip().startswith('#EXTINF'):
                    filtered.pop()
                if not found_first:
                    removed_before_first += 1
                prev_removed = True
                continue
            else:
                if prev_removed and found_first:
                    filtered.append('#EXT-X-DISCONTINUITY\n')
                found_first = True
                prev_removed = False
        filtered.append(line)
    remaining = sum(1 for l in filtered if l.strip() and not l.strip().startswith('#'))
    if not remaining:
        return None, 0, total
    result = ''.join(filtered)
    if removed_before_first > 0 and orig_seq_m:
        result = result.replace(
            orig_seq_m.group(0),
            f'#EXT-X-MEDIA-SEQUENCE:{orig_seq + removed_before_first}',
            1
        )
    return result, remaining, total

_FAV_PROBE_COOLDOWN = 120  # seconds between background probes (persisted across processes)
_FAV_REFRESH_TS_FILE = os.path.join(_KODI_TEMP, 'dltv_fav_refresh_ts')
_FAV_REFRESH_DEBOUNCE = 10  # min seconds between Container.Refresh calls from mark_failed

# ISO 3166-1 alpha-2 country → ISO 639-3 primary language
COUNTRY_LANG = {
    'AE': 'ara', 'AF': 'pus', 'AL': 'sqi', 'AM': 'hye', 'AO': 'por',
    'AR': 'spa', 'AT': 'deu', 'AU': 'eng', 'AZ': 'aze', 'BA': 'bos',
    'BD': 'ben', 'BE': 'fra', 'BG': 'bul', 'BH': 'ara', 'BO': 'spa',
    'BR': 'por', 'CA': 'eng', 'CH': 'deu', 'CL': 'spa', 'CM': 'fra',
    'CN': 'zho', 'CO': 'spa', 'CR': 'spa', 'CU': 'spa', 'CY': 'ell',
    'CZ': 'ces', 'DE': 'deu', 'DK': 'dan', 'DO': 'spa', 'DZ': 'ara',
    'EC': 'spa', 'EE': 'est', 'EG': 'ara', 'ES': 'spa', 'FI': 'fin',
    'FR': 'fra', 'GB': 'eng', 'GE': 'kat', 'GH': 'eng', 'GR': 'ell',
    'GT': 'spa', 'HK': 'zho', 'HN': 'spa', 'HR': 'hrv', 'HU': 'hun',
    'ID': 'ind', 'IE': 'eng', 'IL': 'heb', 'IN': 'hin', 'IQ': 'ara',
    'IR': 'fas', 'IS': 'isl', 'IT': 'ita', 'JM': 'eng', 'JO': 'ara',
    'JP': 'jpn', 'KE': 'eng', 'KR': 'kor', 'KW': 'ara', 'KZ': 'kaz',
    'LB': 'ara', 'LT': 'lit', 'LU': 'fra', 'LV': 'lav', 'LY': 'ara',
    'MA': 'ara', 'MD': 'ron', 'ME': 'srp', 'MK': 'mkd', 'MN': 'mon',
    'MO': 'zho', 'MR': 'ara', 'MX': 'spa', 'MY': 'msa', 'MZ': 'por',
    'NG': 'eng', 'NI': 'spa', 'NL': 'nld', 'NO': 'nor', 'NP': 'nep',
    'NZ': 'eng', 'OM': 'ara', 'PA': 'spa', 'PE': 'spa', 'PH': 'fil',
    'PK': 'urd', 'PL': 'pol', 'PS': 'ara', 'PT': 'por', 'PY': 'spa',
    'QA': 'ara', 'RO': 'ron', 'RS': 'srp', 'RU': 'rus', 'SA': 'ara',
    'SD': 'ara', 'SE': 'swe', 'SG': 'eng', 'SI': 'slv', 'SK': 'slk',
    'SV': 'spa', 'SY': 'ara', 'TH': 'tha', 'TN': 'ara', 'TR': 'tur',
    'TT': 'eng', 'TW': 'zho', 'UA': 'ukr', 'US': 'eng', 'UY': 'spa',
    'UZ': 'uzb', 'VE': 'spa', 'VN': 'vie', 'YE': 'ara', 'ZA': 'eng',
}

LANG_NAMES = {
    'eng': 'English', 'fra': 'French', 'deu': 'German', 'spa': 'Spanish',
    'ita': 'Italian', 'por': 'Portuguese', 'ara': 'Arabic', 'nld': 'Dutch',
    'tur': 'Turkish', 'rus': 'Russian', 'pol': 'Polish', 'ron': 'Romanian',
    'swe': 'Swedish', 'nor': 'Norwegian', 'dan': 'Danish', 'fin': 'Finnish',
    'ces': 'Czech', 'hrv': 'Croatian', 'srp': 'Serbian', 'bul': 'Bulgarian',
    'hun': 'Hungarian', 'ell': 'Greek', 'heb': 'Hebrew', 'fas': 'Persian',
    'urd': 'Urdu', 'msa': 'Malay', 'hin': 'Hindi', 'ben': 'Bengali',
    'tam': 'Tamil', 'ind': 'Indonesian', 'zho': 'Chinese', 'tha': 'Thai',
    'vie': 'Vietnamese', 'jpn': 'Japanese', 'kor': 'Korean',
}

# EPlayer auth — UA/screen/tz/lang values used for fingerprint computation
_AUTH_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'

_SEG_HEADERS = {
    'User-Agent': _AUTH_UA,
    'Accept': '*/*',
    'Accept-Language': 'en-US,en;q=0.9',
    'Accept-Encoding': 'gzip, deflate, br',
    'Origin': _KSOHLS_BASE,
    'Referer': PLAYER_REFERER,
    'Sec-Fetch-Dest': 'empty',
    'Sec-Fetch-Mode': 'cors',
    'Sec-Fetch-Site': 'cross-site',
}

# Thread-safe state store: channel_key → {auth_token, channel_salt, m3u8_url, fetched_at}
_proxy_lock = threading.Lock()
_channel_creds = {}
# Stall tracker: channel_key → {seq: last_seq, count: consecutive_same_seq}
_m3u8_stall = {}
_m3u8_timeouts = {}  # channel_key → consecutive timeout count
_last_sent_seq = {}  # channel_key → last seq number sent to player (for HLS polling throttle)
_sent_seg_urls = {}  # channel_key → set of CDN segment URLs already sent (dedup sliding window)
_last_media_seq_out = {}  # channel_key → last MEDIA-SEQUENCE sent to player (enforce monotonic)
_seg_url_to_channel = {}  # CDN segment URL → channel_key, for download-time dedup tracking
_stall_lock = threading.Lock()
_seg_semaphore = threading.Semaphore(2)  # max 2 concurrent segment downloads
_proxy_abort = threading.Event()  # set on Kodi shutdown to unblock sleeping handler threads
_probe_lock = threading.Lock()
_epg_bg_lock = threading.Lock()

def _do_cleanup_caches():
    """Perform one cache-cleanup pass (called at start and every 300s)."""
    now = time.time()
    # DNS cache: TTL 30 min
    with _dns_cache_lock:
        stale = [h for h, (_, ts) in list(_dns_cache.items()) if now - ts > 1800]
        for h in stale:
            del _dns_cache[h]
    # Channel creds: purge entries older than 10 min
    with _proxy_lock:
        stale = [k for k, v in list(_channel_creds.items()) if now - v.get('fetched_at', 0) > 600]
        for k in stale:
            del _channel_creds[k]
            _m3u8_stall.pop(k, None)
            _last_sent_seq.pop(k, None)
    # C1: cap _sent_seg_urls to last 200 entries per channel; evict _seg_url_to_channel
    # for channels no longer in _channel_creds
    with _stall_lock:
        with _proxy_lock:
            active_keys = set(_channel_creds.keys())
        for ch_key in list(_sent_seg_urls.keys()):
            urls = _sent_seg_urls[ch_key]
            if len(urls) > 200:
                # Keep only the last 200 — rebuild from a sorted slice is not possible
                # on a plain set, so replace with a new set of the last 200 items added.
                _sent_seg_urls[ch_key] = set(list(urls)[-200:])
        dead_urls = [u for u, ch in list(_seg_url_to_channel.items()) if ch not in active_keys]
        for u in dead_urls:
            del _seg_url_to_channel[u]


def _cleanup_caches():
    """Background thread: purge stale entries every 5 minutes. Exits on Kodi abort."""
    monitor = xbmc.Monitor()
    # L7: run cleanup once immediately before entering the wait loop
    _do_cleanup_caches()
    while not monitor.waitForAbort(300):
        _do_cleanup_caches()


def _dns_resolve(hostname, dns_server, port=53, timeout=2):
    """Resolve a hostname via UDP to a custom DNS server. Returns IP string or None."""
    try:
        txid = random.randint(1, 65535)
        # Header with RD flag, 1 question, 0 answers, 0 authority, 1 additional (EDNS0)
        header = struct.pack('>HHHHHH', txid, 0x0100, 1, 0, 0, 1)
        question = b''
        for label in hostname.split('.'):
            enc = label.encode('ascii')
            question += bytes([len(enc)]) + enc
        question += b'\x00' + struct.pack('>HH', 1, 1)  # Type A, Class IN
        # EDNS0 OPT record: allows up to 4096 byte UDP responses
        edns0 = b'\x00' + struct.pack('>HHIH', 41, 4096, 0, 0)
        with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
            sock.settimeout(timeout)
            sock.sendto(header + question + edns0, (dns_server, port))
            resp = sock.recv(4096)
        ancount = struct.unpack('>H', resp[6:8])[0]
        if ancount == 0:
            return None
        # Skip question section
        offset = 12
        while resp[offset] != 0:
            if resp[offset] & 0xC0 == 0xC0:
                offset += 2
                break
            offset += resp[offset] + 1
        else:
            offset += 1
        offset += 4  # QTYPE + QCLASS
        # Parse answer records
        for _ in range(ancount):
            if resp[offset] & 0xC0 == 0xC0:
                offset += 2
            else:
                while resp[offset] != 0:
                    offset += resp[offset] + 1
                offset += 1
            rtype, _rc, _ttl, rdlen = struct.unpack('>HHIH', resp[offset:offset + 10])
            offset += 10
            if rtype == 1 and rdlen == 4:
                return '.'.join(str(b) for b in resp[offset:offset + 4])
            offset += rdlen
        return None
    except Exception:
        return None
_dns_cache = {}  # host -> (ip, timestamp)
_dns_cache_lock = threading.Lock()
_original_getaddrinfo = socket.getaddrinfo

_server_key_cache = {}          # channel_id -> (server_key, timestamp)
_server_key_cache_lock = threading.Lock()
_SERVER_KEY_TTL = 600           # 10 min
_lovecdn_url_cache = {}         # stream_name -> (lovecdn_url, timestamp)
_lovecdn_url_cache_lock = threading.Lock()
_LOVECDN_URL_TTL = 480          # 8 min (token valid ~15 min)
_lovecdn_session = requests.Session()  # persistent session — keeps Cloudflare cookies
_lovecdn_seg_cache = {}         # seg_url -> bytes (pre-downloaded segments, last 6)
_lovecdn_seg_cache_lock = threading.Lock()
_active_dns = None  # set by _apply_custom_dns after reachability check
_DNS_FALLBACK = '8.8.8.8'

_tls = threading.local()


def _get_session():
    """Return per-thread requests.Session (created on first use)."""
    if not hasattr(_tls, 'session') or getattr(_tls.session, 'verify', True) is not False:
        s = requests.Session()
        s.verify = False  # CHEVY proxy uses self-signed/mismatched SSL certs
        _tls.session = s
    return _tls.session
def _fix_lovecdn_pcr(data, seg_dur):
    """Fix corrupt PCR values in a lovecdn TS segment.
    Some lovecdn segments have random/garbage PCR on the video PID while PTS/DTS
    remain correct. Kodi's native VideoPlayer uses PCR as the presentation clock,
    causing severe stuttering when PCR jumps wildly within a segment.
    Fix: anchor to the first valid PCR and linearly interpolate all subsequent
    PCR packets across the segment duration. The first PCR in lovecdn segments is
    always correct (matches PTS), so this preserves the PCR/PTS relationship."""
    n_pkts = len(data) // 188
    if n_pkts < 2:
        return data

    # Find the first PCR packet and its value
    first_pcr = None
    first_pcr_idx = 0
    for i in range(n_pkts):
        off = i * 188
        if data[off] != 0x47:
            continue
        if not ((data[off + 3] >> 4) & 1):  # no adaptation field
            continue
        if data[off + 4] < 7:  # adaptation too short
            continue
        if not (data[off + 5] & 0x10):  # no PCR_flag
            continue
        b = data[off + 6:off + 12]
        pcr_base = (b[0] << 25) | (b[1] << 17) | (b[2] << 9) | (b[3] << 1) | (b[4] >> 7)
        pcr_ext = ((b[4] & 1) << 8) | b[5]
        first_pcr = pcr_base * 300 + pcr_ext
        first_pcr_idx = i
        break

    if first_pcr is None:
        return data  # no PCR in segment, nothing to fix

    # Linearly interpolate PCR from first_pcr to first_pcr + seg_dur * 27MHz
    total_27mhz = seg_dur * 27000000
    data = bytearray(data)
    for i in range(first_pcr_idx, n_pkts):
        off = i * 188
        if data[off] != 0x47:
            continue
        if not ((data[off + 3] >> 4) & 1):
            continue
        if data[off + 4] < 7:
            continue
        if not (data[off + 5] & 0x10):
            continue
        frac = (i - first_pcr_idx) / max(n_pkts - first_pcr_idx, 1)
        pcr = first_pcr + int(frac * total_27mhz)
        pcr_base = (pcr // 300) & 0x1FFFFFFFF  # 33-bit
        pcr_ext = int(pcr % 300)               # 9-bit
        data[off + 6] = (pcr_base >> 25) & 0xFF
        data[off + 7] = (pcr_base >> 17) & 0xFF
        data[off + 8] = (pcr_base >> 9) & 0xFF
        data[off + 9] = (pcr_base >> 1) & 0xFF
        data[off + 10] = ((pcr_base & 1) << 7) | 0x7E | ((pcr_ext >> 8) & 1)
        data[off + 11] = pcr_ext & 0xFF
    return bytes(data)


def _lovecdn_get_media_url(stream_name, force_refresh=False):
    """Fetch and return the current lovecdn media playlist content.
    Returns (media_url, lt_url, media_text, media_base) or None on failure.
    Caches the token URL for _LOVECDN_URL_TTL seconds (8 min)."""
    now = time.time()
    lovecdn_url = lt_url = None

    if not force_refresh:
        with _lovecdn_url_cache_lock:
            cached = _lovecdn_url_cache.get(stream_name)
            if cached and now - cached[1] < _LOVECDN_URL_TTL:
                lovecdn_url, lt_url = cached[0], cached[2]

    if not lovecdn_url:
        with _lovecdn_url_cache_lock:
            expired = _lovecdn_url_cache.get(stream_name)
        lt_url = (expired[2] if expired and len(expired) > 2 else None) \
                 or f'https://lovetier.bz/player/{stream_name}'
        try:
            r = _lovecdn_session.get(lt_url, headers={'User-Agent': UA}, timeout=8)
        except Exception as e:
            log(f'[LovecdnProxy] lovetier fetch error for {stream_name}: {e}')
            return None
        if r.status_code != 200:
            log(f'[LovecdnProxy] lovetier HTTP {r.status_code} for {stream_name}')
            return None
        m3 = re.search(r'streamUrl\s*:\s*"(https?:[^"]+)"', r.text)
        if not m3:
            log(f'[LovecdnProxy] no streamUrl for {stream_name}')
            return None
        lovecdn_url = m3.group(1).replace('\\/', '/')
        with _lovecdn_url_cache_lock:
            _lovecdn_url_cache[stream_name] = (lovecdn_url, now, lt_url)
        log(f'[LovecdnProxy] refreshed token for {stream_name}')

    try:
        r2 = _lovecdn_session.get(lovecdn_url, headers={'User-Agent': UA, 'Referer': lt_url}, timeout=8)
    except Exception as e:
        log(f'[LovecdnProxy] media fetch error for {stream_name}: {e}')
        return None
    if r2.status_code != 200:
        log(f'[LovecdnProxy] lovecdn HTTP {r2.status_code} for {stream_name}')
        with _lovecdn_url_cache_lock:
            _lovecdn_url_cache.pop(stream_name, None)
        return None

    master_base = lovecdn_url.split('?')[0].rsplit('/', 1)[0] + '/'
    media_text = r2.text
    media_base = master_base
    current_url = lovecdn_url
    # Follow master→variant chain
    while '#EXT-X-STREAM-INF' in media_text:
        next_url = None
        preferred_url = None
        for line in media_text.splitlines():
            stripped = line.strip()
            if stripped and not stripped.startswith('#'):
                candidate = stripped if stripped.startswith('http') else urljoin(media_base, stripped)
                if not next_url:
                    next_url = candidate
                if not preferred_url and 'a1' in candidate:
                    preferred_url = candidate
        next_url = preferred_url or next_url
        if not next_url or next_url == current_url:
            break
        try:
            rv = _lovecdn_session.get(next_url, headers={'User-Agent': UA, 'Referer': lt_url}, timeout=8)
        except Exception as e:
            log(f'[LovecdnProxy] variant fetch error: {e}')
            return None
        if rv.status_code != 200:
            log(f'[LovecdnProxy] variant HTTP {rv.status_code}')
            return None
        current_url = next_url
        media_text = rv.text
        media_base = current_url.split('?')[0].rsplit('/', 1)[0] + '/'
    # Cache the resolved media playlist URL
    with _lovecdn_url_cache_lock:
        cached_entry = _lovecdn_url_cache.get(stream_name)
        if cached_entry:
            _lovecdn_url_cache[stream_name] = (current_url, now, cached_entry[2])

    return current_url, lt_url, media_text, media_base


def _lovecdn_prefetch_seg(seg_url):
    """Pre-download a lovecdn segment into memory cache (called in background thread)."""
    with _lovecdn_seg_cache_lock:
        if seg_url in _lovecdn_seg_cache:
            return  # already cached
    try:
        r = _lovecdn_session.get(seg_url, headers={'User-Agent': UA}, timeout=(3, 10))
        if r.status_code == 200:
            with _lovecdn_seg_cache_lock:
                _lovecdn_seg_cache[seg_url] = r.content
                # Keep cache bounded: drop oldest entries beyond 6
                while len(_lovecdn_seg_cache) > 6:
                    _lovecdn_seg_cache.pop(next(iter(_lovecdn_seg_cache)))
            log(f'[LovecdnPrefetch] cached {len(r.content)}B {seg_url[-50:]}')
    except Exception as e:
        log(f'[LovecdnPrefetch] error: {e}')


def _patched_getaddrinfo(host, port, family=0, type=0, proto=0, flags=0):
    if not _active_dns or not host:
        return _original_getaddrinfo(host, port, family, type, proto, flags)
    # Don't intercept IP literals or localhost
    try:
        socket.inet_aton(host)
        return _original_getaddrinfo(host, port, family, type, proto, flags)
    except OSError:
        pass
    if host == 'localhost' or host.endswith('.local'):
        return _original_getaddrinfo(host, port, family, type, proto, flags)
    with _dns_cache_lock:
        entry = _dns_cache.get(host)
    ip = None
    if entry:
        cached_ip, cached_ts = entry
        if time.time() - cached_ts < 1800:  # 30 min TTL
            ip = cached_ip
    if ip is None:
        ip = _dns_resolve(host, _active_dns)
        if ip:
            with _dns_cache_lock:
                _dns_cache[host] = (ip, time.time())
        else:
            # Active DNS failed — try the other server
            _fallback = _DNS_FALLBACK if _active_dns != _DNS_FALLBACK else _CUSTOM_DNS
            log(f'[CustomDNS] resolve failed via {_active_dns} for {host}, trying {_fallback}')
            ip = _dns_resolve(host, _fallback)
            if ip:
                with _dns_cache_lock:
                    _dns_cache[host] = (ip, time.time())
                log(f'[CustomDNS] {host} → {ip} (via {_fallback} fallback)')
            else:
                log(f'[CustomDNS] resolve failed for {host}, fallback to system DNS')
                return _original_getaddrinfo(host, port, family, type, proto, flags)
    results = []
    for socktype in (socket.SOCK_STREAM, socket.SOCK_DGRAM):
        results.append((socket.AF_INET, socktype, 0, '', (ip, port or 0)))
    return results


def _apply_custom_dns():
    global _active_dns
    if not _CUSTOM_DNS:
        return
    # Quick reachability check: resolve a known hostname via the configured DNS server
    _test_ip = _dns_resolve('www.google.com', _CUSTOM_DNS, timeout=2)
    if _test_ip:
        _active_dns = _CUSTOM_DNS
        xbmc.log(f'[Tor Proxy][CustomDNS] Active — using {_CUSTOM_DNS}', xbmc.LOGINFO)
    else:
        _active_dns = _DNS_FALLBACK
        xbmc.log(f'[Tor Proxy][CustomDNS] {_CUSTOM_DNS} unreachable — using {_DNS_FALLBACK} as primary', xbmc.LOGWARNING)
    socket.getaddrinfo = _patched_getaddrinfo


_apply_custom_dns()
threading.Thread(target=_cleanup_caches, daemon=True).start()


def _compute_fingerprint():
    combined = _AUTH_UA + '1920x1080' + 'UTC' + 'en'
    return hashlib.sha256(combined.encode()).hexdigest()[:16]


def _compute_pow_nonce(channel_key, channel_salt, key_id, ts):
    hmac_base = _hmac.new(channel_salt.encode(), channel_key.encode(), hashlib.sha256).hexdigest()
    for nonce in range(100000):
        combined = hmac_base + channel_key + key_id + str(ts) + str(nonce)
        h = hashlib.md5(combined.encode()).hexdigest()
        if int(h[:4], 16) < 0x1000:
            return nonce
    return 99999


def _compute_auth_sig(channel_key, channel_salt, key_id, ts, fp):
    msg = f'{channel_key}|{key_id}|{ts}|{fp}'
    return _hmac.new(channel_salt.encode(), msg.encode(), hashlib.sha256).hexdigest()[:16]


def _xor_decode(arr, key):
    return ''.join(chr(b ^ key) for b in arr)


def _extract_credential(page, field):
    """Extract a credential value supporting plain string or XOR-encoded formats."""
    # Format 1: field: 'value'
    m = re.search(rf"{field}\s*:\s*'([^']+)'", page)
    if m:
        return m.group(1)
    # Format 2: field: _dec_XXXX(_init_YYYY, key)
    m = re.search(rf"{field}\s*:\s*_dec_\w+\((_init_\w+),\s*(\d+)\)", page)
    if m:
        init_name, key = m.group(1), int(m.group(2))
        arr_m = re.search(rf"{init_name}\s*=\s*\[([^\]]+)\]", page)
        if arr_m:
            arr = list(map(int, arr_m.group(1).split(',')))
            return _xor_decode(arr, key)
    return None


_CREDS_JS_SKIP_HOSTS = (
    'code.jquery.com', 'cdn.jsdelivr.net', 'cdnjs.cloudflare.com',
    'ajax.googleapis.com', 'www.google.com', 'www.googletagmanager.com',
    'static.cloudflareinsights.com',
)

def _extract_creds_with_js(page_text, page_url, base_url, sess):
    """Extract authToken+channelSalt from page HTML and any referenced external JS files.
    Skips known CDN libraries (jQuery, Clappr, HLS.js, reCAPTCHA…) — they never contain credentials."""
    at = _extract_credential(page_text, 'authToken')
    cs = _extract_credential(page_text, 'channelSalt')
    if at and cs:
        return at, cs
    # Credentials may be in an external JS file loaded by the page
    for sm in re.finditer(r'<script[^>]+src=["\']([^"\']+)["\']', page_text):
        src = sm.group(1)
        if src.startswith('//'):
            src = 'https:' + src
        elif src.startswith('/'):
            src = base_url.rstrip('/') + src
        elif not src.startswith('http'):
            continue
        if any(h in src for h in _CREDS_JS_SKIP_HOSTS):
            continue
        try:
            log(f'[EPlayerAuth] Fetching external JS: {src[:100]}')
            rjs = sess.get(src, headers={'User-Agent': _AUTH_UA, 'Referer': page_url}, timeout=4)
            at = _extract_credential(rjs.text, 'authToken')
            cs = _extract_credential(rjs.text, 'channelSalt')
            if at and cs:
                log(f'[EPlayerAuth] Credentials found in external JS: {src[:80]}')
                return at, cs
        except Exception as e:
            log(f'[EPlayerAuth] External JS fetch error ({src[:60]}): {e}')
    return None, None


def _is_js_challenge(text):
    """Return True if response looks like a Cloudflare/bot JS challenge page."""
    t = text[:800]
    return ("window['" in t and 'document.location' in t) or 'challenge-form' in t


def _fetch_auth_credentials(channel_id, max_attempts=3):
    """Fetch fresh authToken and channelSalt from the ksohls.ru player page.
    Tries _KSOHLS_BASE first (with stream page referer); if it returns a JS challenge,
    falls back to _KSOHLS_FALLBACK_BASES."""
    # Use the stream page URL as referer — the auth server checks it.
    # Also visit it first: CF shares clearance state across zones on the same account,
    # so a prior visit to dlstreams.top unblocks adffdafdsafds.sbs in the same session.
    stream_referer = abs_url(f'stream/stream-{channel_id}.php')
    try:
        _get_session().get(stream_referer, headers={'User-Agent': UA}, timeout=4)
    except Exception:
        pass
    bases = [_KSOHLS_BASE] + [b for b in _KSOHLS_FALLBACK_BASES if b != _KSOHLS_BASE]
    for base in bases:
        url = f'{base}/premiumtv/daddyhd.php?id={channel_id}'
        for attempt in range(max_attempts):
            try:
                r = _get_session().get(url, headers={
                    'User-Agent': UA,
                    'Referer': stream_referer,
                }, timeout=4)
                if r.status_code in (502, 503, 504):
                    log(f'[EPlayerAuth] Server error {r.status_code} on {base} (attempt {attempt+1}/{max_attempts})')
                    if attempt < max_attempts - 1:
                        time.sleep(1.5)
                    continue
                if _is_js_challenge(r.text):
                    log(f'[EPlayerAuth] JS challenge on {base}, trying next')
                    break  # skip remaining retries for this base
                auth_token, channel_salt = _extract_creds_with_js(r.text, url, base, _get_session())
                if auth_token and channel_salt:
                    return auth_token, channel_salt
                log(f'[EPlayerAuth] Credentials not found on {base} (attempt {attempt+1}/{max_attempts})')
            except Exception as e:
                log(f'[EPlayerAuth] fetch error (attempt {attempt+1}/{max_attempts}): {e}')
            if attempt < max_attempts - 1:
                time.sleep(1)
    return None, None


def _discover_server_urls():
    """Auto-discover CHEVY_PROXY, CHEVY_LOOKUP and _KSOHLS_BASE from the site's auth player page.
    Fetches stream/stream-1.php → finds premiumtv iframe → parses CHEVY URLs from its JS.
    Updates module-level globals and saves a cache file. Safe to call from a background thread."""
    global CHEVY_PROXY, CHEVY_LOOKUP, _KSOHLS_BASE, PLAYER_REFERER
    try:
        log('[Discovery] Starting server URL discovery...')
        watch_url = abs_url('watch.php?id=1')
        stream_url = abs_url('stream/stream-1.php')
        r = requests.get(stream_url, headers={'User-Agent': UA, 'Referer': watch_url}, timeout=8)
        if r.status_code != 200:
            log(f'[Discovery] stream page HTTP {r.status_code}')
            return

        # Find the auth player iframe (premiumtv URL)
        mp = re.search(r'<iframe[^>]+src="(https?://([^/"]+)/premiumtv/[^"]+)"', r.text)
        if not mp:
            log('[Discovery] no premiumtv iframe found')
            return
        player_url, ksohls_host = mp.group(1), mp.group(2)
        ksohls_base = f'https://{ksohls_host}'

        # Fetch the auth player page and scan for CHEVY URLs
        r2 = requests.get(player_url, headers={'User-Agent': UA, 'Referer': stream_url}, timeout=8)
        if r2.status_code != 200:
            log(f'[Discovery] auth player HTTP {r2.status_code}')
            return

        if _is_js_challenge(r2.text):
            log(f'[Discovery] auth player page behind JS challenge — skipping ksohls={ksohls_base}')
            return

        # Skip JS template literals like https://${M3U8_SERVER}/proxy/
        mp2 = re.search(r'(https?://(?!\$\{)[^\s"\'<>{}$]+)/proxy/', r2.text)
        chevy_proxy = mp2.group(1) if mp2 else None
        ml = re.search(r'(https?://(?!\$\{)[^\s"\'<>{}$]+)/server_lookup', r2.text)
        chevy_lookup = ml.group(1) if ml else None

        # If lookup not found as a literal, probe the proxy host's /health endpoint
        if chevy_proxy and not chevy_lookup:
            candidate = re.sub(r'^(https?://[^/]+).*', r'\1', chevy_proxy)
            try:
                rh = _get_session().get(f'{candidate}/health', headers={'User-Agent': UA}, timeout=4)
                if rh.status_code == 200:
                    chevy_lookup = candidate
                    log(f'[Discovery] lookup derived from proxy host: {chevy_lookup}')
            except Exception:
                pass

        # Fall back to current known lookup if still not resolved
        effective_lookup = chevy_lookup or CHEVY_LOOKUP

        log(f'[Discovery] ksohls={ksohls_base} proxy={chevy_proxy} lookup={effective_lookup}')
        if not chevy_proxy:
            log('[Discovery] no proxy found — keeping current values')
            return

        # Update module-level globals (ksohls already validated: not JS-challenge)
        _KSOHLS_BASE = ksohls_base
        PLAYER_REFERER = ksohls_base + '/'
        CHEVY_PROXY = chevy_proxy
        CHEVY_LOOKUP = effective_lookup
        _SEG_HEADERS['Origin'] = ksohls_base
        _SEG_HEADERS['Referer'] = PLAYER_REFERER

        # Persist to cache
        with open(_DISCOVERY_CACHE_FILE, 'w') as _f:
            json.dump({'ksohls': ksohls_base, 'proxy': chevy_proxy, 'lookup': effective_lookup}, _f)

        # Update Kodi settings so the user can see (and override) discovered values
        addon.setSetting('chevy_proxy_url', chevy_proxy)
        addon.setSetting('chevy_lookup_url', effective_lookup)
        addon.setSetting('ksohls_base_url', ksohls_base)
        log(f'[Discovery] Updated: proxy={chevy_proxy} lookup={effective_lookup} ksohls={ksohls_base}')

    except Exception as e:
        log(f'[Discovery] error: {e}')


def _state_file(channel_key):
    return os.path.join(_KODI_TEMP, f'dltv_{channel_key}.json')


def _set_channel_state(channel_key, auth_token, channel_salt, m3u8_url):
    state = {
        'auth_token': auth_token,
        'channel_salt': channel_salt,
        'm3u8_url': m3u8_url,
        'fetched_at': time.time(),
    }
    with _proxy_lock:
        _channel_creds[channel_key] = state
    # Reset stall/timeout/throttle/dedup counters — fresh session, stale counts must not carry over
    with _stall_lock:
        _m3u8_stall.pop(channel_key, None)
        _m3u8_timeouts.pop(channel_key, None)
        _last_sent_seq.pop(channel_key, None)
        _sent_seg_urls.pop(channel_key, None)
        _last_media_seq_out.pop(channel_key, None)
    # Persist to temp file so other plugin processes can read the state
    try:
        with open(_state_file(channel_key), 'w') as f:
            json.dump(state, f)
    except Exception:
        pass


def _get_channel_state(channel_key):
    with _proxy_lock:
        state = _channel_creds.get(channel_key)
    # Always check if the file has been updated by another process
    try:
        path = _state_file(channel_key)
        if os.path.exists(path):
            file_mtime = os.path.getmtime(path)
            in_memory_time = state.get('fetched_at', 0) if state else 0
            if file_mtime > in_memory_time and (time.time() - file_mtime) < 300:
                with open(path) as f:
                    file_state = json.load(f)
                # New session from another process — reset dedup/stall so old
                # segment URLs don't cause immediate dedup skip.
                with _stall_lock:
                    _m3u8_stall.pop(channel_key, None)
                    _m3u8_timeouts.pop(channel_key, None)
                    _last_sent_seq.pop(channel_key, None)
                    _sent_seg_urls.pop(channel_key, None)
                    _last_media_seq_out.pop(channel_key, None)
                with _proxy_lock:
                    _channel_creds[channel_key] = file_state
                return dict(file_state)
    except Exception:
        pass
    if state:
        return dict(state)
    return {}


def _refresh_channel_creds(cid, max_attempts=3):
    """Fetch fresh auth credentials + CDN URL, update channel state.
    Returns updated state dict on success, or None on failure."""
    at, cs = _fetch_auth_credentials(cid, max_attempts=max_attempts)
    if not (at and cs):
        return None
    channel_key = f'premium{cid}'
    url = resolve_stream_url(cid)
    _set_channel_state(channel_key, at, cs, url)
    return _get_channel_state(channel_key)


class _EPlayerProxyHandler(BaseHTTPRequestHandler):
    """Local HTTP proxy that:
    - GET /m3u8/<channel_key>  → fetches live m3u8, rewrites key URIs to /key/...
    - GET /key/<channel_key>/<key_id> → computes auth headers, fetches real AES key
    """

    def do_GET(self):
        if self.path == '/health':
            body = b'dltv-proxy'
            self.send_response(200)
            self.send_header('Content-Length', str(len(body)))
            self.end_headers()
            self.wfile.write(body)
            return
        if self.path == '/version':
            body = _PROXY_ADDON_MTIME.encode()
            self.send_response(200)
            self.send_header('Content-Type', 'text/plain')
            self.send_header('Content-Length', str(len(body)))
            self.end_headers()
            self.wfile.write(body)
            return
        m = re.match(r'^/m3u8/([^/?]+)', self.path)
        if m:
            self._handle_m3u8(m.group(1))
            return
        m = re.match(r'^/stream/([^/?]+)', self.path)
        if m:
            self._handle_stream(m.group(1))
            return
        m = re.match(r'^/key/([^/]+)/(\d+)', self.path)
        if m:
            self._handle_key(m.group(1), m.group(2))
            return
        m = re.match(r'^/seg/(.+)', self.path)
        if m:
            self._handle_segment(m.group(1))
            return
        m = re.match(r'^/raw/([^/]+)/(.+)', self.path)
        if m:
            self._handle_raw(m.group(1), m.group(2))
            return
        m = re.match(r'^/lovecdn/([^/?]+)', self.path)
        if m:
            self._handle_lovecdn(unquote(m.group(1)))
            return
        m = re.match(r'^/lovecdn-ts/([^/?]+)', self.path)
        if m:
            self._handle_lovecdn_ts(unquote(m.group(1)))
            return
        m = re.match(r'^/lovecdn-seg/(.+)', self.path)
        if m:
            self._handle_lovecdn_seg(unquote(m.group(1)))
            return
        self.send_response(404)
        self.end_headers()

    def _handle_m3u8(self, channel_key):
        state = _get_channel_state(channel_key)
        if not state or not state.get('m3u8_url'):
            # Proxy runs in old process — fetch credentials on demand
            m = _PREMIUM_KEY_RX.match(channel_key)
            if m:
                cid = m.group(1)
                log(f'[EPlayerProxy] No state for {channel_key}, fetching credentials for id={cid}')
                state = _refresh_channel_creds(cid)
                if not state:
                    # Auth unavailable (NFS channel or ksohls page has no creds) — noauth fallback
                    log(f'[EPlayerProxy] Auth failed for {channel_key}, using noauth fallback')
                    noauth_url = resolve_stream_url(cid)
                    _set_channel_state(channel_key, 'noauth', 'noauth', noauth_url)
                    state = _get_channel_state(channel_key)
        if not state or not state.get('m3u8_url'):
            self.send_response(503)
            self.end_headers()
            return
        try:
            m3u8_hdrs = {
                'User-Agent': _AUTH_UA,
                'Referer': PLAYER_REFERER,
                'Authorization': f'Bearer {state["auth_token"]}',
                'X-Channel-Key': channel_key,
                'X-User-Agent': _AUTH_UA,
                'Cache-Control': 'no-cache',
                'Pragma': 'no-cache',
            }
            _m3u8_url = state['m3u8_url']
            for _m3u8_attempt in range(3):
                try:
                    _tout = 5 if _m3u8_attempt > 0 else 3
                    r = _get_session().get(_m3u8_url, headers=m3u8_hdrs, timeout=_tout)
                    with _stall_lock:
                        _m3u8_timeouts.pop(channel_key, None)  # reset on success
                    break
                except requests.exceptions.Timeout:
                    if _m3u8_attempt == 0:
                        log(f'[EPlayerProxy] m3u8 timeout for {channel_key}, retrying')
                        time.sleep(1)
                    elif _m3u8_attempt == 1:
                        # Count consecutive timeouts; give up gracefully after 5
                        with _stall_lock:
                            _tc = _m3u8_timeouts.get(channel_key, 0) + 1
                            _m3u8_timeouts[channel_key] = _tc
                        log(f'[EPlayerProxy] m3u8 timeout #{_tc} for {channel_key}')
                        if _tc >= 5:
                            log(f'[EPlayerProxy] {channel_key}: CDN unresponsive ({_tc} timeouts) — ending stream')
                            _mark_channel_failed(channel_key)
                            with _stall_lock:
                                _m3u8_timeouts.pop(channel_key, None)
                            self.send_response(200)
                            self.send_header('Content-Type', 'application/vnd.apple.mpegurl')
                            self.send_header('Content-Length', str(len(_M3U8_ENDLIST)))
                            self.end_headers()
                            self.wfile.write(_M3U8_ENDLIST)
                            return
                        raise
                    else:
                        raise
                except Exception as _e:
                    log(f'[EPlayerProxy] m3u8 conn error (attempt {_m3u8_attempt+1}): {_e}')
                    if _m3u8_attempt == 0:
                        time.sleep(0.1)
                    elif _m3u8_attempt == 1:
                        # Connection error — try substituting a builtin CHEVY host if discovered one is down
                        _cur_host = re.match(r'https?://[^/]+', _m3u8_url)
                        _builtin_host = re.match(r'https?://[^/]+', _CHEVY_PROXY_BUILTIN)
                        if _cur_host and _builtin_host and _cur_host.group() != _builtin_host.group():
                            _fallback_url = _m3u8_url.replace(_cur_host.group(), _builtin_host.group(), 1)
                            log(f'[EPlayerProxy] m3u8 fallback to builtin: {_fallback_url[:80]}')
                            _m3u8_url = _fallback_url
                            try:
                                os.remove(_DISCOVERY_CACHE_FILE)
                            except Exception:
                                pass
                        else:
                            raise
                    else:
                        raise

            # On 403/404, re-auth and retry once (403 = token expired or wrong CDN server key)
            if r.status_code in (403, 404):
                log(f'[EPlayerProxy] m3u8 {r.status_code} for {channel_key}, re-fetching credentials')
                m = _PREMIUM_KEY_RX.match(channel_key)
                if m:
                    cid = m.group(1)
                    state = _refresh_channel_creds(cid)
                    if not state:
                        # Auth unavailable (NFS channel) — retry without auth header
                        log(f'[EPlayerProxy] re-auth failed for {channel_key}, retrying noauth')
                        noauth_url = resolve_stream_url(cid)
                        _set_channel_state(channel_key, 'noauth', 'noauth', noauth_url)
                        state = _get_channel_state(channel_key)
                    if state:
                        new_hdrs = dict(m3u8_hdrs)
                        at = state.get('auth_token', '')
                        if at and at != 'noauth':
                            new_hdrs['Authorization'] = f'Bearer {at}'
                        else:
                            new_hdrs.pop('Authorization', None)
                        try:
                            r = _get_session().get(state['m3u8_url'], headers=new_hdrs, timeout=3)
                            log(f'[EPlayerProxy] m3u8 after re-auth: status={r.status_code}')
                        except Exception as _reauth_e:
                            log(f'[EPlayerProxy] m3u8 re-auth fetch failed: {_reauth_e}')
                            self.send_response(200)
                            self.send_header('Content-Type', 'application/vnd.apple.mpegurl')
                            self.send_header('Content-Length', str(len(_M3U8_ENDLIST)))
                            self.end_headers()
                            self.wfile.write(_M3U8_ENDLIST)
                            return
                    if r.status_code != 200:
                        log(f'[EPlayerProxy] {channel_key}: CDN {r.status_code} after re-auth — ending stream')
                        _mark_channel_failed(channel_key)
                        self.send_response(200)
                        self.send_header('Content-Type', 'application/vnd.apple.mpegurl')
                        self.send_header('Content-Length', str(len(_M3U8_ENDLIST)))
                        self.end_headers()
                        self.wfile.write(_M3U8_ENDLIST)
                        return

            # On 502/503, retry once after a short delay
            if r.status_code in (502, 503):
                log(f'[EPlayerProxy] m3u8 {r.status_code} for {channel_key}, retrying')
                time.sleep(1)
                r = _get_session().get(state['m3u8_url'], headers=m3u8_hdrs, timeout=3)

            content = r.text
            if r.status_code == 200:
                # Finite playlist = channel offline — end gracefully (not 503, avoids restart loop)
                if '#EXT-X-ENDLIST' in content:
                    log(f'[EPlayerProxy] {channel_key}: offline playlist (EXT-X-ENDLIST) — ending stream')
                    _mark_channel_failed(channel_key)
                    self.send_response(200)
                    self.send_header('Content-Type', 'application/vnd.apple.mpegurl')
                    self.send_header('Content-Length', str(len(_M3U8_ENDLIST)))
                    self.end_headers()
                    self.wfile.write(_M3U8_ENDLIST)
                    return
                # Strip image placeholder segments served by known image CDNs.
                # NOTE: tempfileb.aiquickdraw.com is a real video CDN that obfuscates segments as .png — keep those.
                _content_filt, _remaining, _all_seg_count = _apply_m3u8_filter(content)
                if _remaining == 0:
                    _all_segs_raw = [l.strip() for l in content.splitlines()
                                     if l.strip() and not l.strip().startswith('#')]
                    log(f'[EPlayerProxy] {channel_key}: all {_all_seg_count} segments are images — samples: {_all_segs_raw[:2]}')
                    # Wait 3s for CDN to produce real segments (transient state), then re-fetch
                    _proxy_abort.wait(timeout=3)
                    if _proxy_abort.is_set():
                        return
                    _png_reauth_ok = False
                    try:
                        _refetch = _get_session().get(state['m3u8_url'], headers=m3u8_hdrs, timeout=5)
                        if _refetch.status_code == 200 and '#EXTM3U' in _refetch.text:
                            _rf_segs = [l.strip() for l in _refetch.text.splitlines()
                                        if l.strip() and not l.strip().startswith('#')]
                            if any(not _is_placeholder(s) for s in _rf_segs):
                                log(f'[EPlayerProxy] {channel_key}: CDN recovered after 3s wait — continuing')
                                content = _refetch.text
                                _png_reauth_ok = True
                    except Exception:
                        pass
                    # Try re-auth with a fresh CDN URL if 3s wait didn't help
                    _png_cid_m = _PREMIUM_KEY_RX.match(channel_key)
                    if not _png_reauth_ok and _png_cid_m:
                        _png_cid = _png_cid_m.group(1)
                        log(f'[EPlayerProxy] {channel_key}: re-authing to get fresh CDN')
                        state = _refresh_channel_creds(_png_cid, max_attempts=1)
                        if state:
                            m3u8_hdrs['Authorization'] = f'Bearer {state["auth_token"]}'
                            try:
                                _pr = _get_session().get(state['m3u8_url'], headers=m3u8_hdrs, timeout=5)
                                if _pr.status_code == 200 and '#EXTM3U' in _pr.text:
                                    _pr_segs = [l.strip() for l in _pr.text.splitlines()
                                                if l.strip() and not l.strip().startswith('#')]
                                    _pr_video = [s for s in _pr_segs if not _is_placeholder(s)]
                                    if _pr_video:
                                        log(f'[EPlayerProxy] {channel_key}: re-auth got real video segments — continuing')
                                        content = _pr.text
                                        _png_reauth_ok = True
                                    else:
                                        log(f'[EPlayerProxy] {channel_key}: re-auth still all images — channel offline')
                            except Exception as _pe:
                                log(f'[EPlayerProxy] {channel_key}: re-auth fetch failed: {_pe}')
                    if not _png_reauth_ok:
                        log(f'[EPlayerProxy] {channel_key}: no video segments after filtering — ending stream')
                        _mark_channel_failed(channel_key)
                        self.send_response(200)
                        self.send_header('Content-Type', 'application/vnd.apple.mpegurl')
                        self.send_header('Content-Length', str(len(_M3U8_ENDLIST)))
                        self.end_headers()
                        self.wfile.write(_M3U8_ENDLIST)
                        return
                    # Re-apply filter on fresh content
                    _content_filt, _remaining, _all_seg_count = _apply_m3u8_filter(content)
                if _content_filt is not None and _remaining < _all_seg_count:
                    log(f'[EPlayerProxy] {channel_key}: filtered image segments, {_remaining} video segs remain')
                    content = _content_filt
            seq_m = _SEQ_RX.search(content)
            seq = seq_m.group(1) if seq_m else '?'
            log(f'[EPlayerProxy] m3u8 fetched seq={seq} status={r.status_code}')

            # Stall detection: sequence unchanged for 20+ seconds → re-auth
            # Time-based (not count-based) to be immune to parallel request bursts
            _STALL_TIMEOUT = 20  # seconds without sequence advance = stall
            if r.status_code == 200 and seq != '?':
                _now = time.time()
                with _stall_lock:
                    stall = _m3u8_stall.get(channel_key, {'seq': None, 'first_seen': _now})
                    if seq != stall['seq']:
                        stall = {'seq': seq, 'first_seen': _now}
                    _m3u8_stall[channel_key] = stall
                if _now - stall['first_seen'] >= _STALL_TIMEOUT:
                    same_retries = stall.get('same_url_retries', 0)
                    _stall_age = int(_now - stall['first_seen'])
                    log(f'[EPlayerProxy] stall detected (seq={seq} frozen {_stall_age}s), re-auth (same_url_retries={same_retries})')
                    cm = _PREMIUM_KEY_RX.match(channel_key)
                    if cm:
                        cid = cm.group(1)
                        at, cs = _fetch_auth_credentials(cid)
                        if at and cs:
                            old_url = (state or {}).get('m3u8_url', '')
                            new_url = resolve_stream_url(cid)
                            _set_channel_state(channel_key, at, cs, new_url)
                            if new_url != old_url:
                                # New CDN endpoint — reset stall tracker
                                with _stall_lock:
                                    _m3u8_stall[channel_key] = {'seq': None, 'first_seen': time.time(), 'same_url_retries': 0}
                                log(f'[EPlayerProxy] stall: new CDN URL obtained')
                            else:
                                # Same URL after re-auth — wait for CDN to produce new segments
                                same_retries += 1
                                if same_retries >= 3:
                                    # CDN genuinely frozen after 3 attempts — end stream
                                    log(f'[EPlayerProxy] stall: CDN frozen after {same_retries} retries — ending stream')
                                    with _stall_lock:
                                        _m3u8_stall.pop(channel_key, None)
                                    _mark_channel_failed(channel_key)
                                    self.send_response(200)
                                    self.send_header('Content-Type', 'application/vnd.apple.mpegurl')
                                    self.send_header('Content-Length', str(len(_M3U8_ENDLIST)))
                                    self.end_headers()
                                    self.wfile.write(_M3U8_ENDLIST)
                                    return
                                # Not yet giving up — wait 6s then re-fetch CDN playlist
                                log(f'[EPlayerProxy] stall: same URL (retry {same_retries}/2), waiting 6s for CDN')
                                with _stall_lock:
                                    _m3u8_stall[channel_key] = {'seq': seq, 'first_seen': time.time(), 'same_url_retries': same_retries}
                                _proxy_abort.wait(timeout=6)
                                if _proxy_abort.is_set():
                                    return
                                try:
                                    _r2 = _get_session().get(state['m3u8_url'], headers=m3u8_hdrs, timeout=5)
                                    if _r2.status_code == 200:
                                        content = _r2.text
                                        log(f'[EPlayerProxy] stall: CDN re-fetched after wait')
                                except Exception:
                                    pass

            port = _actual_proxy_port or M3U8_PROXY_PORT

            def _rewrite_key(mo):
                uri = mo.group(1)
                km = re.search(r'/key/[^/]+/(\d+)', uri)
                if km:
                    return f'URI="http://127.0.0.1:{port}/key/{channel_key}/{km.group(1)}"'
                return mo.group(0)

            content = re.sub(r'URI="([^"]+)"', _rewrite_key, content)

            # Rewrite segment URLs so Kodi fetches them via the proxy
            # (segments need Origin/Referer headers that Kodi doesn't send)
            # Also handle relative segment URLs using m3u8_url as base.
            # Two-pass safe dedup: remove CDN segments already sent (sliding window replay fix).
            # When no new segments (CDN hasn't advanced), serve an empty live playlist to avoid
            # a backward MEDIA-SEQUENCE jump that would confuse the player.
            _m3u8_base = state['m3u8_url'].split('?')[0].rsplit('/', 1)[0] + '/'
            # Pass 1: collect absolute CDN URLs and count new ones (not yet sent)
            _pl_lines = content.splitlines()
            _pl_segs = []
            for _line in _pl_lines:
                _s = _line.strip()
                if _s and not _s.startswith('#'):
                    _pl_segs.append(_s if _s.startswith('http') else urljoin(_m3u8_base, _s))

            # Pre-check: if ALL CDN segments are already downloaded, wait 2s and re-fetch CDN once.
            # This reduces latency between CDN advancing and player receiving new content,
            # preventing buffer starvation from thin dedup output.
            with _stall_lock:
                _pre_sent = _sent_seg_urls.get(channel_key, set())
                _all_pre_sent = bool(_pl_segs) and all(u in _pre_sent for u in _pl_segs)
            if _all_pre_sent:
                _proxy_abort.wait(timeout=2)
                if _proxy_abort.is_set():
                    return
                try:
                    _rp = _get_session().get(state['m3u8_url'], headers=m3u8_hdrs, timeout=3)
                    if _rp.status_code == 200:
                        _cp = _rp.text
                        _cfp, _, _ = _apply_m3u8_filter(_cp)
                        if _cfp is not None:
                            _cp = _cfp
                        content = _cp
                        _pl_lines = content.splitlines()
                        _pl_segs = [
                            _s if _s.startswith('http') else urljoin(_m3u8_base, _s)
                            for _l in _pl_lines for _s in [_l.strip()]
                            if _s and not _s.startswith('#')
                        ]
                except Exception:
                    pass

            with _stall_lock:
                _already_sent = _sent_seg_urls.get(channel_key, set())
                _new_count = sum(1 for u in _pl_segs if u not in _already_sent)
                _apply_dedup = _new_count >= 1
                _cdnseq_m = _SEQ_RX.search(content)
                _cdn_media_seq = int(_cdnseq_m.group(1)) if _cdnseq_m else 0
                if _apply_dedup:
                    # Pass 2: build deduplicated playlist.
                    # Segments are tracked at DOWNLOAD-TIME (in _handle_segment), not here.
                    # _already_sent = segments the player has ACTUALLY downloaded.
                    # Not-yet-downloaded segments from previous playlists appear as "new" here,
                    # giving the player a naturally thicker buffer without re-serving old content.
                    seg_lines = []
                    _dedup_removed_start = 0
                    _found_first_kept = False
                    _prev_removed = False
                    _seg_idx = 0
                    for _line in _pl_lines:
                        _s = _line.strip()
                        if _s and not _s.startswith('#'):
                            abs_seg = _pl_segs[_seg_idx]
                            _seg_idx += 1
                            if abs_seg in _already_sent:
                                # Already downloaded — remove from playlist
                                if seg_lines and seg_lines[-1].strip().startswith('#EXTINF'):
                                    seg_lines.pop()
                                if not _found_first_kept:
                                    _dedup_removed_start += 1
                                _prev_removed = True
                                log(f'[EPlayerProxy] dedup skip {channel_key} {abs_seg[-40:]}')
                            else:
                                # Not yet downloaded — keep in playlist
                                if _prev_removed and _found_first_kept:
                                    seg_lines.append('#EXT-X-DISCONTINUITY')
                                _found_first_kept = True
                                _prev_removed = False
                                # Map URL→channel so /seg/ handler can track download completion
                                _seg_url_to_channel[abs_seg] = channel_key
                                seg_lines.append(f'http://127.0.0.1:{port}/seg/{quote_plus(abs_seg)}')
                        else:
                            seg_lines.append(_line)
                    _adjusted_seq = _cdn_media_seq + _dedup_removed_start
                    # Enforce monotonically non-decreasing MEDIA-SEQUENCE
                    _prev_out_seq = _last_media_seq_out.get(channel_key, 0)
                    _final_media_seq = max(_adjusted_seq, _prev_out_seq)
                    _last_media_seq_out[channel_key] = _final_media_seq
                    content = '\n'.join(seg_lines)
                    content = _SEQ_RX.sub(f'#EXT-X-MEDIA-SEQUENCE:{_final_media_seq}', content)
                else:
                    # No new segments: all CDN segments already downloaded by the player.
                    # Serving them again would cause duplicate-segment freeze in ffmpegdirect.
                    # Instead, serve a valid empty live playlist — the player waits TARGET-DURATION
                    # and re-polls. Safe with download-time tracking (no parallel-thread race).
                    _tgt_m = re.search(r'#EXT-X-TARGETDURATION:(\d+)', content)
                    _tgt_dur = _tgt_m.group(1) if _tgt_m else '5'
                    _prev_out_seq = _last_media_seq_out.get(channel_key, _cdn_media_seq)
                    content = (
                        '#EXTM3U\n'
                        '#EXT-X-VERSION:3\n'
                        f'#EXT-X-TARGETDURATION:{_tgt_dur}\n'
                        f'#EXT-X-MEDIA-SEQUENCE:{_prev_out_seq}\n'
                    )
                    log(f'[EPlayerProxy] dedup: all segs sent for {channel_key}, empty live pl seq={_prev_out_seq}')

            # HLS polling throttle: if seq unchanged since last response, wait 2s
            # so Kodi doesn't hammer the CDN before it updates its playlist.
            with _stall_lock:
                _prev_seq = _last_sent_seq.get(channel_key)
                _last_sent_seq[channel_key] = seq
            if seq != '?' and seq == _prev_seq:
                _proxy_abort.wait(timeout=2)
                if _proxy_abort.is_set():
                    return

            body = content.encode('utf-8')
            self.send_response(200)
            self.send_header('Content-Type', 'application/vnd.apple.mpegurl')
            self.send_header('Content-Length', str(len(body)))
            self.end_headers()
            self.wfile.write(body)
        except Exception as e:
            log(f'[EPlayerProxy] m3u8 error for {channel_key}: {e}')
            self.send_response(502)
            self.end_headers()

    def _handle_stream(self, channel_key):
        """Serve HLS as a continuous MPEG-TS byte stream.
        Acts as an internal HLS client (URL-based segment tracking like hls.js):
        each unique CDN segment URL is downloaded and forwarded exactly once — no looping."""
        state = _get_channel_state(channel_key)
        if not state or not state.get('m3u8_url'):
            m = _PREMIUM_KEY_RX.match(channel_key)
            if m:
                cid = m.group(1)
                state = _refresh_channel_creds(cid)
        if not state or not state.get('m3u8_url'):
            self.send_response(503)
            self.end_headers()
            return

        log(f'[StreamProxy] {channel_key}: TS stream started')
        try:
            self.send_response(200)
            self.send_header('Content-Type', 'video/MP2T')
            self.end_headers()
        except Exception as e:
            log(f'[StreamProxy] {channel_key}: header error: {e}')
            return

        seen_seqs = set()   # dedup by sequence number — blocks URL-rotated same-content
        seen_urls = set()   # dedup by URL — blocks seq-renumbered same-content
        key_cache = {}
        _STALL_TIMEOUT = 12
        _stall_wait = _STALL_TIMEOUT   # reduced after URL switch to catch fast CDN propagation
        _segs_since_stall = 3          # tracks stability; <3 = keep reduced timeout
        _last_content_time = time.time()   # last time content was queued successfully
        _stall_retries = 0
        _stall_epoch_start = 0.0       # when current continuous stall epoch began (0 = not stalling)
        _aes_fail_streak = 0           # consecutive AES decrypt failures (key rotation detector)
        _STALL_EPOCH_MAX = 75.0        # give up after 75s of continuous stalling

        # Proactive background re-auth: starts at STALL_TIMEOUT/2 so credentials
        # are ready when the stall fires, avoiding a blocking 5-7s delay during playback.
        _reauth_event = threading.Event()
        _reauth_cache = [None]   # [(at, cs, new_url)] or [None]
        _reauth_started_at = [0.0]

        def _bg_reauth():
            cm = _PREMIUM_KEY_RX.match(channel_key)
            if not cm:
                _reauth_event.set()
                return
            try:
                cid = cm.group(1)
                at, cs = _fetch_auth_credentials(cid)
                if at and cs:
                    nu = resolve_stream_url(cid)
                    _reauth_cache[0] = (at, cs, nu)
            except Exception:
                pass
            _reauth_event.set()

        # Segment buffer: absorbs CDN delivery jitter (9-18s gaps between bursts)
        import queue as _q
        _seg_q = _q.Queue(maxsize=10)  # ~55s buffer at 5.5s/seg
        _client_gone = threading.Event()

        # Null TS packet (PID 0x1FFF): keeps Kodi's HTTP connection alive during CDN gaps
        _NULL_PKT = bytes([0x47, 0x1F, 0xFF, 0x10]) + bytes(184)

        # Pre-buffer: emit null packets until Kodi's buffer is deep enough.
        # Wait at least 6s (Kodi needs time to negotiate), and up to 12s if the
        # queue has fewer than 3 segments (so Kodi starts with ≥19s of buffer).
        _PRE_BUFFER_SECS = 6.0
        _PRE_BUFFER_MAX = 12.0
        _stream_start = time.time()

        def _sender():
            """Sender thread.
            Phase 1 — Pre-buffer: emit null packets until queue has ≥3 segments,
              giving the fetcher time to accumulate a head-start.
            Phase 2 — Throttled: send segments at 1× real-time pace. Between
              segments the sender waits seg_dur seconds (emitting null packets),
              which lets the fetcher run ahead and keep the queue filled.
              CDN gaps of up to queue_depth × seg_dur seconds are then covered
              by real segments from the queue rather than null-packet freezes.
            """
            _bps = 250000   # default ~2 Mbps; updated from real segments
            _BURST_INTERVAL = 0.02   # 50 Hz null burst rate

            # Phase 1: pre-buffer — null packets only, no real content.
            while (time.time() - _stream_start < _PRE_BUFFER_SECS
                   or (_seg_q.qsize() < 3 and time.time() - _stream_start < _PRE_BUFFER_MAX)):
                if _client_gone.is_set() or _proxy_abort.is_set():
                    return
                try:
                    self.wfile.write(_NULL_PKT)
                    self.wfile.flush()
                except (BrokenPipeError, OSError, ConnectionResetError):
                    log(f'[StreamProxy] {channel_key}: client disconnected (pre-buffer)')
                    _client_gone.set()
                    return
                time.sleep(0.04)

            # Phase 2: throttled send loop.
            # _pace_clock = real-time moment when next segment may be sent.
            _pace_clock = None
            while not _client_gone.is_set() and not _proxy_abort.is_set():

                # Pace wait: hold for seg_dur seconds between segments so the
                # fetcher can get ahead and fill the queue.
                # Send null packets during the wait to keep Kodi's connection alive.
                if _pace_clock is not None:
                    while not _client_gone.is_set() and not _proxy_abort.is_set():
                        remaining = _pace_clock - time.time()
                        if remaining <= 0:
                            break
                        time.sleep(min(remaining, _BURST_INTERVAL))
                        n = max(1, int(_bps * _BURST_INTERVAL / 188))
                        try:
                            self.wfile.write(_NULL_PKT * n)
                            self.wfile.flush()
                        except (BrokenPipeError, OSError, ConnectionResetError):
                            _client_gone.set()
                            break

                if _client_gone.is_set() or _proxy_abort.is_set():
                    break

                # Get next segment (may block further if CDN gap exceeds queue depth)
                try:
                    item = _seg_q.get(timeout=_BURST_INTERVAL)
                except _q.Empty:
                    n = max(1, int(_bps * _BURST_INTERVAL / 188))
                    try:
                        self.wfile.write(_NULL_PKT * n)
                        self.wfile.flush()
                    except (BrokenPipeError, OSError, ConnectionResetError):
                        _client_gone.set()
                    continue

                if item is None:
                    _seg_q.task_done()
                    break

                seg_body, seg_dur = item
                _bps = max(12500, len(seg_body) / max(seg_dur, 0.1))

                try:
                    self.wfile.write(seg_body)
                    self.wfile.flush()
                except (BrokenPipeError, OSError, ConnectionResetError):
                    log(f'[StreamProxy] {channel_key}: client disconnected')
                    _client_gone.set()
                    _seg_q.task_done()
                    return

                # Advance pace clock by seg_dur + 0.3s.
                # The 0.3s delta lets the fetcher run slightly ahead, building a
                # small queue buffer to absorb CDN delivery jitter.
                # Kept low (300ms) to avoid visible PCR drift in Kodi.
                # If we fell behind (CDN gap), reset to now (no catch-up rush).
                now = time.time()
                _pace_clock = max(now, _pace_clock or now) + seg_dur + 0.5

                _seg_q.task_done()

        _sender_t = threading.Thread(target=_sender, daemon=True)
        _sender_t.start()
        seg_proxy_url = addon.getSetting('seg_proxy').strip()
        seg_proxies = {'http': seg_proxy_url, 'https': seg_proxy_url} if seg_proxy_url else None
        seg_hdrs = dict(_SEG_HEADERS)

        try:
            while not _proxy_abort.is_set() and not _client_gone.is_set():
                state = _get_channel_state(channel_key)
                if not state:
                    break

                m3u8_hdrs = {
                    'User-Agent': _AUTH_UA,
                    'Referer': PLAYER_REFERER,
                    'Authorization': f'Bearer {state["auth_token"]}',
                    'X-Channel-Key': channel_key,
                    'X-User-Agent': _AUTH_UA,
                    'Cache-Control': 'no-cache',
                    'Pragma': 'no-cache',
                }

                # Fetch CDN m3u8
                content = None
                try:
                    r = _get_session().get(state['m3u8_url'], headers=m3u8_hdrs, timeout=5)
                    if r.status_code == 200:
                        content = r.text
                    elif r.status_code in (403, 404):
                        log(f'[StreamProxy] m3u8 {r.status_code}, re-authing')
                        cm = _PREMIUM_KEY_RX.match(channel_key)
                        if cm:
                            _refresh_channel_creds(cm.group(1))
                        _proxy_abort.wait(timeout=1)
                        continue
                    elif r.status_code in (502, 503):
                        _proxy_abort.wait(timeout=2)
                        continue
                except Exception as e:
                    log(f'[StreamProxy] m3u8 fetch error: {e}')
                    if _stall_epoch_start == 0.0:
                        _stall_epoch_start = time.time()
                    elif time.time() - _stall_epoch_start >= _STALL_EPOCH_MAX:
                        log(f'[StreamProxy] {channel_key}: CDN unreachable >{int(_STALL_EPOCH_MAX)}s — ending stream')
                        _mark_channel_failed(channel_key)
                        break
                    _proxy_abort.wait(timeout=2)
                    continue

                if not content:
                    _proxy_abort.wait(timeout=2)
                    continue

                if '#EXT-X-ENDLIST' in content:
                    log(f'[StreamProxy] {channel_key}: EXT-X-ENDLIST — ending stream')
                    _mark_channel_failed(channel_key)
                    break

                # Filter placeholder image segments
                filtered, remaining, total = _apply_m3u8_filter(content)
                if remaining == 0:
                    log(f'[StreamProxy] {channel_key}: all {total} segs are placeholders, waiting')
                    _proxy_abort.wait(timeout=3)
                    _ph_now = time.time()
                    _ph_gap = _ph_now - _last_content_time
                    if _ph_gap >= _stall_wait and _PREMIUM_KEY_RX.match(channel_key):
                        if _stall_epoch_start == 0.0:
                            _stall_epoch_start = _ph_now
                        _ph_stall_total = _ph_now - _stall_epoch_start
                        log(f'[StreamProxy] {channel_key}: placeholder stall {int(_ph_gap)}s epoch={int(_ph_stall_total)}s')
                        if _ph_stall_total >= _STALL_EPOCH_MAX:
                            log(f'[StreamProxy] {channel_key}: placeholder frozen >{int(_STALL_EPOCH_MAX)}s — ending stream')
                            _mark_channel_failed(channel_key)
                            break
                        if _ph_now - _reauth_started_at[0] > _stall_wait:
                            _reauth_started_at[0] = _ph_now
                            _reauth_event.clear()
                            _reauth_cache[0] = None
                            threading.Thread(target=_bg_reauth, daemon=True).start()
                        _reauth_event.wait(timeout=8)
                        result = _reauth_cache[0]
                        _reauth_cache[0] = None
                        if result:
                            at, cs, new_url = result
                            _set_channel_state(channel_key, at, cs, new_url)
                            _last_content_time = time.time()
                    continue
                if filtered:
                    content = filtered

                tgt_m = re.search(r'#EXT-X-TARGETDURATION:(\d+)', content)
                tgt_dur = float(tgt_m.group(1)) if tgt_m else 6.0

                # Parse AES-128 key info
                current_key_id = None
                current_iv = None
                seq_m = _SEQ_RX.search(content)
                base_seq = int(seq_m.group(1)) if seq_m else 0
                for _line in content.splitlines():
                    if _line.strip().startswith('#EXT-X-KEY:'):
                        _uri_m = re.search(r'URI="([^"]+)"', _line)
                        _iv_m = re.search(r'IV=0x([0-9a-fA-F]+)', _line)
                        if _uri_m:
                            _km = re.search(r'/key/[^/]+/(\d+)', _uri_m.group(1))
                            if _km:
                                current_key_id = _km.group(1)
                        if _iv_m:
                            current_iv = bytes.fromhex(_iv_m.group(1).zfill(32))
                        break

                # Parse segment URLs and per-segment EXTINF durations
                m3u8_base = state['m3u8_url'].split('?')[0].rsplit('/', 1)[0] + '/'
                segs = []
                seg_dur_map = {}
                _last_inf = tgt_dur
                for _sl in content.splitlines():
                    _sl = _sl.strip()
                    if _sl.startswith('#EXTINF:'):
                        try:
                            _last_inf = float(_sl[8:].split(',')[0])
                        except Exception:
                            _last_inf = tgt_dur
                    elif _sl and not _sl.startswith('#'):
                        _su = _sl if _sl.startswith('http') else urljoin(m3u8_base, _sl)
                        segs.append(_su)
                        seg_dur_map[_su] = _last_inf
                        _last_inf = tgt_dur

                # Stall detection: trigger only if no content queued for _STALL_TIMEOUT seconds.
                seq_str = str(base_seq)
                _now = time.time()
                _gap = _now - _last_content_time

                # Proactive re-auth: start as soon as CDN is 1.5× a segment overdue,
                # so credentials are ready when the stall fires (avoids blocking buffer drain).
                if (_gap >= max(_last_inf * 1.5, _stall_wait * 0.3)
                        and _PREMIUM_KEY_RX.match(channel_key)
                        and _now - _reauth_started_at[0] > _stall_wait):
                    _reauth_started_at[0] = _now
                    _reauth_event.clear()
                    _reauth_cache[0] = None
                    threading.Thread(target=_bg_reauth, daemon=True).start()

                if _gap >= _stall_wait:
                    # Track total continuous stall duration; give up after _STALL_EPOCH_MAX seconds.
                    if _stall_epoch_start == 0.0:
                        _stall_epoch_start = time.time()
                    _stall_total = time.time() - _stall_epoch_start
                    log(f'[StreamProxy] stall {int(_gap)}s seq={seq_str} retry={_stall_retries} epoch={int(_stall_total)}s')
                    if _stall_total >= _STALL_EPOCH_MAX:
                        log(f'[StreamProxy] {channel_key}: CDN frozen >{int(_STALL_EPOCH_MAX)}s — ending stream')
                        _mark_channel_failed(channel_key)
                        break
                    # Wait for background re-auth (should be nearly done after STALL_TIMEOUT/2 head-start)
                    _reauth_event.wait(timeout=8)
                    result = _reauth_cache[0]
                    _reauth_cache[0] = None
                    if result:
                        at, cs, new_url = result
                        old_url = state.get('m3u8_url', '')
                        _set_channel_state(channel_key, at, cs, new_url)
                        if new_url != old_url:
                            _last_content_time = time.time()
                            _stall_retries = 0
                            # CDN may take 5-15s to propagate the new URL.
                            # Reduce next stall threshold so we retry sooner instead of waiting full 12s.
                            _stall_wait = 7.0
                            _segs_since_stall = 0   # need 3 stable segs before restoring 12s timeout
                            _reauth_event.clear()
                            _reauth_cache[0] = None
                            _reauth_started_at[0] = time.time()
                            threading.Thread(target=_bg_reauth, daemon=True).start()
                            continue   # re-auth OK → fetch new m3u8 immediately
                        else:
                            _stall_retries += 1
                            _stall_wait = 7.0
                            _segs_since_stall = 0
                            _last_content_time = time.time()
                            # Don't block for a second reauth — CDN may already have
                            # the next seg ready. Start bg-reauth for next cycle and
                            # immediately re-poll the M3U8.
                            _reauth_event.clear()
                            _reauth_cache[0] = None
                            _reauth_started_at[0] = time.time()
                            threading.Thread(target=_bg_reauth, daemon=True).start()
                            continue
                    # No reauth result yet — don't sleep, just re-poll M3U8 immediately
                    continue

                # Dual dedup: skip if seq seen (URL rotation) OR url seen (seq renumbering)
                new_segs = [(i, u) for i, u in enumerate(segs)
                            if (base_seq + i) not in seen_seqs and u not in seen_urls]
                if not new_segs:
                    _proxy_abort.wait(timeout=tgt_dur / 2)
                    continue

                _stall_retries = 0
                log(f'[StreamProxy] {channel_key}: seq={seq_str} {len(new_segs)} new seg(s)')

                # Download and stream each new segment
                for seg_idx, seg_url in new_segs:
                    if _proxy_abort.is_set():
                        return
                    seg_seq = base_seq + seg_idx

                    # IV: explicit or sequence-derived
                    iv = current_iv
                    if current_key_id and iv is None:
                        iv = seg_seq.to_bytes(16, 'big')

                    # Download with optional proxy fallback
                    # Only retry with proxy if one is configured; avoids double-wait on CDN timeout.
                    body = None
                    _max_seg_attempts = 2 if seg_proxies else 1
                    for attempt in range(_max_seg_attempts):
                        cur_proxies = seg_proxies if attempt == 1 else None
                        try:
                            sr = _get_session().get(seg_url, headers=seg_hdrs, timeout=(3, 5),
                                                   proxies=cur_proxies)
                            ct = sr.headers.get('Content-Type', '')
                            if sr.status_code == 200 and 'html' not in ct.lower():
                                body = sr.content
                                break
                            elif sr.status_code == 404:
                                seen_seqs.add(seg_seq)
                                seen_urls.add(seg_url)
                                log(f'[StreamProxy] seg 404 (expired): {seg_url[-50:]}')
                                break
                            elif sr.status_code == 429 and attempt == 0 and seg_proxies:
                                continue
                            else:
                                log(f'[StreamProxy] seg {sr.status_code}: {seg_url[-50:]}')
                                break
                        except Exception as e:
                            log(f'[StreamProxy] seg error: {e}')
                        if attempt == 0:
                            time.sleep(0.1)

                    if body is None:
                        continue

                    log(f'[StreamProxy] seg fetched len={len(body)} first8={body[:8].hex()} url={seg_url[-60:]}')

                    # Detect CHEVY plain-TS-with-header mode: segments are NOT AES encrypted,
                    # CHEVY prepends a variable-length header (fake PNG) before the actual TS data.
                    # Use 3 consecutive sync bytes; scan full body (PNG wrappers can exceed 1024B).
                    _chevy_offset = None
                    if body and body[0] != 0x47 and len(body) > 564:
                        for _off in range(len(body) - 564):
                            if (body[_off] == 0x47 and body[_off + 188] == 0x47
                                    and body[_off + 376] == 0x47):
                                _chevy_offset = _off
                                break
                    if _chevy_offset is not None and _chevy_offset > 0:
                        log(f'[StreamProxy] CHEVY plain-TS header detected, skip {_chevy_offset}B')
                        body = body[_chevy_offset:]
                    elif current_key_id and iv:
                        # Fallback: try AES-128 decryption
                        key = _fetch_stream_key(current_key_id, channel_key, state, key_cache)
                        if key:
                            _raw = body
                            body = _aes128_cbc_decrypt(body, key, iv)
                            # TS sync byte check: 0x47 = valid MPEG-TS
                            # If wrong, the CDN rotated the key value → purge cache and retry
                            if body and body[0] != 0x47 and current_key_id in key_cache:
                                log(f'[StreamProxy] stale key {current_key_id}, re-fetching (cache-bust)')
                                del key_cache[current_key_id]
                                key = _fetch_stream_key(current_key_id, channel_key, state, key_cache,
                                                        force_refresh=True)
                                if key:
                                    body = _aes128_cbc_decrypt(_raw, key, iv)
                            # Still corrupt after retry → drop (sender will emit null packets)
                            if body and body[0] != 0x47:
                                seen_seqs.add(seg_seq)
                                seen_urls.add(seg_url)
                                _aes_fail_streak += 1
                                log(f'[StreamProxy] AES mismatch, drop seq={seg_seq} (streak={_aes_fail_streak})')
                                # M9: always force stall detection on AES mismatch (was if >= 1, always true)
                                log(f'[StreamProxy] AES key rotated — forcing stall')
                                _last_content_time = time.time() - _stall_wait
                                continue

                    seen_seqs.add(seg_seq)
                    seen_urls.add(seg_url)
                    seg_dur = seg_dur_map.get(seg_url, tgt_dur)

                    # Enqueue real segment with duration (sender uses dur for bitrate estimate)
                    while not _client_gone.is_set() and not _proxy_abort.is_set():
                        try:
                            _seg_q.put((body, seg_dur), timeout=0.5)
                            break
                        except _q.Full:
                            pass
                    if _client_gone.is_set():
                        return
                    # Log after put so q reflects actual queue depth (including this segment)
                    log(f'[StreamProxy] seg ok {len(body)}B dur={seg_dur:.1f}s first={body[:4].hex()} q={_seg_q.qsize()}')
                    # Reset stall timer only on successful delivery to queue
                    _last_content_time = time.time()
                    _stall_retries = 0
                    _stall_epoch_start = 0.0   # content flowing — reset stall epoch
                    _aes_fail_streak = 0
                    # Restore normal timeout only after 3 consecutive segs (CDN stable again)
                    if _stall_wait < _STALL_TIMEOUT:
                        _segs_since_stall += 1
                        if _segs_since_stall >= 3:
                            _stall_wait = _STALL_TIMEOUT

                # Brief pause before next CDN poll
                _proxy_abort.wait(timeout=0.5)
        finally:
            # H3: ensure sender always receives sentinel to unblock it on any exit path
            log(f'[StreamProxy] {channel_key}: stream ended')
            try:
                _seg_q.put(None, timeout=2)
            except Exception:
                pass
        _sender_t.join(timeout=10)

    def _handle_key(self, channel_key, key_id):
        state = _get_channel_state(channel_key)
        if not state or not state.get('m3u8_url'):
            # Proxy runs in old process — fetch credentials on demand
            m = _PREMIUM_KEY_RX.match(channel_key)
            if m:
                cid = m.group(1)
                state = _refresh_channel_creds(cid)
                if not state:
                    log(f'[EPlayerProxy] Auth failed for key {channel_key}, using noauth fallback')
                    noauth_url = resolve_stream_url(cid)
                    _set_channel_state(channel_key, 'noauth', 'noauth', noauth_url)
                    state = _get_channel_state(channel_key)
        if not state or not state.get('m3u8_url'):
            self.send_response(503)
            self.end_headers()
            return
        try:
            # Derive key host and server_key from m3u8 URL
            # m3u8 URL pattern: https://{host}/proxy/{server_key}/{channel_key}/mono.css
            # Key URL pattern:  https://{host}/key/{server_key}/{key_id}
            m3u8_url = state.get('m3u8_url', '')
            _key_host_m = re.match(r'(https?://[^/]+)', m3u8_url)
            key_host = _key_host_m.group(1) if _key_host_m else CHEVY_LOOKUP
            _srv_key_m = re.search(r'/proxy/([^/]+)/', m3u8_url)
            _srv_key = _srv_key_m.group(1) if _srv_key_m else channel_key
            key_url = f'{key_host}/key/{_srv_key}/{key_id}'
            channel_salt = state.get('channel_salt') or ''
            hdrs = {'User-Agent': _AUTH_UA, 'Referer': m3u8_url or PLAYER_REFERER}
            if channel_salt and channel_salt != 'noauth':
                ts = int(time.time())
                fp = _compute_fingerprint()
                nonce = _compute_pow_nonce(channel_key, channel_salt, key_id, ts)
                auth_sig = _compute_auth_sig(channel_key, channel_salt, key_id, ts, fp)
                hdrs['Authorization'] = f'Bearer {state["auth_token"]}'
                hdrs['X-Key-Timestamp'] = str(ts)
                hdrs['X-Key-Nonce'] = str(nonce)
                hdrs['X-Key-Path'] = auth_sig
                hdrs['X-Fingerprint'] = fp
            r = _get_session().get(key_url, headers=hdrs, timeout=3)
            body = r.content
            log(f'[EPlayerProxy] key {key_id}: {len(body)}B status={r.status_code} host={key_host}')
            self.send_response(200)
            self.send_header('Content-Type', 'application/octet-stream')
            self.send_header('Content-Length', str(len(body)))
            self.end_headers()
            self.wfile.write(body)
        except Exception as e:
            log(f'[EPlayerProxy] key error for {channel_key}/{key_id}: {e}')
            self.send_response(502)
            self.end_headers()

    def _handle_raw(self, encoded_origin, encoded_url):
        """Proxy a URL with a specific Origin/Referer.
        - m3u8/playlist: rewrites relative segment URLs to absolute and routes through /raw/
        - segments (.ts): buffered and forwarded (stream=False for retry on conn errors)
        """
        try:
            origin = unquote(encoded_origin)
            raw_url = unquote(encoded_url)

            # Guard: reject clearly relative URLs (Kodi resolved them incorrectly)
            if not raw_url.startswith('http'):
                log(f'[EPlayerProxy] raw: relative URL rejected: {raw_url}')
                self.send_response(400)
                self.end_headers()
                return

            referer = origin.rstrip('/') + '/'
            hdrs = {'User-Agent': UA, 'Origin': origin, 'Referer': referer}

            # Fetch without streaming so we can inspect Content-Type / content prefix
            r = requests.get(raw_url, headers=hdrs, timeout=5)
            ct = r.headers.get('Content-Type', '')

            # Detect M3U8 by URL extension OR Content-Type OR content starting with #EXTM3U
            is_manifest = (
                '.m3u8' in raw_url.split('?')[0]
                or 'mpegurl' in ct.lower()
                or r.content[:7] == b'#EXTM3U'
            )

            if is_manifest:
                port = _actual_proxy_port or M3U8_PROXY_PORT
                base = r.url.split('?')[0].rsplit('/', 1)[0] + '/'  # use final URL after redirect
                enc_orig = quote_plus(origin)
                seg_lines = []
                for line in r.text.splitlines():
                    stripped = line.strip()
                    if stripped and not stripped.startswith('#'):
                        abs_seg = stripped if stripped.startswith('http') else urljoin(base, stripped)
                        seg_lines.append(f'http://127.0.0.1:{port}/raw/{enc_orig}/{quote_plus(abs_seg)}')
                    else:
                        seg_lines.append(line)
                body = '\n'.join(seg_lines).encode('utf-8')
                self.send_response(200)
                self.send_header('Content-Type', 'application/vnd.apple.mpegurl')
                self.send_header('Content-Length', str(len(body)))
                self.end_headers()
                self.wfile.write(body)
            else:
                # Segment — already buffered by stream=False above
                body = r.content
                self.send_response(r.status_code)
                self.send_header('Content-Type', ct or 'video/mp2t')
                self.send_header('Content-Length', str(len(body)))
                self.end_headers()
                self.wfile.write(body)
        except Exception as e:
            log(f'[EPlayerProxy] raw error: {e}')
            try:
                self.send_response(502)
                self.end_headers()
            except Exception:
                pass

    def _handle_lovecdn(self, stream_name):
        """Proxy M3U8 for lovecdn streams with automatic token refresh.
        Segment URLs are rewritten to go through our local proxy (/lovecdn-seg/)
        which pre-fetches segments into memory — eliminates CDN latency and
        prevents HLS saccade at segment boundaries."""
        try:
            result = _lovecdn_get_media_url(stream_name)
            if not result:
                self.send_response(502)
                self.end_headers()
                return
            current_url, lt_url, media_text, media_base = result
            log(f'[LovecdnM3U8] {stream_name}: ' + ' | '.join(media_text.splitlines()[:20]))

            port = _actual_proxy_port or M3U8_PROXY_PORT
            lines = []
            for line in media_text.splitlines():
                stripped = line.strip()
                # Strip VOD/EVENT markers so libavformat treats stream as live (no loop)
                if stripped.startswith('#EXT-X-PLAYLIST-TYPE') or stripped == '#EXT-X-ENDLIST':
                    continue
                if stripped and not stripped.startswith('#'):
                    abs_seg = stripped if stripped.startswith('http') else urljoin(media_base, stripped)
                    # Route through local cache proxy
                    proxy_url = f'http://127.0.0.1:{port}/lovecdn-seg/{quote_plus(abs_seg)}'
                    lines.append(proxy_url)
                    # Pre-fetch in background so it's ready before Kodi asks for it
                    threading.Thread(
                        target=_lovecdn_prefetch_seg,
                        args=(abs_seg,),
                        daemon=True,
                    ).start()
                else:
                    lines.append(line)
            body = '\n'.join(lines).encode('utf-8')
            self.send_response(200)
            self.send_header('Content-Type', 'application/vnd.apple.mpegurl')
            self.send_header('Content-Length', str(len(body)))
            self.end_headers()
            self.wfile.write(body)
        except Exception as e:
            log(f'[LovecdnProxy] error for {stream_name}: {e}')
            try:
                self.send_response(502)
                self.end_headers()
            except Exception:
                pass

    def _handle_lovecdn_ts(self, stream_name):
        """Serve lovecdn stream as a continuous MPEG-TS byte stream (no HLS jitter).
        Acts as an internal HLS client: polls media playlist, downloads plain TS segments,
        and forwards them with null-packet pacing — same pattern as _handle_stream.

        Key design: HTTP response headers are NOT sent until 2 segments are buffered.
        This ensures ffmpegdirect/libavformat sees real H.264 SPS data immediately,
        avoiding DXVA2 initialization with incomplete stream info (which causes
        'status 2 with 255' errors when null packets are sent first)."""
        import queue as _q

        # Initial token fetch
        result = _lovecdn_get_media_url(stream_name, force_refresh=True)
        if not result:
            self.send_response(502)
            self.end_headers()
            return

        seen_urls = set()
        _seg_q = _q.Queue(maxsize=10)
        _client_gone = threading.Event()
        _NULL_PKT = bytes([0x47, 0x1F, 0xFF, 0x10]) + bytes(184)
        _PRE_BUFFER_MAX = 15.0
        _STALL_MAX = 30.0
        _last_content_time = [time.time()]

        def _download():
            """Download segments in background, filling _seg_q."""
            last_content = time.time()
            while not _proxy_abort.is_set() and not _client_gone.is_set():
                r = _lovecdn_get_media_url(stream_name)
                if not r:
                    _proxy_abort.wait(timeout=3)
                    r = _lovecdn_get_media_url(stream_name, force_refresh=True)
                    if not r:
                        log(f'[LovecdnTS] {stream_name}: token refresh failed, ending stream')
                        break

                _media_url, _lt_url, media_text, media_base = r
                tgt_m = re.search(r'#EXT-X-TARGETDURATION:(\d+)', media_text)
                tgt_dur = float(tgt_m.group(1)) if tgt_m else 6.0

                segs = []
                seg_dur_map = {}
                _last_inf = tgt_dur
                for _sl in media_text.splitlines():
                    _sl = _sl.strip()
                    if _sl.startswith('#EXTINF:'):
                        try:
                            _last_inf = float(_sl[8:].split(',')[0])
                        except Exception:
                            _last_inf = tgt_dur
                    elif _sl and not _sl.startswith('#'):
                        _su = _sl if _sl.startswith('http') else urljoin(media_base, _sl)
                        segs.append(_su)
                        seg_dur_map[_su] = _last_inf
                        _last_inf = tgt_dur

                new_segs = [u for u in segs if u not in seen_urls]
                if not new_segs:
                    if time.time() - last_content >= _STALL_MAX:
                        log(f'[LovecdnTS] {stream_name}: stall >{int(_STALL_MAX)}s — ending stream')
                        break
                    _proxy_abort.wait(timeout=tgt_dur / 2)
                    continue

                for seg_url in new_segs:
                    if _proxy_abort.is_set() or _client_gone.is_set():
                        break
                    try:
                        sr = _get_session().get(seg_url, headers={'User-Agent': UA}, timeout=(3, 8))
                        if sr.status_code == 200:
                            body = sr.content
                            seen_urls.add(seg_url)
                            seg_dur = seg_dur_map.get(seg_url, tgt_dur)
                            while not _client_gone.is_set() and not _proxy_abort.is_set():
                                try:
                                    _seg_q.put((body, seg_dur), timeout=0.5)
                                    break
                                except _q.Full:
                                    pass
                            last_content = time.time()
                            _last_content_time[0] = last_content
                            log(f'[LovecdnTS] seg {len(body)}B dur={seg_dur:.1f}s {seg_url[-50:]}')
                        elif sr.status_code == 403:
                            log(f'[LovecdnTS] seg 403 — clearing token cache for {stream_name}')
                            with _lovecdn_url_cache_lock:
                                _lovecdn_url_cache.pop(stream_name, None)
                        else:
                            log(f'[LovecdnTS] seg {sr.status_code}: {seg_url[-50:]}')
                    except Exception as e:
                        log(f'[LovecdnTS] seg error: {e}')
            _seg_q.put(None)

        _dl_t = threading.Thread(target=_download, daemon=True)
        _dl_t.start()

        # Wait for 2 segments before sending HTTP response.
        # No null packets sent — ffmpegdirect must see real H.264 SPS immediately
        # so DXVA2 (or its fallback) initializes with correct stream parameters.
        _wait_start = time.time()
        while _seg_q.qsize() < 2 and not _proxy_abort.is_set():
            if time.time() - _wait_start > _PRE_BUFFER_MAX:
                log(f'[LovecdnTS] {stream_name}: pre-buffer timeout')
                _client_gone.set()
                _dl_t.join(timeout=5)
                self.send_response(504)
                self.end_headers()
                return
            time.sleep(0.05)

        log(f'[LovecdnTS] {stream_name}: {_seg_q.qsize()} segs ready, starting stream')
        try:
            self.send_response(200)
            self.send_header('Content-Type', 'video/MP2T')
            self.end_headers()
        except Exception as e:
            log(f'[LovecdnTS] {stream_name}: header error: {e}')
            _client_gone.set()
            _dl_t.join(timeout=5)
            return

        def _sender():
            _bps = 250000
            _BURST_INTERVAL = 0.02
            _pace_clock = None
            while not _client_gone.is_set() and not _proxy_abort.is_set():
                if _pace_clock is not None:
                    while not _client_gone.is_set() and not _proxy_abort.is_set():
                        remaining = _pace_clock - time.time()
                        if remaining <= 0:
                            break
                        time.sleep(min(remaining, _BURST_INTERVAL))
                        n = max(1, int(_bps * _BURST_INTERVAL / 188))
                        try:
                            self.wfile.write(_NULL_PKT * n)
                            self.wfile.flush()
                        except (BrokenPipeError, OSError, ConnectionResetError):
                            _client_gone.set()
                            break
                if _client_gone.is_set() or _proxy_abort.is_set():
                    break
                try:
                    item = _seg_q.get(timeout=_BURST_INTERVAL)
                except _q.Empty:
                    n = max(1, int(_bps * _BURST_INTERVAL / 188))
                    try:
                        self.wfile.write(_NULL_PKT * n)
                        self.wfile.flush()
                    except (BrokenPipeError, OSError, ConnectionResetError):
                        _client_gone.set()
                    continue
                if item is None:
                    _seg_q.task_done()
                    break
                seg_body, seg_dur = item
                _bps = max(12500, len(seg_body) / max(seg_dur, 0.1))
                try:
                    self.wfile.write(seg_body)
                    self.wfile.flush()
                except (BrokenPipeError, OSError, ConnectionResetError):
                    log(f'[LovecdnTS] {stream_name}: client disconnected')
                    _client_gone.set()
                    _seg_q.task_done()
                    return
                now = time.time()
                _pace_clock = max(now, _pace_clock or now) + seg_dur
                _seg_q.task_done()

        _sender_t = threading.Thread(target=_sender, daemon=True)
        _sender_t.start()
        _sender_t.join()
        _client_gone.set()
        _dl_t.join(timeout=5)

    def _handle_lovecdn_seg(self, seg_url):
        """Fetch a lovecdn segment or sub-playlist with proper headers.
        Serves from pre-fetch memory cache when available (instant, no CDN latency).
        If the response is an M3U8, segment URLs are rewritten through this proxy
        (handles variant/master playlists that contain relative segment paths)."""
        try:
            # Serve from memory cache if segment was pre-fetched
            with _lovecdn_seg_cache_lock:
                cached_body = _lovecdn_seg_cache.get(seg_url)
            if cached_body is not None:
                self.send_response(200)
                self.send_header('Content-Type', 'video/MP2T')
                self.send_header('Content-Length', str(len(cached_body)))
                self.end_headers()
                self.wfile.write(cached_body)
                log(f'[LovecdnSeg] cache-hit {len(cached_body)}B {seg_url[-40:]}')
                return

            referer = 'https://lovetier.bz/'
            with _lovecdn_url_cache_lock:
                for sname, cached in list(_lovecdn_url_cache.items()):
                    if sname in seg_url and cached and len(cached) > 2 and cached[2]:
                        referer = cached[2]
                        break
            _p = urlparse(referer)
            origin = f'{_p.scheme}://{_p.netloc}'
            r = _lovecdn_session.get(seg_url, headers={
                'User-Agent': UA,
                'Referer': referer,
                'Origin': origin,
            }, timeout=10, stream=True)
            if r.status_code != 200:
                log(f'[LovecdnProxy] seg HTTP {r.status_code}: {seg_url[8:60]}')
                self.send_response(r.status_code)
                self.end_headers()
                return
            ct = r.headers.get('Content-Type', '')
            is_m3u8 = ('mpegurl' in ct or 'm3u8' in ct
                       or seg_url.split('?')[0].lower().endswith('.m3u8'))
            if is_m3u8:
                # Sub-playlist: buffer fully to detect HTML and rewrite segment URLs
                body_bytes = r.content
                body_text = body_bytes.decode('utf-8', errors='replace')
                if '<html' in body_text[:200].lower():
                    log(f'[LovecdnProxy] seg got HTML (Cloudflare?) for {seg_url[8:60]}')
                    with _lovecdn_url_cache_lock:
                        for sname in list(_lovecdn_url_cache.keys()):
                            if sname in seg_url:
                                _lovecdn_url_cache.pop(sname, None)
                                break
                    self.send_response(502)
                    self.end_headers()
                    return
                port = _actual_proxy_port or M3U8_PROXY_PORT
                base = seg_url.split('?')[0].rsplit('/', 1)[0] + '/'
                lines = []
                for line in body_text.splitlines():
                    stripped = line.strip()
                    if stripped and not stripped.startswith('#'):
                        abs_seg = stripped if stripped.startswith('http') else urljoin(base, stripped)
                        lines.append(f'http://127.0.0.1:{port}/lovecdn-seg/{quote_plus(abs_seg)}')
                    else:
                        lines.append(line)
                body = '\n'.join(lines).encode('utf-8')
                self.send_response(200)
                self.send_header('Content-Type', 'application/vnd.apple.mpegurl')
                self.send_header('Content-Length', str(len(body)))
                self.end_headers()
                self.wfile.write(body)
            else:
                # TS segment: stream to Kodi in chunks — avoids buffering 2.5MB before first byte
                cl = r.headers.get('Content-Length')
                self.send_response(200)
                self.send_header('Content-Type', ct or 'video/mp2t')
                if cl:
                    self.send_header('Content-Length', cl)
                self.end_headers()
                total = 0
                for chunk in r.iter_content(chunk_size=65536):
                    if chunk:
                        self.wfile.write(chunk)
                        total += len(chunk)
                log(f'[LovecdnProxy] seg ok {total}B {seg_url[8:60]}')
        except Exception as e:
            log(f'[LovecdnProxy] seg error: {e}')
            try:
                self.send_response(502)
                self.end_headers()
            except Exception:
                pass

    def _handle_segment(self, encoded_url):
        seg_url = unquote(encoded_url)
        log(f'[EPlayerProxy] seg req: {seg_url[8:80]}')
        seg_hdrs = dict(_SEG_HEADERS)
        body = None
        seg_proxy_url = addon.getSetting('seg_proxy').strip()
        proxies = {'http': seg_proxy_url, 'https': seg_proxy_url} if seg_proxy_url else None
        with _seg_semaphore:
            for attempt in range(2):
                current_proxies = proxies if attempt == 1 else None
                try:
                    r = _get_session().get(seg_url, headers=seg_hdrs, timeout=(3, 5),
                                          proxies=current_proxies)
                    ct = r.headers.get('Content-Type', '')
                    if r.status_code == 200 and 'html' not in ct.lower():
                        body = r.content
                        if current_proxies:
                            log(f'[EPlayerProxy] seg ok via proxy: {len(body)}B')
                        break
                    elif r.status_code == 429:
                        if attempt == 0 and proxies:
                            log(f'[EPlayerProxy] seg 429, retrying via proxy')
                            # continue to attempt 1 with proxy
                        else:
                            log(f'[EPlayerProxy] seg 429: CDN rate-limit')
                            break
                    else:
                        log(f'[EPlayerProxy] seg bad: status={r.status_code} ct={ct} size={len(r.content)}B')
                        break
                except requests.exceptions.Timeout:
                    log(f'[EPlayerProxy] seg timeout: {seg_url[:70]}')
                    break
                except Exception as e:
                    err_str = str(e)
                    if '407' in err_str or 'Proxy Authentication Required' in err_str:
                        log(f'[EPlayerProxy] seg proxy 407: incorrect proxy credentials — check the "Proxy segments" setting')
                        break
                    log(f'[EPlayerProxy] seg error (attempt {attempt + 1}): {e}')
                    if attempt == 0:
                        time.sleep(0.1)
        try:
            if body is not None:
                # Strip CHEVY PNG wrapper: plain TS wrapped in a fake PNG file.
                # PNG magic = \x89PNG; TS sync = 0x47. Use 3 consecutive sync bytes for reliable detection.
                # Scan only when body starts with PNG signature (avoids wasteful scan of AES data).
                _PNG_SIG = b'\x89PNG'
                if body and body[:4] == _PNG_SIG and len(body) > 564:
                    for _off in range(len(body) - 564):
                        if (body[_off] == 0x47 and body[_off + 188] == 0x47
                                and body[_off + 376] == 0x47):
                            log(f'[EPlayerProxy] seg stripped CHEVY header {_off}B, size {len(body)}→{len(body)-_off}B')
                            body = body[_off:]
                            break
                    else:
                        log(f'[EPlayerProxy] seg PNG wrapper but no TS sync found in {len(body)}B')
                log(f'[EPlayerProxy] seg ok: {len(body)}B first8={body[:8].hex()}')
                # Mark this segment as downloaded so the m3u8 dedup won't include it again
                _ch = _seg_url_to_channel.get(seg_url)
                if _ch:
                    with _stall_lock:
                        _sent_seg_urls.setdefault(_ch, set()).add(seg_url)
                self.send_response(200)
                self.send_header('Content-Type', 'video/mp2t')
                self.send_header('Content-Length', str(len(body)))
                self.end_headers()
                self.wfile.write(body)
            else:
                log(f'[EPlayerProxy] seg fail 502: {seg_url[8:70]}')
                self.send_response(502)
                self.end_headers()
        except Exception:
            pass

    def do_HEAD(self):
        self.send_response(200)
        self.send_header('Content-Type', 'application/octet-stream')
        self.end_headers()

def _serve_until_abort(server):
    """Run the HTTP server until Kodi signals shutdown. Exits within ~0.5s of abort."""
    monitor = xbmc.Monitor()
    server.timeout = 0.5
    while not monitor.abortRequested():
        server.handle_request()
    _proxy_abort.set()  # unblock any handler threads sleeping in stall retry
    log('[EPlayerProxy] Kodi abort — proxy server stopped')
    server.server_close()


def _ensure_m3u8_proxy():
    global _actual_proxy_port
    if _actual_proxy_port is not None:
        return
    # H5: clear the abort event so handler threads started in this session are not
    # immediately unblocked by a stale set() from a previous proxy session.
    _proxy_abort.clear()
    for port in range(M3U8_PROXY_PORT, M3U8_PROXY_PORT + 20):
        try:
            server = ThreadingHTTPServer(('127.0.0.1', port), _EPlayerProxyHandler)
            server.daemon_threads = True  # request threads die with the process
            t = threading.Thread(target=_serve_until_abort, args=(server,), daemon=True)
            t.start()
            _actual_proxy_port = port
            log(f'[EPlayerProxy] Started on port {port} (mtime={_PROXY_ADDON_MTIME})')
            return
        except OSError as e:
            if e.errno in (98, 10048):
                # Port busy — check if it's our proxy with current code
                try:
                    rh = requests.get(f'http://127.0.0.1:{port}/health', timeout=1)
                    if rh.status_code == 200 and rh.text == 'dltv-proxy':
                        try:
                            rv = requests.get(f'http://127.0.0.1:{port}/version', timeout=1)
                            proxy_mtime = rv.text.strip() if rv.status_code == 200 else ''
                        except Exception:
                            proxy_mtime = ''
                        if proxy_mtime == _PROXY_ADDON_MTIME:
                            _actual_proxy_port = port
                            log(f'[EPlayerProxy] Already running on port {port} (up-to-date)')
                            return
                        else:
                            log(f'[EPlayerProxy] Port {port} has stale proxy (mtime {proxy_mtime} != {_PROXY_ADDON_MTIME}), trying next port')
                except Exception:
                    pass
                log(f'[EPlayerProxy] Port {port} busy (not ours), trying {port + 1}')
            else:
                log(f'[EPlayerProxy] Failed to start on port {port}: {e}')
                break
    _actual_proxy_port = None
    log(f'[EPlayerProxy] Could not bind any port — proxy unavailable')

EXTRA_CHANNELS_DATA = {}

_log_rotated = False  # M4: ensure log rotation happens at most once per session

def log(msg):
    global _log_rotated
    logpath = xbmcvfs.translatePath('special://logpath/')
    filename = 'torproxy.log'
    log_file = os.path.join(logpath, filename)
    try:
        if isinstance(msg, str):
            _msg = f'\n    {msg}'
        else:
            _msg = f'\n    {repr(msg)}'
        if not os.path.exists(log_file):
            with open(log_file, 'w', encoding='utf-8'):
                pass
        # M4: rotate log if it exceeds 2 MB (keep last 500 lines), at most once per session
        if not _log_rotated:
            try:
                if os.path.getsize(log_file) > 2 * 1024 * 1024:
                    with open(log_file, 'r', encoding='utf-8', errors='replace') as _rf:
                        _lines = _rf.readlines()
                    with open(log_file, 'w', encoding='utf-8') as _wf:
                        _wf.writelines(_lines[-500:])
                    _log_rotated = True
            except Exception:
                pass
        with open(log_file, 'a', encoding='utf-8') as f:
            line = '[{} {}]: {}'.format(datetime.now().date(), str(datetime.now().time())[:8], _msg)
            f.write(line.rstrip('\r\n') + '\n')
    except Exception as e:
        try:
            xbmc.log(f'[ Tor Proxy ] Logging Failure: {e}', 2)
        except:
            pass

def should_cache_url(url: str) -> bool:
    """
    Determine if this URL is cacheable.
    Cache:
        - index.php pages (category, schedule, etc.)
        - 24-7-channels.php
    Do NOT cache:
        - watch.php (stream URLs)
    """
    if 'watch.php' in url:
        return False
    if 'index.php' in url or '24-7-channels.php' in url:
        return True
    return False


_CACHE_TTL_VALUES = [1800, 3600, 7200, 21600, 43200]  # 30min, 1h, 2h, 6h, 12h

def get_cache_expiry():
    try:
        return _CACHE_TTL_VALUES[int(addon.getSetting('cache_ttl'))]
    except Exception:
        return 7200  # default 2h

CACHE_EXPIRY = get_cache_expiry()

def _is_error_response(text, url=''):
    if not text:
        return True
    if '24-7-channels.php' in url and 'class="card"' not in text:
        return True
    return False

def _load_page_cache():
    try:
        with open(_PAGE_CACHE_FILE, 'r', encoding='utf-8') as f:
            return json.load(f)
    except Exception:
        return {}

def _save_page_cache(cache):
    try:
        _dir = os.path.dirname(_PAGE_CACHE_FILE)
        fd, tmp_path = tempfile.mkstemp(dir=_dir)
        try:
            with os.fdopen(fd, 'w', encoding='utf-8') as f:
                json.dump(cache, f)
            os.replace(tmp_path, _PAGE_CACHE_FILE)
        except Exception:
            try:
                os.unlink(tmp_path)
            except Exception:
                pass
            raise
    except Exception as e:
        log(f"[fetch_via_proxy] Failed to save page cache: {e}")

def _get_tor_session():
    """
    Restituisce la requests.Session del TorManager singleton (con proxy SOCKS5 configurato).
    FIX: fetch_via_proxy usava requests.get() diretto → nessun proxy Tor →
    dlstreams.top irraggiungibile su Android → lista canali vuota.
    """
    try:
        from tor_manager import get_tor_manager
        return get_tor_manager().session
    except Exception:
        return requests.Session()


def fetch_via_proxy(url, headers=None, use_cache=True):
    headers = headers or {}
    should_cache = should_cache_url(url)

    if should_cache:
        cached = _load_page_cache()
        entry = cached.get(url)
        if isinstance(entry, dict) and 'data' in entry and 'timestamp' in entry:
            if time.time() - entry['timestamp'] < get_cache_expiry() and not _is_error_response(entry['data'], url):
                log(f"[fetch_via_proxy] Returning cached data for {url}")
                return entry['data']

    # FIX: usa la session del TorManager (proxy SOCKS5) invece di requests.get() diretto.
    # requests.get() diretto bypassa Tor → su Android dlstreams.top è bloccato → lista vuota.
    session = _get_tor_session()

    resp_text = ''
    for attempt in range(2):
        for verify_ssl in ([True, False] if attempt == 0 else [False]):
            if not verify_ssl:
                log(f'[fetch_via_proxy] SSL verify disabled for retry: {url[:60]}')
            try:
                resp_text = session.get(url, headers=headers, timeout=15,
                                        verify=verify_ssl, allow_redirects=True).text
                if not _is_error_response(resp_text, url):
                    log(f"[fetch_via_proxy] ok attempt={attempt} verify={verify_ssl} for {url}")
                    break
                log(f"[fetch_via_proxy] bad response verify={verify_ssl}: {resp_text[:80]}")
            except Exception as e:
                log(f"[fetch_via_proxy] failed attempt={attempt} verify={verify_ssl} for {url}: {type(e).__name__}: {e}")
        if not _is_error_response(resp_text, url):
            break
        if attempt == 0:
            time.sleep(1)
    if _is_error_response(resp_text, url):
        return ''

    if should_cache:
        cached = _load_page_cache()
        cached[url] = {'timestamp': int(time.time()), 'data': resp_text}
        _save_page_cache(cached)

    return resp_text




def normalize_origin(url):
    try:
        u = urlparse(url)
        return f'{u.scheme}://{u.netloc}/'
    except:
        return SEED_BASEURL

_active_base_cache = None

def get_active_base():
    global _active_base_cache
    if _active_base_cache:
        return _active_base_cache
    base = addon.getSetting('active_baseurl')
    if base:
        # FIX: usa session Tor invece di requests.get() diretto
        try:
            session = _get_tor_session()
            r = session.get(base, headers={'User-Agent': UA}, timeout=8,
                            verify=False, allow_redirects=True)
            if r.status_code >= 400:
                raise Exception(f'HTTP {r.status_code}')
        except Exception as e:
            log(f'[get_active_base] {base} invalide ({e}), reset vers seed')
            base = ''
            addon.setSetting('active_baseurl', '')
    if not base:
        base = normalize_origin(SEED_BASEURL)
        addon.setSetting('active_baseurl', base)
    if not base.endswith('/'):
        base += '/'
    _active_base_cache = base
    return base

def abs_url(path: str) -> str:
    return urljoin(get_active_base(), path.lstrip('/'))

def get_local_time(utc_time_str):
    if not utc_time_str:
        return ''
    try:
        # Parse manually — datetime.strptime is None in some Kodi/Python envs (_strptime lazy import bug)
        h, m = map(int, utc_time_str.strip().split(':'))
        utc_now = time.gmtime()
        utc_ts = calendar.timegm((utc_now.tm_year, utc_now.tm_mon, utc_now.tm_mday, h, m, 0, 0, 0, 0))
        local = time.localtime(utc_ts)
        use_24h = addon.getSetting('time_format') == '1'
        if use_24h:
            return f'{local.tm_hour:02d}:{local.tm_min:02d}'
        else:
            period = 'AM' if local.tm_hour < 12 else 'PM'
            h12 = local.tm_hour % 12 or 12
            return f'{h12}:{local.tm_min:02d} {period}'
    except Exception as e:
        log(f"Failed to convert time: {e}")
        return utc_time_str or ''


def _build_current_events_map():
    """Return {channel_id: 'LocalTime  EventTitle'} for events currently airing (started < 4h ago)."""
    try:
        url = abs_url('index.php')
        html_text = fetch_via_proxy(url, headers={'User-Agent': UA, 'Referer': get_active_base()})
        if not html_text:
            return {}
        now_ts = time.time()
        utc_now = time.gmtime()
        today_midnight = calendar.timegm((utc_now.tm_year, utc_now.tm_mon, utc_now.tm_mday, 0, 0, 0, 0, 0, 0))
        ev_map = {}
        for time_str, event_title, channels_block in re.findall(
            r'<div\s+class="schedule__event">.*?'
            r'<div\s+class="schedule__eventHeader"[^>]*?>\s*'
            r'(?:<[^>]+>)*?'
            r'<span\s+class="schedule__time"[^>]*data-time="([^"]+)"[^>]*>.*?</span>\s*'
            r'<span\s+class="schedule__eventTitle">\s*([^<]+)\s*</span>.*?'
            r'</div>\s*'
            r'<div\s+class="schedule__channels">(.*?)</div>',
            html_text, re.IGNORECASE | re.DOTALL
        ):
            try:
                h, m = map(int, time_str.strip().split(':'))
            except Exception:
                continue
            # Check today and yesterday to handle midnight crossover
            is_current = False
            for midnight in (today_midnight, today_midnight - 86400):
                event_ts = midnight + h * 3600 + m * 60
                if event_ts <= now_ts < event_ts + 4 * 3600:
                    is_current = True
                    break
            if not is_current:
                continue
            local_time = get_local_time(time_str.strip())
            title = html.unescape(event_title.strip())
            display = f'{local_time}  {title}'
            for href, title_attr, link_text in re.findall(
                r'<a[^>]+href="([^"]+)"[^>]*title="([^"]*)"[^>]*>(.*?)</a>',
                channels_block, re.IGNORECASE | re.DOTALL
            ):
                try:
                    cid = dict(parse_qsl(urlparse(href).query)).get('id') or ''
                except Exception:
                    cid = ''
                if cid and cid not in ev_map:
                    ev_map[cid] = display
        return ev_map
    except Exception as e:
        log(f'[_build_current_events_map] error: {e}')
        return {}


def build_url(query):
    return addon_url + '?' + urlencode(query)

def addDir(title, dir_url, is_folder=True, logo=None, context_menu=None, plot=None):
    li = xbmcgui.ListItem(title)
    clean_title = re.sub(r'<[^>]+>', '', title)
    clean_plot = plot if plot else clean_title
    labels = {'title': clean_title, 'plot': clean_plot, 'mediatype': 'video'}
    if getKodiversion() < 20:
        li.setInfo("video", labels)
    else:
        infotag = li.getVideoInfoTag()
        infotag.setMediaType('video')
        infotag.setTitle(clean_title)
        infotag.setPlot(clean_plot)

    logo = logo or ICON
    li.setArt({'thumb': logo, 'poster': logo, 'banner': logo, 'icon': logo, 'fanart': FANART})
    li.setProperty("IsPlayable", 'false' if is_folder else 'true')
    if context_menu:
        li.addContextMenuItems(context_menu)
    xbmcplugin.addDirectoryItem(handle=addon_handle, url=dir_url, listitem=li, isFolder=is_folder)


def closeDir():
    xbmcplugin.endOfDirectory(addon_handle)

def getKodiversion():
    try:
        return int(xbmc.getInfoLabel("System.BuildVersion")[:2])
    except Exception:
        return 18


def run_diagnostics():
    def ok(text):
        return f'[COLOR lime][OK][/COLOR] {text}'
    def ko(text):
        return f'[COLOR red][KO][/COLOR] {text}'
    def info(text):
        return f'[COLOR cyan][INFO][/COLOR] {text}'

    version = addon.getAddonInfo('version')
    lines = [f'[B]Tor Proxy — v{version} — Diagnostics[/B]', '']

    # 0. Configuration
    lines.append('[B]0. Configuration[/B]')
    lines.append(info(f'Base URL     : {get_active_base()}'))
    lines.append(info(f'CHEVY proxy  : {CHEVY_PROXY}'))
    lines.append(info(f'CHEVY lookup : {CHEVY_LOOKUP}'))
    dns = addon.getSetting('custom_dns').strip()
    lines.append(info(f'Custom DNS   : {dns or "(not set)"}'))
    seg_proxy = addon.getSetting('seg_proxy').strip()
    lines.append(info(f'Seg proxy    : {seg_proxy or "(not set)"}'))
    lines.append('')

    # 1. Channel list
    url = abs_url('24-7-channels.php')
    lines.append(f'[B]1. Channel list[/B]  ({url})')
    fetched = False
    for verify_ssl in [True, False]:
        try:
            r = requests.get(url, headers={'User-Agent': UA}, timeout=10,
                             verify=verify_ssl, allow_redirects=True)
            cards = len(re.findall(r'class="card"', r.text))
            if cards > 0:
                lines.append(ok(f'HTTP {r.status_code} — {cards} channels'))
                fetched = True
                break
            else:
                lines.append(ko(f'HTTP {r.status_code} — 0 channels (verify_ssl={verify_ssl})'))
        except Exception as e:
            lines.append(ko(f'verify_ssl={verify_ssl}: {type(e).__name__}: {str(e)[:80]}'))
    if not fetched:
        lines.append(ko('Failed to load channels'))
    lines.append('')

    # 2. CHEVY CDN — server lookup + m3u8 + AES key
    lines.append('[B]2. CHEVY CDN[/B]')
    # Use ch.116 (beIN SPORTS 1) as test channel — reliably on CHEVY
    _diag_cid = '116'
    _diag_key = f'premium{_diag_cid}'
    m3u8_content = ''
    try:
        _sl_r = requests.get(f'{CHEVY_LOOKUP}/server_lookup?channel_id={_diag_key}',
                             headers={'User-Agent': UA, 'Referer': PLAYER_REFERER}, timeout=6)
        _sk_d = _sl_r.json().get('server_key', '?') if _sl_r.status_code == 200 else '?'
        if _sk_d and _sk_d != 'nfs' and _sk_d != '?':
            lines.append(ok(f'server_lookup ch.{_diag_cid} → {_sk_d}'))
        elif _sk_d == 'nfs':
            lines.append(ko(f'server_lookup ch.{_diag_cid} → nfs (offline)'))
        else:
            lines.append(ko(f'server_lookup HTTP {_sl_r.status_code}'))
    except Exception as e:
        _sk_d = '?'
        lines.append(ko(f'server_lookup: {type(e).__name__}: {str(e)[:60]}'))

    # m3u8 fetch
    m3u8_url = resolve_stream_url(_diag_cid)
    try:
        _m3r = requests.get(m3u8_url, headers={'User-Agent': _AUTH_UA, 'Referer': PLAYER_REFERER},
                            timeout=8)
        m3u8_content = _m3r.text
        _segs_d = [l for l in m3u8_content.splitlines() if l and not l.startswith('#')]
        _ph_d, _real_d = 0, 0
        for _s in _segs_d:
            if _is_placeholder(_s):
                _ph_d += 1
            else:
                _real_d += 1
        if _m3r.status_code == 200 and _real_d > 0:
            lines.append(ok(f'm3u8 HTTP 200 — {_real_d} real segs ({_ph_d} filtered)'))
        elif _m3r.status_code == 200 and _ph_d > 0:
            lines.append(ko(f'm3u8 HTTP 200 — all {_ph_d} segs are placeholders'))
        else:
            lines.append(ko(f'm3u8 HTTP {_m3r.status_code} — {m3u8_content[:80]}'))
    except Exception as e:
        lines.append(ko(f'm3u8: {type(e).__name__}: {str(e)[:60]}'))

    # AES key fetch (no auth needed — CHEVY serves key directly)
    try:
        _km = re.search(r'/key/[^/]+/(\d+)', m3u8_content)
        if _km:
            _kid = _km.group(1)
            _kr = requests.get(f'{CHEVY_LOOKUP}/key/{_diag_key}/{_kid}',
                               headers={'User-Agent': _AUTH_UA, 'Referer': PLAYER_REFERER},
                               timeout=6)
            if _kr.status_code == 200 and len(_kr.content) == 16:
                lines.append(ok(f'AES key /key/{_diag_key}/{_kid} → 16B OK'))
            else:
                lines.append(ko(f'AES key HTTP {_kr.status_code} — {len(_kr.content)}B'))
        else:
            lines.append(info('AES key: no key URI in m3u8 (unencrypted or m3u8 failed)'))
    except Exception as e:
        lines.append(ko(f'AES key: {type(e).__name__}: {str(e)[:60]}'))
    lines.append('')

    # 3. Local proxy
    lines.append('[B]3. Local proxy[/B]')
    try:
        _ensure_m3u8_proxy()
        port = _actual_proxy_port or M3U8_PROXY_PORT
        rh = requests.get(f'http://127.0.0.1:{port}/health', timeout=2)
        if rh.status_code == 200 and rh.text == 'dltv-proxy':
            lines.append(ok(f'Proxy running — port {port}'))
            # Test m3u8 proxy path (noauth — works regardless of ksohls.ru)
            _set_channel_state(_diag_key, 'noauth', 'noauth', m3u8_url)
            rm = requests.get(f'http://127.0.0.1:{port}/m3u8/{_diag_key}', timeout=12)
            proxy_segs = [l for l in rm.text.splitlines() if l and not l.startswith('#')]
            if rm.status_code == 200 and proxy_segs:
                lines.append(ok(f'm3u8 proxy : HTTP 200 — {len(proxy_segs)} segments served'))
            else:
                lines.append(ko(f'm3u8 proxy : HTTP {rm.status_code} — {len(proxy_segs)} segments'))
        else:
            lines.append(ko(f'Proxy not responding (HTTP {rh.status_code})'))
    except Exception as e:
        lines.append(ko(f'Proxy: {type(e).__name__}: {str(e)[:80]}'))
    lines.append('')

    # 4. Stall state
    lines.append('[B]4. Stall state[/B]')
    stall_keys = list(_m3u8_stall.keys())
    with _proxy_lock:
        cred_keys = list(_channel_creds.keys())
    if stall_keys:
        for k in stall_keys:
            st = _m3u8_stall.get(k, {})
            _age = int(time.time() - st.get('first_seen', time.time()))
            lines.append(info(f'{k}: seq={st.get("seq")} frozen={_age}s retries={st.get("same_url_retries", 0)}'))
    else:
        lines.append(ok('No active stall'))
    lines.append(info(f'Cached channels: {", ".join(cred_keys) if cred_keys else "none"}'))
    lines.append('')

    # 5. CDN — CHEVY health
    lines.append('[B]5. CDN — CHEVY health[/B]')
    try:
        _rh = requests.get(f'{CHEVY_LOOKUP}/health',
                           headers={'User-Agent': UA, 'Referer': PLAYER_REFERER},
                           timeout=6)
        if _rh.status_code == 200:
            _hd = _rh.json()
            _wk = _hd.get('workers', {})
            _dm = _hd.get('daemon', {})
            _pool_ok = (_hd.get('status', '?') == 'ok') and not _wk.get('poolExhausted', False)
            _connected = _dm.get('connected', False)
            _using_fb = _dm.get('using_fallback', False)
            _bans = _hd.get('honeypot', {}).get('active_bans', 0)
            _sl = _hd.get('server_locator', {})
            _sess = _hd.get('sessions', {}).get('active', 0)
            _rl = _hd.get('rate_limits', {})
            _pool_str = 'OK' if _pool_ok else 'EXHAUSTED'
            _fb_str = ' [fallback]' if _using_fb else ''
            _daemon_str = 'connected' if _connected else 'DISCO'
            _health_line = f'Pool {_pool_str} — daemon {_daemon_str}{_fb_str} — sessions {_sess}'
            lines.append(ok(_health_line) if (_pool_ok and _connected) else ko(_health_line))
            _bans_str = f' | Bans: {_bans}' if _bans else ''
            _rl_str = f' | RL auth:{_rl.get("auth_tracked_ips",0)} ch:{_rl.get("channel_tracked_ips",0)}'
            lines.append(info(f'Locator: {_sl.get("cache_size",0)} entries — hit {_sl.get("hit_rate_percent","?")}%{_bans_str}{_rl_str}'))
        else:
            lines.append(ko(f'Health HTTP {_rh.status_code}'))
    except Exception as _e5:
        lines.append(ko(f'Health: {type(_e5).__name__}: {str(_e5)[:60]}'))

    # 5b. Per-CDN freshness
    lines.append('')
    _CDN_DEFAULTS = {'ddy6': '773', 'wind': '464', 'zeko': '116'}
    _cdn_probe = dict(_CDN_DEFAULTS)
    if os.path.exists(_CDN_CACHE_FILE):
        try:
            with open(_CDN_CACHE_FILE, 'r') as _cf:
                for _ck2, _sk2 in sorted(json.load(_cf).items()):
                    if _sk2 not in _cdn_probe and _sk2 != 'nfs':
                        _cdn_probe[_sk2] = _ck2.replace('premium', '')
        except Exception:
            pass

    _now_ts = time.time()
    _ph_hdr = {'User-Agent': UA, 'Referer': PLAYER_REFERER}
    for _sk3, _cid3 in sorted(_cdn_probe.items()):
        try:
            _mr = requests.get(f'{CHEVY_PROXY}/proxy/{_sk3}/premium{_cid3}/mono.css',
                               headers=_ph_hdr, timeout=6)
            if _mr.status_code != 200:
                lines.append(ko(f'  {_sk3:<8} HTTP {_mr.status_code}  DEAD'))
                continue
            _mb = _mr.text
            _seq3 = (re.search(r'#EXT-X-MEDIA-SEQUENCE:(\d+)', _mb) or
                     type('', (), {'group': lambda s, i: '?'})()).group(1)
            _nseg3 = len(re.findall(r'#EXTINF:', _mb))
            _tgt3 = int((re.search(r'#EXT-X-TARGETDURATION:(\d+)', _mb) or
                         type('', (), {'group': lambda s, i: '6'})()).group(1))
            _age_str3, _vivant = '?', False
            _dt3_m = re.search(r'#EXT-X-PROGRAM-DATE-TIME:(\S+)', _mb)
            if _dt3_m:
                try:
                    _dt3 = datetime.fromisoformat(_dt3_m.group(1).replace('Z', '+00:00'))
                    _age3 = int(_now_ts - _dt3.timestamp())
                    _lag3 = _age3 - _nseg3 * _tgt3
                    _age_str3 = f'{_age3 // 60}min' if _age3 >= 60 else f'{_age3}s'
                    _vivant = _lag3 < 60
                except Exception:
                    pass
            _lat3 = _drop3 = ''
            _rt3_m = re.search(r'uploader-runtime: ([^\n]+)', _mb)
            if _rt3_m:
                _lm3 = re.search(r'latency=(\d+)ms', _rt3_m.group(1))
                _dm3 = re.search(r'drop=([\d.]+%)', _rt3_m.group(1))
                if _lm3: _lat3 = f'lat={_lm3.group(1)}ms'
                if _dm3: _drop3 = f'drop={_dm3.group(1)}'
            _parts3 = [f'seq={_seq3}', f'{_nseg3} segs']
            if _lat3:   _parts3.append(_lat3)
            if _drop3:  _parts3.append(_drop3)
            _parts3.append(f'age={_age_str3}')
            _det3 = '  '.join(_parts3)
            lines.append(ok(f'  {_sk3:<8} {_det3}  LIVE') if _vivant else
                         ko(f'  {_sk3:<8} {_det3}  FROZEN'))
        except Exception as _ex3:
            lines.append(ko(f'  {_sk3:<8} ERROR: {str(_ex3)[:60]}'))

    # 5c. Local CDN cache summary
    lines.append('')
    if os.path.exists(_CDN_CACHE_FILE):
        try:
            _age_m2 = int((time.time() - os.path.getmtime(_CDN_CACHE_FILE)) / 60)
            with open(_CDN_CACHE_FILE, 'r') as _f2:
                _cdn2 = json.load(_f2)
            _sk_c2 = {}
            for _sv2 in _cdn2.values():
                _sk_c2[_sv2] = _sk_c2.get(_sv2, 0) + 1
            _sum2 = ', '.join(f'{k}:{v}' for k, v in sorted(_sk_c2.items(), key=lambda x: -x[1]))
            lines.append(info(f'Local cache: {len(_cdn2)} ch — age {_age_m2}min — {_sum2}'))
        except Exception:
            lines.append(info('Local CDN cache: corrupted'))
    else:
        lines.append(info('Local CDN cache: empty (auto-filled by ProbeBackground)'))

    msg = '\n'.join(lines)
    log(f'[Diagnostics]\n{msg}')
    xbmcgui.Dialog().textviewer(f'Tor Proxy v{version} — Diagnostics', msg)

def getCategTrans():
    schedule_url = abs_url('index.php')
    try:
        html_text = fetch_via_proxy(schedule_url, headers={'User-Agent': UA, 'Referer': get_active_base()})
        m = re.search(r'<div[^>]+class="filters"[^>]*>(.*?)</div>', html_text, re.IGNORECASE | re.DOTALL)
        if not m:
            log("getCategTrans(): filters block not found")
            return []

        block = m.group(1)
        anchors = re.findall(r'<a[^>]+href="([^"]+)"[^>]*>(.*?)</a>', block, re.IGNORECASE | re.DOTALL)
        if not anchors:
            log("getCategTrans(): no <a> items in filters block")
            return []

        categs = []
        seen = set()
        for href, text_content in anchors:
            name = html.unescape(re.sub(r'\s+', ' ', text_content)).strip()
            if not name or name.lower() == 'all':
                continue
            if name in seen:
                continue
            seen.add(name)
            categs.append((name, '[]'))

        return categs
    except Exception as e:
        xbmcgui.Dialog().ok("Error", f"Error fetching category data: {e}")
        log(f'index parse fail: url={schedule_url} err={e}')
        return []

def Menu_Trans():
    categs = getCategTrans()
    if not categs:
        return
    for categ_name, _ in categs:
        addDir(categ_name, build_url({'mode': 'showChannels', 'trType': categ_name}))
    closeDir()

def ShowChannels(categ, channels_list):
    for item in channels_list:
        title = item.get('title')
        addDir(title, build_url({'mode': 'trList', 'trType': categ, 'channels': json.dumps(item.get('channels'))}), True)
    closeDir()

def getTransData(categ):
    try:
        url = abs_url('index.php?cat=' + quote_plus(categ))
        html_text = fetch_via_proxy(url, headers={'User-Agent': UA, 'Referer': get_active_base()})
        cut = re.search(r'<h2\s+class="collapsible-header\b', html_text, re.IGNORECASE)
        if cut:
            html_text = html_text[:cut.start()]

        events = re.findall(
            r'<div\s+class="schedule__event">.*?'
            r'<div\s+class="schedule__eventHeader"[^>]*?>\s*'
            r'(?:<[^>]+>)*?'
            r'<span\s+class="schedule__time"[^>]*data-time="([^"]+)"[^>]*>.*?</span>\s*'
            r'<span\s+class="schedule__eventTitle">\s*([^<]+)\s*</span>.*?'
            r'</div>\s*'
            r'<div\s+class="schedule__channels">(.*?)</div>',
            html_text, re.IGNORECASE | re.DOTALL
        )

        trns = []
        for time_str, event_title, channels_block in events:
            event_time_local = get_local_time(time_str.strip())
            title = f'[COLOR gold]{event_time_local}[/COLOR] {html.unescape(event_title.strip())}'

            chans = []
            for href, title_attr, link_text in re.findall(
                r'<a[^>]+href="([^"]+)"[^>]*title="([^"]*)"[^>]*>(.*?)</a>',
                channels_block, re.IGNORECASE | re.DOTALL
            ):
                try:
                    u = urlparse(href)
                    qs = dict(parse_qsl(u.query))
                    cid = qs.get('id') or ''
                except Exception:
                    cid = ''
                name = html.unescape((title_attr or link_text).strip())
                if cid:
                    chans.append({'channel_name': name, 'channel_id': cid})

            if chans:
                trns.append({'title': title, 'channels': chans})

        return trns
    except Exception as e:
        log(f'getTransData error for categ={categ}: {e}')
        return []

def TransList(categ, channels):
    dead_cids = _get_dead_cids()
    fav_ids = {f['id'] for f in get_favorites()}
    for channel in channels:
        channel_title = html.unescape(channel.get('channel_name'))
        channel_id = str(channel.get('channel_id', '')).strip()
        if not channel_id:
            continue
        label = f'{_ch_label(channel_title, channel_id, dead_cids)} ({channel_id})'
        fav_label = '★ Remove from favorites' if channel_id in fav_ids else '☆ Add to favorites'
        ctx = [(fav_label, 'RunPlugin(%s)' % build_url({'mode': 'toggle_fav', 'cid': channel_id, 'name': channel_title}))]
        addDir(label, build_url({'mode': 'trLinks', 'trData': json.dumps({'channels': [{'channel_name': channel_title, 'channel_id': channel_id}]})}), False, context_menu=ctx)
    closeDir()

def getSource(trData):
    try:
        data = json.loads(unquote(trData))
        channels_data = data.get('channels')
        if channels_data and isinstance(channels_data, list):
            cid = str(channels_data[0].get('channel_id', '')).strip()
            if not cid:
                return
            if '%7C' in cid or '|' in cid:
                url_stream = abs_url('watchs2watch.php?id=' + cid)
            else:
                url_stream = abs_url('watch.php?id=' + cid)
            xbmcplugin.setContent(addon_handle, 'videos')
            PlayStream(url_stream)
    except Exception as e:
        log(f'getSource failed: {e}')

def get_favorites():
    try:
        return json.loads(addon.getSetting('favorites') or '[]')
    except Exception:
        return []

def save_favorites(favs):
    addon.setSetting('favorites', json.dumps(favs))

def toggle_favorite(cid, name):
    favs = get_favorites()
    ids = [f['id'] for f in favs]
    if cid in ids:
        favs = [f for f in favs if f['id'] != cid]
        save_favorites(favs)
        xbmcgui.Dialog().notification('Tor Proxy', f'Removed from favorites: {name}', ICON, 2000)
    else:
        favs.append({'id': cid, 'name': name})
        save_favorites(favs)
        xbmcgui.Dialog().notification('Tor Proxy', f'Added to favorites: {name}', ICON, 2000)
    xbmc.executebuiltin('Container.Refresh')


_KNOWN_CDNS = [
    ('Auto — Player direct',  '__anyplayer__'),
    ('Auto — CHEVY (CDN)',    None),
]

def list_favorites():
    favs = get_favorites()
    if not favs:
        xbmcgui.Dialog().notification('Tor Proxy', 'No favorites. Long press a channel to add.', ICON, 3000)
        closeDir()
        return
    # Auto-probe on first open (cooldown of 120s prevents re-running on every open)
    _probe_favorites_background(favs)
    fav_ids = {f['id'] for f in favs}
    dead_cids = _get_dead_cids()
    # Load CDN map from cache to show CDN name on red channels
    _fav_cdn_map = {}
    try:
        if os.path.exists(_CDN_CACHE_FILE):
            with open(_CDN_CACHE_FILE, 'r') as _f:
                _fav_cdn_map = json.load(_f)
    except Exception:
        pass
    ev_map = _build_current_events_map()
    # EPG (epgshare01 XMLTV) has priority over the Tor Proxy sports schedule
    xmltv_programs = _fetch_xmltv_background([(f['id'], f['name']) for f in favs])
    ev_map.update(xmltv_programs)
    addDir('[B][COLOR cyan]Check Channels[/COLOR][/B]', build_url({'mode': 'probe_favorites'}), False)
    addDir('[B][COLOR lime]+ Add channel by ID[/COLOR][/B]', build_url({'mode': 'add_by_id'}), False)
    for fav in sorted(favs, key=lambda x: x['name'].lower()):
        cid = fav['id']
        name = fav['name']
        cdn_name = _fav_cdn_map.get(cid) if cid in dead_cids else None
        ctx = [('Remove from favorites', 'RunPlugin(%s)' % build_url({'mode': 'toggle_fav', 'cid': cid, 'name': name}))]
        ev = ev_map.get(cid)
        label = f'{_ch_label(name, cid, dead_cids, cdn_name)} ({cid})'
        if ev:
            label += f'  [COLOR gold]{ev}[/COLOR]'
        addDir(label, build_url({'mode': 'play', 'url': abs_url('watch.php?id=' + cid)}), False, context_menu=ctx, plot=ev)
    closeDir()

def _normalize_ch_name(name):
    name = name.lower().strip()
    name = re.sub(r'[^\w\s]', ' ', name)
    name = re.sub(r'([a-z])(\d)', r'\1 \2', name)  # "sport360" → "sport 360"
    name = re.sub(r'\s+', ' ', name).strip()
    return name

_NAME_TO_LANG = {
    # Language adjectives
    'arabic': 'ara', 'arab': 'ara',
    'english': 'eng', 'british': 'eng', 'scottish': 'eng', 'welsh': 'eng', 'irish': 'eng',
    'french': 'fra',
    'german': 'deu', 'deutsch': 'deu',
    'spanish': 'spa',
    'italian': 'ita',
    'portuguese': 'por',
    'turkish': 'tur',
    'russian': 'rus',
    'polish': 'pol',
    'romanian': 'ron',
    'dutch': 'nld',
    'greek': 'ell',
    'hebrew': 'heb',
    'persian': 'fas', 'farsi': 'fas',
    'hindi': 'hin', 'indian': 'hin',
    'urdu': 'urd',
    'swedish': 'swe',
    'norwegian': 'nor',
    'danish': 'dan',
    'finnish': 'fin',
    'czech': 'ces',
    'slovak': 'slk',
    'croatian': 'hrv',
    'serbian': 'srp',
    'bulgarian': 'bul',
    'hungarian': 'hun',
    'slovenian': 'slv',
    'albanian': 'sqi',
    'ukrainian': 'ukr',
    'malay': 'msa', 'malaysian': 'msa',
    'indonesian': 'ind',
    'chinese': 'zho',
    'japanese': 'jpn',
    'korean': 'kor',
    'thai': 'tha',
    'vietnamese': 'vie',
    'australian': 'eng', 'canadian': 'eng',
    'egyptian': 'ara', 'moroccan': 'ara', 'algerian': 'ara', 'tunisian': 'ara',
    'lebanese': 'ara', 'iraqi': 'ara', 'jordanian': 'ara', 'kuwaiti': 'ara',
    'bahraini': 'ara', 'omani': 'ara', 'qatari': 'ara', 'saudi': 'ara',
    'emirati': 'ara', 'syrian': 'ara', 'libyan': 'ara', 'yemeni': 'ara',
    # Noms de pays Europe
    'france': 'fra',
    'usa': 'eng', 'america': 'eng', 'american': 'eng',
    'germany': 'deu',
    'spain': 'spa', 'espana': 'spa',
    'italy': 'ita',
    'portugal': 'por',
    'netherlands': 'nld',
    'turkey': 'tur',
    'russia': 'rus',
    'poland': 'pol',
    'romania': 'ron',
    'greece': 'ell',
    'israel': 'heb',
    'iran': 'fas',
    'india': 'hin',
    'pakistan': 'urd',
    'malaysia': 'msa',
    'indonesia': 'ind',
    'china': 'zho',
    'japan': 'jpn',
    'korea': 'kor',
    'thailand': 'tha',
    'vietnam': 'vie',
    'brazil': 'por', 'brasil': 'por',
    'mexico': 'spa',
    'argentina': 'spa',
    'colombia': 'spa',
    'belgium': 'fra',
    'switzerland': 'deu',
    'austria': 'deu',
    'sweden': 'swe',
    'norway': 'nor',
    'denmark': 'dan',
    'finland': 'fin',
    # Noms de pays manquants (formes substantif)
    'bulgaria': 'bul',
    'serbia': 'srp',
    'croatia': 'hrv',
    'cyprus': 'ell',
    'ukraine': 'ukr',
    'slovenia': 'slv',
    'albania': 'sqi',
    'scotland': 'eng', 'ireland': 'eng', 'wales': 'eng',
    'australia': 'eng', 'canada': 'eng',
    # Pays arabes
    'qatar': 'ara',
    'egypt': 'ara',
    'morocco': 'ara',
    'algeria': 'ara',
    'tunisia': 'ara',
    'lebanon': 'ara',
    'iraq': 'ara',
    'jordan': 'ara',
    'kuwait': 'ara',
    'bahrain': 'ara',
    'oman': 'ara',
    'uae': 'ara',
    'syria': 'ara',
    'libya': 'ara',
    'sudan': 'ara',
    'yemen': 'ara',
    # French country names (Canal+/beIN channels)
    'afrique': 'fra',   # Canal+ Sport Afrique → français
}

# Pass 1 — 2-letter ISO codes, checked ONLY as the last word of the name
_CODE_TO_LANG = {
    'fr': 'fra', 'de': 'deu', 'es': 'spa', 'it': 'ita', 'pt': 'por',
    'nl': 'nld', 'tr': 'tur', 'ru': 'rus', 'pl': 'pol', 'ro': 'ron',
    'gr': 'ell', 'il': 'heb', 'ae': 'ara', 'sa': 'ara', 'ar': 'ara',
    'uk': 'eng', 'us': 'eng', 'gb': 'eng', 'au': 'eng', 'ca': 'eng',
    'be': 'fra', 'ch': 'deu', 'at': 'deu',
    'se': 'swe', 'no': 'nor', 'dk': 'dan', 'fi': 'fin',
    'hr': 'hrv', 'rs': 'srp', 'bg': 'bul', 'hu': 'hun',
    'sk': 'slk', 'cz': 'ces', 'si': 'slv', 'al': 'sqi',
    'ua': 'ukr', 'ie': 'eng', 'nz': 'eng', 'za': 'eng',
    'pk': 'urd', 'mx': 'spa', 'br': 'por',
    'my': 'msa', 'cn': 'zho', 'jp': 'jpn', 'kr': 'kor',
    'th': 'tha', 'vn': 'vie',
    'eg': 'ara', 'ma': 'ara', 'dz': 'ara', 'tn': 'ara',
    'lb': 'ara', 'qa': 'ara', 'iq': 'ara', 'jo': 'ara',
    'kw': 'ara', 'bh': 'ara', 'om': 'ara', 'sy': 'ara',
    'cy': 'ell',
}


def _lang_from_name(name):
    """Pass 1: detect language from explicit words in the channel name.
    - Last word: checked in _NAME_TO_LANG AND _CODE_TO_LANG.
    - Other words (right→left): _NAME_TO_LANG only (no 2-letter codes).
    Returns a language code or None."""
    words = re.findall(r'[a-z]+', name.lower())
    if not words:
        return None
    last = words[-1]
    if last in _NAME_TO_LANG:
        return _NAME_TO_LANG[last]
    if last in _CODE_TO_LANG:
        return _CODE_TO_LANG[last]
    for word in reversed(words[:-1]):
        if word in _NAME_TO_LANG:
            return _NAME_TO_LANG[word]
    return None

def _get_iptv_org_index():
    """Fetch/cache iptv-org channels.json, return normalized_name→lang_code index."""
    if os.path.exists(IPTV_ORG_INDEX_CACHE):
        if time.time() - os.path.getmtime(IPTV_ORG_INDEX_CACHE) < IPTV_ORG_TTL:
            try:
                with open(IPTV_ORG_INDEX_CACHE, 'r', encoding='utf-8') as f:
                    return json.load(f)
            except Exception:
                pass
    try:
        log('[iptv-org] Fetching channels database...')
        r = requests.get(IPTV_ORG_API, timeout=30)
        size_kb = len(r.content) // 1024
        log(f'[iptv-org] Downloaded {size_kb} KB (encoding: {r.headers.get("Content-Encoding", "none")})')
        index = {}
        tvg_index = {}  # normalized_name → tvg_id (e.g. "BBCOne.uk")
        for ch in r.json():
            tvg_id = ch.get('id', '')
            country = ch.get('country', '')
            lang = COUNTRY_LANG.get(country)
            candidates = [ch.get('name', '')] + ch.get('alt_names', [])
            for candidate in candidates:
                norm = _normalize_ch_name(candidate)
                if norm and tvg_id and norm not in tvg_index:
                    tvg_index[norm] = tvg_id
            if not lang:
                continue
            for candidate in candidates:
                norm = _normalize_ch_name(candidate)
                if norm and norm not in index:
                    index[norm] = lang
        with open(IPTV_ORG_INDEX_CACHE, 'w', encoding='utf-8') as f:
            json.dump(index, f)
        with open(IPTV_ORG_TVG_CACHE, 'w', encoding='utf-8') as f:
            json.dump(tvg_index, f)
        log(f'[iptv-org] Index built: {len(index)} entries, {len(tvg_index)} tvg IDs')
        return index
    except Exception as e:
        log(f'[iptv-org] Fetch error: {e}')
        return {}

def _lookup_lang(name, index):
    """Return the language code for a channel.
    Pass 1: detection from name words (explicit country/language).
    Pass 2: lookup in iptv-org index with progressive normalizations."""
    # Pass 1 — nom seul, sans index
    lang = _lang_from_name(name)
    if lang:
        return lang

    # Pass 2 — index iptv-org
    # Normalize the name, then progressively strip country/language suffixes
    # to improve match chances in the index.
    norm = _normalize_ch_name(name)

    # 2a. Exact match
    if norm in index:
        return index[norm]

    # 2b. Strip trailing number ("Sky Sports 1" → "Sky Sports")
    no_num = re.sub(r'\s+\d+$', '', norm).strip()
    if no_num != norm and no_num in index:
        return index[no_num]

    # 2c. Strip the last word if it is a country/language indicator,
    #     then search again (with and without trailing number)
    parts = norm.split()
    if len(parts) > 1:
        last_word = parts[-1]
        if last_word in _NAME_TO_LANG or last_word in _CODE_TO_LANG:
            stripped = ' '.join(parts[:-1]).strip()
            if stripped in index:
                return index[stripped]
            no_num2 = re.sub(r'\s+\d+$', '', stripped).strip()
            if no_num2 != stripped and no_num2 in index:
                return index[no_num2]

    return None



def _channel_name_to_tvg_id(name, tvg_index):
    """Map a Tor Proxy channel name to an iptv-org tvg_id using progressive name stripping."""
    norm = _normalize_ch_name(name)
    if norm in tvg_index:
        return tvg_index[norm]
    # Remove trailing quality/number suffix: HD, SD, FHD, UHD, 4K, +1, digits
    stripped = re.sub(r'\s+(?:hd|sd|fhd|uhd|4k|\+\d+|\d+)$', '', norm).strip()
    if stripped != norm and stripped in tvg_index:
        return tvg_index[stripped]
    # Remove trailing number only
    no_num = re.sub(r'\s+\d+$', '', stripped or norm).strip()
    if no_num not in (norm, stripped) and no_num in tvg_index:
        return tvg_index[no_num]
    # Remove trailing country/lang word
    parts = (stripped or norm).split()
    if len(parts) > 1 and parts[-1] in _NAME_TO_LANG:
        base = ' '.join(parts[:-1]).strip()
        if base in tvg_index:
            return tvg_index[base]
        base_no_num = re.sub(r'\s+\d+$', '', base).strip()
        if base_no_num != base and base_no_num in tvg_index:
            return tvg_index[base_no_num]
    return None


def _fetch_xmltv_fr_epg(cid_name_pairs):
    """Return {cid: current_program_title} from epgshare01 XMLTV sources selected in settings.

    Downloads each enabled language gz once every 4h, merges XML, caches results 30min.
    cid_name_pairs: list of (channel_id, channel_name)
    """
    if not cid_name_pairs:
        return {}
    now_ts = time.time()

    # Load 30-min program cache
    prog_cache = {}
    try:
        if os.path.exists(_XMLTV_PROG_CACHE) and now_ts - os.path.getmtime(_XMLTV_PROG_CACHE) < _XMLTV_PROG_TTL:
            with open(_XMLTV_PROG_CACHE, 'r', encoding='utf-8') as f:
                prog_cache = json.load(f)
    except Exception:
        pass

    need_lookup = [p for p in cid_name_pairs if p[0] not in prog_cache]
    if not need_lookup:
        return {cid: prog_cache[cid] for cid, _ in cid_name_pairs if prog_cache.get(cid)}

    # Determine enabled sources from settings
    enabled = [sid for sid in _XMLTV_SOURCES if addon.getSetting(sid).lower() == 'true']
    log(f'[xmltv] enabled sources: {enabled}')
    if not enabled:
        enabled = ['epg_fr']  # fallback to French if nothing selected

    # Download/cache each enabled source and merge XML
    xml_parts = []
    for sid in enabled:
        label, url, cache_fname = _XMLTV_SOURCES[sid]
        cache_file = os.path.join(_KODI_TEMP, cache_fname)
        xml_gz = None
        if os.path.exists(cache_file) and now_ts - os.path.getmtime(cache_file) < _XMLTV_TTL:
            try:
                with open(cache_file, 'rb') as f:
                    xml_gz = f.read()
            except Exception:
                pass
        if xml_gz is None:
            try:
                log(f'[xmltv] Downloading {label} EPG...')
                resp = requests.get(url, timeout=30, headers={'User-Agent': UA})
                xml_gz = resp.content
                with open(cache_file, 'wb') as f:
                    f.write(xml_gz)
                log(f'[xmltv] {label}: {len(xml_gz)//1024}KB')
            except Exception as e:
                log(f'[xmltv] {label} download error: {e}')
                continue
        try:
            xml_parts.append(gzip.decompress(xml_gz).decode('utf-8', errors='replace'))
        except Exception as e:
            log(f'[xmltv] {label} decompress error: {e}')

    if not xml_parts:
        return {}

    xml_str = '\n'.join(xml_parts)

    # Collect channel IDs that actually have programme entries (some IDs are defined but empty)
    active_channels = set(re.findall(r'<programme channel="([^"]+)"', xml_str))

    # Build display_name → xmltv_id index, preferring IDs that have programme data
    xmltv_idx = {}
    for xmltv_id, display_name in re.findall(
        r'<channel id="([^"]+)"[^>]*>.*?<display-name[^>]*>([^<]+)</display-name>',
        xml_str, re.DOTALL
    ):
        norm = _normalize_ch_name(display_name)
        if not norm:
            continue
        if norm not in xmltv_idx:
            xmltv_idx[norm] = xmltv_id
        elif xmltv_id in active_channels and xmltv_idx[norm] not in active_channels:
            # Replace inactive ID with one that actually has programmes
            xmltv_idx[norm] = xmltv_id

    # Match each Tor Proxy channel name to an XMLTV ID
    cid_to_xmltv = {}
    for cid, name in need_lookup:
        xmltv_id = _channel_name_to_tvg_id(name, xmltv_idx)
        if xmltv_id:
            cid_to_xmltv[cid] = xmltv_id
        else:
            prog_cache[cid] = ''  # no match, don't retry until cache expires

    if not cid_to_xmltv:
        try:
            with open(_XMLTV_PROG_CACHE, 'w', encoding='utf-8') as f:
                json.dump(prog_cache, f)
        except Exception:
            pass
        return {}

    # Parse timestamps helper (uses datetime/timezone/timedelta imported at module level)
    def _parse_xmltv_ts(s):
        s = s.strip()
        main = s[:14]
        tz = s[15:] if len(s) > 14 else '+0000'
        dt = datetime.strptime(main, '%Y%m%d%H%M%S')
        sign = 1 if tz[0] == '+' else -1
        h, m = int(tz[1:3]), int(tz[3:5])
        return (datetime(*dt.timetuple()[:6]) - timedelta(hours=h*sign, minutes=m*sign)).replace(tzinfo=timezone.utc).timestamp()

    # Scan programmes for matching XMLTV IDs
    target_xmltv = set(cid_to_xmltv.values())
    xmltv_to_program = {}
    for m in re.finditer(
        r'<programme channel="([^"]+)" start="([^"]+)" stop="([^"]+)"[^>]*>.*?<title[^>]*>([^<]+)</title>',
        xml_str, re.DOTALL
    ):
        xmltv_id, start_str, stop_str, title = m.groups()
        if xmltv_id not in target_xmltv or xmltv_id in xmltv_to_program:
            continue
        try:
            if _parse_xmltv_ts(start_str) <= now_ts <= _parse_xmltv_ts(stop_str):
                xmltv_to_program[xmltv_id] = html.unescape(title)
        except Exception:
            pass

    for cid, xmltv_id in cid_to_xmltv.items():
        prog_cache[cid] = xmltv_to_program.get(xmltv_id, '')

    try:
        with open(_XMLTV_PROG_CACHE, 'w', encoding='utf-8') as f:
            json.dump(prog_cache, f)
    except Exception:
        pass

    return {cid: prog_cache[cid] for cid, _ in cid_name_pairs if prog_cache.get(cid)}


def _fetch_xmltv_background(cid_name_pairs):
    """Return existing EPG cache without blocking; start a background refresh if stale."""
    cached = {}
    try:
        if os.path.exists(_XMLTV_PROG_CACHE):
            with open(_XMLTV_PROG_CACHE, 'r', encoding='utf-8') as f:
                prog_cache = json.load(f)
            cached = {cid: prog_cache[cid] for cid, _ in cid_name_pairs if prog_cache.get(cid)}
    except Exception:
        pass

    cache_fresh = (os.path.exists(_XMLTV_PROG_CACHE) and
                   time.time() - os.path.getmtime(_XMLTV_PROG_CACHE) < _XMLTV_PROG_TTL)
    if not cache_fresh:
        if _epg_bg_lock.acquire(blocking=False):
            def _run():
                try:
                    log('[EPGBackground] Starting XMLTV refresh...')
                    _fetch_xmltv_fr_epg(cid_name_pairs)
                    log('[EPGBackground] Done — refreshing container')
                    xbmc.executebuiltin('Container.Refresh')
                except Exception as e:
                    log(f'[EPGBackground] Error: {e}')
                finally:
                    _epg_bg_lock.release()
            threading.Thread(target=_run, daemon=True).start()

    return cached


def list_by_language():
    """Show language folders for all 24/7 channels."""
    xbmcgui.Dialog().notification('Tor Network', 'Loading language index...', ICON, 2000)
    ch_list = channels()
    if not ch_list:
        closeDir()
        return
    index = _get_iptv_org_index()
    if not index:
        xbmcgui.Dialog().notification('Tor Network', 'iptv-org index unavailable', ICON, 4000)
        closeDir()
        return
    lang_counts = {}
    unclassified = 0
    for href, name in ch_list:
        lang = _lookup_lang(name, index)
        if lang:
            lang_counts[lang] = lang_counts.get(lang, 0) + 1
        else:
            unclassified += 1
    for lang_code, count in sorted(lang_counts.items(), key=lambda x: -x[1]):
        lang_name = LANG_NAMES.get(lang_code, lang_code.upper())
        addDir(f'[B]{lang_name}[/B]  ({count})', build_url({'mode': 'lang_channels', 'lang': lang_code}), True)
    if unclassified:
        addDir(f'[Unclassified]  ({unclassified})', build_url({'mode': 'lang_channels', 'lang': '__none__'}), True)
    closeDir()

def list_lang_channels(lang_code):
    """Show channels matching a language code (or unclassified if lang_code='__none__')."""
    xbmcplugin.setContent(addon_handle, 'videos')
    ch_list = channels()
    index = _get_iptv_org_index()
    fav_ids = {f['id'] for f in get_favorites()}
    dead_cids = _get_dead_cids()
    for href, name in ch_list:
        cid_m = re.search(r'id=(\d+)', href)
        cid = cid_m.group(1) if cid_m else ''
        ch_lang = _lookup_lang(name, index) if index else None
        if lang_code == '__none__':
            if ch_lang is not None:
                continue
        else:
            if ch_lang != lang_code:
                continue
        fav_label = '★ Remove from favorites' if cid in fav_ids else '☆ Add to favorites'
        ctx = [(fav_label, 'RunPlugin(%s)' % build_url({'mode': 'toggle_fav', 'cid': cid, 'name': name}))] if cid else []
        label = f'{_ch_label(name, cid, dead_cids)} ({cid})' if cid else name
        addDir(label, build_url({'mode': 'play', 'url': abs_url(href)}), False, context_menu=ctx)
    closeDir()

def _get_cdn_dead_servers():
    """Return frozenset of server_keys currently KO. Cached 5 min to file."""
    try:
        if os.path.exists(_CDN_STATUS_CACHE_FILE) and \
                time.time() - os.path.getmtime(_CDN_STATUS_CACHE_FILE) < _CDN_STATUS_TTL:
            with open(_CDN_STATUS_CACHE_FILE, 'r') as f:
                return frozenset(json.load(f))
    except Exception:
        pass
    dead = set()
    try:
        resp = requests.get(f'{CHEVY_LOOKUP}/health',
                            headers={'User-Agent': UA, 'Referer': PLAYER_REFERER},
                            timeout=3)
        if resp.status_code == 200:
            hdata = resp.json()
            top_status = hdata.get('status', 'ok')
            workers = hdata.get('workers', {})
            daemon = hdata.get('daemon', {})
            pool_ex = workers.get('poolExhausted', False)
            # favorites workers can serve traffic even when pool is exhausted
            favorites_count = daemon.get('favorites_count', 0)
            connected = daemon.get('connected', True)
            # CDN is truly KO only if status is not ok, OR pool exhausted with no fallback at all
            globally_ko = (top_status != 'ok') or (pool_ex and favorites_count == 0 and not connected)
            if globally_ko:
                for sk in workers.get('servers', {}):
                    dead.add(sk)
    except Exception:
        pass
    try:
        with open(_CDN_STATUS_CACHE_FILE, 'w') as f:
            json.dump(list(dead), f)
    except Exception:
        pass
    return frozenset(dead)

def _refresh_favorites_debounced():
    """Trigger Container.Refresh at most once every _FAV_REFRESH_DEBOUNCE seconds."""
    try:
        try:
            with open(_FAV_REFRESH_TS_FILE) as _f:
                last_ts = float(_f.read().strip())
        except Exception:
            last_ts = 0
        now = time.time()
        if now - last_ts < _FAV_REFRESH_DEBOUNCE:
            return
        with open(_FAV_REFRESH_TS_FILE, 'w') as _f:
            _f.write(str(now))
        xbmc.executebuiltin('Container.Refresh')
        log('[FailedCache] Container.Refresh triggered (channel marked KO)')
    except Exception:
        pass


def _mark_channel_failed(channel_key):
    """Record a channel content failure (no valid video segments) for red-label coloring."""
    try:
        failed = {}
        if os.path.exists(_FAILED_CHANNELS_FILE):
            with open(_FAILED_CHANNELS_FILE, 'r') as f:
                failed = json.load(f)
        now = time.time()
        failed[channel_key] = now
        # Prune expired entries while we have the file open
        failed = {k: v for k, v in failed.items() if now - v < _FAILED_CHANNEL_TTL}
        _dir = os.path.dirname(_FAILED_CHANNELS_FILE)
        fd, tmp_path = tempfile.mkstemp(dir=_dir)
        try:
            with os.fdopen(fd, 'w') as f:
                json.dump(failed, f)
            os.replace(tmp_path, _FAILED_CHANNELS_FILE)
        except Exception:
            try:
                os.unlink(tmp_path)
            except Exception:
                pass
            raise
    except Exception:
        pass
    # Debounced Container.Refresh so favorites list updates when user returns to it
    _refresh_favorites_debounced()


def _clear_channel_failed(channel_key):
    """Remove a channel from the failed cache (probe confirmed it's working again)."""
    try:
        if not os.path.exists(_FAILED_CHANNELS_FILE):
            return False
        with open(_FAILED_CHANNELS_FILE, 'r') as f:
            failed = json.load(f)
        if channel_key in failed:
            del failed[channel_key]
            _dir = os.path.dirname(_FAILED_CHANNELS_FILE)
            fd, tmp_path = tempfile.mkstemp(dir=_dir)
            try:
                with os.fdopen(fd, 'w') as f:
                    json.dump(failed, f)
                os.replace(tmp_path, _FAILED_CHANNELS_FILE)
            except Exception:
                try:
                    os.unlink(tmp_path)
                except Exception:
                    pass
                raise
            return True
    except Exception:
        pass
    return False


def _probe_favorites_background(favs, force=False):
    """Probe each favorite channel's m3u8 in background; update failed cache and refresh list."""
    log(f'[ProbeBackground] Called with {len(favs)} favorites (force={force})')
    with _probe_lock:
        now = time.time()
        if not force:
            # Cooldown persisted in a file so it survives across Kodi plugin invocations
            try:
                with open(_FAV_PROBE_TS_FILE) as _f:
                    last_ts = float(_f.read().strip())
            except Exception:
                last_ts = 0
            elapsed = now - last_ts
            if elapsed < _FAV_PROBE_COOLDOWN:
                log(f'[ProbeBackground] Skipped — cooldown ({int(_FAV_PROBE_COOLDOWN - elapsed)}s remaining)')
                return
        # Mark probe as started immediately to avoid race between concurrent addon invocations
        try:
            with open(_FAV_PROBE_TS_FILE, 'w') as _f:
                _f.write(str(now))
        except Exception:
            pass

    log(f'[ProbeBackground] Launching background thread')

    def _run():
        try:
            log(f'[ProbeBackground] Thread started — probing {len(favs)} favorites')
            # Load CDN map for server keys (from cache — no blocking rebuild)
            cdn_map = {}
            try:
                if os.path.exists(_CDN_CACHE_FILE):
                    with open(_CDN_CACHE_FILE, 'r') as f:
                        cdn_map = json.load(f)
                log(f'[ProbeBackground] CDN cache loaded ({len(cdn_map)} entries)')
            except Exception as e:
                log(f'[ProbeBackground] CDN cache load failed: {e}')

            def probe_one(fav):
                cid = fav['id']
                channel_key = f'premium{cid}'
                sk = cdn_map.get(cid)
                if not sk or sk == 'unknown':
                    # Try live lookup — quick, already cached by chevy
                    try:
                        resp = _get_session().get(
                            f'{CHEVY_LOOKUP}/server_lookup?channel_id={channel_key}',
                            headers={'User-Agent': UA, 'Referer': PLAYER_REFERER},
                            timeout=3
                        )
                        sk = resp.json().get('server_key', 'unknown')
                    except Exception:
                        sk = 'unknown'
                if sk == 'unknown':
                    log(f'[ProbeBackground] {channel_key}: no server key — skipped')
                    return cid, sk, None  # skip — no server key
                url = f'{CHEVY_PROXY}/proxy/{sk}/{channel_key}/mono.css'
                ok = _probe_hls_url(url)
                log(f'[ProbeBackground] {channel_key} ({sk}): {"OK" if ok else "KO" if ok is False else "skip"}')
                return cid, sk, ok

            with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
                results = list(executor.map(probe_one, favs))

            # Persist discovered server keys back into the CDN cache
            try:
                updated_cdn = dict(cdn_map)
                for cid, sk, _ in results:
                    if sk and sk != 'unknown':
                        updated_cdn[cid] = sk
                if updated_cdn != cdn_map:
                    with open(_CDN_CACHE_FILE, 'w') as _wf:
                        json.dump(updated_cdn, _wf)
            except Exception as _ce:
                log(f'[ProbeBackground] CDN cache write failed: {_ce}')

            n_ok = sum(1 for _, _sk, ok in results if ok is True)
            n_ko = sum(1 for _, _sk, ok in results if ok is False)
            n_skip = sum(1 for _, _sk, ok in results if ok is None)
            changed = False
            for cid, _sk, ok in results:
                channel_key = f'premium{cid}'
                if ok is False:
                    _mark_channel_failed(channel_key)
                    changed = True
                    log(f'[ProbeBackground] {channel_key}: KO → marked red')
                elif ok is True:
                    if _clear_channel_failed(channel_key):
                        changed = True
                        log(f'[ProbeBackground] {channel_key}: OK → cleared red')

            if changed:
                xbmc.executebuiltin('Container.Refresh')
            log(f'[ProbeBackground] Done — {n_ok} OK / {n_ko} KO / {n_skip} skip — {"refreshed" if changed else "no change"}')
        except Exception as e:
            log(f'[ProbeBackground] Error: {e}')
            import traceback
            log(f'[ProbeBackground] Traceback: {traceback.format_exc()}')

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


def _get_dead_cids():
    """Return frozenset of channel IDs: CDN KO servers + recently failed content."""
    dead = set()
    # CDN server health
    dead_servers = _get_cdn_dead_servers()
    if dead_servers:
        try:
            if os.path.exists(_CDN_CACHE_FILE):
                with open(_CDN_CACHE_FILE, 'r') as f:
                    cdn_map = json.load(f)
                dead.update(cid for cid, sk in cdn_map.items() if sk in dead_servers)
        except Exception:
            pass
    # Proxy-level content failures (no valid video segments served)
    try:
        if os.path.exists(_FAILED_CHANNELS_FILE):
            with open(_FAILED_CHANNELS_FILE, 'r') as f:
                failed = json.load(f)
            now = time.time()
            for k, t in failed.items():
                if now - t < _FAILED_CHANNEL_TTL:
                    m = re.match(r'^premium(\d+)$', k)
                    if m:
                        dead.add(m.group(1))
    except Exception:
        pass
    return frozenset(dead)

def _ch_label(name, cid, dead_cids, cdn_name=None):
    """Wrap label in red if channel is KO. Optionally append CDN name."""
    if cid and cid in dead_cids:
        suffix = f' ({cdn_name})' if cdn_name else ''
        return f'[COLOR red]{name}{suffix}[/COLOR]'
    return name

def list_gen():
    chData = channels()
    if not chData:
        xbmcgui.Dialog().notification('Tor Network', 'Failed to load channels. Check your connection.', ICON, 5000)
        log('[list_gen] channels() returned empty list')
    fav_ids = {f['id'] for f in get_favorites()}
    dead_cids = _get_dead_cids()
    ev_map = _build_current_events_map()
    for href, name in chData:
        cid_m = re.search(r'id=(\d+)', href)
        cid = cid_m.group(1) if cid_m else ''
        fav_label = '★ Remove from favorites' if cid in fav_ids else '☆ Add to favorites'
        ctx = [(fav_label, 'RunPlugin(%s)' % build_url({'mode': 'toggle_fav', 'cid': cid, 'name': name}))] if cid else []
        label = f'{_ch_label(name, cid, dead_cids)} ({cid})' if cid else name
        ev = ev_map.get(cid) if cid else None
        if ev:
            label += f'  [COLOR gold]{ev}[/COLOR]'
        addDir(label, build_url({'mode': 'play', 'url': abs_url(href)}), False, context_menu=ctx, plot=ev)
    closeDir()

_CARD_RX = re.compile(
    r'<a\s+class="card"[^>]*?href="(?P<href>[^"]+)"[^>]*?data-title="(?P<data_title>[^"]*)"[^>]*>'
    r'.*?<div\s+class="card__title">\s*(?P<title>.*?)\s*</div>'
    r'.*?ID:\s*(?P<id>\d+)\s*</div>'
    r'.*?</a>',
    re.IGNORECASE | re.DOTALL
)


def _lookup_channel_name(cid):
    """Return real channel name for given ID string, or None if not found."""
    url = abs_url('24-7-channels.php')
    try:
        resp = fetch_via_proxy(url, headers={'Referer': get_active_base(), 'User-Agent': UA})
    except Exception:
        return None
    for m in _CARD_RX.finditer(resp):
        if m.group('id') == cid:
            name = html.unescape(m.group('title').strip()) or html.unescape(m.group('data_title').strip())
            name = re.sub(r'^\s*\d+(?=[A-Za-z])', '', name).strip()
            return name or None
    return None


def channels():
    url = abs_url('24-7-channels.php')
    headers = {'Referer': get_active_base(), 'User-Agent': UA}

    try:
        resp = fetch_via_proxy(url, headers=headers)
    except Exception as e:
        log(f"[Tor Proxy] channels(): request failed: {e}")
        return []

    card_rx = _CARD_RX

    items = []
    for m in card_rx.finditer(resp):
        href_rel = m.group('href').strip()
        title_dom = html.unescape(m.group('title').strip())
        title_attr = html.unescape(m.group('data_title').strip())
        name = title_dom or title_attr

        is_adult = (
            '18+' in name.upper() or
            'XXX' in name.upper() or
            name.strip().startswith('18+')
        )

        if is_adult:
            continue

        name = re.sub(r'^\s*\d+(?=[A-Za-z])', '', name).strip()
        items.append([href_rel, name])

    return items


def _channel_group(name):
    """Assign a group/category to a channel based on its name."""
    n = name.upper()
    if any(k in n for k in ['SPORT', 'ESPN', 'BEIN', 'DAZN', 'RACING', 'GOLF', 'BOXING',
                              'FIGHT', 'WWE', 'NBA ', 'NFL ', 'MLB ', 'NHL ', 'MOTOGP',
                              'EUROSPORT', 'SKY SPORTS', 'BT SPORT', 'PREMIER', 'CHAMPIONS',
                              'FORMULA', 'ARENA SPORT', 'MATCH TV', 'SETANTA', 'SPORTV']):
        return 'Sports'
    if any(k in n for k in ['NEWS', 'CNN', 'SKY NEWS', 'FOX NEWS', 'AL JAZEERA', 'ALJAZEERA',
                              'FRANCE 24', 'EURONEWS', 'BFMTV', 'BFM TV', 'LCI', 'CNBC',
                              'BLOOMBERG', 'TRT ', 'NHK', 'DW ']):
        return 'News'
    if any(k in n for k in [' UK', 'BBC ', 'ITV ', 'CHANNEL 4', 'CHANNEL 5', 'SKY ONE',
                              'SKY CINEMA', 'SKY ATLANTIC', 'SKY MAX', 'DAVE ', 'E4 ',
                              'MORE4', 'COMEDY CENTRAL UK']):
        return 'United Kingdom'
    if any(k in n for k in ['FRANCE', 'TF1', 'M6 ', 'ARTE', 'CANAL+', 'CNEWS', 'C NEWS',
                              'TMC ', 'TFX', 'W9 ', 'C8 ', 'CSTAR', 'GULLI', 'RMC ', 'NRJ ',
                              'BFM', 'RTL ']):
        return 'France'
    if any(k in n for k in [' US ', 'NBC ', 'CBS ', 'ABC ', 'AMC ', 'HBO ', 'SHOWTIME',
                              'STARZ', ' FX ', 'TBS ', 'BRAVO', 'HISTORY', 'DISCOVERY',
                              'NAT GEO', 'ANIMAL PLANET', 'LIFETIME', 'A&E ']):
        return 'USA'
    if any(k in n for k in ['ARABIC', 'ARAB', 'MBC ', 'OSN ', 'ROTANA', 'AL ARABIYA',
                              'AL MAYADEEN', 'BAHRAIN', 'KUWAIT', 'SAUDI', 'QATAR', ' UAE',
                              'KSA ', 'NILE ']):
        return 'Arabic'
    if any(k in n for k in ['INDIA', 'STAR PLUS', 'ZEE ', 'SONY LIV', 'COLORS', 'HIND']):
        return 'India'
    return 'International'




def _probe_hls_url(url):
    """Quick check: returns True if CDN responds with a live HLS playlist (any segments).
    Returns None (inconclusive) for auth errors (403/407) — CDN reachable but requires auth.
    Returns False for definitive failures (404, 5xx, HTML content, no segments)."""
    try:
        r = _get_session().get(url, headers={'User-Agent': UA, 'Referer': PLAYER_REFERER}, timeout=3)
        if r.status_code in (403, 407):
            return None  # CDN requires auth — inconclusive, don't mark as KO
        if r.status_code != 200:
            return False
        content = r.text
        if content[:5] in ('<!DOC', '<html', '<!doc'):
            return False
        # Finite playlist = channel offline
        if '#EXT-X-ENDLIST' in content:
            return False
        if '#EXTM3U' not in content:
            return len(content) > 0 and '<' not in content[:20]
        _segs = [l.strip() for l in content.splitlines()
                 if l.strip() and not l.strip().startswith('#')]
        return len(_segs) > 0  # CDN is reachable and serving a playlist
    except requests.Timeout:
        return None   # timeout = inconclusive, don't mark dead
    except requests.exceptions.ConnectionError:
        return None   # proxy unreachable — inconclusive, don't mark channel dead
    except Exception:
        return False



_AP_SKIP_HOSTS = ('sstatic', 'histats', 'adsco', 'fidget', 'chatango',
                  'facebook.com', 'google.com', 'ksohls.ru')
_AP_M3U8_RX = re.compile(r'''["'](https?://[^\s"'<>]+\.m3u8[^"']*)["']''')
_AP_IFRAME_RX = re.compile(r'''<iframe[^>]+src=["'](https?://[^"']{10,200})["']''')
_AP_STREAM_URL_RX = re.compile(r'''streamUrl\s*:\s*["'](https?:[^"']+)["']''')
_AP_PATHS = ('stream', 'cast', 'watch', 'plus', 'casting', 'player')


def _try_player_page(channel_id, player_url, watch_url, sess):
    """Try to extract a stream URL from a single player page.
    Returns (url, origin) tuple on success, None otherwise.
    origin is the required HTTP Origin header value, or None if not needed."""
    try:
        r = sess.get(player_url, headers={'User-Agent': UA, 'Referer': watch_url}, timeout=8)
        if r.status_code != 200:
            log(f'[AnyPlayer] HTTP {r.status_code}: {player_url}')
            return None
        html = r.text

        # ── CHEVY/auth-player guard: detect by content, not domain ──────────
        # Don't bail out — some pages embed CHEVY alongside another player.
        # The iframe scan below already skips /premiumtv/ iframes explicitly.
        if 'ksohls.ru' in html or '/premiumtv/daddyhd.php' in html:
            log(f'[AnyPlayer] CHEVY embed detected — scanning for alternative streams: {player_url}')

        # ── Provider: superdinamico.com → edg.ligapk.com (token-free) ───────
        ms = re.search(r'''<iframe[^>]+src=["'](https://[^"']+\.superdinamico\.com/embed\.php[^"']*)["']''', html)
        if ms:
            r2 = sess.get(ms.group(1), headers={'User-Agent': UA, 'Referer': player_url}, timeout=8)
            if r2.status_code == 200:
                ms2 = re.search(r'get_stream\.php\?id=([a-f0-9]{32})', r2.text)
                if ms2:
                    url = f'https://edg.ligapk.com/exemple.php?id={ms2.group(1)}'
                    log(f'[AnyPlayer] superdinamico/ligapk: {url}')
                    return (url, None)
            log(f'[AnyPlayer] superdinamico embed failed (HTTP {r2.status_code})')

        # ── Generic: direct .m3u8 URL in page source — probe each, skip broken ──
        for mm in _AP_M3U8_RX.finditer(html):
            candidate = mm.group(1)
            # skip blogspot/r-strm wrapper URLs (they serve HTML, not m3u8)
            if 'blogspot.com' in candidate or 'r-strm.' in candidate:
                continue
            try:
                rp = sess.get(candidate, headers={'User-Agent': UA}, timeout=4)
                if rp.status_code == 200 and '#EXTM3U' in rp.text[:200]:
                    log(f'[AnyPlayer] direct m3u8 OK: {candidate[:80]}')
                    return (candidate, None)
                log(f'[AnyPlayer] direct m3u8 skip ({rp.status_code}): {candidate[:60]}')
            except Exception:
                log(f'[AnyPlayer] direct m3u8 unreachable: {candidate[:60]}')

        # ── Generic: scan iframes for .m3u8 or streamUrl ────────────────────
        for iframe_url in _AP_IFRAME_RX.findall(html):
            if any(skip in iframe_url for skip in _AP_SKIP_HOSTS) or '/premiumtv/' in iframe_url:
                continue
            log(f'[AnyPlayer] scanning iframe: {iframe_url[:80]}')
            try:
                ri = sess.get(iframe_url, headers={'User-Agent': UA, 'Referer': player_url}, timeout=8)
                if ri.status_code != 200:
                    continue
                for mi in _AP_M3U8_RX.finditer(ri.text):
                    candidate = mi.group(1)
                    if 'blogspot.com' in candidate or 'r-strm.' in candidate:
                        continue
                    try:
                        rp = sess.get(candidate, headers={'User-Agent': UA}, timeout=4)
                        if rp.status_code == 200 and '#EXTM3U' in rp.text[:200]:
                            # Probe first segment to verify segments are accessible
                            _seg_lines = [ln.strip() for ln in rp.text.splitlines()
                                          if ln.strip() and not ln.startswith('#')]
                            _seg_ok = True
                            if _seg_lines:
                                _su = _seg_lines[0]
                                if not _su.startswith('http'):
                                    _su = candidate.rsplit('/', 1)[0] + '/' + _su
                                try:
                                    _sr = sess.get(_su, headers={'User-Agent': UA,
                                                                  'Range': 'bytes=0-0'}, timeout=3)
                                    if _sr.status_code == 403:
                                        # Some CDNs reject Range requests — retry without Range
                                        try:
                                            _sr2 = sess.get(_su, headers={'User-Agent': UA}, timeout=3)
                                            if _sr2.status_code >= 400:
                                                log(f'[AnyPlayer] iframe m3u8 seg fail ({_sr2.status_code} norange): {candidate[:60]}')
                                                _seg_ok = False
                                        except Exception:
                                            pass  # network error — assume segment OK
                                    elif _sr.status_code >= 400:
                                        log(f'[AnyPlayer] iframe m3u8 seg fail ({_sr.status_code}): {candidate[:60]}')
                                        _seg_ok = False
                                except Exception:
                                    pass  # network error — assume segment OK
                            if not _seg_ok:
                                continue
                            log(f'[AnyPlayer] iframe m3u8 OK: {candidate[:80]}')
                            return (candidate, None)
                        log(f'[AnyPlayer] iframe m3u8 skip ({rp.status_code}): {candidate[:60]}')
                    except Exception:
                        log(f'[AnyPlayer] iframe m3u8 unreachable: {candidate[:60]}')
                mi2 = _AP_STREAM_URL_RX.search(ri.text)
                if mi2:
                    url = mi2.group(1).replace('\\/', '/')
                    log(f'[AnyPlayer] iframe streamUrl: {url[:80]}')
                    return (url, None)

                # ── lovecdn-style: ?stream= param + /player/ token (domain-agnostic) ──
                m_stream = re.search(r'[?&]stream=([\w-]+)', iframe_url)
                if m_stream:
                    stream_name = m_stream.group(1)
                    m_lt = re.search(
                        r'(https?://[^/"\'\s]+/player/' + re.escape(stream_name) + r')["\'\s\\]',
                        ri.text)
                    lt_url = m_lt.group(1) if m_lt else f'https://lovetier.bz/player/{stream_name}'
                    r_lt = sess.get(lt_url, headers={'User-Agent': UA, 'Referer': iframe_url}, timeout=8)
                    if r_lt.status_code == 200:
                        m_su = re.search(r'streamUrl\s*:\s*"([^"]+)"', r_lt.text)
                        if m_su:
                            su = m_su.group(1).replace('\\/', '/')
                            try:
                                rp = sess.get(su, headers={'User-Agent': UA, 'Referer': lt_url}, timeout=5)
                                if rp.status_code == 200 and '#EXTM3U' in rp.text[:200]:
                                    _ensure_m3u8_proxy()
                                    port = _actual_proxy_port or M3U8_PROXY_PORT
                                    log(f'[AnyPlayer] lovecdn-style: stream={stream_name} lt={lt_url[:60]}')
                                    return (f'http://127.0.0.1:{port}/lovecdn-ts/{quote_plus(stream_name)}', None)
                                log(f'[AnyPlayer] lovecdn streamUrl HTTP {rp.status_code} for {stream_name} — skip')
                            except Exception:
                                log(f'[AnyPlayer] lovecdn streamUrl unreachable for {stream_name} — skip')
                    else:
                        log(f'[AnyPlayer] lovecdn token HTTP {r_lt.status_code} for {stream_name}')

                # ── wiki.js player (fid + wiki.js src) — fully domain-agnostic ──
                mf = re.search(r'fid\s*=\s*"([^"]+)"', ri.text)
                mw = re.search(r'src=["\'](?:https?:)?//([^/"\']+)/wiki\.js["\']', ri.text)
                if mf and mw:
                    fid = mf.group(1)
                    wiki_host = mw.group(1)
                    st_url = f'https://{wiki_host}/wiki.php?player=desktop&live={fid}'
                    rst = sess.get(st_url, headers={'User-Agent': UA, 'Referer': iframe_url}, timeout=8)
                    if rst.status_code == 200:
                        mca = re.search(r'return\s*\(\[([^\]]+)\]\.join\(', rst.text)
                        if mca:
                            chars = re.findall(r'"(\\?.)"', mca.group(1))
                            url = ''.join(c.replace('\\/', '/') for c in chars)
                            if url.startswith('https://'):
                                log(f'[AnyPlayer] wiki.js fid={fid} host={wiki_host}: {url[:80]}')
                                return (url, f'https://{wiki_host}')

                # ── viewembed / CHEVY-template player (CHANNEL_KEY variable) ──
                m_ck2 = re.search(r"CHANNEL_KEY\s*=\s*['\"]([a-z0-9_-]+)['\"]", ri.text, re.IGNORECASE)
                if m_ck2:
                    vkey = m_ck2.group(1)
                    srv_list = []
                    m_sarr = re.search(r"M3U8_SERVER\b[^=\[]*=\s*\[([^\]]+)\]", ri.text)
                    if not m_sarr:
                        m_sarr = re.search(r"\bservers?\s*=\s*\[([^\]]+)\]", ri.text, re.IGNORECASE)
                    if m_sarr:
                        srv_list = [f'https://{s}' for s in re.findall(r"['\"]([a-z0-9._-]+\.[a-z]{2,})['\"]", m_sarr.group(1))]
                    for _ds in (CHEVY_PROXY, CHEVY_LOOKUP):
                        if _ds and _ds not in srv_list:
                            srv_list.append(_ds)
                    for srv in srv_list:
                        try:
                            sl_r = sess.get(f'{srv}/server_lookup?channel_id={vkey}',
                                            headers={'User-Agent': UA, 'Referer': iframe_url}, timeout=4)
                            if sl_r.status_code == 200:
                                _sk = sl_r.json().get('server_key', 'zeko')
                                cand = f'{srv}/proxy/{_sk}/{vkey}/mono.css'
                                rp = sess.get(cand, headers={'User-Agent': UA, 'Referer': iframe_url}, timeout=5)
                                if rp.status_code == 200 and '#EXTM3U' in rp.text[:200]:
                                    # Probe first segment: skip if it's a PNG placeholder (no TS data).
                                    # AES-encrypted segments appear as random bytes — body[:4] won't be \x89PNG.
                                    # Use _is_placeholder() on segment URLs first (catches cdn.gptimage15.com
                                    # even when segments are AES-encrypted), then fall back to byte probe.
                                    _ve_seg_ok = True
                                    _ve_segs = [l.strip() for l in rp.text.splitlines()
                                                if l.strip() and not l.strip().startswith('#')]
                                    if _ve_segs and all(_is_placeholder(s) for s in _ve_segs):
                                        log(f'[AnyPlayer] viewembed {vkey} all segs are placeholder CDN @ {srv[:40]} — skip')
                                        _ve_seg_ok = False
                                    elif _ve_segs:
                                        _ve_su = _ve_segs[0]
                                        if not _ve_su.startswith('http'):
                                            _ve_su = cand.rsplit('/', 1)[0] + '/' + _ve_su
                                        try:
                                            _ve_sr = sess.get(_ve_su, headers={'User-Agent': UA,
                                                                                'Range': 'bytes=0-2047'},
                                                              timeout=4)
                                            if _ve_sr.status_code in (200, 206):
                                                _ve_body = _ve_sr.content
                                                if _ve_body[:4] == b'\x89PNG':
                                                    # Check for TS sync within the fetched bytes
                                                    _ve_ts = any(
                                                        _ve_body[_o] == 0x47
                                                        and _o + 188 < len(_ve_body)
                                                        and _ve_body[_o + 188] == 0x47
                                                        for _o in range(len(_ve_body) - 188)
                                                    )
                                                    if not _ve_ts:
                                                        log(f'[AnyPlayer] viewembed {vkey} seg is PNG placeholder @ {srv[:40]} — skip')
                                                        _ve_seg_ok = False
                                        except Exception as _ve_e:
                                            log(f'[AnyPlayer] viewembed seg probe error: {_ve_e}')
                                    if _ve_seg_ok:
                                        # Route through EPlayerProxy for proper segment/key handling on all platforms
                                        _set_channel_state(vkey, 'noauth', 'noauth', cand)
                                        _ensure_m3u8_proxy()
                                        _ve_port = _actual_proxy_port or M3U8_PROXY_PORT
                                        log(f'[AnyPlayer] viewembed CHEVY key={vkey} server={srv[:40]}')
                                        return (f'http://127.0.0.1:{_ve_port}/m3u8/{vkey}', None)
                                log(f'[AnyPlayer] viewembed {vkey} m3u8 HTTP {rp.status_code} @ {srv[:40]}')
                            else:
                                log(f'[AnyPlayer] viewembed server_lookup HTTP {sl_r.status_code} @ {srv[:40]}')
                        except Exception as _e_ve:
                            log(f'[AnyPlayer] viewembed {srv[:40]} error: {_e_ve}')

            except Exception as e:
                log(f'[AnyPlayer] iframe error {iframe_url[:60]}: {e}')
                # lovecdn-style fallback: if iframe fetch failed but URL has ?stream=,
                # try lovetier.bz directly (lovecdn.ru may be down).
                m_stream_fb = re.search(r'[?&]stream=([\w-]+)', iframe_url)
                if m_stream_fb:
                    stream_name = m_stream_fb.group(1)
                    lt_url = f'https://lovetier.bz/player/{stream_name}'
                    try:
                        r_lt = sess.get(lt_url, headers={'User-Agent': UA, 'Referer': iframe_url}, timeout=8)
                        if r_lt.status_code == 200 and re.search(r'streamUrl\s*:\s*"https?:', r_lt.text):
                            _ensure_m3u8_proxy()
                            port = _actual_proxy_port or M3U8_PROXY_PORT
                            log(f'[AnyPlayer] lovecdn fallback (lovetier direct): stream={stream_name}')
                            return (f'http://127.0.0.1:{port}/lovecdn-ts/{quote_plus(stream_name)}', None)
                        log(f'[AnyPlayer] lovecdn fallback lovetier HTTP {r_lt.status_code} stream={stream_name}')
                    except Exception as e2:
                        log(f'[AnyPlayer] lovecdn fallback lovetier error stream={stream_name}: {e2}')

        return None

    except Exception as e:
        log(f'[AnyPlayer] error for {player_url}: {e}')
        return None


def get_any_player_stream(channel_id):
    """Try all player pages (stream, cast, watch, plus, casting, player) in order.
    Applies auto-detection on each: superdinamico/ligapk, lovecdn-style (domain-agnostic),
    wiki.js, direct .m3u8, generic iframe scan. Skips pages using ksohls.ru (=CHEVY).
    Returns (url, origin) tuple on success, or None if all fail."""
    watch_url = abs_url(f'watch.php?id={channel_id}')
    sess = _get_session()
    for path in _AP_PATHS:
        player_url = abs_url(f'{path}/stream-{channel_id}.php')
        log(f'[AnyPlayer] Trying {path}/stream-{channel_id}.php')
        result = _try_player_page(channel_id, player_url, watch_url, sess)
        if result:
            return result
    log(f'[AnyPlayer] no stream found for id={channel_id}')
    return None


def get_stream_page_url(channel_id):
    """Fetch auth credentials via stream-{id}.php → enviromentalspace.cyou player.
    Returns (auth_token, channel_salt) or (None, None).
    The stream-{id}.php page now embeds an enviromentalspace.cyou player that has
    the same XOR-encoded authToken/channelSalt as ksohls.ru — usable with CHEVY proxy.
    """
    try:
        watch_url = abs_url(f'watch.php?id={channel_id}')
        stream_url_page = abs_url(f'stream/stream-{channel_id}.php')

        sess = _get_session()
        r = sess.get(stream_url_page, headers={
            'User-Agent': UA,
            'Referer': watch_url,
        }, timeout=12)
        if r.status_code != 200:
            log(f'[StreamPage] HTTP {r.status_code} for id={channel_id}')
            return None, None

        # Find any premiumtv player iframe (enviromentalspace.cyou or similar)
        mp = re.search(r'<iframe[^>]+src="(https?://[^"]+premiumtv[^"]+)"', r.text)
        if not mp:
            log(f'[StreamPage] no premiumtv iframe for id={channel_id}')
            return None, None
        player_url = mp.group(1)
        log(f'[StreamPage] player URL: {player_url[:80]}')

        r2 = sess.get(player_url, headers={
            'User-Agent': UA,
            'Referer': stream_url_page,
        }, timeout=8)
        player_base = re.sub(r'^(https?://[^/]+).*', r'\1', player_url)
        auth_token, channel_salt = _extract_creds_with_js(r2.text, player_url, player_base, sess)
        if auth_token and channel_salt:
            log(f'[StreamPage] Got credentials for channel {channel_id}')
            return auth_token, channel_salt
        log(f'[StreamPage] no credentials in player page for id={channel_id}')
        return None, None
    except Exception as e:
        log(f'[StreamPage] Error for channel {channel_id}: {e}')
        return None, None


def resolve_stream_url(channel_id, forced_key=None):
    channel_key = f'premium{channel_id}'
    if forced_key:
        log(f'[resolve_stream_url] Forced CDN for {channel_id}: {forced_key}')
        if forced_key == 'top1/cdn':
            return f'{CHEVY_PROXY}/proxy/top1/cdn/{channel_key}/mono.css'
        return f'{CHEVY_PROXY}/proxy/{forced_key}/{channel_key}/mono.css'
    # Session cache
    with _server_key_cache_lock:
        cached = _server_key_cache.get(channel_id)
        if cached and time.time() - cached[1] < _SERVER_KEY_TTL:
            server_key = cached[0]
            log(f'[resolve_stream_url] cache hit for {channel_id}: {server_key}')
            if server_key == 'top1/cdn':
                return f'{CHEVY_PROXY}/proxy/top1/cdn/{channel_key}/mono.css'
            return f'{CHEVY_PROXY}/proxy/{server_key}/{channel_key}/mono.css'
    server_key = 'zeko'
    _lookup_url = CHEVY_LOOKUP
    _proxy_url = CHEVY_PROXY
    for attempt in range(3):
        try:
            resp = _get_session().get(
                f'{_lookup_url}/server_lookup?channel_id={channel_key}',
                headers={'User-Agent': UA, 'Referer': PLAYER_REFERER},
                timeout=4
            )
            server_key = resp.json().get('server_key', 'zeko')
            with _server_key_cache_lock:
                _server_key_cache[channel_id] = (server_key, time.time())
            _proxy_url = CHEVY_PROXY if _lookup_url == CHEVY_LOOKUP else _CHEVY_PROXY_BUILTIN
            break
        except Exception as e:
            log(f'[resolve_stream_url] server_lookup failed (attempt {attempt+1}): {e}')
            if attempt == 0:
                time.sleep(0.3)
            elif attempt == 1 and _lookup_url != _CHEVY_LOOKUP_BUILTIN:
                # Discovered server is down — try builtin fallback and invalidate cache
                log(f'[resolve_stream_url] falling back to builtin CHEVY server')
                _lookup_url = _CHEVY_LOOKUP_BUILTIN
                _proxy_url = _CHEVY_PROXY_BUILTIN
                try:
                    os.remove(_DISCOVERY_CACHE_FILE)
                except Exception:
                    pass
    if server_key == 'top1/cdn':
        return f'{_proxy_url}/proxy/top1/cdn/{channel_key}/mono.css'
    return f'{_proxy_url}/proxy/{server_key}/{channel_key}/mono.css'

def PlayStream(link):
    try:
        log(f'[PlayStream] Starting: {link}')

        parsed = urlparse(link)
        qs = dict(parse_qsl(parsed.query))
        channel_id = qs.get('id', '').split('|')[0].strip()

        if not channel_id:
            log('[PlayStream] No channel ID found')
            xbmcgui.Dialog().notification('Tor Proxy', 'Channel not found (missing ID)', ICON, 4000)
            xbmcplugin.setResolvedUrl(addon_handle, False, xbmcgui.ListItem())
            return

        log(f'[PlayStream] Channel ID: {channel_id}')
        channel_key = f'premium{channel_id}'

        # CDN selection dialog
        cdn_idx = xbmcgui.Dialog().select(
            f'CDN \u2014 ch.{channel_id}',
            [label for label, _ in _KNOWN_CDNS]
        )
        if cdn_idx < 0:
            xbmcplugin.setResolvedUrl(addon_handle, False, xbmcgui.ListItem())
            return
        _, forced_cdn = _KNOWN_CDNS[cdn_idx]

        use_player6 = False
        auth_token, channel_salt = None, None
        player_origin = None
        m3u8_url = None

        if forced_cdn == '__anyplayer__':
            ap_result = get_any_player_stream(channel_id)
            if ap_result:
                real_m3u8_url, player_origin = ap_result
                log(f'[PlayStream] AnyPlayer URL: {real_m3u8_url} (origin={player_origin})')
                use_player6 = True
            else:
                # AnyPlayer failed — try ksohls auth before noauth (noauth often returns placeholder images)
                log('[PlayStream] AnyPlayer failed — trying ksohls auth before noauth fallback')
                with concurrent.futures.ThreadPoolExecutor(max_workers=2) as _ex2:
                    _f_url = _ex2.submit(resolve_stream_url, channel_id, None)
                    _f_auth = _ex2.submit(_fetch_auth_credentials, channel_id)
                    real_m3u8_url = _f_url.result()
                    auth_token, channel_salt = _f_auth.result()
                if not auth_token:
                    auth_token, channel_salt = get_stream_page_url(channel_id)
                if auth_token and channel_salt and auth_token != 'noauth':
                    log(f'[PlayStream] AnyPlayer failed but ksohls auth OK — using CHEVY stream proxy')
                    _set_channel_state(channel_key, auth_token, channel_salt, real_m3u8_url)
                    _ensure_m3u8_proxy()
                    m3u8_url = f'http://127.0.0.1:{_actual_proxy_port or M3U8_PROXY_PORT}/stream/{channel_key}'
                    log(f'[PlayStream] Using TS stream proxy (auth): {m3u8_url}')
                else:
                    log(f'[PlayStream] Auth also failed — falling back to CHEVY no-auth proxy')
                    log(f'[PlayStream] Primary M3U8 URL (noauth fallback): {real_m3u8_url}')
                    _set_channel_state(channel_key, 'noauth', 'noauth', real_m3u8_url)
                    _ensure_m3u8_proxy()
                    m3u8_url = f'http://127.0.0.1:{_actual_proxy_port or M3U8_PROXY_PORT}/m3u8/{channel_key}'
                    log(f'[PlayStream] Using m3u8 proxy (no-auth): {m3u8_url}')

        elif forced_cdn == '__streampage__':
            # StreamPage uses enviromentalspace.cyou auth + CHEVY CDN (TS proxy path)
            auth_token, channel_salt = get_stream_page_url(channel_id)
            if not auth_token:
                xbmcgui.Dialog().notification('Tor Proxy', f'StreamPage unavailable (ch.{channel_id})', ICON, 4000)
                xbmcplugin.setResolvedUrl(addon_handle, False, xbmcgui.ListItem())
                return
            real_m3u8_url = resolve_stream_url(channel_id)
            log(f'[PlayStream] StreamPage auth OK, CDN: {real_m3u8_url}')

        else:
            # CHEVY path — reuse cached credentials if fresh (< 5 min), otherwise
            # resolve CDN URL and fetch auth in parallel to minimise spinner time.
            cached = _get_channel_state(channel_key)
            auth_is_fresh = (cached and cached.get('auth_token')
                             and cached.get('auth_token') != 'noauth'
                             and (time.time() - cached.get('fetched_at', 0)) < 300)
            if auth_is_fresh:
                real_m3u8_url = resolve_stream_url(channel_id, forced_cdn)
                auth_token = cached['auth_token']
                channel_salt = cached['channel_salt']
                log(f'[PlayStream] Reusing cached credentials for {channel_key}')
            else:
                with concurrent.futures.ThreadPoolExecutor(max_workers=2) as _ex:
                    _fut_resolve = _ex.submit(resolve_stream_url, channel_id, forced_cdn)
                    _fut_auth = _ex.submit(_fetch_auth_credentials, channel_id)
                    real_m3u8_url = _fut_resolve.result()
                    auth_token, channel_salt = _fut_auth.result()
                if not auth_token:
                    log('[PlayStream] ksohls.ru auth failed — trying StreamPage fallback')
                    auth_token, channel_salt = get_stream_page_url(channel_id)
            log(f'[PlayStream] Primary M3U8 URL: {real_m3u8_url}')

        if m3u8_url:
            pass  # already set by __anyplayer__ noauth fallback
        elif use_player6:
            # If the detected player returned a required Origin, route through raw proxy.
            # Otherwise play the URL directly (e.g. lovecdn/ligapk don't need Origin).
            if player_origin:
                _ensure_m3u8_proxy()
                encoded_origin = quote_plus(player_origin)
                encoded_url = quote_plus(real_m3u8_url)
                m3u8_url = f'http://127.0.0.1:{_actual_proxy_port or M3U8_PROXY_PORT}/raw/{encoded_origin}/{encoded_url}'
                log(f'[PlayStream] Using raw proxy for Player6 (origin={player_origin})')
            else:
                m3u8_url = real_m3u8_url
                log(f'[PlayStream] Using Player 6 stream directly')
        else:
            if auth_token and channel_salt and auth_token != 'noauth':
                log(f'[PlayStream] Got auth credentials for {channel_key}')
                _set_channel_state(channel_key, auth_token, channel_salt, real_m3u8_url)
                _ensure_m3u8_proxy()
                m3u8_url = f'http://127.0.0.1:{_actual_proxy_port or M3U8_PROXY_PORT}/stream/{channel_key}'
                log(f'[PlayStream] Using TS stream proxy: {m3u8_url}')
            else:
                # Auth unavailable — try AnyPlayer first (handles channels like ch.121 where
                # CHEVY key endpoint returns a dummy key, making noauth m3u8 proxy unusable).
                log('[PlayStream] Auth unavailable — trying AnyPlayer fallback before noauth proxy')
                ap_result = get_any_player_stream(channel_id)
                if ap_result:
                    real_m3u8_url, player_origin = ap_result
                    log(f'[PlayStream] AnyPlayer fallback OK: {real_m3u8_url} (origin={player_origin})')
                    if player_origin:
                        _ensure_m3u8_proxy()
                        encoded_origin = quote_plus(player_origin)
                        encoded_url = quote_plus(real_m3u8_url)
                        m3u8_url = f'http://127.0.0.1:{_actual_proxy_port or M3U8_PROXY_PORT}/raw/{encoded_origin}/{encoded_url}'
                        log(f'[PlayStream] Using raw proxy for AnyPlayer fallback (origin={player_origin})')
                    else:
                        m3u8_url = real_m3u8_url
                        log(f'[PlayStream] Using AnyPlayer fallback stream directly')
                    use_player6 = True
                else:
                    log('[PlayStream] AnyPlayer fallback failed — using m3u8 proxy (noauth)')
                    _set_channel_state(channel_key, 'noauth', 'noauth', real_m3u8_url)
                    _ensure_m3u8_proxy()
                    m3u8_url = f'http://127.0.0.1:{_actual_proxy_port or M3U8_PROXY_PORT}/m3u8/{channel_key}'
                    log(f'[PlayStream] Using m3u8 proxy (no-auth): {m3u8_url}')

        _is_ts_proxy = not use_player6 and f'/stream/{channel_key}' in m3u8_url
        _is_m3u8_proxy = not use_player6 and f'/m3u8/{channel_key}' in m3u8_url
        liz = xbmcgui.ListItem(f'Channel {channel_id}', path=m3u8_url)
        liz.setContentLookup(False)
        if _is_ts_proxy:
            # TS byte stream — use Kodi's native VideoPlayer (no inputstream needed)
            liz.setMimeType('video/MP2T')
        elif _is_m3u8_proxy:
            # HLS with AES-128: open_mode=ffmpeg so FFmpeg polls live playlist AND
            # handles AES-128-CBC decryption. open_mode=curl only fetches M3U8 once
            # (no live re-poll) which causes stream to stall after initial segments.
            liz.setMimeType('application/vnd.apple.mpegurl')
            liz.setProperty('inputstream', 'inputstream.ffmpegdirect')
            liz.setProperty('inputstream.ffmpegdirect.is_realtime_stream', 'true')
            liz.setProperty('inputstream.ffmpegdirect.open_mode', 'ffmpeg')
        elif '/lovecdn-ts/' in m3u8_url:
            # lovecdn via TS proxy: internal HLS client downloads segments and forwards
            # as a continuous MPEG-TS byte stream — no HLS re-polling, no loop risk.
            liz.setMimeType('video/MP2T')
        elif '/lovecdn/' in m3u8_url:
            # lovecdn HLS via ffmpegdirect (fallback, not currently used)
            liz.setMimeType('application/vnd.apple.mpegurl')
            liz.setProperty('inputstream', 'inputstream.ffmpegdirect')
            liz.setProperty('inputstream.ffmpegdirect.is_realtime_stream', 'true')
            liz.setProperty('inputstream.ffmpegdirect.open_mode', 'ffmpeg')
        elif use_player6 and '127.0.0.1' in m3u8_url and '/m3u8/' in m3u8_url:
            # EPlayerProxy URL returned from AnyPlayer (e.g. viewembed CHEVY) — ffmpegdirect via proxy
            liz.setMimeType('application/vnd.apple.mpegurl')
            liz.setProperty('inputstream', 'inputstream.ffmpegdirect')
            liz.setProperty('inputstream.ffmpegdirect.is_realtime_stream', 'true')
            liz.setProperty('inputstream.ffmpegdirect.open_mode', 'ffmpeg')
        else:
            liz.setMimeType('application/vnd.apple.mpegurl')
        liz.setProperty('IsPlayable', 'true')

        xbmcplugin.setResolvedUrl(addon_handle, True, liz)
        log(f'[PlayStream] Stream started ({"Player6" if use_player6 else "TS proxy" if _is_ts_proxy else "direct"})')

    except Exception as e:
        log(f'[PlayStream] Error: {e}')
        xbmcgui.Dialog().notification('Tor Proxy', f'Error: {str(e)[:80]}', ICON, 6000)
        xbmcplugin.setResolvedUrl(addon_handle, False, xbmcgui.ListItem())

def Search_Events():
    keyboard = xbmcgui.Dialog().input("Enter search term", type=xbmcgui.INPUT_ALPHANUM)
    if not keyboard or keyboard.strip() == '':
        closeDir()
        return
    term = keyboard.lower().strip()

    try:
        html_text = fetch_via_proxy(abs_url('index.php'), headers={'User-Agent': UA, 'Referer': get_active_base()})
        events = re.findall(
            r"<div\s+class=\"schedule__event\">.*?"
            r"<div\s+class=\"schedule__eventHeader\"[^>]*?>\s*"
            r"(?:<[^>]+>)*?"
            r"<span\s+class=\"schedule__time\"[^>]*data-time=\"([^\"]+)\"[^>]*>.*?</span>\s*"
            r"<span\s+class=\"schedule__eventTitle\">\s*([^<]+)\s*</span>.*?"
            r"</div>\s*"
            r"<div\s+class=\"schedule__channels\">(.*?)</div>",
            html_text, re.IGNORECASE | re.DOTALL
        )

        rows = {}
        seen = set()
        for time_str, raw_title, channels_block in events:
            title_clean = html.unescape(raw_title.strip())
            if term not in title_clean.lower():
                continue
            if title_clean in seen:
                continue
            seen.add(title_clean)
            event_time_local = get_local_time(time_str.strip())
            rows[title_clean] = channels_block

        for title, chblock in rows.items():
            links = []
            for href, title_attr, link_text in re.findall(
                r'<a[^>]+href="([^"]+)"[^>]*title="([^"]*)".*?>(.*?)</a>',
                chblock, re.IGNORECASE | re.DOTALL
            ):
                name = html.unescape(title_attr or link_text)
                links.append({'channel_name': name, 'channel_id': href})
            addDir(title, build_url({'mode': 'trLinks', 'trData': json.dumps({'channels': links})}), False)

    except Exception as e:
        log(f'Search_Events error: {e}')
    closeDir()

def Search_Channels():
    keyboard = xbmcgui.Dialog().input("Enter channel name", type=xbmcgui.INPUT_ALPHANUM)
    if not keyboard or keyboard.strip() == '':
        closeDir()
        return
    term = keyboard.lower().strip()
    chData = channels()
    fav_ids = {f['id'] for f in get_favorites()}
    dead_cids = _get_dead_cids()
    for href, title in chData:
        if term in title.lower():
            cid_m = re.search(r'id=(\d+)', href)
            cid = cid_m.group(1) if cid_m else ''
            fav_label = '★ Remove from favorites' if cid in fav_ids else '☆ Add to favorites'
            ctx = [(fav_label, 'RunPlugin(%s)' % build_url({'mode': 'toggle_fav', 'cid': cid, 'name': title}))] if cid else []
            label = f'{_ch_label(title, cid, dead_cids)} ({cid})' if cid else title
            addDir(label, build_url({'mode': 'play', 'url': abs_url(href)}), False, context_menu=ctx)
    closeDir()

def Search_Channel_By_Number():
    keyboard = xbmcgui.Dialog().input("Channel number", type=xbmcgui.INPUT_NUMERIC)
    if not keyboard or not keyboard.strip():
        xbmcplugin.endOfDirectory(addon_handle, False)
        return
    channel_id = keyboard.strip().lstrip('0') or '0'
    if not channel_id.isdigit():
        xbmcgui.Dialog().notification('Tor Proxy', 'Invalid number', ICON, 3000)
        xbmcplugin.endOfDirectory(addon_handle, False)
        return

    # Look up channel name from the channel list
    ch_name = f'Channel {channel_id}'
    for href, name in channels():
        m = re.search(r'id=(\d+)', href)
        if m and m.group(1) == channel_id:
            ch_name = name
            break

    fav_ids = {f['id'] for f in get_favorites()}
    fav_label = '★ Remove from favorites' if channel_id in fav_ids else '☆ Add to favorites'
    ctx = [(fav_label, 'RunPlugin(%s)' % build_url({'mode': 'toggle_fav', 'cid': channel_id, 'name': ch_name}))]
    dead_cids = _get_dead_cids()
    label = f'{_ch_label(ch_name, channel_id, dead_cids)} ({channel_id})'
    addDir(label, build_url({'mode': 'play', 'url': abs_url(f'watch.php?id={channel_id}')}), False, context_menu=ctx)
    closeDir()


def load_extra_channels(force_reload=False):
    global EXTRA_CHANNELS_DATA
    _LOCAL_CACHE_EXPIRY = 24 * 60 * 60  # L5: renamed from CACHE_EXPIRY to avoid shadowing module-level name

    saved = addon.getSetting('extra_channels_cache')
    if saved and not force_reload:
        try:
            saved_data = json.loads(saved)
            if time.time() - saved_data.get('timestamp', 0) < _LOCAL_CACHE_EXPIRY:
                EXTRA_CHANNELS_DATA = saved_data.get('channels', {})
                if EXTRA_CHANNELS_DATA:
                    return EXTRA_CHANNELS_DATA
        except:
            pass

    try:
        resp = requests.get(EXTRA_M3U8_URL, headers={'User-Agent': UA}, timeout=10).text
    except Exception as e:
        log(f'[load_extra_channels] Network error: {e}')
        xbmcgui.Dialog().notification('Tor Proxy', 'Extra channels unavailable', ICON, 3000)
        return {}

    categories = {}
    lines = resp.splitlines()

    for i, line in enumerate(lines):
        if not line.startswith('#EXTINF:'):
            continue

        title_match = re.search(r',(.+)$', line)
        cat_match = re.search(r'group-title="([^"]+)"', line)
        logo_match = re.search(r'tvg-logo="([^"]+)"', line)

        if not title_match:
            continue

        title = title_match.group(1).strip()
        category = cat_match.group(1).strip() if cat_match else 'Uncategorized'
        logo = logo_match.group(1) if logo_match else ICON

        is_adult = (
            '18+' in category.upper() or
            'XXX' in category.upper() or
            '18+' in title.upper() or
            'XXX' in title.upper()
        )

        if is_adult:
            continue

        stream_url = lines[i + 1].strip() if i + 1 < len(lines) else ''
        if not stream_url:
            continue

        categories.setdefault(category, []).append({
            'title': title,
            'url': stream_url,
            'logo': logo
        })

    EXTRA_CHANNELS_DATA = categories

    addon.setSetting(
        'extra_channels_cache',
        json.dumps({'timestamp': int(time.time()), 'channels': EXTRA_CHANNELS_DATA})
    )

    return EXTRA_CHANNELS_DATA

def ExtraChannels_Main():
    global EXTRA_CHANNELS_DATA
    if not EXTRA_CHANNELS_DATA:
        load_extra_channels() 
        if not EXTRA_CHANNELS_DATA:
            xbmcgui.Dialog().ok("Error", "Extra channels could not be loaded.")
            return

    addDir('[B][COLOR gold]Search Extra Channels / VODs[/COLOR][/B]',
           build_url({'mode': 'extra_search'}), True)

    for cat in sorted(EXTRA_CHANNELS_DATA.keys()):
        is_adult_cat = (
            '18+' in cat.upper() or
            'XXX' in cat.upper()
        )

        if is_adult_cat:
            continue
    
        addDir(cat, build_url({'mode': 'extra_list', 'category': cat}), True, logo="https://images-ext-1.discordapp.net/external/fUzDq2SD022-veHyDJTHKdYTBzD9371EnrUscXXrf0c/%3Fsize%3D4096/https/cdn.discordapp.com/icons/1373713080206495756/1fe97e658bc7fb0e8b9b6df62259c148.png?format=webp&quality=lossless")

    
    closeDir()



def ExtraChannels_Search():
    """
    Open a dialog to search for a channel or VOD in the extra list.
    """
    keyboard = xbmcgui.Dialog().input("Search Extra Channels / VODs", type=xbmcgui.INPUT_ALPHANUM)
    if not keyboard or keyboard.strip() == '':
        closeDir()
        return
    search_term = keyboard.strip()
    ExtraChannels_List(None, search_term) 


def ExtraChannels_List(category=None, search=None):
    """
    List ExtraChannels, optionally filtering by category or search term,
    enforcing adult access where needed.
    """
    global EXTRA_CHANNELS_DATA
    if not EXTRA_CHANNELS_DATA:
        load_extra_channels()  
        if not EXTRA_CHANNELS_DATA:
            xbmcgui.Dialog().ok("Error", "Extra channels could not be loaded.")
            return

    items_to_show = []

    for cat, streams in EXTRA_CHANNELS_DATA.items():
        if category and cat != category:
            continue

        is_adult_cat = (
            '18+' in cat.upper() or
            'XXX' in cat.upper()
        )
        if is_adult_cat:
            continue

        for item in streams:
            # L6: removed redundant inner `if category and cat != category` check (already filtered above)
            if search and search.lower() not in item['title'].lower():
                continue

            is_adult = (
                '18+' in item['title'].upper() or
                'XXX' in item['title'].upper()
            )
            if is_adult:
                continue

            items_to_show.append({
                'title': item['title'],
                'url': item['url'],
                'logo': item.get('logo', ICON)
            })

    for item in items_to_show:
        addDir(
            item['title'],
            build_url({'mode': 'extra_play', 'url': item['url'], 'logo': item.get('logo', ICON), 'name': item['title']}),
            False,
            logo=item.get('logo', ICON)
        )

    closeDir()


def ExtraChannels_Play(url, name='Extra Channel', logo=ICON):
    """
    Play a channel or VOD from ExtraChannels, enforcing adult access.
    """
    try:

        log(f'[ExtraChannels_Play] Original URL: {url}')

        if 'a1xmedia' in url.lower() or 'a1xs.vip' in url.lower():
            headers = {
                'User-Agent': UA,
                'Accept': '*/*',
                'Accept-Language': 'en-US,en;q=0.9',
                'Referer': 'https://a1xs.vip/'
            }
            try:
                response = requests.head(url, headers=headers, allow_redirects=True, timeout=10)
                url = response.url
                log(f'[ExtraChannels_Play] Resolved A1XMedia URL: {url}')
            except Exception as e:
                log(f'[ExtraChannels_Play] Failed to resolve A1XMedia URL, using original: {e}')

        elif 'daddylive' in url.lower() or 'dlhd' in url.lower():
            parsed_url = urlparse(url)
            qs_url = dict(parse_qsl(parsed_url.query))
            channel_id = qs_url.get('id', '').split('|')[0].strip()
            if not channel_id:
                m = re.search(r'(?:id=|premium)(\d+)', url)
                if m:
                    channel_id = m.group(1)
            if channel_id:
                PlayStream(abs_url('watch.php?id=' + channel_id))
                return
            log(f'[ExtraChannels_Play] Could not extract channel ID from: {url}')

        logo = logo or ICON
        liz = xbmcgui.ListItem(name, path=url)
        liz.setArt({'thumb': logo, 'icon': logo, 'fanart': FANART})
        if getKodiversion() < 20:
            liz.setInfo('video', {'title': name, 'plot': name})
        else:
            _infotag = liz.getVideoInfoTag()
            _infotag.setMediaType('video')
            _infotag.setTitle(name)
            _infotag.setPlot(name)

        if '.m3u8' in url.lower():
            liz.setProperty('inputstream', 'inputstream.adaptive')
            liz.setProperty('inputstream.adaptive.manifest_type', 'hls')
            liz.setMimeType('application/vnd.apple.mpegurl')
            log('[ExtraChannels_Play] HLS stream detected')
        elif url.lower().endswith('.mp4'):
            liz.setMimeType('video/mp4')
            log('[ExtraChannels_Play] MP4 stream detected')
        else:
            liz.setMimeType('video')
            log('[ExtraChannels_Play] Generic video stream')

        liz.setProperty('IsPlayable', 'true')
        xbmcplugin.setResolvedUrl(addon_handle, True, liz)
        log(f'[ExtraChannels_Play] Stream started for: {name}')

    except Exception as e:
        log(f'[ExtraChannels_Play] Error: {e}')
        import traceback
        log(f'Traceback: {traceback.format_exc()}')
        xbmcgui.Dialog().notification("Tor Proxy", "Failed to play channel", ICON, 3000)


# ── Auto-discover server URLs: load cache if fresh, refresh in background if stale ──

def _proxy_host_reachable(url, timeout=1.5):
    """Quick TCP connectivity check — returns True if the proxy host accepts connections."""
    try:
        from urllib.parse import urlparse as _up
        import socket as _sock
        _p = _up(url)
        _port = _p.port or (443 if _p.scheme == 'https' else 80)
        _c = _sock.create_connection((_p.hostname, _port), timeout=timeout)
        _c.close()
        return True
    except Exception:
        return False

try:
    _disco_fresh = False
    if os.path.exists(_DISCOVERY_CACHE_FILE):
        if time.time() - os.path.getmtime(_DISCOVERY_CACHE_FILE) < _DISCOVERY_CACHE_TTL:
            with open(_DISCOVERY_CACHE_FILE) as _f:
                _d = json.load(_f)
            if _d.get('proxy') and _d.get('lookup') and _d.get('ksohls'):
                # Validate cached proxy is still reachable before committing to it
                if _proxy_host_reachable(_d['proxy']):
                    CHEVY_PROXY = _d['proxy']
                    CHEVY_LOOKUP = _d['lookup']
                    _KSOHLS_BASE = _d['ksohls']
                    PLAYER_REFERER = _KSOHLS_BASE + '/'
                    _SEG_HEADERS['Origin'] = _KSOHLS_BASE
                    _SEG_HEADERS['Referer'] = PLAYER_REFERER
                    addon.setSetting('chevy_proxy_url', CHEVY_PROXY)
                    addon.setSetting('chevy_lookup_url', CHEVY_LOOKUP)
                    addon.setSetting('ksohls_base_url', _KSOHLS_BASE)
                    _disco_fresh = True
                    log(f'[Discovery] Cache OK: ksohls={_KSOHLS_BASE} proxy={CHEVY_PROXY}')
                else:
                    log(f'[Discovery] Cached proxy unreachable ({_d["proxy"]}) — resetting to builtin servers')
                    try:
                        os.remove(_DISCOVERY_CACHE_FILE)
                    except Exception:
                        pass
                    # Reset globals and addon settings to builtin defaults
                    CHEVY_PROXY = _CHEVY_PROXY_BUILTIN
                    CHEVY_LOOKUP = _CHEVY_LOOKUP_BUILTIN
                    addon.setSetting('chevy_proxy_url', '')
                    addon.setSetting('chevy_lookup_url', '')
    if not _disco_fresh:
        threading.Thread(target=_discover_server_urls, daemon=True).start()
except Exception as _e:
    log(f'[Discovery] init error: {_e}')


# ══════════════════════════════════════════════════════════════════════════
# DISPATCHER WRAPPERS
# Bridge tra il sistema dispatcher di plugin.video.torproxy e le funzioni
# di addon.py. Ogni wrapper legge i parametri corretti da sys.argv[2]
# perché addon.py usa build_url con chiavi custom (lang=, trType=, cid=, ...)
# che il dispatcher non passa via url/title.
# ══════════════════════════════════════════════════════════════════════════

def _params():
    """Legge e restituisce i parametri correnti dall'URL Kodi (sys.argv[2])."""
    from urllib.parse import parse_qsl as _pqs
    import sys as _sys
    try:
        return dict(_pqs(_sys.argv[2][1:]))
    except Exception:
        return {}


def _set_handle_and_url():
    """Aggiorna addon_handle e addon_url ai valori correnti di var."""
    global addon_handle, addon_url
    addon_handle = var.handle
    addon_url    = var.plugin_url


# ── Menu principale ────────────────────────────────────────────────────

def show_menu() -> None:
    """Menu principale DaddyHD — mode=daddy_menu."""
    _set_handle_and_url()
    func.create_listitem(Item(
        title='[B][COLOR deepskyblue]•[/COLOR] [COLOR white] PROGRAMMAZIONE LIVE SPORTS[/COLOR][/B]',
        type='dir', mode='daddy_categories', thumbnail=ICON, is_media=False,
    ))
    func.create_listitem(Item(
        title='[B][COLOR deepskyblue]•[/COLOR] [COLOR white] CANALI LIVE TV[/COLOR][/B]',
        type='dir', mode='daddy_channels', thumbnail=ICON, is_media=False,
    ))
    func.create_listitem(Item(
        title='[B][COLOR deepskyblue]•[/COLOR] [COLOR white] CANALI LIVE TV PER PAESE[/COLOR][/B]',
        type='dir', mode='daddy_lang', thumbnail=ICON, is_media=False,
    ))
    func.create_listitem(Item(
        title='[B][COLOR deepskyblue]•[/COLOR] [COLOR white] CANALI FAVORITI LIVE TV[/COLOR][/B]',
        type='dir', mode='daddy_favourites', thumbnail=ICON, is_media=False,
    ))
    func.create_listitem(Item(
        title='[B][COLOR deepskyblue]•[/COLOR] [COLOR white] CERCA EVENTI SCHEDULE[/COLOR][/B]',
        type='dir', mode='daddy_search_events', thumbnail=ICON, is_media=False,
    ))
    func.create_listitem(Item(
        title='[B][COLOR deepskyblue]•[/COLOR] [COLOR white] CERCA CANALI LIVE TV[/COLOR][/B]',
        type='dir', mode='daddy_search_channels', thumbnail=ICON, is_media=False,
    ))
    func.create_listitem(Item(
        title='[B][COLOR deepskyblue]•[/COLOR] [COLOR red] DIAGNOSTICA[/COLOR][/B]',
        type='dir', mode='daddy_diagnostics', thumbnail=ICON, is_media=False,
    ))
    func.create_listitem(Item(
        title='[B][COLOR deepskyblue]•[/COLOR] [COLOR yellow] REFRESH SCHEDULE[/COLOR][/B]',
        type='file', mode='daddy_refresh', thumbnail=ICON, is_media=False,
    ))


# ── Canali e schedule ──────────────────────────────────────────────────

def show_channels() -> None:
    """Live TV Channels — mode=daddy_channels."""
    _set_handle_and_url()
    list_gen()


def show_categories(date: str = '') -> None:
    """Live Sports Schedule (lista categorie) — mode=daddy_categories."""
    _set_handle_and_url()
    Menu_Trans()


def show_cat_channels() -> None:
    """
    Mostra i canali di una categoria sportiva — mode=showChannels.
    Legge trType dai params URL (generato da getCategTrans → addDir).
    """
    _set_handle_and_url()
    p = _params()
    categ = p.get('trType', '')
    if not categ:
        Menu_Trans()
        return
    channels_list = getTransData(categ)
    ShowChannels(categ, channels_list)


def show_trans_list() -> None:
    """
    Lista canali per un evento — mode=trList.
    Legge trType e channels dai params URL.
    """
    _set_handle_and_url()
    p = _params()
    categ    = p.get('trType', '')
    channels_json = p.get('channels', '[]')
    try:
        channels_data = json.loads(unquote(channels_json))
    except Exception:
        channels_data = []
    TransList(categ, channels_data)


def show_tr_links() -> None:
    """
    Lancia la sorgente stream di un evento — mode=trLinks.
    Legge trData dai params URL.
    """
    _set_handle_and_url()
    p = _params()
    tr_data = p.get('trData', '')
    if tr_data:
        getSource(tr_data)


def show_matches(category: str = '', date: str = '') -> None:
    """Alias show_categories per retrocompatibilità dispatcher."""
    _set_handle_and_url()
    Menu_Trans()


# ── Per lingua/paese ───────────────────────────────────────────────────

def show_lang() -> None:
    """Lista lingue/paesi — mode=daddy_lang."""
    _set_handle_and_url()
    list_by_language()


def show_lang_channels() -> None:
    """
    Canali per una lingua — mode=lang_channels.
    Legge lang dai params URL (generato da list_by_language → addDir).
    QUESTO è il wrapper corretto: senza di lui, list_by_language veniva
    richiamata di nuovo mostrando la lista paesi invece dei canali.
    """
    _set_handle_and_url()
    p = _params()
    lang = p.get('lang', 'eng')
    if lang and lang != '__none__':
        list_lang_channels(lang)
    else:
        list_by_language()


# ── Preferiti ─────────────────────────────────────────────────────────

def show_favourites() -> None:
    """Canali preferiti — mode=daddy_favourites."""
    _set_handle_and_url()
    list_favorites()


def toggle_fav() -> None:
    """
    Toggle preferito — mode=toggle_fav.
    Legge cid e name dai params URL (generato da TransList/list_gen → addDir).
    """
    _set_handle_and_url()
    p = _params()
    cid  = p.get('cid', '')
    name = p.get('name', '')
    if cid:
        toggle_favorite(cid, name)


def add_favourite(link: str, title: str = '', is_search: bool = False) -> None:
    """Aggiunge ai preferiti — mode=daddy_add_fav."""
    try:
        cid_m = re.search(r'id=(\d+)', link)
        if not cid_m:
            return
        cid = cid_m.group(1)
        favs = get_favorites()
        if not any(f.get('id') == cid for f in favs):
            real_name = _lookup_channel_name(cid) or title or f'Channel {cid}'
            favs.append({'id': cid, 'name': real_name})
            save_favorites(favs)
            xbmcgui.Dialog().notification('DaddyHD', f'Added: {real_name}', ICON, 3000)
            var.execute_builtin('Container.Refresh')
        else:
            xbmcgui.Dialog().notification('DaddyHD', 'Already in favorites', ICON, 2000)
    except Exception as e:
        func.log(f'[daddyhd] add_favourite error: {e}')


def remove_favourite(link: str, title: str = '', is_search: bool = False) -> None:
    """Rimuove dai preferiti — mode=daddy_remove_fav."""
    try:
        cid_m = re.search(r'id=(\d+)', link)
        if not cid_m:
            return
        cid = cid_m.group(1)
        favs = [f for f in get_favorites() if f.get('id') != cid]
        save_favorites(favs)
        var.execute_builtin('Container.Refresh')
        xbmcgui.Dialog().notification('DaddyHD', f'Removed: {title}', ICON, 2000)
    except Exception as e:
        func.log(f'[daddyhd] remove_favourite error: {e}')


# ── Ricerca ────────────────────────────────────────────────────────────

def show_search() -> None:
    """Ricerca eventi — mode=daddy_search."""
    _set_handle_and_url()
    Search_Events()


def show_search_events() -> None:
    """Ricerca eventi — mode=daddy_search_events."""
    _set_handle_and_url()
    Search_Events()


def show_search_channels() -> None:
    """Ricerca canali — mode=daddy_search_channels."""
    _set_handle_and_url()
    Search_Channels()


# ── Play ───────────────────────────────────────────────────────────────

def play_stream_by_params() -> None:
    """
    Riproduce stream leggendo url dai params — mode=play.
    addon.py genera: build_url({'mode': 'play', 'url': watch_url})
    """
    _set_handle_and_url()
    p = _params()
    url = p.get('url', '')
    if url:
        PlayStream(url)
    else:
        func.log('[daddyhd] play_stream_by_params: url mancante')
        var.system_exit()


def play(link: str, title: str = '', icon: str = '',
         match: str = '', is_search: bool = False) -> None:
    """
    Riproduce un canale dal dispatcher — mode=daddy_play.
    link può essere JSON [[title, url]] o URL diretto.
    """
    _set_handle_and_url()
    try:
        # Prova prima dai params (mode=play generato da addPlayItem/addDir)
        p = _params()
        url_from_params = p.get('url', '')

        if url_from_params:
            PlayStream(url_from_params)
            return

        # link JSON [[title, url]] dal nostro dispatcher
        url_raw = json.loads(link) if isinstance(link, str) and link.startswith('[') else link
        if isinstance(url_raw, list):
            if len(url_raw) > 1:
                url_raw = func.get_multilink(url_raw)
            elif url_raw:
                url_raw = url_raw[0][1] if isinstance(url_raw[0], (list, tuple)) else url_raw[0]
        if not url_raw:
            var.system_exit()
            return
        if url_raw.startswith('/'):
            full_url = f'{get_active_base().rstrip("/")}{url_raw}'
        elif 'watch.php' in url_raw or 'watchs2watch' in url_raw:
            full_url = url_raw
        else:
            cid_m = re.search(r'stream-(\d+)\.php', url_raw)
            full_url = abs_url(f'watch.php?id={cid_m.group(1)}') if cid_m else url_raw
        PlayStream(full_url)
    except Exception as e:
        func.log(f'[daddyhd] play error: {e}\n{traceback.format_exc()}')
        var.system_exit()


# ── Extra channels ─────────────────────────────────────────────────────

def show_extra_channels() -> None:
    """Extra Channels — mode=extra_channels."""
    _set_handle_and_url()
    ExtraChannels_Main()


def show_extra_list() -> None:
    """Lista extra channels — mode=extra_list."""
    _set_handle_and_url()
    p = _params()
    ExtraChannels_List(p.get('category'), p.get('search'))


def play_extra() -> None:
    """Play extra channel — mode=extra_play."""
    _set_handle_and_url()
    p = _params()
    ExtraChannels_Play(p.get('url', ''), name=p.get('name', ''), logo=p.get('logo', ICON))


# ── Diagnostica e Refresh ──────────────────────────────────────────────

def run_diag() -> None:
    """Diagnostica — mode=daddy_diagnostics."""
    _set_handle_and_url()
    run_diagnostics()


def refresh() -> None:
    """Refresh schedule — mode=daddy_refresh."""
    _set_handle_and_url()
    try:
        cache = _load_page_cache()
        removed = sum(1 for u in list(cache) if 'index.php' in u or '24-7' in u)
        for u in list(cache):
            if 'index.php' in u or '24-7' in u:
                del cache[u]
        _save_page_cache(cache)
        xbmcgui.Dialog().notification(
            'DaddyHD', f'Schedule cleared ({removed} pages)', ICON, 2500
        )
    except Exception as e:
        func.log(f'[daddyhd] refresh error: {e}')
    var.execute_builtin('Container.Refresh')

# Bridge tra il sistema dispatcher di plugin.video.torproxy e le funzioni
# dell'addon Torproxy. Adatta i parametri del dispatcher a quelli di addon.py.
# ══════════════════════════════════════════════════════════════════════════

def _set_handle_and_url():
    """Aggiorna addon_handle e addon_url ai valori correnti di var."""
    global addon_handle, addon_url
    addon_handle = var.handle
    addon_url    = var.plugin_url


def show_menu() -> None:
    """Menu principale DaddyHD — mode=daddy_menu."""
    _set_handle_and_url()
    func.get_schedule_and_channels()

    # Live Sports Schedule
    func.create_listitem(Item(
        title='[B][COLOR deepskyblue]•[/COLOR] [COLOR white]PROGRAMMAZIONE LIVE SPORTS [/COLOR][/B]',
        type='dir', mode='daddy_categories',
        thumbnail=ICON, is_media=False,
    ))
    # Live TV Channels
    func.create_listitem(Item(
        title='[B][COLOR deepskyblue]•[/COLOR] [COLOR white]CANALI LIVE TV [/COLOR][/B]',
        type='dir', mode='daddy_channels',
        thumbnail=ICON, is_media=False,
    ))
    # Live TV by Language (usa list_by_language di Torproxy)
    func.create_listitem(Item(
        title='[B][COLOR deepskyblue]•[/COLOR] [COLOR white] CANALI LIVE TV PER PAESE[/COLOR][/B]',
        type='dir', mode='daddy_lang',
        thumbnail=ICON, is_media=False,
    ))
    # Favorites
    func.create_listitem(Item(
        title='[B][COLOR deepskyblue]•[/COLOR] [COLOR white]CANALI FAVORITI LIVE TV [/COLOR][/B]',
        type='dir', mode='daddy_favourites',
        thumbnail=ICON, is_media=False,
    ))
    # Search Events
    func.create_listitem(Item(
        title='[B][COLOR deepskyblue]•[/COLOR] [COLOR white]CERCA EVENTI SCHEDULE[/COLOR][/B]',
        type='dir', mode='daddy_search_events',
        thumbnail=ICON, is_media=False,
    ))
    # Search Channels
    func.create_listitem(Item(
        title='[B][COLOR deepskyblue]•[/COLOR] [COLOR white]CERCA CANALI LIVE TV [/COLOR][/B]',
        type='dir', mode='daddy_search_channels',
        thumbnail=ICON, is_media=False,
    ))
    # Diagnostics
    func.create_listitem(Item(
        title='[B][COLOR deepskyblue]•[/COLOR] [COLOR red]DIAGNOSTICA[/COLOR][/B]',
        type='dir', mode='daddy_diagnostics',
        thumbnail=ICON, is_media=False,
    ))
    # Refresh Schedule
    func.create_listitem(Item(
        title='[B][COLOR deepskyblue]•[/COLOR] [COLOR blue]REFRESCH SCHEDULE[/COLOR][/B]',
        type='file', mode='daddy_refresh',
        thumbnail=ICON, is_media=False,
    ))


def show_channels() -> None:
    """Live TV Channels — mode=daddy_channels."""
    _set_handle_and_url()
    list_gen()


def show_categories(date: str = '') -> None:
    """Live Sports Schedule — mode=daddy_categories."""
    _set_handle_and_url()
    Menu_Trans()


def show_matches(category: str = '', date: str = '') -> None:
    """Mostra i match di una categoria sport — mode=daddy_matches."""
    _set_handle_and_url()
    if category:
        channels_list = getTransData(category)
        ShowChannels(category, channels_list)
    else:
        Menu_Trans()


def show_favourites() -> None:
    """Canali preferiti — mode=daddy_favourites."""
    _set_handle_and_url()
    list_favorites()


def add_favourite(link: str, title: str = '', is_search: bool = False) -> None:
    """Aggiunge ai preferiti — mode=daddy_add_fav."""
    try:
        cid_m = re.search(r'id=(\d+)', link)
        if not cid_m:
            return
        cid = cid_m.group(1)
        favs = get_favorites()
        if not any(f.get('id') == cid for f in favs):
            real_name = _lookup_channel_name(cid) or title or f'Channel {cid}'
            favs.append({'id': cid, 'name': real_name})
            save_favorites(favs)
            xbmcgui.Dialog().notification('Tor Proxy', f'Added: {real_name}', ICON, 3000)
            var.execute_builtin('Container.Refresh')
        else:
            xbmcgui.Dialog().notification('Tor Proxy', f'Already in favorites', ICON, 2000)
    except Exception as e:
        func.log(f'[daddyhd] add_favourite error: {e}')


def remove_favourite(link: str, title: str = '', is_search: bool = False) -> None:
    """Rimuove dai preferiti — mode=daddy_remove_fav."""
    try:
        cid_m = re.search(r'id=(\d+)', link)
        if not cid_m:
            return
        cid = cid_m.group(1)
        favs = [f for f in get_favorites() if f.get('id') != cid]
        save_favorites(favs)
        var.execute_builtin('Container.Refresh')
        xbmcgui.Dialog().notification('Tor Proxy', f'Removed: {title}', ICON, 2000)
    except Exception as e:
        func.log(f'[daddyhd] remove_favourite error: {e}')


def show_search() -> None:
    """Ricerca eventi — mode=daddy_search."""
    _set_handle_and_url()
    Search_Events()


def show_search_events() -> None:
    """Ricerca eventi — mode=daddy_search_events."""
    _set_handle_and_url()
    Search_Events()


def show_search_channels() -> None:
    """Ricerca canali — mode=daddy_search_channels."""
    _set_handle_and_url()
    Search_Channels()


def show_lang() -> None:
    """Live TV by language — mode=daddy_lang."""
    _set_handle_and_url()
    list_by_language()


def play(link: str, title: str = '', icon: str = '',
         match: str = '', is_search: bool = False) -> None:
    """Risolve e riproduce un canale — mode=daddy_play."""
    _set_handle_and_url()
    try:
        # link e' JSON [[title, url]] oppure path diretto
        url_raw = json.loads(link) if isinstance(link, str) and link.startswith('[') else link
        if isinstance(url_raw, list):
            if len(url_raw) > 1:
                url_raw = func.get_multilink(url_raw)
            elif url_raw:
                url_raw = url_raw[0][1] if isinstance(url_raw[0], (list, tuple)) else url_raw[0]
        if not url_raw:
            var.system_exit()
            return
        # Costruisce watch URL da path o URL completo
        if url_raw.startswith('/'):
            full_url = f'{get_active_base().rstrip("/")}{url_raw}'
        elif 'watch.php' in url_raw or 'watchs2watch' in url_raw:
            full_url = url_raw
        else:
            cid_m = re.search(r'stream-(\d+)\.php', url_raw)
            if cid_m:
                full_url = abs_url(f'watch.php?id={cid_m.group(1)}')
            else:
                full_url = url_raw
        PlayStream(full_url)
    except Exception as e:
        func.log(f'[daddyhd] play error: {e}\n{traceback.format_exc()}')
        var.system_exit()


def run_diag() -> None:
    """Diagnostica — mode=daddy_diagnostics."""
    _set_handle_and_url()
    run_diagnostics()


def refresh() -> None:
    """Refresh schedule — mode=daddy_refresh."""
    _set_handle_and_url()
    try:
        cache = _load_page_cache()
        removed = sum(1 for u in list(cache) if 'index.php' in u or '24-7' in u)
        for u in list(cache):
            if 'index.php' in u or '24-7' in u:
                del cache[u]
        _save_page_cache(cache)
        xbmcgui.Dialog().notification(
            'DaddyHD', f'Schedule cleared ({removed} pages)', ICON, 2500
        )
    except Exception as e:
        func.log(f'[daddyhd] refresh error: {e}')
    var.execute_builtin('Container.Refresh')

