#!/usr/bin/env python3
"""
====================================================================
 DAZN - Manager & DRM Extractor (Widevine WVD)
 Auto-Extractor: Live & Linear Streams (DAZN 1, Plus, ecc.)
 Output: dazn_channels.json & dazn_playlist.m3u
====================================================================
"""
import os
import sys
import re
import json
import time
import base64
import uuid
import hashlib
import argparse
import xml.etree.ElementTree as ET
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple, Any
from urllib.parse import urlparse, parse_qs, urlencode, urlunparse

# Prova ad importare curl_cffi per impersonazione TLS Chrome (bypassa CloudFront/WAF)
try:
    from curl_cffi import requests as cffi_requests
    HAS_CURL_CFFI = True
except ImportError:
    import requests as cffi_requests
    HAS_CURL_CFFI = False

import requests
from pywidevine import PSSH, Cdm, Device

# Garantisci supporto UTF-8 su terminali Windows
if sys.platform == 'win32':
    try:
        sys.stdout.reconfigure(encoding='utf-8', errors='replace')
        sys.stderr.reconfigure(encoding='utf-8', errors='replace')
    except Exception:
        pass

# ── Colori ANSI per il terminale ──────────────────────────────────
GREEN   = '\033[32m'
YELLOW  = '\033[33m'
RED     = '\033[31m'
CYAN    = '\033[36m'
BLUE    = '\033[34m'
MAGENTA = '\033[35m'
BOLD    = '\033[1m'
DIM     = '\033[2m'
RESET   = '\033[0m'

# ── Costanti & File di Sistema (Percorsi assoluti) ─────────────────
SCRIPT_DIR       = os.path.dirname(os.path.abspath(__file__))
CONFIG_FILE      = os.path.join(SCRIPT_DIR, "dazn_config.json")
TOKEN_FILE       = os.path.join(SCRIPT_DIR, "dazn_tokens.json")
CACHE_FILE       = os.path.join(SCRIPT_DIR, "pssh_cache.json")
WVD_FILE         = os.path.join(SCRIPT_DIR, "WVD.wvd")
PROXY_FILE       = os.path.join(SCRIPT_DIR, "proxy.txt")
OUTPUT_JSON_FILE = os.path.join(SCRIPT_DIR, "dazn_channels.json")
PLAYLIST_FILE    = os.path.join(SCRIPT_DIR, "dazn_playlist.m3u")

# Intervallo di aggiornamento predefinito: 3 ore (in secondi)
DEFAULT_REFRESH_SECONDS = 3 * 3600

# System ID ufficiale Widevine
WIDEVINE_SYSTEM_ID = "edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"
WIDEVINE_SYSTEM_ID_BYTES = bytes.fromhex("edef8ba979d64acea3c827dcd51d21ed")

pssh_cache: Dict[str, Any] = {}

# ── Helper Logging & Banner ───────────────────────────────────────
def log(msg: str, color: str = RESET) -> None:
    print(f"{color}[{datetime.now().strftime('%H:%M:%S')}] {msg}{RESET}", flush=True)

def print_banner() -> None:
    banner = f"""{YELLOW}
  ____     _     ______  _   _ 
 |  _ \\   / \\   |__  / \\| \\ | |
 | | | | / _ \\    / /|  \\|  \\| |
 | |_| |/ ___ \\  / /_| |\\ \\  |
 |____//_/   \\_\\/____|_| \\_|
{BLUE}{'═' * 70}{RESET}
{CYAN}  ◆ DAZN Auto-Manager · Widevine Extractor & Decryptor ◆{RESET}
{BLUE}{'═' * 70}{RESET}"""
    print(banner)

# ── Gestione Proxy (proxy.txt o dazn_config.json) ─────────────────
class ProxyManager:
    """Carica, analizza, valida e ruota i proxy da proxy.txt o da dazn_config.json"""
    def __init__(self, filepath: str = PROXY_FILE):
        self.filepath = filepath
        self.proxies: List[str] = []
        self.config_proxy: Optional[Dict[str, str]] = None
        self.current_idx = 0
        self.load()

    def parse_proxy_line(self, line: str) -> Optional[str]:
        line = line.strip()
        if not line or line.startswith('#'):
            return None
        
        if line.startswith(('http://', 'https://', 'socks5://', 'socks5h://')):
            return line
        
        parts = line.split(':')
        if len(parts) == 4:
            host, port, user, pwd = parts
            return f"http://{user}:{pwd}@{host}:{port}"
        elif len(parts) == 2:
            return f"http://{parts[0]}:{parts[1]}"
        elif '@' in line:
            return f"http://{line}"
        
        return None

    def set_config_proxy(self, proxy_conf: Any) -> None:
        if isinstance(proxy_conf, dict):
            http_p = proxy_conf.get('http') or proxy_conf.get('https')
            https_p = proxy_conf.get('https') or proxy_conf.get('http')
            if http_p:
                self.config_proxy = {"http": http_p, "https": https_p or http_p}
        elif isinstance(proxy_conf, str) and proxy_conf.strip():
            parsed = self.parse_proxy_line(proxy_conf.strip()) or proxy_conf.strip()
            self.config_proxy = {"http": parsed, "https": parsed}

    def load(self, config_proxy: Any = None) -> None:
        self.proxies = []
        if config_proxy is not None:
            self.set_config_proxy(config_proxy)

        if os.path.exists(self.filepath):
            try:
                with open(self.filepath, 'r', encoding='utf-8', errors='ignore') as f:
                    for line in f:
                        parsed = self.parse_proxy_line(line)
                        if parsed and parsed not in self.proxies:
                            self.proxies.append(parsed)
            except Exception:
                pass

        self.current_idx = 0

    def get_dict(self) -> Optional[Dict[str, str]]:
        if self.proxies:
            proxy_url = self.proxies[self.current_idx % len(self.proxies)]
            return {"http": proxy_url, "https": proxy_url}
        if self.config_proxy:
            return self.config_proxy
        return None

    def rotate(self) -> Optional[Dict[str, str]]:
        if self.proxies:
            self.current_idx = (self.current_idx + 1) % len(self.proxies)
        return self.get_dict()

    def count(self) -> int:
        return len(self.proxies) if self.proxies else (1 if self.config_proxy else 0)

    def test_all(self) -> List[Tuple[str, bool, str, float]]:
        results = []
        list_to_test = list(self.proxies)
        if not list_to_test and self.config_proxy:
            list_to_test.append(self.config_proxy['http'])

        for p in list_to_test:
            proxies_dict = {"http": p, "https": p}
            t0 = time.time()
            try:
                r = requests.get('https://api.ipify.org?format=json', proxies=proxies_dict, timeout=10)
                dur = round((time.time() - t0) * 1000, 1)
                if r.ok:
                    ip = r.json().get('ip', 'OK')
                    results.append((p, True, ip, dur))
                else:
                    results.append((p, False, f"HTTP {r.status_code}", dur))
            except Exception as e:
                dur = round((time.time() - t0) * 1000, 1)
                results.append((p, False, str(e)[:30], dur))
        return results

proxy_manager = ProxyManager(PROXY_FILE)

# ── Gestione Cache PSSH & Configurazione ───────────────────────────
def load_config() -> Dict[str, Any]:
    default_config = {
        "dazn": {
            "token": "",
            "device_id": "57b6a530-70a4-4d65-b412-46a1a5c22b38",
            "daznid": "66125bf7-0b92-47fc-b618-93cfea67f55c",
            "app_version": "0.147.8",
            "player_id": "@dazn/peng-html5-core/web/web",
            "language": "it",
            "proxy": {
                "http": "",
                "https": ""
            },
            "headers": {
                "referer": "https://www.dazn.com/",
                "sec-ch-ua": '"Chromium";v="152", "Not?A_Brand";v="24", "Google Chrome";v="152"',
                "sec-ch-ua-mobile": "?0",
                "sec-ch-ua-platform": '"Windows"',
                "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36",
                "x-brand": "DAZN"
            },
            "license_headers": {
                "content-type": "application/octet-stream",
                "referer": "https://www.dazn.com/",
                "sec-ch-ua": '"Chromium";v="152", "Not?A_Brand";v="24", "Google Chrome";v="152"',
                "sec-ch-ua-mobile": "?0",
                "sec-ch-ua-platform": '"Windows"',
                "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36",
                "x-brand": "DAZN"
            },
            "channels": [
                {
                    "id": "4hqcjx5zv91f1bdx71io4bnrl",
                    "name": "DAZN 1",
                    "playback_url": ""
                }
            ]
        },
        "loop": {
            "refresh_interval_hours": 3
        }
    }

    if not os.path.exists(CONFIG_FILE):
        with open(CONFIG_FILE, 'w', encoding='utf-8') as f:
            json.dump(default_config, f, indent=2, ensure_ascii=False)
        cfg = default_config
    else:
        try:
            with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
                cfg = json.load(f)
            if 'dazn' not in cfg:
                cfg['dazn'] = default_config['dazn']
        except Exception:
            cfg = default_config

    if 'dazn' in cfg and 'proxy' in cfg['dazn']:
        proxy_manager.set_config_proxy(cfg['dazn']['proxy'])

    return cfg

def save_config(cfg: Dict[str, Any]) -> None:
    try:
        current = {}
        if os.path.exists(CONFIG_FILE):
            with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
                current = json.load(f)
        current.update(cfg)
        with open(CONFIG_FILE, 'w', encoding='utf-8') as f:
            json.dump(current, f, indent=2, ensure_ascii=False)
    except Exception as e:
        log(f"Errore salvataggio config: {e}", RED)

def load_cache() -> None:
    global pssh_cache
    if os.path.exists(CACHE_FILE):
        try:
            with open(CACHE_FILE, 'r', encoding='utf-8') as f:
                pssh_cache = json.load(f)
        except Exception:
            pssh_cache = {}

def save_cache() -> None:
    with open(CACHE_FILE, 'w', encoding='utf-8') as f:
        json.dump(pssh_cache, f, indent=2, ensure_ascii=False)

# ── Gestione Token JWT DAZN ───────────────────────────────────────
def decode_jwt_payload(token: str) -> Dict[str, Any]:
    """Decodifica il payload di un JWT DAZN senza richiedere librerie esterne"""
    try:
        parts = token.strip().split('.')
        if len(parts) != 3:
            return {}
        payload_b64 = parts[1]
        rem = len(payload_b64) % 4
        if rem > 0:
            payload_b64 += '=' * (4 - rem)
        decoded = base64.urlsafe_b64decode(payload_b64)
        return json.loads(decoded.decode('utf-8'))
    except Exception:
        return {}

def check_token_status(token: str) -> Tuple[bool, int, Dict[str, Any]]:
    """Verifica validità e scadenza del JWT DAZN"""
    payload = decode_jwt_payload(token)
    if not payload:
        return False, 0, {}
    exp = payload.get('exp', 0)
    now = int(time.time())
    remaining = exp - now
    is_valid = remaining > 60  # Almeno 1 minuto di validità residua
    return is_valid, remaining, payload

def get_token(config: Dict[str, Any], cli_token: Optional[str] = None) -> str:
    """Recupera il token attivo dal file tokens, dalla config o dagli argomenti CLI"""
    token = cli_token
    if not token and os.path.exists(TOKEN_FILE):
        try:
            t_data = json.load(open(TOKEN_FILE, 'r', encoding='utf-8'))
            t_cand = t_data.get('token', '')
            if t_cand:
                val, rem, _ = check_token_status(t_cand)
                if val:
                    token = t_cand
                else:
                    log(f"⚠️ Token salvato in {TOKEN_FILE} è scaduto (scaduto da {-rem}s)", YELLOW)
        except Exception:
            pass

    if not token:
        token = config.get('dazn', {}).get('token', '')

    if token:
        val, rem, payload = check_token_status(token)
        if val:
            mins = rem // 60
            hrs = mins // 60
            log(f"🔑 Token DAZN valido · Scadenza tra {hrs}h {mins % 60}m (ViewerID: {payload.get('viewerId', 'N/A')})", GREEN)
            # Sincronizza token_file
            t_info = {
                "token": token,
                "viewer_id": payload.get("viewerId", ""),
                "daznid": payload.get("user", ""),
                "device_id": payload.get("deviceId", ""),
                "country": payload.get("country", "it"),
                "user_status": payload.get("userstatus", ""),
                "issued": payload.get("issued", 0),
                "exp": payload.get("exp", 0),
                "updated_at": datetime.now().isoformat()
            }
            with open(TOKEN_FILE, 'w', encoding='utf-8') as f:
                json.dump(t_info, f, indent=2, ensure_ascii=False)
        else:
            log(f"⚠️ ATTENZIONE: Il token fornito è SCADUTO o NON VALIDO!", RED)
            log(f"Aggiorna il token in {CONFIG_FILE} o usa --token <nuovo_token>", YELLOW)

    return token

# ── Client HTTP con TLS Impersonation ──────────────────────────────
def create_session(proxy_dict: Optional[Dict[str, str]] = None) -> Any:
    """Crea una sessione HTTP ottimizzata con impersonazione Chrome per bypassare CloudFront"""
    if HAS_CURL_CFFI:
        s = cffi_requests.Session(impersonate="chrome120")
    else:
        s = requests.Session()
    if proxy_dict:
        s.proxies = proxy_dict
    return s

# ── Risoluzione Stream & DRM Widevine ─────────────────────────────
def is_widevine_pssh(pssh_b64: str) -> bool:
    """Verifica se una stringa PSSH Base64 appartiene a Widevine"""
    try:
        raw = base64.b64decode(pssh_b64)
        if len(raw) >= 28 and raw[12:28] == WIDEVINE_SYSTEM_ID_BYTES:
            return True
        p = PSSH(pssh_b64)
        return str(p.system_id).lower() == WIDEVINE_SYSTEM_ID
    except Exception:
        return False

def extract_widevine_pssh(mpd_content: str) -> Tuple[Optional[str], Optional[str]]:
    """Estrae il blocco PSSH Widevine e il relativo KID dal manifesto DASH MPD"""
    try:
        root = ET.fromstring(mpd_content)
    except Exception as e:
        log(f"Errore parsing XML del manifesto DASH: {e}", RED)
        return None, None

    candidates = []
    adaptation_sets = [elem for elem in root.iter() if elem.tag.endswith('AdaptationSet')]
    if not adaptation_sets:
        adaptation_sets = [root]

    for aset in adaptation_sets:
        mime = (aset.attrib.get('mimeType') or aset.attrib.get('contentType') or '').lower()
        is_video = 'video' in mime or 'mp4' in mime

        cps = [elem for elem in aset.iter() if elem.tag.endswith('ContentProtection')]
        for cp in cps:
            scheme_id = cp.attrib.get('schemeIdUri', '').lower()
            is_wv_scheme = WIDEVINE_SYSTEM_ID in scheme_id

            pssh_nodes = [elem for elem in cp.iter() if elem.tag.endswith('pssh')]
            for node in pssh_nodes:
                text = (node.text or '').strip()
                if not text:
                    continue
                if is_wv_scheme or is_widevine_pssh(text):
                    candidates.append({
                        'pssh': text,
                        'is_video': is_video,
                        'is_wv_scheme': is_wv_scheme
                    })

    if not candidates:
        for elem in root.iter():
            if elem.tag.endswith('pssh') and elem.text and elem.text.strip():
                text = elem.text.strip()
                if is_widevine_pssh(text):
                    candidates.append({
                        'pssh': text,
                        'is_video': True,
                        'is_wv_scheme': True
                    })

    if not candidates:
        return None, None

    candidates.sort(key=lambda c: (c['is_video'], c['is_wv_scheme']), reverse=True)
    best_pssh = candidates[0]['pssh']

    expected_kid = None
    try:
        p = PSSH(best_pssh)
        if p.key_ids:
            first_kid = p.key_ids[0]
            if hasattr(first_kid, 'hex') and callable(getattr(first_kid, 'hex')):
                expected_kid = first_kid.hex()
            elif hasattr(first_kid, 'hex') and isinstance(first_kid.hex, str):
                expected_kid = first_kid.hex
            elif isinstance(first_kid, bytes):
                expected_kid = first_kid.hex()
            else:
                expected_kid = str(first_kid).replace('-', '')
    except Exception:
        pass

    return best_pssh, expected_kid

def build_playback_url(channel: Dict[str, Any], config: Dict[str, Any], viewer_id: str) -> str:
    """Costruisce o normalizza l'endpoint Playback con SessionId e timestamp freschi"""
    if channel.get('playback_url'):
        base_url = channel['playback_url']
        # Se ha già tutti i parametri, aggiorna solo il SessionId per garantire freschezza
        p = urlparse(base_url)
        q = parse_qs(p.query)
        asset_id = channel.get('id') or (q.get('AssetId', [''])[0])
        ts = int(time.time() * 1000)
        rnd_hex = uuid.uuid4().hex[:6].upper()
        v_id = viewer_id or 'unknown'
        q['SessionId'] = [f"{ts}-{v_id}-{asset_id}-{rnd_hex}"]
        if 'MtaLanguageCode' in q:
            q['MtaLanguageCode'] = ['']
        return urlunparse((p.scheme, p.netloc, p.path, p.params, urlencode(q, doseq=True), p.fragment))

    dazn_cfg = config.get('dazn', {})
    asset_id = channel.get('id', '4hqcjx5zv91f1bdx71io4bnrl')
    app_version = dazn_cfg.get('app_version', '0.147.8')
    player_id = dazn_cfg.get('player_id', '@dazn/peng-html5-core/web/web')
    lang = dazn_cfg.get('language', 'it')
    v_id = viewer_id or 'unknown'
    ts = int(time.time() * 1000)
    rnd_hex = uuid.uuid4().hex[:6].upper()
    session_id = f"{ts}-{v_id}-{asset_id}-{rnd_hex}"

    params = {
        "AppVersion": app_version,
        "DrmType": "WIDEVINE",
        "Format": "MPEG-DASH",
        "PlayerId": player_id,
        "Platform": "web",
        "Model": "unknown",
        "Secure": "true",
        "Manufacturer": "microsoft",
        "PlayReadyInitiator": "false",
        "Capabilities": "hcst,mta",
        "AssetId": asset_id,
        "MtaLanguageCode": "",
        "LanguageCode": lang,
        "SessionId": session_id
    }
    return f"https://api.playback.indazn.com/v5/Playback?{urlencode(params)}"

def request_playback_info(playback_url: str, token: str, config: Dict[str, Any], proxy_dict: Optional[Dict[str, str]]) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
    """Effettua la chiamata all'endpoint Playback DAZN con headers personalizzati e bypass WAF"""
    dazn_cfg = config.get('dazn', {})
    headers = {
        "Authorization": f"Bearer {token}",
        "Origin": "https://www.dazn.com",
        "Referer": "https://www.dazn.com/",
        "sec-ch-ua": dazn_cfg.get("headers", {}).get("sec-ch-ua", '"Chromium";v="152", "Not?A_Brand";v="24", "Google Chrome";v="152"'),
        "sec-ch-ua-mobile": "?0",
        "sec-ch-ua-platform": '"Windows"',
        "user-agent": dazn_cfg.get("headers", {}).get("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36"),
        "x-brand": "DAZN",
        "x-correlation-id": str(uuid.uuid4()),
        "x-dazn-device": dazn_cfg.get("device_id", "57b6a530-70a4-4d65-b412-46a1a5c22b38"),
        "x-daznid": dazn_cfg.get("daznid", "66125bf7-0b92-47fc-b618-93cfea67f55c")
    }

    s = create_session(proxy_dict)
    try:
        r = s.get(playback_url, headers=headers, timeout=25)
        if not r.ok:
            try:
                err_json = r.json()
                msg = err_json.get('odata.error', {}).get('message', {}).get('value') or r.text[:200]
            except Exception:
                msg = r.text[:200]
            return None, f"HTTP {r.status_code}: {msg}"
        
        return r.json(), None
    except Exception as e:
        return None, str(e)

def get_keys_wvd(pssh_b64: str, asset_id: str, la_url: str, config: Dict[str, Any], tokens_payload: Dict[str, Any], expected_kid: Optional[str] = None, proxy_dict: Optional[Dict[str, str]] = None) -> Tuple[Optional[str], Optional[str]]:
    """Decifra la chiave di contenuto Widevine usando WVD.wvd e l'endpoint di Licenza DAZN"""
    global pssh_cache

    if expected_kid:
        cache_key = f"{asset_id}:{expected_kid.lower()}"
    else:
        cache_key = f"{asset_id}:{hashlib.sha256(pssh_b64.encode()).hexdigest()[:16]}"

    if cache_key in pssh_cache:
        cached = pssh_cache[cache_key]
        return cached.get('kid'), cached.get('key')

    if not os.path.exists(WVD_FILE):
        log(f"ERRORE: File CDM {WVD_FILE} non trovato!", RED)
        return None, None

    try:
        device = Device.load(WVD_FILE)
        cdm = Cdm.from_device(device)
        session = cdm.open()
        challenge = cdm.get_license_challenge(session, PSSH(pssh_b64))

        dazn_cfg = config.get('dazn', {})
        lic_headers = {
            "Content-Type": "application/octet-stream",
            "Origin": "https://www.dazn.com",
            "Referer": "https://www.dazn.com/",
            "sec-ch-ua": dazn_cfg.get("license_headers", {}).get("sec-ch-ua", '"Chromium";v="152", "Not?A_Brand";v="24", "Google Chrome";v="152"'),
            "sec-ch-ua-mobile": "?0",
            "sec-ch-ua-platform": '"Windows"',
            "user-agent": dazn_cfg.get("license_headers", {}).get("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36"),
            "x-brand": "DAZN",
            "x-correlation-id": str(uuid.uuid4()),
            "x-daznid": dazn_cfg.get("daznid", tokens_payload.get("user", "66125bf7-0b92-47fc-b618-93cfea67f55c"))
        }

        s = create_session(proxy_dict)
        r = s.post(la_url, headers=lic_headers, data=challenge, timeout=30)
        if not r.ok:
            log(f"Chiamata License fallita (HTTP {r.status_code}): {r.text[:200]}", RED)
            cdm.close(session)
            return None, None

        cdm.parse_license(session, r.content)

        content_keys: List[Tuple[str, str]] = []
        for k in cdm.get_keys(session):
            if k.type != 'SIGNING':
                k_kid = k.kid.hex if hasattr(k.kid, 'hex') and isinstance(k.kid.hex, str) else (k.kid.hex() if hasattr(k.kid, 'hex') else str(k.kid).replace('-', ''))
                k_key = k.key.hex() if callable(getattr(k.key, 'hex', None)) else getattr(k.key, 'hex', str(k.key))
                content_keys.append((k_kid.lower(), k_key.lower()))
        cdm.close(session)

        if not content_keys:
            log("Nessuna chiave di contenuto trovata nella licenza DRM", RED)
            return None, None

        kid, key = None, None
        if expected_kid:
            for c_kid, c_key in content_keys:
                if c_kid == expected_kid.lower():
                    kid, key = c_kid, c_key
                    break

        if not kid and content_keys:
            kid, key = content_keys[0]

        if kid and key:
            pssh_cache[cache_key] = {'kid': kid, 'key': key}
            save_cache()
            log(f"Chiave DRM estratta con successo: {kid}:{key}", GREEN)
        return kid, key
    except Exception as e:
        log(f"Errore licenza DRM Widevine: {e}", RED)
        return None, None

def extract_channel(channel: Dict[str, Any], config: Dict[str, Any], token: str, tokens_payload: Dict[str, Any]) -> Dict[str, Any]:
    """Esegue l'estrazione completa per un singolo canale DAZN"""
    proxy_dict = proxy_manager.get_dict()
    viewer_id = tokens_payload.get('viewerId', '')
    playback_url = build_playback_url(channel, config, viewer_id)

    log(f"Richiesta Playback per '{channel.get('name', 'DAZN Channel')}'...", CYAN)
    playback_data, err = request_playback_info(playback_url, token, config, proxy_dict)
    if err or not playback_data:
        log(f"Errore Playback: {err}", RED)
        return {
            "id": channel.get("id"),
            "name": channel.get("name", "DAZN"),
            "status": "ERROR",
            "error": err
        }

    asset = playback_data.get("Asset", {})
    channel_name = asset.get("Title") or channel.get("name") or "DAZN Channel"
    details_list = playback_data.get("PlaybackDetails", [])

    if not details_list:
        log(f"Nessun PlaybackDetails disponibile nella risposta", RED)
        return {
            "id": channel.get("id"),
            "name": channel_name,
            "status": "NO_STREAMS",
            "error": "Nessun PlaybackDetails trovato"
        }

    # Seleziona il CDN preferito (priorità a dct-fs-live / Fastly come richiesto)
    chosen_detail = details_list[0]
    for pref in ['dct-fs-live', 'dcs-fs-live', 'dct-ak-live', 'dcs-ak-live', 'fs', 'ak']:
        found_cdn = False
        for d in details_list:
            if pref in d.get('CdnName', '').lower():
                chosen_detail = d
                found_cdn = True
                break
        if found_cdn:
            break

    raw_manifest_url = chosen_detail.get("ManifestUrl", "")
    la_url = chosen_detail.get("LaUrl", "")
    cdn_token = chosen_detail.get("CdnToken", {})
    resolution = chosen_detail.get("Resolution", "fhd")
    cdn_name = chosen_detail.get("CdnName", "")

    # Costruisci l'MPD URL completo componendo il parametro token nella query string
    token_name = cdn_token.get("Name", "dazn-token")
    token_val = cdn_token.get("Value", "")
    if token_name and token_val:
        sep = '&' if '?' in raw_manifest_url else '?'
        composed_manifest_url = f"{raw_manifest_url}{sep}{token_name}={token_val}"
    else:
        composed_manifest_url = raw_manifest_url

    # Scarica MPD per estrarre la PSSH Widevine
    s = create_session(proxy_dict)
    ua = config.get("dazn", {}).get("headers", {}).get("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36")
    mpd_headers = {
        "Origin": "https://www.dazn.com",
        "Referer": "https://www.dazn.com/",
        "User-Agent": ua
    }
    if token_name and token_val:
        mpd_headers["Cookie"] = f"{token_name}={token_val}"

    try:
        r_mpd = s.get(composed_manifest_url, headers=mpd_headers, timeout=25)
        mpd_text = r_mpd.text if r_mpd.ok else ""
    except Exception as e:
        log(f"Errore download manifesto MPD: {e}", RED)
        mpd_text = ""

    kid, key = None, None
    if mpd_text:
        pssh_b64, expected_kid = extract_widevine_pssh(mpd_text)
        if pssh_b64 and la_url:
            kid, key = get_keys_wvd(
                pssh_b64=pssh_b64,
                asset_id=channel.get("id", asset.get("Id", "")),
                la_url=la_url,
                config=config,
                tokens_payload=tokens_payload,
                expected_kid=expected_kid,
                proxy_dict=proxy_dict
            )

    # Prepara comandi di riproduzione mpv ed ffplay (con Cookie per autorizzare tutti i frammenti .m4s DASH)
    cookie_hdr = f"{token_name}={token_val}" if (token_name and token_val) else ""
    mpv_cmd = f'mpv "{composed_manifest_url}" --user-agent="{ua}"'
    if cookie_hdr:
        mpv_cmd += f' --http-header-fields="Cookie: {cookie_hdr}"'
    if kid and key:
        mpv_cmd += f' --demuxer-lavf-o="decryption_key={kid}:{key}"'
        ffplay_cmd = f'ffplay -user_agent "{ua}"'
        if cookie_hdr:
            ffplay_cmd += f' -headers "Cookie: {cookie_hdr}"'
        ffplay_cmd += f' -cenc_decryption_key {key} "{composed_manifest_url}"'
    else:
        ffplay_cmd = f'ffplay -user_agent "{ua}"'
        if cookie_hdr:
            ffplay_cmd += f' -headers "Cookie: {cookie_hdr}"'
        ffplay_cmd += f' "{composed_manifest_url}"'

    result_entry = {
        "id": channel.get("id", asset.get("Id")),
        "name": channel_name,
        "event_id": asset.get("EventId", ""),
        "status": "OK" if (kid and key) else ("MANIFEST_ONLY" if composed_manifest_url else "ERROR"),
        "manifest_url": composed_manifest_url,
        "base_manifest_url": raw_manifest_url,
        "cdn_name": cdn_name,
        "resolution": resolution,
        "cdn_token": cdn_token,
        "license_url": la_url,
        "kid": kid,
        "key": key,
        "key_string": f"{kid}:{key}" if (kid and key) else None,
        "cookie_header": f"Cookie: {cookie_hdr}" if cookie_hdr else None,
        "mpv_command": mpv_cmd,
        "ffplay_command": ffplay_cmd,
        "extracted_at": datetime.now().isoformat()
    }
    return result_entry

# ── Funzione Principale di Estrazione e Salvataggio ────────────────
def extract_and_save_all(cli_token: Optional[str] = None) -> Dict[str, Any]:
    """Estrae tutti i canali configurati, risolve le chiavi DRM e aggiorna JSON & M3U"""
    config = load_config()
    load_cache()
    proxy_manager.load(config_proxy=config.get('dazn', {}).get('proxy'))

    token = get_token(config, cli_token)
    if not token:
        log("ERRORE CRITICO: Nessun token DAZN disponibile! Configuralo in dazn_config.json", RED)
        return {}

    _, _, tokens_payload = check_token_status(token)
    channels = config.get('dazn', {}).get('channels', [])
    if not channels:
        log("Nessun canale configurato in dazn_config.json", YELLOW)
        return {}

    log(f"🚀 Avvio estrazione di {len(channels)} canali DAZN...", CYAN)
    results = []
    m3u_lines = ["#EXTM3U x-tvg-url=\"\""]
    found = 0

    first_ffplay_cmd = None
    first_mpv_cmd = None

    for i, ch in enumerate(channels, 1):
        name = ch.get('name', ch.get('id', f'Canale {i}'))
        print(f"  [{i:2d}/{len(channels)}] {name:<25}", end=" ", flush=True)

        res = extract_channel(ch, config, token, tokens_payload)
        results.append(res)

        if res.get("status") == "OK":
            kid = res.get("kid", "")
            key = res.get("key", "")
            manifest = res.get("manifest_url", "")
            ua = config.get("dazn", {}).get("headers", {}).get("user-agent", "")

            if not first_ffplay_cmd:
                first_ffplay_cmd = res.get("ffplay_command")
                first_mpv_cmd = res.get("mpv_command")

            # Aggiungi voce alla playlist M3U compatibile Kodi
            m3u_lines.append(f'#EXTINF:-1 tvg-id="{res.get("id")}" tvg-name="{res.get("name")}" group-title="DAZN",{res.get("name")}')
            m3u_lines.append('#KODIPROP:inputstream=inputstream.adaptive')
            m3u_lines.append('#KODIPROP:inputstream.adaptive.manifest_type=mpd')
            m3u_lines.append('#KODIPROP:inputstream.adaptive.license_type=com.widevine.alpha')
            m3u_lines.append(f'#KODIPROP:inputstream.adaptive.license_key={kid}:{key}')
            if ua:
                m3u_lines.append(f'#KODIPROP:inputstream.adaptive.stream_headers=User-Agent={ua}&Referer=https://www.dazn.com/')
            m3u_lines.append(manifest)

            print(f"{GREEN}✅ OK ({kid[:8]}...:{key[:8]}...){RESET}")
            found += 1
        elif res.get("status") == "MANIFEST_ONLY":
            m3u_lines.append(f'#EXTINF:-1 tvg-id="{res.get("id")}" tvg-name="{res.get("name")}" group-title="DAZN",{res.get("name")}')
            m3u_lines.append(res.get("manifest_url", ""))
            print(f"{YELLOW}⚠️  Solo Manifest{RESET}")
        else:
            print(f"{RED}❌ Errore ({res.get('error', 'N/A')}){RESET}")

    output_data = {
        "updated_at": datetime.now().isoformat(),
        "total_channels": len(channels),
        "keys_found": found,
        "proxy_used": proxy_manager.get_dict(),
        "channels": results
    }

    # Salva dazn_channels.json
    with open(OUTPUT_JSON_FILE, 'w', encoding='utf-8') as f:
        json.dump(output_data, f, indent=2, ensure_ascii=False)

    # Salva dazn_playlist.m3u
    with open(PLAYLIST_FILE, 'w', encoding='utf-8') as f:
        f.write("\n".join(m3u_lines) + "\n")

    # Salva file batch pronti all'uso per ffplay e mpv
    if first_ffplay_cmd:
        ffplay_bat = os.path.join(SCRIPT_DIR, "play_ffplay.bat")
        with open(ffplay_bat, 'w', encoding='utf-8') as f:
            f.write(f"@echo off\n{first_ffplay_cmd}\npause\n")

    if first_mpv_cmd:
        mpv_bat = os.path.join(SCRIPT_DIR, "play_mpv.bat")
        with open(mpv_bat, 'w', encoding='utf-8') as f:
            f.write(f"@echo off\n{first_mpv_cmd}\npause\n")

    log(f"💾 Salvato con successo in: {BOLD}{OUTPUT_JSON_FILE}{RESET} ({found}/{len(channels)} canali)", GREEN)
    log(f"📋 Playlist M3U aggiornata in: {BOLD}{PLAYLIST_FILE}{RESET}", GREEN)
    if first_ffplay_cmd:
        log(f"🎬 Launcher generati: play_ffplay.bat e play_mpv.bat", CYAN)
        log(f"▶️  Comando FFplay:\n{first_ffplay_cmd}", YELLOW)
    return output_data

# ── Loop Automatico ───────────────────────────────────────────────
def run_auto_loop(interval_hours: int = 3, cli_token: Optional[str] = None) -> None:
    """Esegue l'estrazione all'avvio e ripete periodicamente"""
    print_banner()
    config = load_config()
    proxy_manager.load(config_proxy=config.get('dazn', {}).get('proxy'))

    refresh_seconds = interval_hours * 3600
    log(f"Intervallo di aggiornamento: {BOLD}{interval_hours} ore{RESET}", CYAN)

    while True:
        try:
            print(f"\n{BLUE}{'═' * 70}{RESET}")
            log("🔄 CICLO DI AGGIORNAMENTO CHIAVI & CANALI DAZN", CYAN)
            print(f"{BLUE}{'═' * 70}{RESET}")

            extract_and_save_all(cli_token=cli_token)

            next_refresh = datetime.now() + timedelta(seconds=refresh_seconds)
            print(f"\n{GREEN}{'═' * 70}{RESET}")
            log("✨ Estrazione completata! Il bot entra in STANDBY.", GREEN)
            log(f"⏰ Prossimo aggiornamento programmato: {BOLD}{next_refresh.strftime('%d/%m/%Y alle ore %H:%M:%S')}{RESET}", YELLOW)
            print(f"{DIM}Premi CTRL+C per arrestare il bot in qualsiasi momento.{RESET}")
            print(f"{GREEN}{'═' * 70}{RESET}\n")

            start_time = time.time()
            while (time.time() - start_time) < refresh_seconds:
                remaining_seconds = int(refresh_seconds - (time.time() - start_time))
                sleep_chunk = min(1800, remaining_seconds)
                time.sleep(sleep_chunk)
                if remaining_seconds > 60:
                    rem_h = remaining_seconds // 3600
                    rem_m = (remaining_seconds % 3600) // 60
                    log(f"💤 Standby attivo · Prossimo aggiornamento tra: {rem_h}h {rem_m}m", DIM)

        except KeyboardInterrupt:
            print(f"\n\n{YELLOW}🛑 Bot arrestato dall'utente.{RESET}\n")
            break
        except Exception as e:
            log(f"Errore durante il ciclo: {e}", RED)
            log("Nuovo tentativo tra 5 minuti...", YELLOW)
            time.sleep(300)

# ── Main Entrypoint ───────────────────────────────────────────────
def main():
    parser = argparse.ArgumentParser(description="DAZN - DRM Widevine Stream & Key Extractor")
    parser.add_argument("--once", action="store_true", help="Esegui una sola volta ed esci")
    parser.add_argument("--loop", action="store_true", help="Esegui in loop continuo con intervallo programmato")
    parser.add_argument("--token", type=str, default=None, help="Specifica o aggiorna il JWT Bearer Token")
    parser.add_argument("--asset", type=str, default=None, help="Estrai solo uno specifico AssetId")
    parser.add_argument("--url", type=str, default=None, help="Usa uno specifico URL Playback personalizzato")
    parser.add_argument("--test-proxy", action="store_true", help="Testa tutti i proxy configurati ed esci")
    parser.add_argument("--clear-cache", action="store_true", help="Cancella la cache delle chiavi pssh_cache.json")
    args = parser.parse_args()

    if args.clear_cache:
        if os.path.exists(CACHE_FILE):
            os.remove(CACHE_FILE)
            log("Cache pssh_cache.json eliminata con successo!", GREEN)
        return

    if args.test_proxy:
        cfg = load_config()
        proxy_manager.load(config_proxy=cfg.get('dazn', {}).get('proxy'))
        log(f"Test di {proxy_manager.count()} proxy in corso...", CYAN)
        results = proxy_manager.test_all()
        for p, ok, ip_msg, dur in results:
            col = GREEN if ok else RED
            log(f"  {p} -> {col}{ip_msg}{RESET} ({dur}ms)")
        return

    if args.asset or args.url:
        print_banner()
        cfg = load_config()
        token = get_token(cfg, args.token)
        _, _, tokens_payload = check_token_status(token)
        target_ch = {
            "id": args.asset or "custom_asset",
            "name": f"Asset {args.asset or 'Custom'}",
            "playback_url": args.url or ""
        }
        res = extract_channel(target_ch, cfg, token, tokens_payload)
        print("\n" + json.dumps(res, indent=2, ensure_ascii=False))
        return

    if args.once:
        print_banner()
        extract_and_save_all(cli_token=args.token)
        return

    # Di default se eseguito interattivamente, avvia l'estrazione singola o il loop
    run_auto_loop(cli_token=args.token)

if __name__ == '__main__':
    main()
