# -*- coding: utf-8 -*-
from __future__ import unicode_literals  # ← precisa vir logo no início no Python 2

import sys

# Kodi 19+ usa Python 3. O addon não depende de six/kodi-six para as rotas
# internas, evitando falha de instalação em builds enxutas.
text_type = str
PY3 = True
from urllib.parse import urlparse, parse_qs, urlencode, parse_qsl, unquote_plus, quote, urljoin
import re
import os
import ipaddress
import base64
import unicodedata
import logging
import time
import calendar
import hashlib
import atexit
import io
import json

# Os módulos de reprodução/catálogo não são necessários para desenhar a Home.
# Carregá-los aqui fazia a rota inicial compilar/importar proxy, requests e
# InputStream Helper mesmo sem rede. Mantemos o contrato dos módulos, mas
# resolvemos cada um somente na primeira rota que realmente o utiliza.
inputstreamhelper = None
_INPUTSTREAMHELPER_TRIED = False

try:
    from kodi_six import xbmc, xbmcplugin, xbmcgui, xbmcaddon, xbmcvfs
except ImportError:
    import xbmc
    import xbmcplugin
    import xbmcgui
    import xbmcaddon
    import xbmcvfs


class _LazyModule(object):
    """Adia módulos de catálogo/TV até a rota realmente precisar deles."""
    def __init__(self, module_name):
        self.module_name = module_name
        self.module = None

    def _resolve(self):
        if self.module is None:
            self.module = __import__(self.module_name, fromlist=['*'])
        return self.module

    def __getattr__(self, name):
        return getattr(self._resolve(), name)


tmdb = _LazyModule('resources.lib.tmdb')
m3u = _LazyModule('resources.lib.m3u')
pluto = _LazyModule('resources.lib.pluto')
subtitles = _LazyModule('resources.lib.subtitles')
proxy = _LazyModule('resources.lib.proxy')
vod_engine = _LazyModule('resources.lib.vod_engine')
common = _LazyModule('resources.lib.common')
requests = _LazyModule('requests')

# Constantes sem estado usadas apenas nas rotas que fazem HTTP. São duplicadas
# deliberadamente aqui para evitar importar resources.lib.common na Home.
DEFAULT_TIMEOUT = 12
DEFAULT_USER_AGENT = (
    'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
    '(KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36'
)
PORT = 8088
URL_HLSRETRY = "http://127.0.0.1:{}/hlsretry?url=".format(PORT)
URL_TS_DOWNLOADER = "http://127.0.0.1:{}/tsdownloader?url=".format(PORT)

# Cabeçalhos declarados em behaviorHints.proxyHeaders.request. Os de sessão
# seguem apenas pela ponte loopback do proxy e nunca são gravados em plugin://,
# favoritos, histórico ou logs. O fallback legado conserva exclusivamente os
# cabeçalhos não sensíveis.
_VOD_SAFE_PROVIDER_HEADERS = {
    'user-agent': 'User-Agent',
    'referer': 'Referer',
    'origin': 'Origin',
    'accept': 'Accept',
    'accept-language': 'Accept-Language',
}
_VOD_SESSION_PROVIDER_HEADERS = {
    'cookie': 'Cookie',
    'authorization': 'Authorization',
    'x-api-key': 'X-Api-Key',
    'x-auth-token': 'X-Auth-Token',
}
_VOD_HEADER_MAX_VALUE = 512
_VOD_SESSION_HEADER_MAX_VALUE = 4096
_VOD_HEADERS_TOTAL_MAX = 8192
_VOD_HEADER_TOKEN_MAX_LENGTH = 2048
_VOD_DIAG_SESSION_NAMES = ('Cookie', 'Authorization', 'X-Api-Key', 'X-Auth-Token')


def _vod_diag_host(video_url):
    """Retorna somente o host da origem; nunca serializa URL, token ou query."""
    try:
        parsed = urlparse((video_url or '').strip())
        host = (parsed.hostname or '').strip().lower()
        return host or 'origem-desconhecida'
    except Exception:
        return 'origem-desconhecida'


def _vod_diag_source_id(video_url):
    """Fingerprint curto para correlacionar eventos sem registrar URL assinada."""
    try:
        raw = (video_url or '').strip().encode('utf-8', 'ignore')
        return hashlib.sha256(raw).hexdigest()[:12]
    except Exception:
        return 'sem-id'


def _vod_diag_session_labels(raw_headers):
    clean = _sanitize_vod_provider_headers(raw_headers, include_session=True)
    labels = [key for key in _VOD_DIAG_SESSION_NAMES if clean.get(key)]
    return '+'.join(labels) if labels else 'nenhuma'


def _vod_diag_content_type(content_type):
    try:
        value = (content_type or '').split(';', 1)[0].strip().lower()
    except Exception:
        value = ''
    return value or 'não-informado'


def _vod_diag_log(stage, video_url, stream_kind='', status=None, result='', provider_headers=None,
                  content_type='', range_state='', effective_host='', elapsed_ms=None, detail=''):
    """Escreve uma linha de perícia VOD sem vazar segredo do provider.

    O evento é propositalmente curto e sempre útil em log: identifica a fonte
    por hash, host, tipo, resposta e presença de sessão, mas nunca inclui URL,
    token, cookie, Authorization ou cabeçalhos em valor bruto.
    """
    parts = [
        'etapa={}'.format(stage or 'desconhecida'),
        'fonte={}'.format(_vod_diag_source_id(video_url)),
        'host={}'.format(_vod_diag_host(video_url)),
        'tipo={}'.format((stream_kind or 'progressive').lower()),
        'sessao={}'.format(_vod_diag_session_labels(provider_headers)),
    ]
    if status not in (None, ''):
        parts.append('http={}'.format(status))
    if content_type:
        parts.append('mime={}'.format(_vod_diag_content_type(content_type)))
    if range_state:
        parts.append('range={}'.format(range_state))
    if effective_host and effective_host != _vod_diag_host(video_url):
        parts.append('destino={}'.format(effective_host))
    if elapsed_ms is not None:
        try:
            parts.append('tempo={}ms'.format(max(0, int(elapsed_ms))))
        except Exception:
            pass
    if result:
        parts.append('resultado={}'.format(str(result).replace(' ', '_')[:96]))
    if detail:
        parts.append('detalhe={}'.format(str(detail).replace(' ', '_')[:96]))
    try:
        payload = '[VOD Diagnóstico] ' + ' | '.join(parts)
        if common.debug_enabled(addon=ADDON):
            common.log_debug(payload, addon=ADDON, component='VOD')
        elif str(result or '').lower() in ('recusada', 'bloqueada_antes_do_player'):
            common.log('[VOD] fonte={} | host={} | resultado={} | detalhe={}'.format(
                _vod_diag_source_id(video_url), _vod_diag_host(video_url), result or 'recusada', detail or 'não-informado'
            ), level=getattr(xbmc, 'LOGWARNING', 2))
    except Exception:
        pass


def _vod_signature_expiry(video_url):
    """Lê somente metadados de expiração de assinaturas conhecidas.

    Não tenta interpretar tokens opacos. O retorno é ``(epoch, esquema)`` ou
    ``(None, '')``. AWS/R2 e Google usam data UTC + janela em segundos; URLs
    com ``exp``/``expires`` usam timestamp Unix quando plausível.
    """
    try:
        raw_query = parse_qs(urlparse((video_url or '').strip()).query or '', keep_blank_values=False)
        query = {str(key or '').lower(): values for key, values in raw_query.items()}
    except Exception:
        return None, ''

    def _first(key):
        values = query.get(key) or []
        return str(values[0]).strip() if values else ''

    for key in ('expire', 'expires', 'expiry', 'expiration', 'exp', 'e'):
        value = _first(key)
        try:
            stamp = float(value)
        except (TypeError, ValueError):
            continue
        if stamp > 100000000000:
            stamp /= 1000.0
        if 978307200 <= stamp <= 4102444800:
            return stamp, 'unix'

    for prefix, scheme in (('x-amz-', 'aws-r2'), ('x-goog-', 'google')):
        raw_date = _first(prefix + 'date')
        raw_ttl = _first(prefix + 'expires')
        if not raw_date or not raw_ttl:
            continue
        try:
            issued = calendar.timegm(time.strptime(raw_date, '%Y%m%dT%H%M%SZ'))
            ttl = int(float(raw_ttl))
        except (TypeError, ValueError, OverflowError):
            continue
        if ttl < 0 or ttl > 604800:
            continue
        return float(issued + ttl), scheme
    return None, ''


def _vod_signature_expiry_label(epoch):
    try:
        return time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(float(epoch)))
    except Exception:
        return 'desconhecida'


def _sanitize_vod_provider_headers(raw_headers, include_session=True):
    if not isinstance(raw_headers, dict):
        return {}
    clean = {}
    total = 0
    for raw_key, raw_value in raw_headers.items():
        try:
            key = str(raw_key or '').strip().lower()
            value = str(raw_value or '').strip()
        except Exception:
            continue
        canonical = _VOD_SAFE_PROVIDER_HEADERS.get(key)
        is_session = False
        if not canonical and include_session:
            canonical = _VOD_SESSION_PROVIDER_HEADERS.get(key)
            is_session = bool(canonical)
        if not canonical or not value:
            continue
        if '\r' in value or '\n' in value:
            continue
        max_len = _VOD_SESSION_HEADER_MAX_VALUE if is_session else _VOD_HEADER_MAX_VALUE
        if len(value) > max_len or (total + len(value)) > _VOD_HEADERS_TOTAL_MAX:
            continue
        clean[canonical] = value
        total += len(value)
    return clean


def _encode_vod_provider_headers(raw_headers):
    clean = _sanitize_vod_provider_headers(raw_headers, include_session=True)
    if not clean:
        return ''
    # Serviço persistente do proxy recebe os valores no corpo HTTP loopback e
    # devolve somente uma referência aleatória. Isto evita expor Cookie ou
    # Authorization em parâmetros plugin://, no histórico e no kodi.log.
    try:
        ref = proxy.register_vod_provider_headers(clean)
        if ref:
            return ref
    except Exception:
        pass
    # Sem serviço loopback disponível, preserva a compatibilidade anterior com
    # cabeçalhos não sensíveis. Credenciais nunca entram no fallback em URL.
    legacy = _sanitize_vod_provider_headers(clean, include_session=False)
    if not legacy:
        return ''
    try:
        payload = json.dumps(legacy, ensure_ascii=True, sort_keys=True, separators=(',', ':')).encode('utf-8')
        return base64.urlsafe_b64encode(payload).decode('ascii').rstrip('=')
    except Exception:
        return ''


def _decode_vod_provider_headers(token):
    value = str(token or '').strip()
    if not value or len(value) > _VOD_HEADER_TOKEN_MAX_LENGTH:
        return {}
    try:
        bridged = proxy.get_vod_provider_headers(value)
        if bridged:
            return _sanitize_vod_provider_headers(bridged, include_session=True)
    except Exception:
        pass
    # Compatibilidade somente para favoritos/URLs gerados pela versão anterior.
    if not re.match(r'^[A-Za-z0-9_-]+$', value):
        return {}
    try:
        value += '=' * (-len(value) % 4)
        decoded = base64.urlsafe_b64decode(value.encode('ascii')).decode('utf-8')
        return _sanitize_vod_provider_headers(json.loads(decoded), include_session=False)
    except Exception:
        return {}


def _register_vod_playback_bundle(entries):
    """Cria uma referência local para a lista de fontes VOD.

    Uma chamada para toda a lista é suficiente. Assim o plugin:// de cada item
    carrega somente ``vod_session``/``vod_item`` e o Kodi não imprime URLs
    assinadas ou tokens no log nativo.
    """
    try:
        return proxy.register_vod_playback_bundle(entries or {}) or ''
    except Exception:
        return ''


def _resolve_vod_playback_item(session_ref, item_key):
    try:
        payload = proxy.get_vod_playback_item(session_ref, item_key)
        return payload if isinstance(payload, dict) else {}
    except Exception:
        return {}


def _inputstream_headers_value(raw_headers):
    # InputStream Adaptive escreve propriedades no ambiente Kodi; não envia
    # Cookie/Authorization para DASH diretamente. Fontes com sessão seguem pelo
    # proxy para HLS/progressivo, sem vazar credenciais.
    clean = _sanitize_vod_provider_headers(raw_headers, include_session=False)
    if not clean:
        return ''
    try:
        return urlencode(sorted(clean.items()))
    except Exception:
        return ''

ADDON_HANDLE = int(sys.argv[1])
BASE_URL = sys.argv[0]
ADDON = xbmcaddon.Addon()
TRANSLATE = xbmcvfs.translatePath

def _ensure_profile_dir(path):
    """Cria somente o perfil local sem puxar o módulo HTTP/common na Home."""
    if not path:
        return path
    try:
        if xbmcvfs.exists(path):
            return path
    except Exception:
        pass
    try:
        if xbmcvfs.mkdirs(path):
            return path
    except Exception:
        pass
    try:
        if not os.path.exists(path):
            os.makedirs(path)
    except Exception:
        pass
    return path

# Sempre trabalhe com caminhos traduzidos pelo Kodi. Isso evita depender de
# como cada porta expõe special:// em Android, Linux embarcado, macOS e iOS.
homeDir = TRANSLATE(ADDON.getAddonInfo('path'))
addonIcon = os.path.join(homeDir, 'icon.png')
addonFanart = os.path.join(homeDir, 'fanart.jpg')
profile = _ensure_profile_dir(TRANSLATE(ADDON.getAddonInfo('profile')))
TV_ADULT_PIN_FILE = os.path.join(profile, 'tv_adult_pin.txt')
HTTP_SESSION = None

ONEPLAY_DESC = '''
Para trabalhar em equipe precisamos ter empatia, transparência, solidariedade e muita lealdade, esses ingredientes são necessários para o sucesso em grupo.

[B][COLOR aquamarine]Repositório Oficial ONEPLAY[/COLOR][/B]
[COLOR blue]https://oneplayhd.github.io[/COLOR]

[B][COLOR aquamarine]Grupo Oficial FACEBOOK[/COLOR][/B]
[COLOR blue]https://tinyurl.com/oneplay2019[/COLOR]

[B][COLOR aquamarine]Grupo Oficial TELEGRAM[/COLOR][/B]
[COLOR blue]http://t.me/oneplay2019[/COLOR]
    '''
_DNS_READY = False

# VOD progressivo vindo do catálogo TMDB é o único fluxo que pode entregar
# MKV/MP4 remoto diretamente ao CFileCache do Kodi. Algumas origens aceitam
# abrir o arquivo mas retornam Range incoerente ao demuxer; isso prende o Stop.
# A verificação abaixo é curta, em memória e exclusiva dessa rota.
VOD_RANGE_PREFLIGHT_TIMEOUT = (3, 5)
VOD_RANGE_PREFLIGHT_TTL_OK = 600.0
VOD_RANGE_PREFLIGHT_TTL_UNSAFE = 300.0
_VOD_RANGE_PREFLIGHT_CACHE = {}

# Catálogo HdHub é consultado só ao abrir fontes. Ao contrário das listas TMDB,
# não deve usar a sessão global com retries: um provider lento não pode congelar
# a janela VOD por dezenas de segundos.
VOD_CATALOG_TIMEOUT = (2.5, 6.0)

# Hosts já comprovados nos logs como páginas intermediárias/sem Range confiável.
# A lista é deliberadamente curta e exata: não bloqueia CDNs ou hosts vizinhos.


def _cleanup_runtime_refs():
    global ADDON
    try:
        ADDON = None
    except Exception:
        pass

atexit.register(_cleanup_runtime_refs)

def _dns_override_enabled():
    """Lê a escolha de DNS sem importar o resolvedor na rota local."""
    try:
        return (ADDON.getSetting('proxy_dns_override') or 'false').strip().lower() == 'true'
    except Exception:
        return False


def ensure_customdns():
    """Sincroniza DNS alternativo apenas antes de uma rota que usa rede.

    A Home e as ações de manutenção local não devem carregar dns.py nem tocar
    no resolvedor do processo. Quando a opção está desligada, a importação é
    evitada integralmente.
    """
    global _DNS_READY
    if not _dns_override_enabled():
        _DNS_READY = False
        return False
    try:
        from resources.lib import dns as dns_helper
        _DNS_READY = bool(dns_helper.apply_configured_override(addon=ADDON))
    except Exception:
        _DNS_READY = False
    return _DNS_READY

def _configure_python_logging():
    try:
        debug_enabled = ADDON.getSetting('mododebug').lower() == 'true'
    except Exception:
        debug_enabled = False
    # Não altera o logger raiz do processo Kodi: ele é compartilhado por
    # todos os addons e portas. Ajustamos somente bibliotecas usadas aqui.
    for logger_name in ('urllib3', 'urllib3.connectionpool', 'requests', 'requests.packages.urllib3', 'chardet'):
        try:
            logger = logging.getLogger(logger_name)
            logger.setLevel(logging.WARNING if debug_enabled else logging.ERROR)
        except Exception:
            pass


def _set_listitem_info(item, *args, **kwargs):
    info_type = 'video'
    info = {}
    if args:
        if len(args) >= 1 and args[0]:
            info_type = args[0]
        if len(args) >= 2 and isinstance(args[1], dict):
            info.update(args[1])
    info_type = kwargs.get('type', info_type) or 'video'
    extra_info = kwargs.get('infoLabels')
    if isinstance(extra_info, dict):
        info.update(extra_info)

    normalized = {}
    for key, value in info.items():
        try:
            normalized[str(key).strip().lower()] = value
        except Exception:
            normalized[key] = value

    try:
        lowered_type = str(info_type).strip().lower()
    except Exception:
        lowered_type = 'video'

    if lowered_type == 'video':
        # Não manter VideoInfoTag em cache global: no Android o objeto é nativo do
        # ListItem e pode ficar inválido quando a janela Kodi é destruída. Cada item
        # resolve seu próprio tag uma vez por chamada, sem reter ponteiros nativos.
        try:
            tag = item.getVideoInfoTag()
        except Exception:
            tag = None
        if tag:
            try:
                title = normalized.get('title')
                if title not in (None, '') and hasattr(tag, 'setTitle'):
                    tag.setTitle(str(title))
                plot = normalized.get('plot')
                if plot not in (None, '') and hasattr(tag, 'setPlot'):
                    tag.setPlot(str(plot))
                year = normalized.get('year')
                if year not in (None, '', 0) and hasattr(tag, 'setYear'):
                    try:
                        tag.setYear(int(year))
                    except Exception:
                        pass
                genre = normalized.get('genre')
                if genre not in (None, '') and hasattr(tag, 'setGenres'):
                    if isinstance(genre, (list, tuple)):
                        tag.setGenres([str(x) for x in genre if x not in (None, '')])
                    else:
                        tag.setGenres([str(genre)])
                tvshowtitle = normalized.get('tvshowtitle')
                if tvshowtitle not in (None, '') and hasattr(tag, 'setTvShowTitle'):
                    tag.setTvShowTitle(str(tvshowtitle))
                season = normalized.get('season')
                if season not in (None, '') and hasattr(tag, 'setSeason'):
                    try:
                        tag.setSeason(int(season))
                    except Exception:
                        pass
                episode = normalized.get('episode')
                if episode not in (None, '') and hasattr(tag, 'setEpisode'):
                    try:
                        tag.setEpisode(int(episode))
                    except Exception:
                        pass
                tagline = normalized.get('tagline')
                if tagline not in (None, '') and hasattr(tag, 'setTagLine'):
                    tag.setTagLine(str(tagline))
            except Exception:
                pass
        try:
            mediatype = normalized.get('mediatype')
            if mediatype not in (None, ''):
                item.setProperty('mediatype', str(mediatype))
        except Exception:
            pass
        return

    try:
        _set_listitem_info(item, info_type, info)
    except Exception:
        pass


_configure_python_logging()


def _get_http_session():
    """Cria requests.Session somente na primeira operação HTTP real."""
    global HTTP_SESSION
    if HTTP_SESSION is None:
        HTTP_SESSION = common.make_session()
    return HTTP_SESSION


def _get_inputstreamhelper():
    """Carrega o Helper apenas quando Pluto realmente precisar dele."""
    global inputstreamhelper, _INPUTSTREAMHELPER_TRIED
    if _INPUTSTREAMHELPER_TRIED:
        return inputstreamhelper
    _INPUTSTREAMHELPER_TRIED = True
    try:
        inputstreamhelper = __import__('inputstreamhelper')
    except Exception:
        inputstreamhelper = None
    return inputstreamhelper


# Wrappers locais mantidos para leitura simples no roteador principal.
# `common` é lazy: a Home só pede configurações ao Kodi, sem montar sessão
# requests, adaptadores HTTP ou cache de catálogo.
def _setting_bool(key, default=False):
    try:
        value = ADDON.getSetting(key)
        return str(value if value not in (None, '') else ('true' if default else 'false')).lower() == 'true'
    except Exception:
        return default

def _set_setting_bool(key, value):
    try:
        ADDON.setSetting(key, 'true' if value else 'false')
    except Exception:
        pass

def _setting_text(key, default=''):
    try:
        value = ADDON.getSetting(key)
        return value if value not in (None, '') else default
    except Exception:
        return default


def _setting_enum_value(key, values, default=None):
    if not values:
        return default
    try:
        index = int(_setting_text(key, '0'))
        if 0 <= index < len(values):
            return values[index]
    except Exception:
        pass
    return values[0] if default is None else default


def _normalize_text(value):
    try:
        value = value or ''
        if not isinstance(value, text_type):
            value = text_type(value)
        value = unicodedata.normalize('NFKD', value)
        value = ''.join(ch for ch in value if not unicodedata.combining(ch))
        return value.strip().lower()
    except Exception:
        try:
            return text_type(value).strip().lower()
        except Exception:
            return ''


def _is_tv_adult_group(group_name):
    normalized = _normalize_text(group_name)
    if not normalized:
        return False
    keywords = ('18+', '+18', '18 +', '+ 18', 'xxx', 'porn', 'porno', 'sex', 'adulttime', 'adulto', 'onlyfans', 'only fans', 'privacy', 'novinha', 'novinho', 'interracialsexx', 'mypervyfamily', 'brazzersexxtra', 'blacked raw', 'brad montana', 'mia khalifa', 'brasileirinhas')
    return any(keyword in normalized for keyword in keywords)


def _tv_adult_protection_enabled():
    return _setting_bool('tv_adult_protect', False)


def _sanitize_tv_adult_pin(value):
    value = ''.join(ch for ch in (value or '') if ch.isdigit())
    return value if len(value) == 4 else ''


def _read_tv_adult_pin_file():
    try:
        if not os.path.exists(TV_ADULT_PIN_FILE):
            return ''
        with io.open(TV_ADULT_PIN_FILE, 'r', encoding='utf-8') as fh:
            return _sanitize_tv_adult_pin(fh.read().strip())
    except Exception:
        return ''


def _write_tv_adult_pin_file(pin):
    pin = _sanitize_tv_adult_pin(pin)
    if not pin:
        return False
    try:
        with io.open(TV_ADULT_PIN_FILE, 'w', encoding='utf-8') as fh:
            fh.write(pin)
        return True
    except Exception:
        return False


def _save_tv_adult_pin(pin):
    pin = _sanitize_tv_adult_pin(pin)
    if not pin:
        return False
    saved = False
    try:
        ADDON.setSetting('tv_adult_pin', pin)
        saved = True
    except Exception:
        pass
    if _write_tv_adult_pin_file(pin):
        saved = True
    return saved


def _get_tv_adult_pin():
    pin_from_file = _read_tv_adult_pin_file()
    pin_from_setting = _sanitize_tv_adult_pin(_setting_text('tv_adult_pin', ''))

    current = pin_from_file or pin_from_setting or '0000'

    if pin_from_file:
        if pin_from_setting != pin_from_file:
            try:
                ADDON.setSetting('tv_adult_pin', pin_from_file)
            except Exception:
                pass
    elif pin_from_setting:
        _write_tv_adult_pin_file(pin_from_setting)
    else:
        _save_tv_adult_pin(current)

    return current


def _prompt_tv_adult_pin():
    expected = _get_tv_adult_pin()
    try:
        entered = xbmcgui.Dialog().input('Digite o PIN das categorias adultas', type=xbmcgui.INPUT_NUMERIC)
    except Exception:
        return False
    entered = ''.join(ch for ch in (entered or '') if ch.isdigit())
    if entered == expected:
        return True
    _show_error('PIN incorreto.', title='OnePlay', dialog=True)
    return False


def change_tv_adult_pin():
    current = _get_tv_adult_pin()
    prompt_current = 'Digite o PIN atual'
    if current == '0000':
        prompt_current += ' (padrão: 0000)'
    try:
        entered_current = xbmcgui.Dialog().input(prompt_current, type=xbmcgui.INPUT_NUMERIC)
    except Exception:
        entered_current = ''
    entered_current = ''.join(ch for ch in (entered_current or '') if ch.isdigit())
    if entered_current != current:
        _show_error('PIN atual incorreto.', title='OnePlay', dialog=True)
        return
    try:
        new_pin = xbmcgui.Dialog().input('Digite o novo PIN (4 dígitos)', type=xbmcgui.INPUT_NUMERIC)
    except Exception:
        new_pin = ''
    new_pin = ''.join(ch for ch in (new_pin or '') if ch.isdigit())
    if len(new_pin) != 4:
        _show_error('O novo PIN deve ter 4 dígitos.', title='OnePlay', dialog=True)
        return
    try:
        confirm_pin = xbmcgui.Dialog().input('Confirme o novo PIN', type=xbmcgui.INPUT_NUMERIC)
    except Exception:
        confirm_pin = ''
    confirm_pin = ''.join(ch for ch in (confirm_pin or '') if ch.isdigit())
    if confirm_pin != new_pin:
        _show_error('A confirmação do PIN não confere.', title='OnePlay', dialog=True)
        return
    if not _save_tv_adult_pin(new_pin):
        _show_error('Não foi possível salvar o novo PIN.', title='OnePlay', dialog=True)
        return
    _notify('OnePlay', 'PIN das categorias adultas atualizado.', xbmcgui.NOTIFICATION_INFO, 2500)


def open_tv_adult_groups(params):
    url = params.get('url')
    if not url:
        _show_error('URL da lista M3U inválida.', title='Kodi', dialog=True)
        return
    if not _tv_adult_protection_enabled():
        _show_error('Categorias adultas desativadas nesta configuração.', title='OnePlay', dialog=True)
        return
    if not _prompt_tv_adult_pin():
        return
    try:
        channels, groups = m3u.parse_m3u(url)
        adult_groups = []
        counts = {}
        for channel in channels or []:
            group_name = channel.get('group') or 'Outros'
            if not _is_tv_adult_group(group_name):
                continue
            counts[group_name] = counts.get(group_name, 0) + 1
            if group_name not in adult_groups:
                adult_groups.append(group_name)
        if not adult_groups:
            _show_error('Nenhuma categoria adulta foi encontrada nesta lista.', title='OnePlay', dialog=True)
            return
        icon = TRANSLATE(os.path.join(homeDir, 'resources', 'images', 'tv.png'))
        for group in adult_groups:
            label = '{} ({})'.format(group, counts.get(group, 0))
            url_ = build_url({'action': 'opengroup', 'url': url, 'group': group})
            li = xbmcgui.ListItem(label)
            _set_listitem_info(li, 'video', {'title': label, 'plot': 'Abrir a categoria {} de canais.'.format(group), 'mediatype': 'video'})
            li.setArt({'icon': icon, 'thumb': icon, 'poster': icon, 'fanart': addonFanart})
            xbmcplugin.addDirectoryItem(handle=ADDON_HANDLE, url=url_, listitem=li, isFolder=True)
        _end_directory_with_view(content='movies', view='List', delay_ms=40, repeat=2, default_once_key='default_view_tv_list_applied')
    except Exception as exc:
        _log_debug('Erro ao abrir categorias adultas: {}'.format(exc))
        _show_error('Erro ao processar as categorias adultas.', title='Kodi', dialog=True)


def _log_debug(message):
    common.log_debug(message, addon=ADDON, component='Routes')
    try:
        common.log_debug_traceback('Routes', message, sys.exc_info()[1], addon=ADDON)
    except Exception:
        pass

def _log_warning(message):
    common.log(message, level=getattr(xbmc, 'LOGWARNING', 2))

def _log_error(message):
    common.log(message, level=getattr(xbmc, 'LOGERROR', 4))

def _notify(title, message, icon=None, milliseconds=2800):
    try:
        if icon is None:
            icon = xbmcgui.NOTIFICATION_INFO
        xbmcgui.Dialog().notification(title, message, icon, milliseconds)
    except:
        pass

def _vod_operational_message(reason):
    """Traduz o pré-teste em orientação curta sem expor URL, token ou host."""
    text = str(reason or '').strip().lower()
    match = re.search(r'http\s*(\d{3})', text)
    status = int(match.group(1)) if match else 0
    if status == 401:
        return 'A fonte exige autenticação que não foi aceita pelo servidor. Escolha outra fonte.'
    if status == 403:
        return 'A fonte foi bloqueada pelo servidor (HTTP 403). Escolha outra fonte.'
    if status in (404, 410):
        return 'A fonte foi removida ou expirou (HTTP {}). Escolha outra fonte.'.format(status)
    if status == 429:
        return 'A fonte está temporariamente limitada pelo servidor (HTTP 429). Tente mais tarde ou escolha outra.'
    if 500 <= status <= 599:
        return 'O servidor desta fonte está indisponível no momento (HTTP {}). Escolha outra fonte.'.format(status)
    if 'não é mídia' in text:
        return 'A fonte respondeu com uma página, não com vídeo. Escolha outra fonte.'
    if 'range' in text:
        return 'A fonte não oferece reprodução segura neste aparelho. Escolha outra fonte.'
    if 'url' in text:
        return 'A fonte retornou um endereço inválido ou expirado. Escolha outra fonte.'
    return 'Esta fonte não está disponível para reprodução segura. Escolha outra qualidade ou servidor.'


def _show_error(message, title='OnePlay', dialog=False):
    if dialog:
        try:
            xbmcgui.Dialog().ok(title, message)
            return
        except:
            pass
    if _setting_bool('notificarerros', True):
        _notify(title, message, xbmcgui.NOTIFICATION_ERROR, 3500)


def _tv_proxy_wait_title():
    try:
        return ADDON.getAddonInfo('name') or 'OnePlay'
    except Exception:
        return 'OnePlay'


def _notify_tv_proxy_wait(milliseconds=6500):
    _notify(_tv_proxy_wait_title(), 'AGUARDE....', addonIcon, milliseconds)


def _open_tv_proxy_wait_dialog():
    title = _tv_proxy_wait_title()
    try:
        dialog = xbmcgui.DialogProgressBG()
        dialog.create(title, 'AGUARDE....')
        try:
            dialog.update(0, title, 'AGUARDE....')
        except TypeError:
            try:
                dialog.update(0, 'AGUARDE....')
            except Exception:
                pass
        return dialog
    except Exception:
        _notify(title, 'AGUARDE....', addonIcon, 9000)
        return None


def _update_tv_proxy_wait_dialog(dialog, percent=0):
    if not dialog:
        return
    try:
        dialog.update(max(0, min(100, int(percent))), _tv_proxy_wait_title(), 'AGUARDE....')
    except TypeError:
        try:
            dialog.update(max(0, min(100, int(percent))), 'AGUARDE....')
        except Exception:
            pass
    except Exception:
        pass


def _close_tv_proxy_wait_dialog(dialog):
    if not dialog:
        return
    try:
        dialog.close()
    except Exception:
        pass


def _wait_tv_proxy_playback_started(player, dialog=None, timeout_ms=18000, min_wait_ms=1200):
    start = time.time()
    last_notify = 0
    timeout_ms = max(2500, int(timeout_ms))
    min_wait_ms = max(0, int(min_wait_ms))
    while True:
        elapsed_ms = int((time.time() - start) * 1000)
        if elapsed_ms >= timeout_ms:
            return False
        percent = int((elapsed_ms * 100) / timeout_ms)
        _update_tv_proxy_wait_dialog(dialog, percent)
        if not dialog and (time.time() - last_notify) >= 3.0:
            _notify_tv_proxy_wait(4000)
            last_notify = time.time()
        if elapsed_ms >= min_wait_ms:
            try:
                if player.isPlayingVideo() or player.isPlaying():
                    _update_tv_proxy_wait_dialog(dialog, 100)
                    return True
            except Exception:
                pass
        try:
            xbmc.sleep(250)
        except Exception:
            time.sleep(0.25)




def _ensure_pluto_session(params=None):
    if isinstance(params, dict):
        incoming = (params.get('psid') or '').strip()
    else:
        incoming = (params or '').strip()
    return pluto.ensure_session(incoming)

def _pluto_entry_mode():
    return _setting_enum_value('pluto_entrada', ['channels', 'categories'], 'categories')


def _pluto_country_code():
    return _setting_enum_value('pluto_country', ['auto', 'br', 'us', 'mx', 'es', 'fr', 'it', 'de', 'gb', 'ca', 'au', 'ar', 'co', 'cl'], 'auto')


def _pluto_country_folder_mode_enabled():
    return False


def _pluto_all_countries_enabled():
    return False


def _pluto_merge_duplicates():
    return _setting_bool('pluto_merge_duplicates', True)


def _pluto_prefer_selected_country():
    return _setting_bool('pluto_prefer_selected_country', True)


def _pluto_epg_timezone_mode():
    return _setting_enum_value('pluto_epg_timezone', ['auto', 'selected_country'], 'auto')


def _pluto_retry_countries(params=None, include_auto_fallback=True):
    params = dict(params or {})
    countries = []
    current = pluto.normalize_country_code(_pluto_effective_country(params))
    if current not in countries:
        countries.append(current)
    explicit_country = bool(params.get('country_code'))
    if include_auto_fallback and not explicit_country and current != 'auto':
        countries.append('auto')
    return countries


def _pluto_call_with_fallback(fetcher, label, params=None, include_auto_fallback=True):
    params = dict(params or {})
    countries = _pluto_retry_countries(params, include_auto_fallback=include_auto_fallback)
    last_exc = None
    for idx, country_code in enumerate(countries):
        try_params = dict(params)
        try_params['country_code'] = country_code
        try:
            result = fetcher(try_params)
        except Exception as exc:
            last_exc = exc
            _log_warning('Pluto {} falhou no país {}: {}'.format(label, country_code, exc))
            continue
        if result:
            if idx > 0:
                _log_warning('Pluto {} recuperado via fallback de país {}.'.format(label, country_code))
            return result
        if idx + 1 < len(countries):
            _log_warning('Pluto {} sem dados no país {}. Tentando fallback.'.format(label, country_code))
    if last_exc is not None:
        raise last_exc
    return []


def _pluto_resolve_live_url(params):
    params = dict(params or {})
    slug = params.get('slug', '')
    channel_id = params.get('channel_id', '')
    requested_country = pluto.normalize_country_code(_pluto_effective_country(params))
    last_country = requested_country
    for country_code in _pluto_retry_countries(params):
        last_country = country_code
        url = pluto.resolve_stream_url(slug=slug, channel_id=channel_id, country=country_code)
        if url:
            if country_code != requested_country:
                _log_warning('Pluto live recuperado via fallback de país {}.'.format(country_code))
            return url, country_code
        if country_code != 'auto':
            _log_warning('Pluto live sem URL no país {}. Tentando fallback.'.format(country_code))
    return None, last_country


def _pluto_resolve_vod_url(params):
    params = dict(params or {})
    stitched_url = params.get('stitched_url', '')
    requested_country = pluto.normalize_country_code(_pluto_effective_country(params))
    last_country = requested_country
    for country_code in _pluto_retry_countries(params):
        last_country = country_code
        url = pluto.resolve_vod_stream_url(stitched_url=stitched_url, country=country_code)
        if url:
            if country_code != requested_country:
                _log_warning('Pluto VOD recuperado via fallback de país {}.'.format(country_code))
            return url, country_code
        if country_code != 'auto':
            _log_warning('Pluto VOD sem URL no país {}. Tentando fallback.'.format(country_code))
    return None, last_country


def _current_skin_signature():
    skin_id = ''
    skin_theme = ''
    try:
        skin_id = xbmc.getSkinDir() or ''
    except Exception:
        pass
    try:
        skin_theme = xbmc.getInfoLabel('Skin.CurrentTheme') or ''
    except Exception:
        pass
    signature = '{}|{}'.format(skin_id, skin_theme).strip('|')
    return signature or 'default'


def _apply_default_view_once(flag_key, view, delay_ms=40, repeat=2):
    signature_key = '{}_skin_signature'.format(flag_key)
    current_signature = _current_skin_signature()
    last_signature = _setting_text(signature_key, '')

    if last_signature != current_signature:
        _set_setting_bool(flag_key, False)
        try:
            ADDON.setSetting(signature_key, current_signature)
        except Exception:
            pass

    if _setting_bool(flag_key, False):
        return
    try:
        setview(view)
    except Exception:
        return
    extra_retries = max(0, int(repeat) - 1)
    for _ in range(extra_retries):
        try:
            xbmc.sleep(delay_ms)
        except Exception:
            pass
        try:
            setview(view)
        except Exception:
            break
    _set_setting_bool(flag_key, True)
    try:
        ADDON.setSetting(signature_key, current_signature)
    except Exception:
        pass

def _complete_plugin_action(succeeded=True):
    for builtin in ('Dialog.Close(busydialog)', 'Dialog.Close(busydialognocancel)'):
        try:
            xbmc.executebuiltin(builtin)
        except:
            pass
    try:
        xbmcplugin.endOfDirectory(ADDON_HANDLE, succeeded=succeeded, updateListing=False, cacheToDisc=False)
    except TypeError:
        try:
            xbmcplugin.endOfDirectory(ADDON_HANDLE, succeeded=succeeded)
        except:
            pass
    except:
        pass

def _add_settings_item(plot='Abrir as configurações do addon OnePlay.'):
    label = 'Configurações'
    icon = TRANSLATE(os.path.join(homeDir, 'resources', 'images', 'configuracoes.png'))
    li = xbmcgui.ListItem(label)
    li.setArt({'icon': icon, 'thumb': icon, 'poster': icon, 'fanart': addonFanart})
    _set_listitem_info(li, 'video', {'mediatype': 'video'})
    _set_listitem_info(li, type="Video", infoLabels={"Title": label, "Plot": plot})
    li.setProperty('IsPlayable', 'false')
    xbmcplugin.addDirectoryItem(handle=ADDON_HANDLE, url=build_url({'action': 'open_settings'}), listitem=li, isFolder=False)


def _add_pluto_shortcut(label, action, plot, icon_name='pluto.png'):
    icon = TRANSLATE(os.path.join(homeDir, 'resources', 'images', icon_name))
    if not os.path.exists(icon):
        icon = TRANSLATE(os.path.join(homeDir, 'resources', 'images', 'pluto.png'))
    li = xbmcgui.ListItem(label)
    li.setArt({'icon': icon, 'thumb': icon, 'poster': icon, 'fanart': addonFanart})
    _set_listitem_info(li, 'video', {'mediatype': 'video'})
    _set_listitem_info(li, type="Video", infoLabels={"Title": label, "Plot": plot})
    li.setProperty('IsPlayable', 'false')
    pluto_session_id = pluto.new_session_id()
    xbmcplugin.addDirectoryItem(handle=ADDON_HANDLE, url=build_url({'action': action, 'psid': pluto_session_id}), listitem=li, isFolder=True)


def _add_pluto_item(plot=None):
    label = 'Pluto TV'
    icon = TRANSLATE(os.path.join(homeDir, 'resources', 'images', 'pluto.png'))
    if not os.path.exists(icon):
        icon = TRANSLATE(os.path.join(homeDir, 'resources', 'images', 'tv.png'))
    if plot is None:
        mode_label = 'categorias' if _pluto_entry_mode() == 'categories' else 'canais'
        plot = 'Abrir Pluto TV em {} com player dedicado separado do fluxo oficial.'.format(mode_label)
    li = xbmcgui.ListItem(label)
    li.setArt({'icon': icon, 'thumb': icon, 'poster': icon, 'fanart': addonFanart})
    _set_listitem_info(li, 'video', {'mediatype': 'video'})
    _set_listitem_info(li, type="Video", infoLabels={"Title": label, "Plot": plot})
    li.setProperty('IsPlayable', 'false')
    pluto_session_id = pluto.new_session_id()
    xbmcplugin.addDirectoryItem(handle=ADDON_HANDLE, url=build_url({'action': 'pluto_entry', 'psid': pluto_session_id}), listitem=li, isFolder=True)

def clear_subtitles_cache(notify=True):
    removed = 0
    try:
        removed = subtitles.clear_subtitles_cache(addon=ADDON)
    except Exception as exc:
        _log_debug('Erro limpando cache de legendas: {}'.format(exc))
    if notify:
        if removed:
            _notify('OnePlay', 'Cache de legendas limpo.', xbmcgui.NOTIFICATION_INFO, 2500)
        else:
            _notify('OnePlay', 'Nenhuma legenda em cache para limpar.', xbmcgui.NOTIFICATION_INFO, 2500)
    return removed

def clear_epg_cache(notify=True):
    removed = 0
    try:
        removed = m3u.clear_epg_cache()
    except Exception as exc:
        _log_debug('Erro limpando cache de EPG: {}'.format(exc))
    if notify:
        if removed:
            _notify('OnePlay', 'Cache de EPG limpo.', xbmcgui.NOTIFICATION_INFO, 2500)
        else:
            _notify('OnePlay', 'Nenhum arquivo de EPG em cache para limpar.', xbmcgui.NOTIFICATION_INFO, 2500)
    return removed


def clear_navigation_cache(notify=True):
    removed = 0
    try:
        removed = m3u.clear_navigation_cache()
    except Exception as exc:
        _log_debug('Erro limpando cache de navegação: {}'.format(exc))
    if notify:
        _notify('OnePlay', 'Cache de navegação limpo.' if removed else 'Nenhum cache de navegação para limpar.', xbmcgui.NOTIFICATION_INFO, 2500)
    return removed


def clear_catalog_cache(notify=True):
    removed = 0
    try:
        removed += int(tmdb.clear_tmdb_cache() or 0)
    except Exception as exc:
        _log_debug('Erro limpando cache TMDB: {}'.format(exc))
    try:
        removed += int(pluto.clear_pluto_persistent_cache() or 0)
    except Exception as exc:
        _log_debug('Erro limpando cache Pluto: {}'.format(exc))
    if notify:
        _notify('OnePlay', 'Cache de catálogo e Pluto limpo.' if removed else 'Nenhum cache de catálogo para limpar.', xbmcgui.NOTIFICATION_INFO, 2500)
    return removed


def clear_tv_history(notify=True):
    keys = ('last_tv_list_url', 'last_tv_list_name', 'last_tv_group', 'last_tv_group_url')
    for key in keys:
        try:
            ADDON.setSetting(key, '')
        except:
            pass
    if notify:
        _notify('OnePlay', 'Atalhos recentes da TV ao vivo foram limpos.', xbmcgui.NOTIFICATION_INFO, 2500)


def _show_text_dialog(title, text_value):
    body = text_value or 'Nenhuma informação disponível.'
    try:
        xbmcgui.Dialog().textviewer(title, body)
    except Exception:
        try:
            xbmcgui.Dialog().ok(title, body)
        except Exception:
            pass


def _add_movie_synopsis_context(item, title, description):
    """Expõe sinopse sem recolocar uma pasta intermediária no fluxo VOD.

    Filmes sempre seguem da vitrine para a lista única de fontes. A sinopse
    permanece disponível no menu de contexto para quem quiser consultá-la,
    sem uma segunda abertura e sem reativar o antigo ``fluxofilmes=details``.
    """
    if item is None:
        return
    try:
        info_url = build_url({
            'action': 'show_text_dialog',
            'title': title or 'Sinopse',
            'text': description or 'Nenhuma sinopse disponível.',
        })
        item.addContextMenuItems([
            ('Sinopse', 'RunPlugin({})'.format(info_url)),
        ])
    except Exception as exc:
        _log_debug('Falha não crítica ao criar menu de sinopse: {}'.format(exc))


def open_settings():
    opened = False
    try:
        ADDON.openSettings()
        opened = True
    except Exception:
        try:
            xbmc.executebuiltin('Addon.OpenSettings({})'.format(ADDON.getAddonInfo('id')))
            opened = True
        except Exception:
            try:
                _show_error('Não foi possível abrir as configurações do addon.', title='OnePlay', dialog=True)
            except:
                pass
    _complete_plugin_action(succeeded=opened)


def _resolve_tv_mode():
    try:
        mode = ADDON.getSetting('modocanais')
    except:
        mode = ''
    # Valores válidos na configuração: 0=Perguntar sempre, 1=M3U8, 2=MPEGTS.
    if mode == '1':
        return 0
    if mode == '2':
        return 1
    # Compat legado: se algum perfil antigo ainda trouxer "3", trata como MPEGTS.
    if mode == '3':
        return 1
    try:
        selected = xbmcgui.Dialog().select('Escolha uma opção', ['MODO M3U8', 'MODO MPEGTS'])
    except:
        selected = -1
    if selected == 0:
        return 0
    if selected == 1:
        return 1
    return -1

def _tv_epg_enabled():
    return _setting_bool('tv_epg_ativo', False)


def _pluto_epg_enabled():
    return _setting_bool('pluto_epg_ativo', True)


def _apply_tv_epg(channel, epg_map):
    name = channel.get('name', 'Canal')
    plot = 'Abrir o canal para reprodução.'
    if not _tv_epg_enabled():
        return name, plot
    label_suffix, epg_plot = m3u.format_epg_entry(epg_map.get(channel.get('id')))
    if label_suffix and _setting_bool('tv_epg_nomelista', True):
        name = '{} - [COLOR aquamarine]{}[/COLOR]'.format(name, label_suffix)
    if epg_plot:
        plot = epg_plot
    return name, plot


def _get_tv_epg_metadata(m3u_url, channel):
    metadata = {'plot': 'Abrir o canal para reprodução.', 'current_title': '', 'next_title': ''}
    if not _tv_epg_enabled() or not m3u_url or not channel:
        return metadata
    try:
        epg_map = m3u.get_epg_for_channels(m3u_url, [channel])
        entry = epg_map.get(channel.get('id'))
        if not entry:
            return metadata
        meta = m3u.describe_epg_entry(entry)
        metadata.update({
            'plot': meta.get('plot') or metadata['plot'],
            'current_title': meta.get('current_title', ''),
            'next_title': meta.get('next_title', ''),
        })
    except Exception as exc:
        _log_debug('Falha ao buscar metadados EPG do canal em reprodução: {}'.format(exc))
    return metadata

def desc_vip():
    return '''[B][COLOR aquamarine]OFICIAL[/COLOR] [COLOR white]Kodi e Android[/COLOR][/B]

Ao entrar no [COLOR aquamarine]VIP[/COLOR], será exibido um [COLOR aquamarine]QR Code[/COLOR] com o WhatsApp de atendimento e os valores dos planos.
Escolha o plano desejado e entre em contato para realizar a contratação.

Depois, confirme em [COLOR aquamarine]OK[/COLOR] e selecione o [COLOR aquamarine]Servidor[/COLOR] informado.
Digite seu [COLOR aquamarine]usuário[/COLOR] e confirme.
Em seguida, digite sua [COLOR aquamarine]senha[/COLOR] e confirme novamente.

Após a validação, os conteúdos VIP serão liberados.

Conexão recomendada: [COLOR gold]30 MB[/COLOR]'''


try:
    class QRCodeDialog(xbmcgui.WindowXMLDialog):
        def __init__(self, *args, **kwargs):
            self.image_path = kwargs.pop('image_path', '')
            xbmcgui.WindowXMLDialog.__init__(self, *args, **kwargs)

        def onInit(self):
            try:
                self.getControl(1001).setImage(self.image_path)
            except Exception:
                pass

        def onAction(self, action):
            try:
                action_id = action.getId()
            except Exception:
                action_id = action
            close_actions = (
                getattr(xbmcgui, 'ACTION_PREVIOUS_MENU', 10),
                getattr(xbmcgui, 'ACTION_NAV_BACK', 92),
                getattr(xbmcgui, 'ACTION_BACKSPACE', 110),
            )
            if action_id in close_actions:
                self.close()

        def onClick(self, control_id):
            if control_id == 1002:
                self.close()
except Exception:
    QRCodeDialog = None


def show_qr_code():
    qr_image_path = TRANSLATE(os.path.join(homeDir, 'resources', 'images', 'qrcode.png'))
    if not xbmcvfs.exists(qr_image_path):
        _show_error('Arquivo do QR Code não encontrado.', title='QR Code', dialog=True)
        return
    if QRCodeDialog is not None:
        try:
            dialog = QRCodeDialog('QRCodeDialog.xml', ADDON.getAddonInfo('path'), 'Default', '1080i', image_path=qr_image_path)
            dialog.doModal()
            del dialog
            return
        except Exception as exc:
            _log_debug('Falha ao abrir QR Code em janela customizada: {}'.format(exc))
    try:
        dialog = Donate_()
        dialog.doModal()
    except Exception:
        _show_error('Não foi possível abrir o QR Code.', title='QR Code', dialog=True)

try:
    class Donate_(xbmcgui.WindowDialog):
        def __init__(self):
            try:
                self.image = xbmcgui.ControlImage(440, 128, 400, 400, TRANSLATE(os.path.join(homeDir, 'resources', 'images','qrcode.png')))
                self.text = xbmcgui.ControlLabel(x=495,y=600,width=1000,height=25,label='[B][COLOR yellow]PRESSIONE VOLTAR PARA SAIR[/COLOR][/B]',textColor='yellow')
                self.addControl(self.image)
                self.addControl(self.text)
            except:
                pass
except:
    pass

def setview(name):
    mode = {'Wall': '500',
            'List': '50',
            'Poster': '51',
            'Shift': '53',
            'InfoWall': '54',
            'WideList': '55',
            'Fanart': '502'
            }.get(name, '50')
    view = 'Container.SetViewMode(%s)'%mode
    xbmc.executebuiltin(view)

def _end_directory_with_view(content=None, view=None, succeeded=True, cache=True, delay_ms=40, repeat=1, default_once_key=None):
    if content:
        try:
            xbmcplugin.setContent(ADDON_HANDLE, content)
        except Exception:
            pass
    try:
        xbmcplugin.endOfDirectory(ADDON_HANDLE, succeeded=succeeded, updateListing=False, cacheToDisc=cache)
    except TypeError:
        try:
            xbmcplugin.endOfDirectory(ADDON_HANDLE, succeeded=succeeded)
        except Exception:
            pass
    except Exception:
        pass
    if view:
        if default_once_key:
            _apply_default_view_once(default_once_key, view, delay_ms=delay_ms, repeat=repeat)
        else:
            try:
                setview(view)
            except Exception:
                return
            extra_retries = max(0, int(repeat) - 1)
            for _ in range(extra_retries):
                try:
                    xbmc.sleep(delay_ms)
                except Exception:
                    pass
                try:
                    setview(view)
                except Exception:
                    break

def build_url(query):
    if PY3:
        return BASE_URL + '?' + urlencode(query, encoding='utf-8')
    else:
        try:
            query = {k: (v.encode('utf-8') if isinstance(v, unicode) else v) for k, v in query.items()}
        except NameError:
            pass
        return BASE_URL + '?' + urlencode(query)

def home():
    items = [('Pesquisar', 'Buscar filmes e séries pelo nome do conteúdo.', TRANSLATE(os.path.join(homeDir, 'resources','images','pesquisar.png')), {'action': 'search'}),
             ('TV ao vivo', 'Entrar nas listas e categorias de canais ao vivo.', TRANSLATE(os.path.join(homeDir, 'resources','images','tv.png')), {'action': 'tv'}),
             ('Filmes', 'Explorar filmes em alta, top avaliados e lançamentos.', TRANSLATE(os.path.join(homeDir, 'resources','images','filmes.png')), {'action': 'menu_movies'}),
             ('Séries', 'Explorar séries em alta, top avaliadas e lançamentos.', TRANSLATE(os.path.join(homeDir, 'resources','images','series.png')), {'action': 'menu_series'}),
             ('Configurações', 'Abrir as configurações do addon OnePlay.', TRANSLATE(os.path.join(homeDir, 'resources','images','configuracoes.png')), {'action': 'open_settings'})]

    if _setting_bool('mostrarboasvindas', True):
        items.insert(0, ('[COLOR aquamarine]:::[/COLOR]BEM-VINDOS AO ONEPLAY[COLOR aquamarine]:::[/COLOR]', ONEPLAY_DESC, addonIcon, {'action': 'donate'}))

    if _setting_bool('exibirvip', True):
        items.insert(1, ('VIP', desc_vip(), TRANSLATE(os.path.join(homeDir, 'resources', 'images','vip.png')), {'action': ''}))

    for label, description, icon, query in items:
        if label == 'VIP':
            url = 'plugin://plugin.video.oneplayvip/?origin=oneplay_matrix'
        else:
            url = build_url(query)
        li = xbmcgui.ListItem(label)
        li.setArt({'icon': icon, 'thumb': icon, 'poster': icon, 'fanart': addonFanart})
        _set_listitem_info(li, 'video', {'mediatype': 'video'})
        _set_listitem_info(li, type="Video", infoLabels={"Title": label, "Plot": description})
        if label in ('Configurações', '[COLOR aquamarine]:::[/COLOR]BEM-VINDOS AO ONEPLAY[COLOR aquamarine]:::[/COLOR]'):
            li.setProperty('IsPlayable', 'false')
        is_folder = label not in ('Configurações', '[COLOR aquamarine]:::[/COLOR]BEM-VINDOS AO ONEPLAY[COLOR aquamarine]:::[/COLOR]')
        xbmcplugin.addDirectoryItem(handle=ADDON_HANDLE, url=url, listitem=li, isFolder=is_folder)

    _end_directory_with_view(content='movies', view='List', delay_ms=40, repeat=2, default_once_key='default_view_home_list_applied')

# --- As funções em TV (m3u, proxy, etc) não foram alteradas ---
def menu_tv():
    options = m3u.get_lists()
    icon = TRANSLATE(os.path.join(homeDir, 'resources','images','tv.png'))

    if _setting_bool('tv_lembrarultimalista', True):
        last_url = _setting_text('last_tv_list_url', '')
        last_name = _setting_text('last_tv_list_name', 'Última lista')
        if last_url and last_url in options:
            recent_label = '[COLOR mediumaquamarine]Continuar[/COLOR] - {}'.format(last_name)
            recent_url = build_url({'action': 'openm3u', 'url': last_url, 'source_name': last_name})
            li = xbmcgui.ListItem(recent_label)
            _set_listitem_info(li, 'video', {'mediatype': 'video'})
            _set_listitem_info(li, type="Video", infoLabels={"Title": recent_label, "Plot": 'Abrir rapidamente a última lista usada.'})
            li.setArt({'icon': icon, 'thumb': icon, 'poster': icon, 'fanart': addonFanart})
            xbmcplugin.addDirectoryItem(handle=ADDON_HANDLE, url=recent_url, listitem=li, isFolder=True)

    for index, option in enumerate(options):
        name = 'LISTA {}'.format(index + 1)
        url = build_url({'action': 'openm3u', 'url': option, 'source_name': name})
        li = xbmcgui.ListItem(name)
        _set_listitem_info(li, 'video', {'mediatype': 'video'})
        _set_listitem_info(li, type="Video", infoLabels={"Title": name, "Plot": 'Abrir a {} de canais ao vivo.'.format(name.lower())})
        li.setArt({'icon': icon, 'thumb': icon, 'poster': icon, 'fanart': addonFanart})
        xbmcplugin.addDirectoryItem(handle=ADDON_HANDLE, url=url, listitem=li, isFolder=True)

    if _setting_bool('pluto_exibir_menu', True):
        _add_pluto_item()
    _add_settings_item()
    _end_directory_with_view(content='movies', view='List', delay_ms=40, repeat=2, default_once_key='default_view_tv_list_applied')

def openm3u(params):
    url = params.get('url')
    source_name = params.get('source_name', 'Lista')
    if not url:
        _show_error('URL da lista M3U inválida.', title='Kodi', dialog=True)
        return
    try:
        if _setting_bool('tv_lembrarultimalista', True):
            try:
                ADDON.setSetting('last_tv_list_url', url)
                ADDON.setSetting('last_tv_list_name', source_name)
            except:
                pass

        ordered_groups, group_counts = m3u.get_group_index(url)
        if not ordered_groups:
            _show_error('Nenhum grupo encontrado na lista.', title='Kodi', dialog=True)
            return

        last_group = _setting_text('last_tv_group', '')
        last_group_url = _setting_text('last_tv_group_url', '')
        icon = TRANSLATE(os.path.join(homeDir, 'resources', 'images', 'tv.png'))
        protect_adult = _tv_adult_protection_enabled()

        if _setting_bool('tv_lembrarultimogrupo', True) and last_group and last_group_url == url and group_counts.get(last_group, 0) > 0:
            if not _is_tv_adult_group(last_group):
                recent_group_label = '[COLOR mediumaquamarine]Último grupo[/COLOR] - {}'.format(last_group)
                recent_group_url = build_url({'action': 'opengroup', 'url': url, 'group': last_group})
                li = xbmcgui.ListItem(recent_group_label)
                _set_listitem_info(li, 'video', {'title': recent_group_label, 'mediatype': 'video'})
                _set_listitem_info(li, type="Video", infoLabels={"Title": recent_group_label, "Plot": 'Abrir rapidamente o último grupo acessado nesta lista.'})
                li.setArt({'icon': icon, 'thumb': icon, 'poster': icon, 'fanart': addonFanart})
                xbmcplugin.addDirectoryItem(handle=ADDON_HANDLE, url=recent_group_url, listitem=li, isFolder=True)

        hide_empty = _setting_bool('tv_ocultargruposvazios', True)
        adult_group_names = []
        adult_total = 0
        for group in ordered_groups:
            count = group_counts.get(group, 0)
            if hide_empty and count <= 0:
                continue
            if _is_tv_adult_group(group):
                if protect_adult:
                    adult_group_names.append(group)
                    adult_total += count
                continue
            url_ = build_url({'action': 'opengroup', 'url': url, 'group': group})
            label = '{} ({})'.format(group, count)
            li = xbmcgui.ListItem(label)
            _set_listitem_info(li, 'video', {'title': label, 'plot': 'Abrir a categoria {} de canais.'.format(group), 'mediatype': 'video'})
            li.setArt({'icon': icon, 'thumb': icon, 'poster': icon, 'fanart': addonFanart})
            xbmcplugin.addDirectoryItem(handle=ADDON_HANDLE, url=url_, listitem=li, isFolder=True)
        if protect_adult and adult_group_names:
            label = '[COLOR red]Conteúdos Adulto[/COLOR] ({})'.format(adult_total)
            url_ = build_url({'action': 'open_tv_adult_groups', 'url': url})
            li = xbmcgui.ListItem(label)
            _set_listitem_info(li, 'video', {'title': label, 'plot': 'Abrir categorias adultas em TV ao vivo mediante PIN.', 'mediatype': 'video'})
            li.setArt({'icon': icon, 'thumb': icon, 'poster': icon, 'fanart': addonFanart})
            xbmcplugin.addDirectoryItem(handle=ADDON_HANDLE, url=url_, listitem=li, isFolder=True)
        _end_directory_with_view(content='movies', view='List', delay_ms=40, repeat=2, default_once_key='default_view_tv_list_applied')
    except Exception:
        _show_error('Erro ao processar a lista M3U.', title='Kodi', dialog=True)

def opengroup(params):   
    url = params.get('url')
    group = params.get('group')
    if group:
        try:
            group = group.decode('utf-8')
        except:
            pass
    if not url:
        _show_error('URL da lista M3U inválida.', title='Kodi', dialog=True)
        return
    if _is_tv_adult_group(group):
        if not _tv_adult_protection_enabled():
            _show_error('Categorias adultas desativadas nesta configuração.', title='OnePlay', dialog=True)
            return
        if not _prompt_tv_adult_pin():
            return
    try:
        if _setting_bool('tv_lembrarultimogrupo', True) and group:
            try:
                ADDON.setSetting('last_tv_group', group)
                ADDON.setSetting('last_tv_group_url', url)
            except:
                pass
        channel_filter = m3u.get_group_channels(url, group)
        if not channel_filter:
            _show_error('Nenhum canal encontrado na lista.', title='Kodi', dialog=True)
            return
        epg_map = {}
        if _tv_epg_enabled():
            try:
                epg_map = m3u.get_epg_for_channels(url, channel_filter)
            except Exception as exc:
                _log_debug('Falha ao carregar EPG da TV ao vivo: {}'.format(exc))
                epg_map = {}
        for channel in channel_filter:
            display_name, display_plot = _apply_tv_epg(channel, epg_map)
            url_ = build_url({'action': 'play_proxy', 'name': channel['name'], 'url': channel['url'], 'icon': channel['logo'], 'm3u_url': url, 'tvg_id': channel.get('tvg_id', ''), 'tvg_name': channel.get('tvg_name', ''), 'group': channel.get('group', '')})
            li = xbmcgui.ListItem(display_name)
            _set_listitem_info(li, 'video', {'title': display_name, 'plot': display_plot, 'mediatype': 'video'})
            li.setArt({'icon': channel['logo'], 'thumb': channel['logo'], 'poster': channel['logo'], 'fanart': addonFanart})
            li.setProperty('IsPlayable', 'false')
            if _tv_epg_enabled() and (channel.get('tvg_id') or channel.get('tvg_name') or channel.get('name')):
                try:
                    full_epg_url = build_url({
                        'action': 'show_full_epg',
                        'm3u_url': url,
                        'channel_id': channel.get('id', ''),
                        'name': channel.get('name', 'Canal'),
                        'tvg_id': channel.get('tvg_id', ''),
                        'tvg_name': channel.get('tvg_name', ''),
                        'group': channel.get('group', '')
                    })
                    li.addContextMenuItems([('Ver Programação Completa', 'Container.Update({})'.format(full_epg_url))])
                except Exception:
                    pass
            xbmcplugin.addDirectoryItem(handle=ADDON_HANDLE, url=url_, listitem=li, isFolder=False)
        _end_directory_with_view(content='movies', view='List', delay_ms=40, repeat=2, default_once_key='default_view_tv_list_applied')
    except Exception as e:
        _show_error('Erro ao processar a lista M3U.', title='Kodi', dialog=True)


def show_full_epg(params):
    """Mostra a Programação Completa do canal em uma pasta do Kodi.

    Inspirado no fluxo do OnePlayVIP, mas adaptado ao Matrix/M3U:
    - abre pelo menu de contexto com Container.Update;
    - usa o cache/índice XMLTV do resources/lib/m3u.py;
    - não reproduz itens da grade;
    - mantém fallback: se o cache ainda não existir, tenta carregar pelo mesmo
      método usado ao abrir a listagem do canal.
    """
    m3u_url = params.get('m3u_url', '')
    channel_name = params.get('name', '') or params.get('tvg_name', '') or 'Canal'
    channel = {
        'id': params.get('channel_id', '') or base64.urlsafe_b64encode(channel_name.encode('utf-8')).decode('ascii'),
        'name': channel_name,
        'tvg_id': params.get('tvg_id', ''),
        'tvg_name': params.get('tvg_name', '') or channel_name,
        'group': params.get('group', '')
    }

    try:
        if not _tv_epg_enabled():
            _notify('EPG', 'O EPG está desativado nas configurações.', xbmcgui.NOTIFICATION_WARNING, 3500)
            _end_directory_with_view(content='movies', succeeded=True, cache=False)
            return
        if not m3u_url:
            _notify('EPG', 'Lista M3U inválida para programação.', xbmcgui.NOTIFICATION_WARNING, 3500)
            _end_directory_with_view(content='movies', succeeded=True, cache=False)
            return

        try:
            xbmc.executebuiltin('ActivateWindow(busydialognocancel)')
        except Exception:
            pass
        try:
            programs = m3u.get_full_epg_for_channel(m3u_url, channel, limit=0, include_current=True)
        finally:
            try:
                xbmc.executebuiltin('Dialog.Close(busydialognocancel)')
            except Exception:
                pass

        if not programs:
            _notify('EPG', 'Sem programação para este canal. Aguarde o cache em background.', xbmcgui.NOTIFICATION_WARNING, 4500)
            _end_directory_with_view(content='movies', succeeded=True, cache=False)
            return

        icon = TRANSLATE(os.path.join(homeDir, 'resources', 'images', 'tv.png'))
        now_ts = int(time.time())
        last_day = ''
        count = 0

        for program in programs:
            if not isinstance(program, dict):
                continue
            title = (program.get('title') or 'Sem título').strip() or 'Sem título'
            desc = (program.get('desc') or '').strip()
            day_label = m3u.format_epg_program_day(program, now_ts=now_ts)
            if day_label and day_label != last_day:
                sep_label = '[COLOR gray]{}[/COLOR]'.format(day_label)
                sep = xbmcgui.ListItem(sep_label)
                _set_listitem_info(sep, 'video', {'title': sep_label, 'plot': 'Separador da programação.', 'mediatype': 'video'})
                sep.setArt({'icon': icon, 'thumb': icon, 'poster': icon, 'fanart': addonFanart})
                sep.setProperty('IsPlayable', 'false')
                xbmcplugin.addDirectoryItem(handle=ADDON_HANDLE, url=build_url({'action': 'noop'}), listitem=sep, isFolder=False)
                last_day = day_label

            time_label = m3u.format_epg_program_range(program)
            label = '{} | {}'.format(time_label, title) if time_label else title
            if m3u.is_epg_program_current(program, now_ts=now_ts):
                label = '[COLOR aquamarine]{}[/COLOR]'.format(label)

            plot_lines = []
            if channel_name:
                plot_lines.append('[COLOR aquamarine]{}[/COLOR]'.format(channel_name))
            if time_label:
                plot_lines.append('[COLOR gold]{}[/COLOR]'.format(time_label))
            if desc:
                plot_lines.append('')
                plot_lines.append(desc)
            plot = '\n'.join(plot_lines) if plot_lines else 'Item da programação.'

            li = xbmcgui.ListItem(label)
            _set_listitem_info(li, 'video', {'title': title, 'plot': plot, 'mediatype': 'video'})
            li.setArt({'icon': icon, 'thumb': icon, 'poster': icon, 'fanart': addonFanart})
            li.setProperty('IsPlayable', 'false')
            xbmcplugin.addDirectoryItem(handle=ADDON_HANDLE, url=build_url({'action': 'noop'}), listitem=li, isFolder=False)
            count += 1

        if not count:
            _notify('EPG', 'Nenhum programa encontrado para este canal.', xbmcgui.NOTIFICATION_WARNING, 3500)
        _end_directory_with_view(content='videos', view='List', delay_ms=40, repeat=2, default_once_key='default_view_tv_list_applied')
    except Exception as exc:
        _log_debug('Erro ao abrir Programação Completa: {}'.format(exc))
        try:
            xbmc.executebuiltin('Dialog.Close(busydialognocancel)')
        except Exception:
            pass
        _show_error('Não foi possível abrir a Programação Completa.', title='EPG', dialog=False)
        _end_directory_with_view(content='movies', succeeded=True, cache=False)

def play_proxy(params):
    url = params.get('url')
    if not url:
        _show_error('URL inválida.', title='Kodi', dialog=True)
        return
    if _setting_bool('confirmartv', False):
        try:
            channel_name = params.get('name', 'Canal')
            message = 'Deseja reproduzir este canal?'
            if channel_name:
                message += '\n' + channel_name
            confirmed = xbmcgui.Dialog().yesno('OnePlay', message)
        except Exception:
            confirmed = True
        if not confirmed:
            return
    original_name = params.get('name', 'Canal')
    op = _resolve_tv_mode()
    if op not in (0, 1):
        return
    wait_dialog = _open_tv_proxy_wait_dialog()
    try:
        if op == 0:
            name = original_name + ' (M3U8)'
            url = unquote_plus(url)
            url = m3u.convert_to_m3u8(url)
            url = URL_HLSRETRY + quote(url)
        elif op == 1:
            name = original_name + ' (TS)'
            url = unquote_plus(url)
            url = m3u.convert_to_ts(url)
            url = URL_TS_DOWNLOADER + quote(url)
        # O servidor local pertence ao service.py, que vive durante toda a
        # sessão do Kodi. A rota do plugin apenas aguarda o serviço já iniciado;
        # assim o default.py não fica preso por uma thread HTTP após dar play.
        if not proxy.wait_for_service_proxy(timeout=3.0):
            _show_error('Proxy nativo ainda está iniciando. Feche e abra o Kodi uma vez e tente novamente.', title='Kodi', dialog=True)
            return
        channel_meta = {
            'name': original_name,
            'tvg_id': params.get('tvg_id', ''),
            'tvg_name': params.get('tvg_name', '') or original_name,
            'group': params.get('group', ''),
            'id': base64.urlsafe_b64encode(original_name.encode('utf-8')).decode('ascii')
        }
        epg_meta = _get_tv_epg_metadata(params.get('m3u_url', ''), channel_meta)
        plot = epg_meta.get('plot') or 'Abrir o canal para reprodução.'
        li = xbmcgui.ListItem(name)
        _set_listitem_info(li, 'video', {
            'title': name,
            'plot': plot,
            'mediatype': 'video'
        })
        _set_listitem_info(li, type="Video", infoLabels={
            "Title": name,
            "Plot": plot
        })
        li.setArt({'icon': params.get('icon', ''), 'thumb': params.get('icon', ''), 'poster': params.get('icon', ''), 'fanart': addonFanart})
        player = xbmc.Player()
        player.play(item=url, listitem=li)
        _wait_tv_proxy_playback_started(player, wait_dialog)
    finally:
        _close_tv_proxy_wait_dialog(wait_dialog)

# --- Funções de Filmes e Séries (navegação) não foram alteradas ---

def _pluto_effective_country(params=None):
    params = params or {}
    return (params.get('country_code') or _pluto_country_code())



def _pluto_effective_all_countries(params=None):
    params = params or {}
    if params.get('country_code'):
        return False
    if 'all_countries' in params:
        return str(params.get('all_countries', '')).lower() in ('1', 'true', 'yes')
    return False



def _pluto_requires_country_folders(params=None):
    params = params or {}
    if params.get('country_code'):
        return False
    return _pluto_country_code() == 'auto'



def _pluto_country_art(country_code):
    fallback = TRANSLATE(os.path.join(homeDir, 'resources', 'images', 'pluto.png'))
    flag_path = TRANSLATE(os.path.join(homeDir, 'resources', 'images', 'flags', '{}.png'.format((country_code or '').lower())))
    try:
        if os.path.exists(flag_path):
            return flag_path
    except Exception:
        pass
    return fallback



def _add_pluto_country_folders(action, media_title, plot_template, extra_params=None):
    extra_params = extra_params or {}
    for country_code, country_name in pluto.get_supported_countries():
        if country_code == 'auto':
            continue
        label = country_name
        plot = plot_template.format(country=country_name, media=media_title)
        art = _pluto_country_art(country_code)
        li = xbmcgui.ListItem(label)
        _set_listitem_info(li, 'video', {'title': label, 'plot': plot, 'mediatype': 'video'})
        _set_listitem_info(li, type="Video", infoLabels={"Title": label, "Plot": plot})
        li.setArt({'icon': art, 'thumb': art, 'poster': art, 'fanart': addonFanart})
        query = {'action': action, 'country_code': country_code, 'psid': pluto.new_session_id()}
        query.update(extra_params)
        xbmcplugin.addDirectoryItem(handle=ADDON_HANDLE, url=build_url(query), listitem=li, isFolder=True)



def _get_pluto_channels(params=None):
    return _pluto_call_with_fallback(
        lambda try_params: pluto.get_channels(
            hide_no_logo=_setting_bool('pluto_ocultarsemlogo', False),
            include_epg=_pluto_epg_enabled(),
            country=_pluto_effective_country(try_params),
            all_countries=_pluto_effective_all_countries(try_params),
            merge_duplicates=_pluto_merge_duplicates(),
            prioritize_country=_pluto_prefer_selected_country(),
            timezone_mode=_pluto_epg_timezone_mode()
        ),
        label='canais',
        params=params
    )


def _get_pluto_channels_grouped(params=None):
    return _pluto_call_with_fallback(
        lambda try_params: pluto.get_channels_grouped_by_category(
            hide_no_logo=_setting_bool('pluto_ocultarsemlogo', False),
            include_epg=_pluto_epg_enabled(),
            country=_pluto_effective_country(try_params),
            all_countries=_pluto_effective_all_countries(try_params),
            merge_duplicates=_pluto_merge_duplicates(),
            prioritize_country=_pluto_prefer_selected_country(),
            timezone_mode=_pluto_epg_timezone_mode()
        ),
        label='categorias canais',
        params=params
    )


def _get_pluto_channels_for_category(category, params=None):
    return _pluto_call_with_fallback(
        lambda try_params: pluto.get_channels_for_category(
            category=category,
            hide_no_logo=_setting_bool('pluto_ocultarsemlogo', False),
            include_epg=_pluto_epg_enabled(),
            country=_pluto_effective_country(try_params),
            all_countries=_pluto_effective_all_countries(try_params),
            merge_duplicates=_pluto_merge_duplicates(),
            prioritize_country=_pluto_prefer_selected_country(),
            timezone_mode=_pluto_epg_timezone_mode()
        ),
        label='canais por categoria ({})'.format(category),
        params=params
    )


def _get_pluto_vod_categories(media_type, params=None):
    return _pluto_call_with_fallback(
        lambda try_params: pluto.get_vod_categories(
            media_type=media_type,
            country=_pluto_effective_country(try_params),
            all_countries=_pluto_effective_all_countries(try_params),
            merge_duplicates=_pluto_merge_duplicates(),
            prioritize_country=_pluto_prefer_selected_country()
        ),
        label='categorias {}'.format(media_type),
        params=params
    )


def _get_pluto_vod_items(category_key, media_type, params=None):
    return _pluto_call_with_fallback(
        lambda try_params: pluto.get_vod_items(
            category_key=category_key,
            media_type=media_type,
            country=_pluto_effective_country(try_params),
            all_countries=_pluto_effective_all_countries(try_params),
            merge_duplicates=_pluto_merge_duplicates(),
            prioritize_country=_pluto_prefer_selected_country()
        ),
        label='itens {} ({})'.format(media_type, category_key),
        params=params
    )


def _get_pluto_series_seasons(series_id, params=None):
    return _pluto_call_with_fallback(
        lambda try_params: pluto.get_series_seasons(
            series_id,
            country=_pluto_effective_country(try_params)
        ),
        label='temporadas série {}'.format(series_id),
        params=params
    )


def pluto_entry(params=None):
    params = params or {}
    psid = _ensure_pluto_session(params)
    if _pluto_requires_country_folders(params):
        _add_pluto_country_folders('pluto_entry', 'Pluto TV', 'Abrir {media} do país {country}.')
        _end_directory_with_view(content='videos', view='List', delay_ms=40, repeat=2, default_once_key='default_view_tv_list_applied')
        return
    if _pluto_entry_mode() == 'categories':
        channels_pluto_categories(params=params, psid=psid)
        return
    channels_pluto(params=params, psid=psid)


def channels_pluto(params=None, psid=''):
    params = params or {}
    try:
        channels = _get_pluto_channels(params)
    except Exception as exc:
        _log_error('Falha ao carregar Pluto TV: {}'.format(exc))
        _show_error('Não foi possível carregar os canais do Pluto TV.', dialog=True)
        return
    if not channels:
        _show_error('Nenhum canal do Pluto TV foi encontrado.', dialog=True)
        return
    for channel in channels:
        name = channel.get('display_name') or channel.get('name') or 'Pluto TV'
        desc = channel.get('description', '')
        thumb = channel.get('thumb', '')
        slug = channel.get('slug', '')
        channel_id = channel.get('channel_id', '')
        if not (slug or channel_id):
            continue
        icon = thumb or TRANSLATE(os.path.join(homeDir, 'resources', 'images', 'pluto.png'))
        url_ = build_url({'action': 'play_pluto', 'name': name, 'description': desc, 'icon': icon, 'slug': slug, 'channel_id': channel_id, 'psid': psid, 'country_code': channel.get('country_code', _pluto_country_code())})
        li = xbmcgui.ListItem(name)
        _set_listitem_info(li, 'video', {'title': name, 'plot': desc, 'mediatype': 'video'})
        _set_listitem_info(li, type="Video", infoLabels={"Title": name, "Plot": desc})
        li.setArt({'icon': icon, 'thumb': icon, 'poster': icon, 'fanart': addonFanart})
        li.setProperty('IsPlayable', 'true')
        xbmcplugin.addDirectoryItem(handle=ADDON_HANDLE, url=url_, listitem=li, isFolder=False)
    _end_directory_with_view(content='videos', view='List', delay_ms=40, repeat=2, default_once_key='default_view_tv_list_applied')


def channels_pluto_categories(params=None, psid=''):
    params = params or {}
    try:
        grouped = _get_pluto_channels_grouped(params)
    except Exception as exc:
        _log_debug('Falha ao carregar categorias do Pluto TV: {}'.format(exc))
        _show_error('Não foi possível carregar as categorias do Pluto TV.', dialog=True)
        return
    if not grouped:
        _show_error('Nenhuma categoria do Pluto TV foi encontrada.', dialog=True)
        return
    icon = TRANSLATE(os.path.join(homeDir, 'resources', 'images', 'pluto.png'))
    for category, channels in grouped:
        label = '{} ({})'.format(category, len(channels))
        li = xbmcgui.ListItem(label)
        _set_listitem_info(li, 'video', {'title': label, 'plot': 'Abrir canais da categoria {} no Pluto TV.'.format(category), 'mediatype': 'video'})
        _set_listitem_info(li, type="Video", infoLabels={"Title": label, "Plot": 'Abrir canais da categoria {} no Pluto TV.'.format(category)})
        li.setArt({'icon': icon, 'thumb': icon, 'poster': icon, 'fanart': addonFanart})
        xbmcplugin.addDirectoryItem(handle=ADDON_HANDLE, url=build_url({'action': 'channels_pluto_category', 'category': category, 'psid': psid, 'country_code': _pluto_effective_country(params)}), listitem=li, isFolder=True)
    _end_directory_with_view(content='videos', view='List', delay_ms=40, repeat=2, default_once_key='default_view_tv_list_applied')


def channels_pluto_category(params, psid=''):
    category = params.get('category', '')
    try:
        channels = _get_pluto_channels_for_category(category, params)
    except Exception as exc:
        _log_error('Falha ao carregar categoria Pluto {}: {}'.format(category, exc))
        _show_error('Não foi possível carregar os canais dessa categoria do Pluto TV.', dialog=True)
        return
    if not channels:
        _show_error('Nenhum canal foi encontrado nessa categoria do Pluto TV.', dialog=True)
        return
    for channel in channels:
        name = channel.get('display_name') or channel.get('name') or 'Pluto TV'
        desc = channel.get('description', '')
        thumb = channel.get('thumb', '')
        slug = channel.get('slug', '')
        channel_id = channel.get('channel_id', '')
        if not (slug or channel_id):
            continue
        icon = thumb or TRANSLATE(os.path.join(homeDir, 'resources', 'images', 'pluto.png'))
        url_ = build_url({'action': 'play_pluto', 'name': name, 'description': desc, 'icon': icon, 'slug': slug, 'channel_id': channel_id, 'psid': psid, 'country_code': channel.get('country_code', _pluto_country_code())})
        li = xbmcgui.ListItem(name)
        _set_listitem_info(li, 'video', {'title': name, 'plot': desc, 'mediatype': 'video'})
        _set_listitem_info(li, type="Video", infoLabels={"Title": name, "Plot": desc})
        li.setArt({'icon': icon, 'thumb': icon, 'poster': icon, 'fanart': addonFanart})
        li.setProperty('IsPlayable', 'true')
        xbmcplugin.addDirectoryItem(handle=ADDON_HANDLE, url=url_, listitem=li, isFolder=False)
    _end_directory_with_view(content='videos', view='List', delay_ms=40, repeat=2, default_once_key='default_view_tv_list_applied')



def _pluto_mark_unresolved():
    """Finaliza explicitamente a rota plugin quando o Pluto não pode resolver.

    Sem este retorno o Kodi trata a URL do plugin como item reproduzível que
    simplesmente nunca respondeu, exibindo "skipping unplayable item" sem a
    causa real. A falha continua visível ao usuário, mas o fluxo é encerrado
    de forma determinística em Kodi 19+.
    """
    try:
        failed_item = xbmcgui.ListItem()
        failed_item.setProperty('IsPlayable', 'false')
        xbmcplugin.setResolvedUrl(handle=ADDON_HANDLE, succeeded=False, listitem=failed_item)
    except Exception:
        pass


def _inputstream_adaptive_available():
    """Detecta InputStream Adaptive sem depender do helper estar no sys.path.

    O helper é útil para instalações que ainda precisam instalar o binário,
    mas o player Pluto não deve ser bloqueado quando o Adaptive já está
    presente e o helper não foi declarado por outro addon.
    """
    try:
        if xbmc.getCondVisibility('System.HasAddon(inputstream.adaptive)'):
            return True
    except Exception:
        pass
    try:
        xbmcaddon.Addon('inputstream.adaptive')
        return True
    except Exception:
        return False


def _prepare_pluto_hls_inputstream():
    """Retorna o addon InputStream apto para HLS ou string vazia.

    Prioriza o Adaptive já instalado. Só chama InputStream Helper como fallback
    para instalação/validação em builds onde o binário ainda não existe; isso
    evita diálogos e falsos negativos em Kodi 19/20/21 quando o Helper não
    estava declarado na árvore de dependências do plugin.
    """
    if _inputstream_adaptive_available():
        return 'inputstream.adaptive'

    helper_module = _get_inputstreamhelper()
    if helper_module is not None:
        try:
            helper = helper_module.Helper('hls')
            if helper.check_inputstream():
                addon_name = getattr(helper, 'inputstream_addon', '') or 'inputstream.adaptive'
                return addon_name
        except Exception as exc:
            _log_warning('Pluto: InputStream Helper falhou ao preparar HLS: {}'.format(exc))

    return ''


def _split_pluto_stream_headers(stream_url):
    stream_url = stream_url or ''
    if '|' in stream_url:
        base_url, headers = stream_url.split('|', 1)
        return base_url, headers
    return stream_url, ''


def _configure_pluto_hls_item(list_item, stream_url, inputstream_addon, headers=''):
    """Configura um ListItem HLS de forma idêntica para live e VOD."""
    list_item.setPath(stream_url)
    list_item.setProperty('IsPlayable', 'true')
    list_item.setProperty('inputstream', inputstream_addon)
    # Compatibilidade com builds Matrix/Leia que ainda leem o alias legado.
    list_item.setProperty('inputstreamaddon', inputstream_addon)
    list_item.setProperty('inputstream.adaptive.manifest_type', 'hls')
    header_value = headers or 'User-Agent={}'.format(DEFAULT_USER_AGENT)
    list_item.setProperty('inputstream.adaptive.stream_headers', header_value)
    list_item.setProperty('inputstream.adaptive.manifest_headers', header_value)
    list_item.setMimeType('application/x-mpegURL')
    list_item.setContentLookup(False)

def play_pluto(params):
    slug = params.get('slug', '')
    channel_id = params.get('channel_id', '')
    name = params.get('name', 'Pluto TV')
    icon = params.get('icon', '')
    description = params.get('description', '')
    _ensure_pluto_session(params.get('psid', ''))
    url, resolved_country = _pluto_resolve_live_url(params)
    if not url:
        _log_error('Pluto live sem URL válida para {}.'.format(name))
        _show_error('URL do canal Pluto inválida.', dialog=True)
        return
    helper_module = _get_inputstreamhelper()
    if helper_module is None:
        _show_error('O módulo InputStream Helper não está disponível para reproduzir o Pluto TV.', dialog=True)
        return
    try:
        helper = helper_module.Helper('hls')
        if not helper.check_inputstream():
            _show_error('O InputStream Adaptive não está disponível para reproduzir o Pluto TV.', dialog=True)
            return
    except Exception as exc:
        _log_error('Falha no inputstream do Pluto TV: {}'.format(exc))
        _show_error('Falha ao inicializar o player dedicado do Pluto TV.', dialog=True)
        return

    headers = ''
    if '|' in url:
        url, headers = url.split('|', 1)

    li = xbmcgui.ListItem(path=url)
    li.setArt({'icon': icon, 'thumb': icon, 'poster': icon, 'fanart': addonFanart})
    _set_listitem_info(li, 'video', {'title': name, 'plot': description, 'mediatype': 'video'})
    _set_listitem_info(li, type="Video", infoLabels={"Title": name, "Plot": description})
    li.setProperty('inputstream', helper.inputstream_addon)
    li.setProperty('inputstream.adaptive.manifest_type', 'hls')
    li.setProperty('inputstream.adaptive.live_delay', '0')
    li.setProperty('inputstream.adaptive.manifest_update_parameter', 'full')
    li.setMimeType('application/x-mpegURL')
    if headers:
        li.setProperty('inputstream.adaptive.stream_headers', headers)
        li.setProperty('inputstream.adaptive.manifest_headers', headers)
    li.setContentLookup(False)
    xbmcplugin.setResolvedUrl(handle=ADDON_HANDLE, succeeded=True, listitem=li)

def pluto_movies(params=None):
    params = params or {}
    psid = _ensure_pluto_session(params)
    if _pluto_requires_country_folders(params):
        _add_pluto_country_folders('pluto_movies', 'Filmes - Pluto TV', 'Abrir {media} do país {country}.')
        _end_directory_with_view(content='movies', view='List', delay_ms=40, repeat=2)
        return
    try:
        categories = _get_pluto_vod_categories('movie', params)
    except Exception as exc:
        _log_error('Falha ao carregar categorias Pluto Filmes: {}'.format(exc))
        _show_error('Não foi possível carregar as categorias do Pluto Filmes.', dialog=True)
        return
    if not categories:
        _show_error('Nenhuma categoria do Pluto Filmes foi encontrada.', dialog=True)
        return
    icon = TRANSLATE(os.path.join(homeDir, 'resources', 'images', 'pluto.png'))
    for category in categories:
        label = '{} ({})'.format(category.get('name', 'Pluto Filmes'), category.get('item_count', 0))
        plot = 'Abrir a categoria {} do Pluto Filmes.'.format(category.get('name', 'Pluto Filmes'))
        if _pluto_effective_all_countries(params):
            plot += ' Fonte base: {}.'.format(category.get('country_label', 'Pluto'))
        li = xbmcgui.ListItem(label)
        _set_listitem_info(li, 'video', {'title': label, 'plot': plot, 'mediatype': 'video'})
        _set_listitem_info(li, type="Video", infoLabels={"Title": label, "Plot": plot})
        li.setArt({'icon': icon, 'thumb': icon, 'poster': icon, 'fanart': addonFanart})
        xbmcplugin.addDirectoryItem(
            handle=ADDON_HANDLE,
            url=build_url({'action': 'pluto_vod_category', 'media_type': 'movie', 'category_key': category.get('key', ''), 'psid': psid, 'country_code': _pluto_effective_country(params)}),
            listitem=li,
            isFolder=True
        )
    _end_directory_with_view(content='movies', view='List', delay_ms=40, repeat=2)


def pluto_series(params=None):
    params = params or {}
    psid = _ensure_pluto_session(params)
    if _pluto_requires_country_folders(params):
        _add_pluto_country_folders('pluto_series', 'Séries - Pluto TV', 'Abrir {media} do país {country}.')
        _end_directory_with_view(content='tvshows', view='List', delay_ms=40, repeat=2)
        return
    try:
        categories = _get_pluto_vod_categories('series', params)
    except Exception as exc:
        _log_error('Falha ao carregar categorias Pluto Séries: {}'.format(exc))
        _show_error('Não foi possível carregar as categorias do Pluto Séries.', dialog=True)
        return
    if not categories:
        _show_error('Nenhuma categoria do Pluto Séries foi encontrada.', dialog=True)
        return
    icon = TRANSLATE(os.path.join(homeDir, 'resources', 'images', 'pluto.png'))
    for category in categories:
        label = '{} ({})'.format(category.get('name', 'Pluto Séries'), category.get('item_count', 0))
        plot = 'Abrir a categoria {} do Pluto Séries.'.format(category.get('name', 'Pluto Séries'))
        if _pluto_effective_all_countries(params):
            plot += ' Fonte base: {}.'.format(category.get('country_label', 'Pluto'))
        li = xbmcgui.ListItem(label)
        _set_listitem_info(li, 'video', {'title': label, 'plot': plot, 'mediatype': 'video'})
        _set_listitem_info(li, type="Video", infoLabels={"Title": label, "Plot": plot})
        li.setArt({'icon': icon, 'thumb': icon, 'poster': icon, 'fanart': addonFanart})
        xbmcplugin.addDirectoryItem(
            handle=ADDON_HANDLE,
            url=build_url({'action': 'pluto_vod_category', 'media_type': 'series', 'category_key': category.get('key', ''), 'psid': psid, 'country_code': _pluto_effective_country(params)}),
            listitem=li,
            isFolder=True
        )
    _end_directory_with_view(content='tvshows', view='List', delay_ms=40, repeat=2)


def pluto_vod_category(params=None):
    params = params or {}
    psid = _ensure_pluto_session(params)
    category_key = params.get('category_key', '')
    media_type = params.get('media_type', 'movie')
    try:
        items = _get_pluto_vod_items(category_key, media_type, params)
    except Exception as exc:
        _log_error('Falha ao carregar itens VOD Pluto [{}]: {}'.format(media_type, exc))
        _show_error('Não foi possível carregar os itens desta categoria do Pluto.', dialog=True)
        return
    if not items:
        _show_error('Nenhum item foi encontrado nesta categoria do Pluto.', dialog=True)
        return
    default_icon = TRANSLATE(os.path.join(homeDir, 'resources', 'images', 'pluto.png'))
    for item in items:
        name = item.get('name', 'Pluto')
        plot = item.get('summary', '') or item.get('description', '')
        icon = item.get('poster', '') or default_icon
        fanart = item.get('fanart', '') or addonFanart
        if media_type == 'movie':
            target_url = build_url({
                'action': 'play_pluto_vod',
                'media_type': media_type,
                'name': name,
                'description': plot,
                'icon': icon,
                'fanart': fanart,
                'stitched_url': item.get('stitched_url', ''),
                'country_code': item.get('country_code', _pluto_country_code()),
                'psid': psid,
            })
            is_folder = False
        else:
            target_url = build_url({
                'action': 'pluto_vod_seasons',
                'series_id': item.get('id', ''),
                'name': name,
                'description': plot,
                'icon': icon,
                'fanart': fanart,
                'country_code': item.get('country_code', _pluto_country_code()),
                'psid': psid,
            })
            is_folder = True
        li = xbmcgui.ListItem(name)
        _set_listitem_info(li, 'video', {'title': name, 'plot': plot, 'mediatype': 'video'})
        _set_listitem_info(li, type="Video", infoLabels={"Title": name, "Plot": plot})
        li.setArt({'icon': icon, 'thumb': icon, 'poster': icon, 'fanart': fanart})
        if not is_folder:
            li.setProperty('IsPlayable', 'true')
        xbmcplugin.addDirectoryItem(handle=ADDON_HANDLE, url=target_url, listitem=li, isFolder=is_folder)
    if media_type == 'movie':
        _end_directory_with_view(content='movies', view='List', delay_ms=40, repeat=2)
    else:
        _end_directory_with_view(content='tvshows', view='List', delay_ms=40, repeat=2)


def pluto_vod_seasons(params=None):
    params = params or {}
    psid = _ensure_pluto_session(params)
    series_id = params.get('series_id', '')
    country_code = _pluto_effective_country(params)
    try:
        seasons = _get_pluto_series_seasons(series_id, params)
    except Exception as exc:
        _log_debug('Falha ao carregar temporadas Pluto [{}]: {}'.format(series_id, exc))
        _show_error('Não foi possível carregar as temporadas desta série do Pluto.', dialog=True)
        return
    if not seasons:
        _show_error('Nenhuma temporada foi encontrada para esta série do Pluto.', dialog=True)
        return
    default_icon = params.get('icon', '') or TRANSLATE(os.path.join(homeDir, 'resources', 'images', 'pluto.png'))
    for season in seasons:
        label = season.get('name', 'Temporada {}'.format(season.get('season_number', 0)))
        plot = season.get('description', '') or 'Abrir episódios desta temporada do Pluto.'
        li = xbmcgui.ListItem(label)
        _set_listitem_info(li, 'video', {'title': label, 'plot': plot, 'mediatype': 'video'})
        _set_listitem_info(li, type="Video", infoLabels={"Title": label, "Plot": plot})
        li.setArt({'icon': default_icon, 'thumb': default_icon, 'poster': default_icon, 'fanart': params.get('fanart', addonFanart)})
        xbmcplugin.addDirectoryItem(
            handle=ADDON_HANDLE,
            url=build_url({
                'action': 'pluto_vod_episodes',
                'series_id': series_id,
                'season_number': str(season.get('season_number', 0)),
                'name': params.get('name', 'Séries - Pluto TV'),
                'icon': params.get('icon', default_icon),
                'fanart': params.get('fanart', addonFanart),
                'country_code': country_code,
                'psid': psid,
            }),
            listitem=li,
            isFolder=True
        )
    _end_directory_with_view(content='seasons', view='List', delay_ms=40, repeat=2)


def pluto_vod_episodes(params=None):
    params = params or {}
    psid = _ensure_pluto_session(params)
    series_id = params.get('series_id', '')
    country_code = _pluto_effective_country(params)
    season_number = int(params.get('season_number', '0') or 0)
    try:
        seasons = _get_pluto_series_seasons(series_id, params)
    except Exception as exc:
        _log_debug('Falha ao carregar episódios Pluto [{}]: {}'.format(series_id, exc))
        _show_error('Não foi possível carregar os episódios desta temporada do Pluto.', dialog=True)
        return
    selected = None
    for season in seasons:
        if int(season.get('season_number', 0) or 0) == season_number:
            selected = season
            break
    if not selected:
        _show_error('Nenhum episódio foi encontrado para esta temporada do Pluto.', dialog=True)
        return
    default_icon = params.get('icon', '') or TRANSLATE(os.path.join(homeDir, 'resources', 'images', 'pluto.png'))
    for episode in selected.get('episodes', []):
        title = '{} - S{}E{}'.format(params.get('name', 'Séries - Pluto TV'), str(season_number).zfill(2), str(episode.get('episode_number', 0)).zfill(2))
        ep_name = episode.get('name', '')
        plot = episode.get('summary', '') or episode.get('description', '')
        icon = episode.get('poster', '') or default_icon
        fanart = episode.get('fanart', '') or params.get('fanart', addonFanart)
        li = xbmcgui.ListItem(title)
        _set_listitem_info(li, 'video', {'title': title, 'plot': plot, 'mediatype': 'episode'})
        _set_listitem_info(li, type="Video", infoLabels={"Title": title, "Plot": plot})
        li.setArt({'icon': icon, 'thumb': icon, 'poster': icon, 'fanart': fanart})
        li.setProperty('IsPlayable', 'true')
        xbmcplugin.addDirectoryItem(
            handle=ADDON_HANDLE,
            url=build_url({
                'action': 'play_pluto_vod',
                'media_type': 'episode',
                'name': '{} - {}'.format(title, ep_name) if ep_name else title,
                'description': plot,
                'icon': icon,
                'fanart': fanart,
                'stitched_url': episode.get('stitched_url', ''),
                'country_code': country_code,
                'psid': psid,
            }),
            listitem=li,
            isFolder=False
        )
    _end_directory_with_view(content='episodes', view='WideList', delay_ms=40, repeat=2)


def play_pluto_vod(params):
    name = params.get('name', 'Pluto')
    icon = params.get('icon', '')
    description = params.get('description', '')
    country_code = _pluto_effective_country(params)
    _ensure_pluto_session(params.get('psid', ''))
    url, resolved_country = _pluto_resolve_vod_url(params)
    if not url:
        _log_error('Pluto VOD sem URL válida para {}.'.format(name))
        _show_error('URL do conteúdo Pluto inválida.', dialog=True)
        return
    helper_module = _get_inputstreamhelper()
    if helper_module is None:
        _show_error('O módulo InputStream Helper não está disponível para reproduzir o Pluto.', dialog=True)
        return
    try:
        helper = helper_module.Helper('hls')
        if not helper.check_inputstream():
            _show_error('O InputStream Adaptive não está disponível para reproduzir o Pluto.', dialog=True)
            return
    except Exception as exc:
        _log_error('Falha ao preparar o player do Pluto VOD: {}'.format(exc))
        _show_error('Falha ao preparar o player do Pluto.', dialog=True)
        return
    headers = ''
    if '|User-Agent=' in url:
        url, headers = url.split('|', 1)
    li = xbmcgui.ListItem(name)
    _set_listitem_info(li, 'video', {'title': name, 'plot': description, 'mediatype': 'video'})
    _set_listitem_info(li, type="Video", infoLabels={"Title": name, "Plot": description})
    li.setArt({'icon': icon, 'thumb': icon, 'poster': icon, 'fanart': params.get('fanart', addonFanart)})
    li.setPath(url)
    li.setProperty('IsPlayable', 'true')
    try:
        addon_name = helper.inputstream_addon
    except Exception:
        addon_name = 'inputstream.adaptive'
    li.setProperty('inputstream', addon_name)
    li.setProperty('inputstreamaddon', addon_name)
    li.setProperty('inputstream.adaptive.manifest_type', 'hls')
    li.setProperty('inputstream.adaptive.stream_headers', headers or 'User-Agent={}'.format(DEFAULT_USER_AGENT))
    li.setProperty('inputstream.adaptive.manifest_headers', headers or 'User-Agent={}'.format(DEFAULT_USER_AGENT))
    li.setMimeType('application/x-mpegURL')
    li.setContentLookup(False)
    xbmcplugin.setResolvedUrl(handle=ADDON_HANDLE, succeeded=True, listitem=li)

def menu_movies():  
    items = [
        ('Filmes - Em Alta', 'Ver os filmes em tendência na semana.', {'action': 'list', 'type': 'movie', 'category': 'trending'}),
        ('Filmes - Top Avaliados', 'Ver os filmes mais bem avaliados.', {'action': 'list', 'type': 'movie', 'category': 'top'}),
        ('Filmes - Em Cartaz', 'Ver os filmes atualmente em cartaz conforme a região configurada.', {'action': 'list', 'type': 'movie', 'category': 'now_playing'}),
        ('Filmes - Próximos Lançamentos', 'Ver os próximos lançamentos de filmes conforme a região configurada.', {'action': 'list', 'type': 'movie', 'category': 'upcoming'}),
    ]
    for label, description, query in items:
        url = build_url(query)
        icon = TRANSLATE(os.path.join(homeDir, 'resources','images','filmes.png'))
        li = xbmcgui.ListItem(label)
        _set_listitem_info(li, 'video', {'mediatype': 'video'})
        _set_listitem_info(li, type="Video", infoLabels={"Title": label, "Plot": description})
        li.setArt({'icon': icon, 'thumb': icon, 'poster': icon, 'fanart': addonFanart})
        xbmcplugin.addDirectoryItem(handle=ADDON_HANDLE, url=url, listitem=li, isFolder=True)
    if _setting_bool('pluto_exibir_filmes', True):
        _add_pluto_shortcut('Filmes - Pluto TV', 'pluto_movies', 'Abrir os filmes sob demanda do Pluto TV respeitando o país configurado em Pluto TV.')
    _add_settings_item('Abrir as configurações do addon OnePlay a partir da área de filmes.')
    _end_directory_with_view(content='movies', view='List', delay_ms=40, repeat=2)

def menu_series():  
    items = [
        ('Séries - Em Alta', 'Ver as séries em tendência na semana.', {'action': 'list', 'type': 'series', 'category': 'trending'}),
        ('Séries - Top Avaliadas', 'Ver as séries mais bem avaliadas.', {'action': 'list', 'type': 'series', 'category': 'top'}),
        ('Séries - No Ar', 'Ver séries com episódios em exibição nos próximos dias.', {'action': 'list', 'type': 'series', 'category': 'on_the_air'}),
        ('Séries - Estreias Recentes', 'Ver séries com estreia inicial recente.', {'action': 'list', 'type': 'series', 'category': 'recent_premieres'}),
    ]    
    for label, description, query in items:
        url = build_url(query)
        icon = TRANSLATE(os.path.join(homeDir, 'resources','images','series.png'))
        li = xbmcgui.ListItem(label)
        _set_listitem_info(li, 'video', {'mediatype': 'video'})
        _set_listitem_info(li, type="Video", infoLabels={"Title": label, "Plot": description})
        li.setArt({'icon': icon, 'thumb': icon, 'poster': icon, 'fanart': addonFanart})
        xbmcplugin.addDirectoryItem(handle=ADDON_HANDLE, url=url, listitem=li, isFolder=True)
    if _setting_bool('pluto_exibir_series', True):
        _add_pluto_shortcut('Séries - Pluto TV', 'pluto_series', 'Abrir as séries sob demanda do Pluto TV respeitando o país configurado em Pluto TV.')
    _add_settings_item('Abrir as configurações do addon OnePlay a partir da área de séries.')
    _end_directory_with_view(content='tvshows', view='List', delay_ms=40, repeat=2)            

def list_items(params):  
    # ... (sem alterações aqui)
    next_button = False
    page = int(params.get('page', '1'))
    items = tmdb.get_items(params.get('type'), params.get('category'), page)
    if items:
        next_button = True

    for item in items:
        title = '{} ({})'.format(item['title'], item['year']) if item['year'] else item['title']
        fanart = item['background'] if item.get('background') else ''
        poster = item.get('poster') or ''
        media_type = params.get('type') or 'movie'
        route_params = {
            'type': media_type,
            'id': item['id'],
            'name': item['title'],
            'year': item['year'],
            'fanart': fanart,
            'poster': poster,
            'description': item.get('description', ''),
        }
        # Filme vai direto à lista unificada. Série precisa manter o passo de
        # temporadas/episódios, portanto continua na rota de detalhe.
        route_params['action'] = 'list_streams' if media_type == 'movie' else 'details'
        url = build_url(route_params)
        li = xbmcgui.ListItem(title)
        if poster or fanart:
            li.setArt({
                'thumb': poster or None,
                'icon': poster or None,
                'poster': poster or None,
                'fanart': fanart or None,
            })
        _set_listitem_info(li, 'video', {'title': title, 'plot': item.get('description', ''), 'year': int(item['year']) if item['year'] else 0, 'mediatype': 'video'})
        if media_type == 'movie':
            _add_movie_synopsis_context(li, title, item.get('description', ''))
        xbmcplugin.addDirectoryItem(handle=ADDON_HANDLE, url=url, listitem=li, isFolder=True)

    next_page_url = build_url({
        'action': 'list',
        'type': params.get('type'),
        'category': params.get('category'),
        'page': str(page + 1)
    })
    if next_button:
        next_li = xbmcgui.ListItem('[Próxima Página]')
        icon = TRANSLATE(os.path.join(homeDir, 'resources','images','proximo.png'))
        next_li.setArt({'icon': icon, 'thumb': icon, 'poster': icon, 'fanart': addonFanart})
        _set_listitem_info(next_li, 'video', {'mediatype': 'video'})
        xbmcplugin.addDirectoryItem(handle=ADDON_HANDLE, url=next_page_url, listitem=next_li, isFolder=True)

    if params.get('type') == 'series':
        _end_directory_with_view(content='tvshows', view='List', delay_ms=40, repeat=2)
    else:
        _end_directory_with_view(content='movies', view='List', delay_ms=40, repeat=2)

def list_seasons(params):
    # ... (sem alterações aqui)
    tmdb_id = params['id']
    seasons = tmdb.get_seasons_vod_fast(params['type'], tmdb_id)

    for season in seasons:
        name = season['name']
        url = build_url({
            'action': 'list_episodes',
            'type': params['type'],
            'id': tmdb_id,
            'season_number': str(season['season_number']),
            'poster': params['poster'],
            'name': params['name'],
            'year': params['year'],
            'fanart': params.get('fanart', ''),
            'vod_imdb_id': params.get('vod_imdb_id', '')
        })
        li = xbmcgui.ListItem(name)
        if season.get('poster'):
            li.setArt({'thumb': season['poster'], 'icon': season['poster'], 'poster': season['poster']})
        _set_listitem_info(li, 'video', {'title': name, 'plot': season.get('description', ''), 'year': int(season['year']) if season['year'] else 0, 'mediatype': 'video'})
        xbmcplugin.addDirectoryItem(handle=ADDON_HANDLE, url=url, listitem=li, isFolder=True)

    if params.get('type') == 'series':
        _end_directory_with_view(content='seasons', view='List', delay_ms=40, repeat=2)
    else:
        _end_directory_with_view(content='movies', view='List', delay_ms=40, repeat=2)

def list_episodes(params):
    # ... (sem alterações aqui, exceto a action no build_url)
    tmdb_id = params['id']
    season_number = params['season_number']
    episodes = tmdb.get_episodes_vod_fast(params['type'], tmdb_id, season_number)

    for episode in episodes:
        name = '{} - S{}E{}'.format(params['name'], season_number.zfill(2), str(episode['episode_number']).zfill(2))
        url = build_url({
            'action': 'list_streams', ## MODIFICADO: de 'audio' para 'list_streams'
            'type': params['type'],
            'id': tmdb_id,
            'season_number': season_number,
            'episode_number': str(episode['episode_number']),
            'name': params['name'],
            'poster': params['poster'],
            'year': params['year'],
            'fanart': params.get('fanart', ''),
            'vod_imdb_id': params.get('vod_imdb_id', '')
        })
        li = xbmcgui.ListItem(name)
        if episode.get('poster'):
            li.setArt({'thumb': episode['poster'], 'icon': episode['poster'], 'poster': episode['poster']})
        _set_listitem_info(li, 'video', {'title': name, 'plot': episode.get('description', ''), 'year': int(episode['year']) if episode['year'] else 0, 'mediatype': 'video'})
        xbmcplugin.addDirectoryItem(handle=ADDON_HANDLE, url=url, listitem=li, isFolder=True)

    if params.get('type') == 'series':
        _end_directory_with_view(content='episodes', view='WideList', delay_ms=40, repeat=2)
    else:
        _end_directory_with_view(content='movies', view='List', delay_ms=40, repeat=2)

def _stream_title_text(stream):
    return stream.get('title', stream.get('description', '')).replace('\n', ' ').replace('\t', '').replace('SKYFLIX API', 'OnePlay API').strip()

def _stream_is_download_only(stream):
    """Identifica rótulos explícitos de fonte para download, sem excluí-la do menu.

    Algumas fontes ainda retornam um vídeo válido, então não são apagadas. Elas
    apenas deixam de ganhar prioridade automática e ficam no final da lista.
    """
    values = (
        stream.get('name', '') or '',
        stream.get('title', '') or '',
        stream.get('description', '') or '',
    )
    text = ' '.join(values).lower().replace('_', ' ')
    markers = (
        'download only', 'download-only', 'somente download', 'apenas download',
        'para download', 'baixar somente', '10gbps download',
        '[download]', '[ download ]', 'download |', 'download -',
    )
    return any(marker in text for marker in markers)

_QUALITY_TOKEN_PATTERNS = {
    '4k': (r'(^|[^0-9a-z])(4k|2160p?|uhd)([^0-9a-z]|$)',),
    '1080': (r'(^|[^0-9a-z])(1080p?|fhd)([^0-9a-z]|$)',),
    '720': (r'(^|[^0-9a-z])(720p?)([^0-9a-z]|$)',),
    'light': (r'(^|[^0-9a-z])(480p?|360p?|sd)([^0-9a-z]|$)',),
}
_DIRECT_MEDIA_HINTS = ('.m3u8', '.mp4', '.mkv', '.webm', '.mpd', '.avi', '.mov', '.m4v', '.ts', '/hls/', '/mp4/', '/mkv/', '/webm/', '/dash/')


# A descoberta de fontes HdHub foi movida integralmente para
# ``resources.lib.vod_engine``. A camada de rotas não monta mais URLs de
# provider, tokens de qualidade ou cascatas de perfis; ela recebe apenas fontes
# normalizadas e já validadas pelo contrato manifest.json.

def _log_stream_inventory(streams, stage_label):
    explicit = direct_unknown = opaque = auto_like = dublado_like = 0
    for stream in streams:
        info = _stream_quality_info(stream)
        if info['quality_key']:
            explicit += 1
        elif info['is_direct_media']:
            direct_unknown += 1
        else:
            opaque += 1
        if info['is_auto']:
            auto_like += 1
        if info['is_dublado']:
            dublado_like += 1
    _log_debug('Inventário {} -> total={} explicitas={} diretas_indefinidas={} opacas={} auto_like={} dublado_like={}'.format(stage_label, len(streams), explicit, direct_unknown, opaque, auto_like, dublado_like))


def _stream_quality_match(scan_text):
    scan_text = (scan_text or '').lower()
    for quality_key in ('4k', '1080', '720', 'light'):
        for pattern in _QUALITY_TOKEN_PATTERNS[quality_key]:
            if re.search(pattern, scan_text):
                return quality_key
    return None


def _stream_quality_info(stream):
    name = stream.get('name', '') or ''
    desc = stream.get('description', '') or ''
    title = stream.get('title', '') or ''
    url = stream.get('url', '') or ''
    parsed = urlparse(url)
    host = (parsed.netloc or '').lower()
    path = unquote_plus((parsed.path or '').lower())
    combined = '{} {} {}'.format(name, desc, title).strip().lower()

    # O caminho da mídia e o tipo normalizado pelo motor têm precedência sobre
    # o texto do provider. Isso mantém a nomenclatura coerente entre FSL,
    # Pixeldrain, HLS e links externos.
    quality_key = _stream_quality_match(path) or _stream_quality_match(combined)
    is_auto = '- auto' in combined or '(auto)' in combined or combined.endswith(' auto')
    is_dublado = 'dublado' in combined
    normalized_kind = (stream.get('__vod_kind') or '').lower()
    is_direct_media = normalized_kind in ('hls', 'dash', 'progressive') or any(hint in path for hint in _DIRECT_MEDIA_HINTS)
    is_opaque = not is_direct_media

    if re.search(r'(^|[^a-z0-9])av1([^a-z0-9]|$)|av01', combined):
        codec_risk = 'av1'
    elif '10bit' in combined or '10-bit' in combined:
        codec_risk = '10bit'
    elif 'hdr' in combined or 'dolby vision' in combined or ' dv ' in (' ' + combined + ' '):
        codec_risk = 'hdr'
    elif 'hevc' in combined or 'x265' in combined or 'h.265' in combined:
        codec_risk = 'hevc'
    else:
        codec_risk = 'safe'

    if quality_key == '4k':
        label = '4K'
    elif quality_key == '1080':
        label = '1080p'
    elif quality_key == '720':
        label = '720p'
    elif quality_key == 'light':
        label = '480/360/SD'
    elif is_direct_media:
        label = 'qualidade não declarada'
    else:
        label = 'qualidade não declarada'
    return {
        'combined': combined,
        'host': host,
        'path': path,
        'quality_key': quality_key,
        'quality_label': label,
        'codec_risk': codec_risk,
        'is_auto': is_auto,
        'is_dublado': is_dublado,
        'is_direct_media': is_direct_media,
        'is_opaque': is_opaque,
    }


def _stream_source_family(stream):
    return (stream.get('__vod_source_family') or 'Fonte externa').strip()


def _stream_delivery_label(stream):
    kind = (stream.get('__vod_kind') or '').lower()
    family = _stream_source_family(stream)
    if kind == 'unknown' and family in ('FSL', 'FSL v2'):
        return 'Arquivo direto por token'
    return {
        'hls': 'HLS',
        'dash': 'DASH',
        'progressive': 'Arquivo direto',
        'external-url': 'Link externo',
        'youtube': 'YouTube',
        'torrent': 'Torrent • PureTorrent',
        'archive': 'Arquivo',
        'unsupported': 'Protocolo externo',
        'unknown': 'Link sem formato',
    }.get(kind, 'Fonte externa')


def _stream_manual_reason_text(stream):
    if not stream.get('__vod_playable', True):
        return stream.get('__vod_info_state') or 'não reproduzível pelo player VOD nativo'
    reasons = list(stream.get('__vod_caution_reasons') or ())
    labels = {
        'download': 'marcada pelo provider para download',
        'link-opaco': 'link sem formato de mídia declarado',
        'fsl-token': 'URL temporária por token; escolha manual recomendada',
        'not-web-ready': 'requer tratamento especial do provider',
        'intermediario': 'servidor intermediário; teste manual recomendado',
    }
    readable = [labels.get(reason, reason) for reason in reasons if reason != 'informativa']
    return '; '.join(readable)


def _stream_plot_text(stream, provider_label='HdHub'):
    """Mantém a perícia técnica no detalhe do item, fora do título."""
    rows = [
        'Origem: {} • {}'.format(provider_label, _stream_source_family(stream)),
        'Entrega: {}'.format(_stream_delivery_label(stream)),
    ]
    size = (stream.get('__vod_size_label') or '').strip()
    if size:
        rows.append('Tamanho informado: {}'.format(size))
    if (stream.get('__vod_kind') or '').lower() == 'torrent':
        file_idx = stream.get('__vod_torrent_file_idx')
        trackers = stream.get('__vod_torrent_trackers') or ()
        rows.append('Uso: abrir manualmente no PureTorrent')
        if file_idx is not None:
            rows.append('Arquivo indicado pelo HdHub: #{}'.format(file_idx))
        if trackers:
            rows.append('Trackers encaminhados: {}'.format(len(trackers)))
    elif stream.get('__vod_playable', True):
        if stream.get('__vod_caution'):
            rows.append('Uso: escolha manual — {}'.format(_stream_manual_reason_text(stream) or 'fonte cautelosa'))
        else:
            rows.append('Uso: escolha manual — fonte direta pronta para reprodução')
    else:
        rows.append('Uso: informativa — {}'.format(_stream_manual_reason_text(stream)))
    raw = (stream.get('__vod_raw_title') or stream.get('__vod_raw_name') or stream.get('title') or '').strip()
    if raw:
        rows.append('Detalhe do provider: {}'.format(raw[:360]))
    return '\n'.join(rows)


def _stream_audio_label(stream):
    """Converte abreviações comuns do provider em rótulos legíveis.

    O provider mistura palavras e siglas como DUB, LEG e DUAL nos campos
    name/title/description. No título exibimos somente a informação útil ao
    usuário, sem expor a taxonomia técnica do espelho.
    """
    combined = _stream_quality_info(stream).get('combined', '')
    boundary = r'(^|[^a-z0-9]){}([^a-z0-9]|$)'
    if re.search(boundary.format(r'dual(?:[\s_-]*(?:audio|áudio))?|dualaudio'), combined):
        return 'Áudio duplo'
    if re.search(boundary.format(r'dublado|dub'), combined):
        return 'Dublado'
    if re.search(boundary.format(r'legendado|leg'), combined):
        return 'Legendado'
    return ''


def _stream_quality_badge(stream):
    quality_key = _stream_quality_info(stream).get('quality_key')
    return {
        '4k': '[COLOR orange]4K[/COLOR]',
        '1080': '[COLOR gold]1080p[/COLOR]',
        '720': '[COLOR deepskyblue]720p[/COLOR]',
        'light': '[COLOR springgreen]SD[/COLOR]',
    }.get(quality_key, '')


def _stream_title_tags(stream):
    """Retorna somente dados decisórios para a lista manual.

    HLS, DASH, FSL, host, token, PureTorrent e demais detalhes operacionais
    continuam disponíveis no plot. Eles não precisam poluir todos os títulos.
    """
    tags = []
    kind = (stream.get('__vod_kind') or '').lower()
    playable = stream.get('__vod_playable', True)

    # Torrent tem rota reproduzível própria no PureTorrent, ainda que o
    # normalizador o marque como não-web-ready para o player HTTP nativo.
    if kind == 'torrent':
        tags.append('[COLOR darkorange]Torrent[/COLOR]')
    elif not playable:
        tags.append('[COLOR gray]Detalhes[/COLOR]')
    elif _stream_is_download_only(stream):
        tags.append('[COLOR gray]Download[/COLOR]')

    quality = _stream_quality_badge(stream)
    if quality:
        tags.append(quality)

    audio = _stream_audio_label(stream)
    if audio:
        tags.append(audio)

    size = (stream.get('__vod_size_label') or '').strip()
    if size:
        tags.append(size)
    return tags


def _build_stream_item_name(params, type_, stream):
    """Monta um título curto, humano e estável para cada fonte VOD."""
    if type_ == 'series':
        base = '{} - S{}E{}'.format(
            params['name'], params['season_number'].zfill(2),
            params['episode_number'].zfill(2)
        )
    else:
        base = '{} ({})'.format(params['name'], params['year'])

    tags = _stream_title_tags(stream)
    return '{}{}'.format(base, ' [COLOR silver]•[/COLOR] {}'.format(
        ' [COLOR silver]•[/COLOR] '.join(tags)
    ) if tags else '')


def _disambiguate_stream_titles(rows, params, type_):
    """Enumera somente títulos realmente idênticos.

    A maior parte das fontes já se diferencia por qualidade, áudio ou tamanho.
    Quando o provider entrega clones visuais, ``Fonte 1/2/...`` evita escolhas
    cegas sem trazer siglas técnicas de volta ao título.
    """
    bases = []
    counts = {}
    for stream in rows:
        base = _build_stream_item_name(params, type_, stream)
        bases.append(base)
        counts[base] = counts.get(base, 0) + 1

    seen = {}
    result = []
    for stream, base in zip(rows, bases):
        if counts.get(base, 0) > 1:
            seen[base] = seen.get(base, 0) + 1
            base = '{} [COLOR silver]•[/COLOR] Fonte {}'.format(base, seen[base])
        result.append(base)
    return result


def _stream_request_rank(stream):
    try:
        return int(stream.get('__request_rank', 999))
    except Exception:
        return 999


def _stream_request_label(stream):
    return stream.get('__request_label', 'sem-perfil')


def _stream_priority_bucket(stream):
    """Ordem fixa da lista manual: compatibilidade antes de resolução.

    A fonte continua sendo escolhida pelo usuário. Este ranking não decide
    reprodução: só evita que download, informativa ou codec pesado ocupem o
    topo quando houver uma mídia direta declarada pelo provider.
    """
    info = _stream_quality_info(stream)
    if not stream.get('__vod_playable', True):
        return 95 + (_stream_request_rank(stream) / 100.0)
    if _stream_is_download_only(stream):
        return 90 + (_stream_request_rank(stream) / 100.0)
    if stream.get('__vod_caution'):
        return 80 + (_stream_request_rank(stream) / 100.0)

    # Ordem fixa, conservadora e focada em compatibilidade: 1080p AVC/H.264
    # tende a abrir melhor em aparelhos modestos do que 4K/HEVC/AV1. O rótulo
    # de qualidade permanece visível para a escolha manual.
    compatibility_penalty = {
        'safe': 0.0,
        'hevc': 1.10,
        '10bit': 1.60,
        'hdr': 1.85,
        'av1': 2.30,
    }.get(info.get('codec_risk'), 0.0)
    delivery_kind = (stream.get('__vod_kind') or '').lower()
    delivery_penalty = 0.0 if delivery_kind in ('hls', 'dash', 'progressive') else 1.35
    quality_key = info['quality_key']
    if quality_key:
        explicit_order = {'1080': 0, '720': 1, '4k': 2, 'light': 3}
        return explicit_order.get(quality_key, 9) + compatibility_penalty + delivery_penalty
    if info['is_direct_media']:
        base = 10 if info['is_auto'] else 11 if info['is_dublado'] else 12
        return base + compatibility_penalty + delivery_penalty + (_stream_request_rank(stream) / 100.0)
    base = 20 if info['is_auto'] else 21 if info['is_dublado'] else 22
    return base + compatibility_penalty + delivery_penalty + (_stream_request_rank(stream) / 100.0)


def _stream_sort_key(stream):
    info = _stream_quality_info(stream)
    return (
        _stream_priority_bucket(stream),
        _stream_request_rank(stream),
        info['host'],
        info['combined'],
    )


def _log_stream_order_preview(streams, limit=8):
    preview = []
    for stream in streams[:limit]:
        info = _stream_quality_info(stream)
        bucket = _stream_priority_bucket(stream)
        host = info['host'] or 'sem-host'
        preview.append('[{}|{}|{}|{}] {}'.format(
            bucket, _stream_request_label(stream), info['quality_label'], host,
            info['combined'][:100]
        ))
    if preview:
        _log_debug('Ordenação manual de fontes => {}'.format(' | '.join(preview)))


def _build_play_params(params, imdb_id, type_, stream_url, item_name, stream_kind='',
                       provider_headers_token='', vod_session_ref='', vod_session_item=''):
    play_params = {
        'action': 'play_item_with_subtitles',
        'imdb_id': imdb_id,
        'type': type_,
        'name': item_name,
        'poster': params.get('poster', ''),
        'fanart': params.get('fanart', ''),
        'stream_kind': stream_kind or '',
    }
    if vod_session_ref and vod_session_item:
        play_params['vod_session'] = vod_session_ref
        play_params['vod_item'] = vod_session_item
    else:
        # Compatibilidade somente quando o service local não estiver disponível
        # para registrar a sessão. Fontes novas sempre seguem pela referência.
        play_params['video_url'] = stream_url
        if provider_headers_token:
            play_params['vod_headers'] = provider_headers_token
    if type_ == 'series':
        play_params['season'] = params['season_number']
        play_params['episode'] = params['episode_number']
    return play_params

def _render_vod_empty_directory(reason=''):
    """Mostra estado vazio como diretório válido, sem erro GetDirectory.

    O provider pode não publicar Stream Objects com destino técnico utilizável.
    Isso não é falha de navegação do Kodi; a rota deve terminar com sucesso e
    informar o motivo em uma página normal.
    """
    title = 'Nenhuma fonte reproduzível no momento'
    plot = reason or ('O provedor não entregou uma fonte compatível agora. '
                      'Volte e tente novamente mais tarde.')
    try:
        item = xbmcgui.ListItem(title)
        _set_listitem_info(item, 'video', {
            'title': title,
            'plot': plot,
            'mediatype': 'video',
        })
        item.setProperty('IsPlayable', 'false')
        xbmcplugin.addDirectoryItem(
            handle=ADDON_HANDLE,
            url=build_url({'action': 'noop'}),
            listitem=item,
            isFolder=False
        )
    except Exception as exc:
        _log_debug('Falha ao montar estado vazio VOD: {}'.format(exc))
    _end_directory_with_view(content='videos', view='WideList', succeeded=True, cache=False, delay_ms=40, repeat=1)


_VOD_ROUTE_LABELS = {
    'imdb': 'IMDb',
    'tmdb': 'TMDB',
    'kitsu': 'Kitsu',
}


def _vod_route_label(route):
    return _VOD_ROUTE_LABELS.get((route or '').lower(), 'HdHub')


def _get_vod_identifier_context(params):
    """Obtém apenas IDs exatos já declarados pelo catálogo.

    IMDb vem de external_ids do TMDB e é carregado uma vez por abertura. Kitsu
    nunca é deduzido por título: só é usado quando o item já traz o ID exato.
    """
    tmdb_id = params.get('id', '')
    type_ = params.get('type', '')
    imdb_id = (params.get('vod_imdb_id') or '').strip()
    if not imdb_id:
        try:
            imdb_id = tmdb.get_imdb_id_tmdb_vod_fast(tmdb_id, type_)
        except Exception as exc:
            _log_debug('VOD IMDb não disponível para {}: {}.'.format(tmdb_id, exc.__class__.__name__))
            imdb_id = ''
    return {
        'tmdb_id': tmdb_id,
        'imdb_id': imdb_id,
        'kitsu_id': (params.get('kitsu_id') or '').strip(),
    }


def _create_vod_batch_progress():
    try:
        progress = xbmcgui.DialogProgressBG()
        progress.create('OnePlay', 'Buscando fontes IMDb, TMDB e Kitsu...')
        return progress
    except Exception:
        return None


def _render_consolidated_vod_streams(params, batch, imdb_id):
    """Renderiza em uma lista única as fontes já retornadas pela busca em lote.

    A consulta IMDb/TMDB/Kitsu é feita antes desta função. Aqui apenas reunimos
    os Stream Objects já normalizados pelo motor, preservando a origem visível
    em cada item. Não há nova chamada ao HdHub e não há deduplicação entre
    identificadores: fontes que o provider entregou em rotas distintas continuam
    acessíveis para escolha manual.
    """
    type_ = params.get('type', '')
    rows = []
    route_counts = []
    route_order = {'imdb': 0, 'tmdb': 1, 'kitsu': 2}

    for route_result in (batch.get('routes') or []):
        if not isinstance(route_result, dict):
            continue
        route = (route_result.get('kind') or '').strip().lower()
        provider_label = _vod_route_label(route)
        streams = list(route_result.get('streams') or [])
        route_counts.append('{}={}'.format(route or 'desconhecida', len(streams)))
        for stream in streams:
            if not isinstance(stream, dict):
                continue
            # Copiamos a estrutura somente para a camada visual; o resultado
            # normalizado do motor permanece imutável para os outros fluxos.
            item = dict(stream)
            item['__vod_ui_provider_label'] = provider_label
            item['__vod_ui_route_order'] = route_order.get(route, 9)
            rows.append(item)

    if not rows:
        details = []
        for route_result in (batch.get('routes') or []):
            if not isinstance(route_result, dict):
                continue
            route = _vod_route_label(route_result.get('kind', ''))
            reason = (route_result.get('reason') or '').strip()
            if reason:
                details.append('{}: {}'.format(route, reason))
        reason = 'Nenhuma das origens retornou fontes exibíveis para este título.'
        if details:
            reason = '{} {}'.format(reason, ' • '.join(details[:3]))
        _render_vod_empty_directory(reason)
        return

    rows.sort(key=lambda stream: (
        _stream_sort_key(stream),
        stream.get('__vod_ui_route_order', 9),
        _stream_request_rank(stream),
        _stream_quality_info(stream)['host'],
        _stream_quality_info(stream)['combined'],
    ))

    _log_debug('VOD HdHub unificado: {} fontes exibíveis ({}).'.format(
        len(rows), ', '.join(route_counts)
    ))
    _log_stream_inventory(rows, 'consolidado')
    _log_stream_order_preview(rows)

    clean_titles = _disambiguate_stream_titles(rows, params, type_)
    playback_entries = {}
    for source_index, stream in enumerate(rows):
        if not stream.get('__vod_playable', True) or (stream.get('__vod_kind') or '').lower() == 'torrent':
            continue
        stream_url = str(stream.get('url') or '').strip()
        try:
            scheme = (urlparse(stream_url).scheme or '').lower()
        except Exception:
            scheme = ''
        if scheme not in ('http', 'https'):
            continue
        item_key = 's{}'.format(source_index)
        headers_ref = _encode_vod_provider_headers(stream.get('__vod_request_headers'))
        playback_entries[item_key] = {
            'url': stream_url,
            'provider_headers_token': headers_ref,
            'stream_kind': stream.get('__vod_kind', ''),
        }
        stream['__vod_playback_item'] = item_key
        stream['__vod_provider_headers_ref'] = headers_ref
    playback_bundle_ref = _register_vod_playback_bundle(playback_entries)
    if playback_entries and not playback_bundle_ref:
        _log_debug('VOD sessão local indisponível; usando compatibilidade legada somente nesta lista.')

    for stream, base_item_name in zip(rows, clean_titles):
        provider_label = (stream.get('__vod_ui_provider_label') or 'HdHub').strip()
        stream_url = stream.get('url')
        item_name = '[COLOR aquamarine][{}][/COLOR] {}'.format(provider_label, base_item_name)
        li = xbmcgui.ListItem(item_name)
        li.setArt({'thumb': params.get('poster'), 'icon': params.get('poster'), 'fanart': params.get('fanart', '')})
        source_plot = _stream_plot_text(stream, provider_label)
        _set_listitem_info(li, 'video', {'title': item_name, 'plot': source_plot, 'mediatype': 'video'})

        if (stream.get('__vod_kind') or '').lower() == 'torrent' and stream.get('__vod_torrent_magnet'):
            torrent_params = {
                'action': 'play_torrent_with_puretorrent',
                'torrent_magnet': stream.get('__vod_torrent_magnet', ''),
                'torrent_file_idx': (
                    str(stream.get('__vod_torrent_file_idx'))
                    if stream.get('__vod_torrent_file_idx') is not None else ''
                ),
                'name': item_name,
            }
            li.setProperty('IsPlayable', 'true')
            xbmcplugin.addDirectoryItem(
                handle=ADDON_HANDLE, url=build_url(torrent_params), listitem=li, isFolder=False
            )
        elif stream.get('__vod_playable', True):
            play_params = _build_play_params(
                params, imdb_id, type_, stream_url, item_name,
                stream_kind=stream.get('__vod_kind', ''),
                provider_headers_token=stream.get('__vod_provider_headers_ref', ''),
                vod_session_ref=playback_bundle_ref,
                vod_session_item=stream.get('__vod_playback_item', '')
            )
            li.setProperty('IsPlayable', 'true')
            xbmcplugin.addDirectoryItem(
                handle=ADDON_HANDLE, url=build_url(play_params), listitem=li, isFolder=False
            )
        else:
            info_url = build_url({
                'action': 'show_text_dialog',
                'title': 'Informações da fonte',
                'text': source_plot,
            })
            li.setProperty('IsPlayable', 'false')
            xbmcplugin.addDirectoryItem(
                handle=ADDON_HANDLE, url=info_url, listitem=li, isFolder=False
            )

    # Cabeçalho intencionalmente neutro: o provider é detalhe técnico e a
    # origem continua identificada em cada item, sem expor contrato/versão.
    category = 'Opções de reprodução'
    try:
        xbmcplugin.setPluginCategory(ADDON_HANDLE, category)
    except Exception:
        pass
    _end_directory_with_view(content='videos', view='WideList', succeeded=True, cache=False, delay_ms=40, repeat=1)


def select_vod_provider(params):
    """Consulta IMDb/TMDB/Kitsu uma vez e sempre entrega lista unificada.

    A organização por pasta foi removida do fluxo VOD. URLs antigas com
    ``vod_layout=by_origin`` são aceitas apenas para compatibilidade e caem na
    mesma lista direta, mantendo a origem identificada em cada conteúdo.
    """
    type_ = params.get('type', '')
    legacy_layout = (params.get('vod_layout') or '').strip().lower()
    legacy_route = (params.get('vod_route') or '').strip().lower()
    if legacy_layout == 'by_origin' or legacy_route in ('imdb', 'tmdb', 'kitsu'):
        _log_debug('VOD UI: rota/layout legado ignorado (layout={}, rota={}); usando lista unificada.'.format(
            legacy_layout or 'padrão', legacy_route or 'nenhuma'))
    context = _get_vod_identifier_context(params)
    season = params.get('season_number')
    episode = params.get('episode_number')
    progress = _create_vod_batch_progress()

    def _batch_progress(done, total, route, state):
        if progress is None:
            return
        try:
            if state == 'concluido':
                progress.update(100, 'Organizando todas as fontes...')
                return
            percent = int((float(done) / max(1, total)) * 100)
            progress.update(percent, 'Consultando fontes ({})...'.format(_vod_route_label(route)))
        except Exception:
            pass

    try:
        batch = vod_engine.get_default_engine().resolve_all_routes(
            type_, context['tmdb_id'], imdb_id=context['imdb_id'],
            season=season, episode=episode, kitsu_id=context['kitsu_id'],
            progress_callback=_batch_progress
        )
    except Exception as exc:
        _log_error('Consulta consolidada VOD falhou: {}.'.format(exc.__class__.__name__))
        _render_vod_empty_directory('Não foi possível consultar fontes deste título agora.')
        return
    finally:
        try:
            if progress is not None:
                progress.close()
        except Exception:
            pass

    _render_consolidated_vod_streams(params, batch, context['imdb_id'])


# Fluxo VOD unificado: não há mais seleção por pasta de origem.
def list_streams(params):
    """Abre a lista VOD unificada, sem folders por identificador.

    O motor continua consultando apenas IDs exatos aceitos pelo manifesto.
    ``vod_route`` e ``vod_layout`` existem somente para não quebrar favoritos,
    histórico ou telas antigas: são normalizados para a abertura consolidada.
    """
    cleaned = dict(params or {})
    legacy_layout = (cleaned.pop('vod_layout', '') or '').strip().lower()
    legacy_route = (cleaned.pop('vod_route', '') or '').strip().lower()
    if legacy_layout == 'by_origin' or legacy_route in ('imdb', 'tmdb', 'kitsu'):
        _log_debug('VOD UI: link legado redirecionado para fontes unificadas (layout={}, rota={}).'.format(
            legacy_layout or 'padrão', legacy_route or 'nenhuma'))
    select_vod_provider(cleaned)


def get_and_download_subtitles(imdb_id, type_, season=None, episode=None):
    try:
        subtitle_paths = subtitles.get_and_download_subtitles(
            imdb_id,
            type_,
            season=season,
            episode=episode,
            addon=ADDON,
            clear_before=True
        )
    except Exception as exc:
        _log_debug('Erro ao buscar legendas: {}'.format(exc))
        subtitle_paths = []

    return subtitle_paths


def _is_adaptive_manifest_url(video_url, label=''):
    try:
        probe = '{} {}'.format(video_url or '', label or '').lower()
    except Exception:
        probe = ''
    return ('.m3u8' in probe or '.mpd' in probe or 'application/dash+xml' in probe)


def _parse_http_content_range(value):
    """Converte Content-Range em (inicio, fim, total) sem aceitar valores ambíguos."""
    try:
        match = re.match(r'^\s*bytes\s+(\d+)\s*-\s*(\d+)\s*/\s*(\d+)\s*$', value or '', re.I)
        if not match:
            return None
        start, end, total = [int(piece) for piece in match.groups()]
        if start < 0 or end < start or total <= end:
            return None
        return start, end, total
    except Exception:
        return None


def _vod_range_cache_key(video_url):
    try:
        return (video_url or '').strip()
    except Exception:
        return ''


def _vod_range_cache_get(video_url):
    key = _vod_range_cache_key(video_url)
    now = time.time()
    try:
        item = _VOD_RANGE_PREFLIGHT_CACHE.get(key)
        if not item or item.get('until', 0) <= now:
            _VOD_RANGE_PREFLIGHT_CACHE.pop(key, None)
            return None
        return bool(item.get('ok')), item.get('reason', '')
    except Exception:
        return None


def _vod_range_cache_put(video_url, ok, reason='', metadata=None):
    key = _vod_range_cache_key(video_url)
    if not key:
        return
    try:
        ttl = VOD_RANGE_PREFLIGHT_TTL_OK if ok else VOD_RANGE_PREFLIGHT_TTL_UNSAFE
        item = {
            'ok': bool(ok),
            'reason': reason or '',
            'until': time.time() + ttl,
        }
        if isinstance(metadata, dict):
            item['metadata'] = dict(metadata)
        _VOD_RANGE_PREFLIGHT_CACHE[key] = item
    except Exception:
        pass


def _vod_range_cache_metadata(video_url):
    key = _vod_range_cache_key(video_url)
    now = time.time()
    try:
        item = _VOD_RANGE_PREFLIGHT_CACHE.get(key)
        if not item or item.get('until', 0) <= now or not item.get('ok'):
            return {}
        metadata = item.get('metadata') or {}
        if not isinstance(metadata, dict):
            return {}
        total = int(metadata.get('total') or 0)
        content_type = str(metadata.get('content_type') or '').strip().lower()
        if total <= 0 or not content_type:
            return {}
        return {
            'status': 206,
            'start': 0,
            'end': 0,
            'total': total,
            'content_type': content_type,
        }
    except Exception:
        return {}


def _vod_probe_range(video_url, range_value, provider_headers=None):
    """Consulta uma única faixa e devolve metadados seguros para diagnóstico.

    Nenhuma URL, token ou valor de cabeçalho sai desta função. O host final e o
    tempo apenas permitem distinguir redirect, rejeição de sessão e servidor
    sem suporte a Range nos logs de auditoria.
    """
    response = None
    started_at = time.monotonic()
    try:
        # Pré-teste não usa a sessão global com retries para 5xx. Em uma fonte
        # inválida, retries transformariam uma resposta conclusiva rápida em
        # vários segundos de espera antes de o Kodi poder tentar outra opção.
        headers = _sanitize_vod_provider_headers(provider_headers)
        headers.setdefault('User-Agent', DEFAULT_USER_AGENT)
        headers.setdefault('Accept', '*/*')
        headers['Accept-Encoding'] = 'identity'
        headers['Connection'] = 'close'
        headers['Range'] = range_value
        response = requests.get(
            video_url,
            headers=headers,
            timeout=VOD_RANGE_PREFLIGHT_TIMEOUT,
            allow_redirects=True,
            stream=True,
        )
        status = int(getattr(response, 'status_code', 0) or 0)
        response_headers = getattr(response, 'headers', {}) or {}
        content_type = (response_headers.get('Content-Type', '') or '').lower()
        content_range = (response_headers.get('Content-Range', '') or '')
        effective_host = _vod_diag_host(getattr(response, 'url', '') or video_url)
        # Não basta o servidor anunciar 206/Content-Range: algumas origens
        # problemáticas devolvem o cabeçalho e encerram o corpo em EOF. Ler
        # um único byte valida que aquela faixa realmente pode ser consumida.
        body_started = False
        for chunk in response.iter_content(chunk_size=1):
            if chunk:
                body_started = True
                break
        elapsed_ms = int((time.monotonic() - started_at) * 1000)
        return status, content_type, _parse_http_content_range(content_range), body_started, effective_host, elapsed_ms
    finally:
        if response is not None:
            try:
                response.close()
            except Exception:
                pass


def _validate_progressive_vod_source(video_url, label='', provider_headers=None, stream_kind=''):
    """Valida fonte direta antes do player sem punir origem lenta e legítima.

    Apenas respostas conclusivas são rejeitadas: HTTP de erro, HTML/JSON ou
    ausência de Range válido. Timeout/erro transitório é inconclusivo e segue
    para o proxy, que continua como autoridade de reprodução. HLS/DASH fica
    fora deste teste porque possui fluxo adaptativo próprio.
    """
    try:
        raw_url = (video_url or '').strip()
        parsed = urlparse(raw_url)
    except Exception:
        return False, 'url inválida'
    if not raw_url:
        return False, 'url vazia'
    if (parsed.scheme or '').lower() not in ('http', 'https'):
        return True, 'esquema local'
    try:
        source_host = (parsed.netloc or 'origem desconhecida').split('@')[-1]
    except Exception:
        source_host = 'origem desconhecida'
    if (stream_kind or '').lower() in ('hls', 'dash') or _is_adaptive_manifest_url(raw_url, label):
        _vod_diag_log('preflight', raw_url, stream_kind=stream_kind, provider_headers=provider_headers,
                      result='adaptativo_sem_probe')
        return True, 'manifesto adaptativo'

    cached = _vod_range_cache_get(raw_url)
    if cached is not None:
        cached_ok, cached_reason = cached
        _vod_diag_log('preflight-cache', raw_url, stream_kind=stream_kind, provider_headers=provider_headers,
                      result='aprovada' if cached_ok else 'recusada', detail=cached_reason or 'resultado_anterior')
        return cached

    response = None
    try:
        status, content_type, content_range, body_started, effective_host, elapsed_ms = _vod_probe_range(
            raw_url, 'bytes=0-0', provider_headers=provider_headers
        )
    except requests.RequestException as exc:
        _vod_diag_log('preflight', raw_url, stream_kind=stream_kind, provider_headers=provider_headers,
                      result='inconclusivo_segue_proxy', detail=exc.__class__.__name__)
        return True, 'preflight inconclusivo'
    except Exception as exc:
        _vod_diag_log('preflight', raw_url, stream_kind=stream_kind, provider_headers=provider_headers,
                      result='inconclusivo_segue_proxy', detail=exc.__class__.__name__)
        return True, 'preflight inconclusivo'

    media_like = not any(token in (content_type or '') for token in ('text/', 'application/json', 'application/xml', 'text/html'))
    range_ok = bool(content_range and content_range[0] == 0 and content_range[1] == 0 and body_started)
    if status == 206 and range_ok and media_like:
        _vod_range_cache_put(raw_url, True, 'range seguro', metadata={
            'status': 206,
            'start': 0,
            'end': 0,
            'total': content_range[2],
            'content_type': content_type,
        })
        _vod_diag_log('preflight', raw_url, stream_kind=stream_kind, status=status,
                      provider_headers=provider_headers, content_type=content_type, range_state='ok',
                      effective_host=effective_host, elapsed_ms=elapsed_ms, result='aprovada')
        return True, 'range seguro'

    # HTTP conclusivo sem Range seguro, corpo de página ou erro 4xx/5xx: o proxy
    # também rejeitaria. Interrompemos antes de o VideoPlayer abrir e deixar a
    # interface presa numa tentativa sem mídia.
    if status >= 400:
        reason = 'HTTP {}'.format(status)
    elif not media_like:
        reason = 'resposta não é mídia'
    elif status in (200, 204, 206):
        reason = 'servidor sem Range seguro'
    else:
        # Resposta ambígua: mantém a fonte para o proxy decidir, sem falso negativo.
        _vod_diag_log('preflight', raw_url, stream_kind=stream_kind, status=status,
                      provider_headers=provider_headers, content_type=content_type,
                      range_state='ambigua', effective_host=effective_host, elapsed_ms=elapsed_ms,
                      result='segue_proxy')
        return True, 'preflight ambíguo'

    range_state = 'ok' if range_ok else ('sem-range' if not content_range else 'range-invalido')
    _vod_range_cache_put(raw_url, False, reason)
    _vod_diag_log('preflight', raw_url, stream_kind=stream_kind, status=status,
                  provider_headers=provider_headers, content_type=content_type, range_state=range_state,
                  effective_host=effective_host, elapsed_ms=elapsed_ms, result='recusada', detail=reason)
    return False, reason


_VOD_ARCHIVE_SUFFIXES = (
    '.zip', '.rar', '.7z', '.iso', '.tar', '.tar.gz', '.tgz', '.gz', '.bz2', '.xz',
)
_TORRENT_BTIH_HEX_RE = re.compile(r'^[A-F0-9]{40}$', re.I)
_TORRENT_BTIH_BASE32_RE = re.compile(r'^[A-Z2-7]{32}$', re.I)
_TORRENT_MAX_TRACKERS = 12


def _stream_is_archive_url(video_url):
    """Reconhece somente arquivo compactado no caminho da URL.

    Query tokens e nomes de host são deliberadamente ignorados para não marcar
    URLs FSL por token como arquivo. Esta função corrige o guard chamado pelo
    caminho VOD manual antes de chegar ao proxy.
    """
    try:
        parsed = urlparse((video_url or '').strip())
        path = (parsed.path or '').lower()
    except Exception:
        return False
    return any(path.endswith(suffix) for suffix in _VOD_ARCHIVE_SUFFIXES)


def _safe_puretorrent_magnet(raw_magnet):
    """Reconstrói um magnet BTIH seguro aceitando somente tr=http(s)/udp.

    O OnePlay é ponte de UI; PureTorrent mantém DHT, peers, buffer, metadata e
    seleção do arquivo. Campos livres ou esquemas fora do contrato são ignorados.
    """
    try:
        parsed = urlparse((raw_magnet or '').strip())
        if (parsed.scheme or '').lower() != 'magnet':
            return ''
        query = parse_qs(parsed.query or '', keep_blank_values=False)
    except Exception:
        return ''

    xt_values = query.get('xt') or []
    btih = ''
    for xt in xt_values:
        value = str(xt or '').strip()
        prefix = 'urn:btih:'
        if value.lower().startswith(prefix):
            candidate = value[len(prefix):].strip().upper()
            if _TORRENT_BTIH_HEX_RE.match(candidate) or _TORRENT_BTIH_BASE32_RE.match(candidate):
                btih = candidate
                break
    if not btih:
        return ''

    dn = ''
    for value in query.get('dn') or []:
        try:
            candidate = re.sub(r'[\x00-\x1f\x7f]+', ' ', str(value or '')).strip()
        except Exception:
            candidate = ''
        if candidate:
            dn = candidate[:180]
            break

    trackers = []
    seen = set()
    for value in query.get('tr') or []:
        try:
            tracker = str(value or '').strip()
            parsed_tracker = urlparse(tracker)
            scheme = (parsed_tracker.scheme or '').lower()
            host = (parsed_tracker.hostname or '').strip()
            port = parsed_tracker.port
        except Exception:
            continue
        # Mantém somente o subconjunto Stremio tracker:http/udp e nunca
        # encaminha loopback, rede privada ou hosts .local ao PureTorrent.
        if scheme not in ('udp', 'http') or not host:
            continue
        host_lower = host.lower().rstrip('.')
        if host_lower == 'localhost' or host_lower.endswith('.local'):
            continue
        try:
            ip_value = ipaddress.ip_address(host_lower)
            if not ip_value.is_global:
                continue
        except ValueError:
            pass
        if port is not None and not (1 <= int(port) <= 65535):
            continue
        if '\r' in tracker or '\n' in tracker or len(tracker) > 1024:
            continue
        key = tracker.lower()
        if key in seen:
            continue
        seen.add(key)
        trackers.append(tracker)
        if len(trackers) >= _TORRENT_MAX_TRACKERS:
            break

    parts = [('xt', 'urn:btih:{}'.format(btih))]
    if dn:
        parts.append(('dn', dn))
    parts.extend(('tr', value) for value in trackers)
    return 'magnet:?' + '&'.join(
        '{}={}'.format(quote(str(key), safe=''), quote(str(value), safe=''))
        for key, value in parts
    )


def _puretorrent_available():
    try:
        if xbmc.getCondVisibility('System.HasAddon(plugin.video.puretorrent)'):
            return True
    except Exception:
        pass
    try:
        xbmcaddon.Addon('plugin.video.puretorrent')
        return True
    except Exception:
        return False


def play_torrent_with_puretorrent(params):
    """Entrega magnet validado ao interceptor do PureTorrent sem criar motor BT."""
    magnet = _safe_puretorrent_magnet(params.get('torrent_magnet', ''))
    if not magnet:
        _show_error('O HdHub retornou um torrent sem infoHash BTIH válido.', title='Torrent indisponível', dialog=True)
        _complete_plugin_action(succeeded=False)
        return
    if not _puretorrent_available():
        _show_error('Instale ou ative o PureTorrent para abrir esta fonte torrent.', title='PureTorrent', dialog=True)
        _complete_plugin_action(succeeded=False)
        return
    try:
        file_idx = params.get('torrent_file_idx', '')
        _log_debug('VOD HdHub encaminhando torrent ao PureTorrent: btih={} fileIdx={}.'.format(
            (parse_qs(urlparse(magnet).query).get('xt') or [''])[0].replace('urn:btih:', '')[:12],
            file_idx if file_idx != '' else 'não declarado'
        ))
        # O PureTorrent já intercepta PlayMedia magnético em seu service.py.
        # Não há RunPlugin/engine duplicado nem transferência de parâmetros de
        # rede para fora do magnet validado.
        xbmc.executebuiltin('PlayMedia({})'.format(magnet))
        _complete_plugin_action(succeeded=True)
    except Exception as exc:
        _log_debug('Falha ao encaminhar torrent ao PureTorrent: {}.'.format(exc.__class__.__name__))
        _show_error('Não foi possível iniciar o PureTorrent para esta fonte.', title='PureTorrent', dialog=True)
        _complete_plugin_action(succeeded=False)


def _stream_url_is_expired(video_url):
    """Rejeita somente assinaturas com expiração semanticamente explícita."""
    expires_at, _scheme = _vod_signature_expiry(video_url)
    return bool(expires_at is not None and expires_at <= time.time() + 5.0)


def _prepare_vod_playback_url(video_url, label='', stream_kind='', provider_headers_token='',
                              playback_session_ref='', playback_session_item=''):
    """Entrega a fonte ao caminho correto sem alterar o proxy Live.

    * HLS usa o /hlsretry já validado do proxy nativo;
    * DASH é entregue ao InputStream Adaptive de forma direta;
    * progressivo/desconhecido usa /vod e conserva o guard de Range por URL.
    """
    try:
        raw_url = (video_url or '').strip()
        parsed = urlparse(raw_url)
    except Exception:
        return ''
    if not raw_url:
        return ''
    if (parsed.scheme or '').lower() not in ('http', 'https'):
        return raw_url
    if _stream_is_archive_url(raw_url):
        _log_debug('Fonte VOD recusada: arquivo compactado não é mídia reproduzível.')
        return ''
    expires_at, signature_scheme = _vod_signature_expiry(raw_url)
    if expires_at is not None and expires_at <= time.time() + 5.0:
        _vod_diag_log('assinatura', raw_url, stream_kind=stream_kind,
                      result='expirada', detail='{}:{}'.format(signature_scheme or 'desconhecida', _vod_signature_expiry_label(expires_at)))
        return ''

    kind = (stream_kind or '').lower()
    if kind == 'dash':
        # O proxy /vod exige Range, enquanto DASH trabalha por manifestos e
        # segmentos. InputStream Adaptive é dependência obrigatória do addon.
        return raw_url

    try:
        proxied = proxy.build_vod_playback_url(
            raw_url, provider_headers_token=provider_headers_token, stream_kind=kind,
            playback_session_ref=playback_session_ref, playback_item_key=playback_session_item
        )
    except Exception as exc:
        _log_debug('Proxy VOD indisponível; mantendo URL direta: {}.'.format(exc.__class__.__name__))
        proxied = ''
    if proxied:
        return proxied
    return raw_url

def _prime_vod_proxy_head(video_url, stream_kind, provider_headers, playback_session_ref, playback_session_item):
    """Repassa ao proxy o probe Range recém-aprovado para o HEAD do Kodi."""
    kind = (stream_kind or '').lower()
    if kind in ('hls', 'dash') or not playback_session_ref or not playback_session_item:
        return False
    preflight = _vod_range_cache_metadata(video_url)
    if not preflight:
        return False
    try:
        primed = bool(proxy.prime_vod_playback_item(playback_session_ref, playback_session_item, preflight))
    except Exception:
        primed = False
    _vod_diag_log('handoff', video_url, stream_kind=stream_kind, provider_headers=provider_headers,
                  result='preflight_repassado' if primed else 'preflight_nao_repassado',
                  detail='head_local' if primed else 'loopback_indisponivel')
    return primed


def _resolved_failure():
    try:
        failed = xbmcgui.ListItem()
        xbmcplugin.setResolvedUrl(handle=ADDON_HANDLE, succeeded=False, listitem=failed)
    except Exception:
        pass


def _build_runtime_play_item(params, video_url, name, subtitle_paths=None, stream_kind='', provider_headers=None,
                             preflight_metadata=None):
    li = xbmcgui.ListItem(name)
    li.setArt({'thumb': params.get('poster'), 'icon': params.get('poster'), 'fanart': params.get('fanart')})
    _set_listitem_info(li, 'video', {'title': name})
    li.setPath(video_url)
    kind = (stream_kind or '').lower()
    # Para VOD direto já aprovado pelo Range preflight, o Kodi recebe MIME e a
    # indicação de que não precisa inspecionar conteúdo antes do player. Isso
    # reduz uma etapa de Stat/lookup sem alterar a leitura real pelo proxy.
    if kind not in ('hls', 'dash') and isinstance(preflight_metadata, dict):
        content_type = str(preflight_metadata.get('content_type') or '').strip().lower()
        if content_type:
            try:
                li.setMimeType(content_type)
                li.setContentLookup(False)
                li.setProperty('IsPlayable', 'true')
                common.log_event('VOD', 'runtime_preparado', addon=ADDON,
                                 mime=content_type, content_lookup='desligado')
            except Exception as exc:
                _log_debug('Não foi possível aplicar MIME local ao VOD: {}.'.format(exc.__class__.__name__))
    if kind == 'dash':
        try:
            li.setProperty('inputstream', 'inputstream.adaptive')
            li.setProperty('inputstreamaddon', 'inputstream.adaptive')
            li.setProperty('inputstream.adaptive.manifest_type', 'mpd')
            header_value = _inputstream_headers_value(provider_headers)
            if header_value:
                li.setProperty('inputstream.adaptive.manifest_headers', header_value)
                li.setProperty('inputstream.adaptive.stream_headers', header_value)
            li.setMimeType('application/dash+xml')
            li.setContentLookup(False)
        except Exception as exc:
            _log_debug('Não foi possível preparar DASH no InputStream Adaptive: {}.'.format(exc.__class__.__name__))
    if subtitle_paths:
        try:
            li.setSubtitles(subtitle_paths)
        except Exception:
            pass
    return li

def _player_is_playing(player):
    try:
        return bool(player.isPlaying())
    except Exception:
        return False


_SUBTITLE_PREPARE_BUDGET_SECS = 3.5


def _subtitle_start_mode():
    # 0 = prepara antes do player; 1 = worker em segundo plano (legado).
    return _setting_enum_value('modo_inicio_legenda', ['prepare_before_player', 'background'], 'prepare_before_player')


def _open_subtitle_prepare_dialog():
    try:
        dialog = xbmcgui.DialogProgressBG()
        dialog.create('OnePlay', 'Buscando legenda...')
        try:
            dialog.update(0, 'OnePlay', 'Buscando legenda...')
        except TypeError:
            dialog.update(0, 'Buscando legenda...')
        return dialog
    except Exception:
        return None


def _update_subtitle_prepare_dialog(dialog, stage, language='', candidate=0):
    if not dialog:
        return
    language_labels = {'pt-br': 'Português (Brasil)', 'pt-pt': 'Português (Portugal)', 'en': 'Inglês', 'es': 'Espanhol'}
    label = language_labels.get((language or '').lower(), language or 'idioma selecionado')
    if stage == 'consultando':
        message = 'Buscando legenda em {}...'.format(label)
        percent = 25
    elif stage == 'baixando':
        message = 'Baixando legenda em {}...'.format(label)
        percent = 65
    elif stage == 'tempo_limite':
        message = 'Legenda demorou mais que o esperado. Iniciando vídeo...'
        percent = 100
    else:
        message = 'Buscando legenda...'
        percent = 10
    try:
        dialog.update(percent, 'OnePlay', message)
    except TypeError:
        try:
            dialog.update(percent, message)
        except Exception:
            pass
    except Exception:
        pass


def _prepare_subtitles_before_player(params, imdb_id, type_):
    """Busca a legenda antes do setResolvedUrl com orçamento curto e aviso visível."""
    if not _setting_bool('legendasauto', True) or not imdb_id or not type_:
        return []
    _clear_subtitle_cache_for_new_playback()
    dialog = _open_subtitle_prepare_dialog()
    started = time.monotonic()
    common.log_event('Subtitles', 'preparacao_inicio', addon=ADDON,
                     modo='antes_do_player', tipo=(type_ or ''),
                     limite_ms=int(_SUBTITLE_PREPARE_BUDGET_SECS * 1000))
    try:
        paths = subtitles.get_and_download_subtitles(
            imdb_id, type_, season=params.get('season'), episode=params.get('episode'),
            addon=ADDON, clear_before=False, time_budget=_SUBTITLE_PREPARE_BUDGET_SECS,
            progress_callback=lambda stage, language='', candidate=0: _update_subtitle_prepare_dialog(
                dialog, stage, language, candidate
            )
        )
    except Exception as exc:
        _log_debug('Falha não crítica na preparação de legenda: {}.'.format(exc.__class__.__name__))
        paths = []
    finally:
        try:
            if dialog:
                dialog.close()
        except Exception:
            pass
    elapsed_ms = int((time.monotonic() - started) * 1000)
    common.log_event('Subtitles', 'preparacao_pronta' if paths else 'preparacao_sem_resultado',
                     addon=ADDON, modo='antes_do_player', tempo_ms=elapsed_ms,
                     anexada='sim' if paths else 'nao')
    return paths


def _clear_subtitle_cache_for_new_playback():
    """Limpa somente resíduo transitório antes de iniciar o próximo VOD.

    A limpeza é local e curta. Conforme o modo selecionado, a nova legenda é
    preparada antes do player ou no worker em segundo plano.
    """
    try:
        subtitles.clear_subtitles_cache(addon=ADDON)
        _log_debug('Cache transitório de legendas limpo antes do novo play.')
        return True
    except Exception as exc:
        _log_debug('Falha não crítica ao limpar cache de legendas: {}.'.format(exc.__class__.__name__))
        return False


def _queue_async_subtitles(params, imdb_id, type_, stream_kind='', playback_session_ref='', playback_session_item=''):
    if not _setting_bool('legendasauto', True):
        return ''
    if not imdb_id or not type_:
        _log_debug('Legenda automática não agendada: metadados insuficientes.')
        return ''
    _clear_subtitle_cache_for_new_playback()
    try:
        job_id = subtitles.create_async_subtitle_job(
            imdb_id, type_,
            season=params.get('season'),
            episode=params.get('episode'),
            playback_session=playback_session_ref,
            playback_item=playback_session_item,
            stream_kind=stream_kind,
            addon=ADDON,
        )
    except Exception as exc:
        _log_debug('Falha não crítica ao agendar legenda em segundo plano: {}.'.format(exc.__class__.__name__))
        job_id = ''
    if job_id:
        common.log_event('Subtitles', 'agendada', addon=ADDON,
                         job=job_id[:8], modo='background', tipo=(type_ or ''))
    return job_id


def _start_async_subtitle_worker(job_id):
    """Dispara um script Kodi independente depois de entregar o player.

    ``RunScript`` evita que a busca de OpenSubtitles bloqueie a rota de play.
    O worker recebe apenas um ID opaco de job; ele lê metadados públicos locais,
    baixa a legenda em paralelo e só a anexa ao player da sessão correspondente.
    """
    if not job_id:
        return False
    try:
        worker_path = os.path.join(homeDir, 'resources', 'subtitles_worker.py').replace('\\', '/')
        command = 'RunScript("{}","{}")'.format(worker_path.replace('"', ''), str(job_id).replace('"', ''))
        xbmc.executebuiltin(command)
        _log_debug('Worker de legenda iniciado em segundo plano (job={}).'.format(str(job_id)[:8]))
        return True
    except Exception as exc:
        _log_debug('Falha não crítica ao iniciar worker de legenda: {}.'.format(exc.__class__.__name__))
        return False


def play_item_with_subtitles(params):
    name = params.get('name', 'Reproduzindo')
    imdb_id = params.get('imdb_id')
    type_ = params.get('type')
    playback_session_ref = params.get('vod_session', '')
    playback_session_item = params.get('vod_item', '')
    session_entry = {}
    if playback_session_ref or playback_session_item:
        session_entry = _resolve_vod_playback_item(playback_session_ref, playback_session_item)
        if not session_entry:
            _show_error('Esta fonte temporária expirou. Volte ao conteúdo e abra a lista de fontes novamente.',
                        title='Fonte indisponível', dialog=True)
            _resolved_failure()
            return
    video_url = session_entry.get('url') if session_entry else params.get('video_url')
    stream_kind = session_entry.get('stream_kind') if session_entry else params.get('stream_kind', '')
    stream_kind = stream_kind or params.get('stream_kind', '')
    provider_headers_token = (session_entry.get('provider_headers_token') if session_entry else params.get('vod_headers', '')) or ''
    provider_headers = _decode_vod_provider_headers(provider_headers_token)
    _vod_diag_log('selecionada', video_url, stream_kind=stream_kind, provider_headers=provider_headers,
                  result='iniciando_preflight')
    source_ok, source_reason = _validate_progressive_vod_source(
        video_url, name, provider_headers=provider_headers,
        stream_kind=stream_kind
    )
    if not source_ok:
        _vod_diag_log('decisao', video_url, stream_kind=stream_kind, provider_headers=provider_headers,
                      result='bloqueada_antes_do_player', detail=source_reason)
        _show_error(_vod_operational_message(source_reason), title='Fonte indisponível', dialog=True)
        _resolved_failure()
        return

    playback_url = _prepare_vod_playback_url(
        video_url, name, stream_kind,
        provider_headers_token=provider_headers_token,
        playback_session_ref=playback_session_ref,
        playback_session_item=playback_session_item
    )
    if not playback_url:
        _vod_diag_log('decisao', video_url, stream_kind=stream_kind, provider_headers=provider_headers,
                      result='bloqueada_antes_do_player', detail='assinatura_expirada_ou_url_incompativel')
        _show_error('Esta fonte expirou ou não é compatível com reprodução VOD. Escolha outra qualidade ou servidor.', title='Fonte indisponível', dialog=True)
        _resolved_failure()
        return

    if (stream_kind or '').lower() == 'dash' and _vod_diag_session_labels(provider_headers) != 'nenhuma':
        _vod_diag_log('dash', video_url, stream_kind=stream_kind, provider_headers=provider_headers,
                      result='sessao_sensivel_nao_vai_ao_inputstream', detail='fonte_pode_exigir_proxy_do_provider')

    # O preflight já abriu Range 0-0 com sucesso. O Kodi faz um Stat/HEAD logo
    # após setResolvedUrl; reutilizar esse metadado local evita uma segunda
    # abertura remota antes do primeiro GET do player.
    _prime_vod_proxy_head(video_url, stream_kind, provider_headers,
                           playback_session_ref, playback_session_item)

    subtitle_paths = None
    subtitle_job_id = ''
    subtitle_mode = _subtitle_start_mode()
    if subtitle_mode == 'prepare_before_player':
        # Evita disputa de rede/CPU no primeiro buffer em aparelhos modestos.
        # A janela é curta e visível: se não houver resposta, o vídeo segue.
        subtitle_paths = _prepare_subtitles_before_player(params, imdb_id, type_)
    else:
        subtitle_job_id = _queue_async_subtitles(
            params, imdb_id, type_, stream_kind=stream_kind,
            playback_session_ref=playback_session_ref,
            playback_session_item=playback_session_item,
        )

    preflight_metadata = _vod_range_cache_metadata(video_url)
    li = _build_runtime_play_item(
        params, playback_url, name, subtitle_paths=subtitle_paths,
        stream_kind=stream_kind, provider_headers=provider_headers,
        preflight_metadata=preflight_metadata
    )

    delivery = 'inputstream-dash' if (stream_kind or '').lower() == 'dash' else ('proxy-hls' if (stream_kind or '').lower() == 'hls' else 'proxy-vod')
    _vod_diag_log('player', video_url, stream_kind=stream_kind, provider_headers=provider_headers,
                  result='entregue', detail='{}|legenda={}'.format(
                      delivery, 'preparada' if subtitle_paths else ('background' if subtitle_job_id else 'nenhuma')
                  ))
    xbmcplugin.setResolvedUrl(handle=ADDON_HANDLE, succeeded=True, listitem=li)
    if subtitle_job_id:
        _start_async_subtitle_worker(subtitle_job_id)

def _movie_streams_params(params, meta):
    return {
        'action': 'list_streams',
        'type': params['type'],
        'id': params['id'],
        'name': meta['name'],
        'poster': meta['poster'],
        'year': meta['year'],
        'fanart': params.get('fanart', meta['background']),
        'vod_imdb_id': meta.get('imdb_id', '')
    }


def show_details(params):
    # Detalhe VOD usa uma consulta TMDB curta e sem retry, com os dados da
    # própria lista como fallback. Assim, uma instabilidade remota não segura a
    # janela até o Kodi matar o script.
    fallback_meta = {
        'name': params.get('name', ''),
        'year': params.get('year', ''),
        'poster': params.get('poster', ''),
        'background': params.get('fanart', ''),
        'description': params.get('description', ''),
        'imdb_id': params.get('vod_imdb_id', ''),
    }
    meta = tmdb.get_meta_vod_fast(params['type'], params['id'], fallback=fallback_meta)
    name = '{} ({})'.format(meta['name'], meta['year']) if meta['year'] else meta['name']
    if params['type'] == 'series':
        url = build_url({'action': 'list_seasons', 'type': params['type'], 'id': params['id'], 'name': meta.get('name') or params.get('name', ''), 'poster': meta.get('poster') or params.get('poster', ''), 'year': meta.get('year') or params.get('year', ''), 'fanart': params.get('fanart', meta.get('background') or ''), 'vod_imdb_id': meta.get('imdb_id', '')})
        li = xbmcgui.ListItem(name)
        _set_listitem_info(li, 'video', {'title': name, 'plot': meta['description'], 'year': int(meta['year']) if meta['year'] else 0})
        li.setArt({'thumb': meta['poster'], 'poster': meta['poster'], 'fanart': meta['background']})
        _set_listitem_info(li, 'video', {'mediatype': 'video'})
        xbmcplugin.addDirectoryItem(handle=ADDON_HANDLE, url=url, listitem=li, isFolder=True)
        xbmcplugin.endOfDirectory(ADDON_HANDLE)
        xbmcplugin.setContent(ADDON_HANDLE, 'movies')
        setview('List')
    else: # movie
        # Compatibilidade com favoritos, histórico e URLs que ainda apontem
        # para ``action=details``: o filme nunca recria uma pasta de detalhes.
        _log_debug('VOD UI: detalhe legado de filme normalizado para fontes unificadas.')
        list_streams(_movie_streams_params(params, meta))

def _render_search_empty_directory(query):
    """Finaliza a lupa sem itens mantendo a navegação Kodi válida."""
    title = 'Nenhum resultado encontrado'
    plot = 'Não encontramos filmes ou séries para "{}".'.format((query or '').strip()[:180])
    try:
        item = xbmcgui.ListItem(title)
        _set_listitem_info(item, 'video', {'title': title, 'plot': plot, 'mediatype': 'video'})
        item.setProperty('IsPlayable', 'false')
        xbmcplugin.addDirectoryItem(
            handle=ADDON_HANDLE,
            url=build_url({'action': 'noop'}),
            listitem=item,
            isFolder=False
        )
    except Exception as exc:
        _log_debug('Falha ao montar estado vazio da lupa: {}'.format(exc))
    _end_directory_with_view(content='movies', view='List', succeeded=True, cache=False, delay_ms=40, repeat=1)


def search(params):
    if not params.get('query'):
        keyboard = xbmc.Keyboard('', 'Digite o nome')
        keyboard.doModal()
        if not keyboard.isConfirmed():
            return
        query = keyboard.getText()
        if not query or not str(query).strip():
            _show_error('Por favor, insira um termo de busca válido.', title='Erro', dialog=True)
            return
        params = {'action': 'search', 'query': query, 'page': '1'}
        search(params)
        return

    query = params.get('query')
    if not query or not str(query).strip():
        _show_error('Termo de busca inválido.', title='Erro', dialog=True)
        return
    try:
        page = max(1, int(params.get('page', '1')))
    except Exception:
        page = 1

    # Uma chamada multi pesquisa filmes e séries juntos. O TMDB devolve pessoas
    # no mesmo endpoint; elas são filtradas pelo módulo tmdb antes de chegar à UI.
    try:
        items, has_next = tmdb.search_all(query, page)
    except Exception as exc:
        _log_error('Lupa TMDB falhou de forma controlada: {}.'.format(exc.__class__.__name__))
        items, has_next = [], False

    if not items:
        _render_search_empty_directory(query)
        return

    for item in items:
        type_ = item.get('type') or 'movie'
        fanart = item.get('background') or ''
        poster = item.get('poster') or ''
        base_title = '{} ({})'.format(item['title'], item['year']) if item.get('year') else item['title']
        prefix = 'Filme' if type_ == 'movie' else 'Série'
        title = '{}: {}'.format(prefix, base_title)
        route_params = {
            'type': type_, 'id': item['id'],
            'name': item['title'], 'year': item.get('year', ''),
            'fanart': fanart, 'poster': poster, 'description': item.get('description', ''),
        }
        route_params['action'] = 'list_streams' if type_ == 'movie' else 'details'
        url = build_url(route_params)
        li = xbmcgui.ListItem(title)
        if poster or fanart:
            li.setArt({'thumb': poster or None, 'poster': poster or None, 'fanart': fanart or None})
        _set_listitem_info(li, 'video', {
            'title': title, 'plot': item.get('description', ''),
            'year': int(item['year']) if item.get('year') else 0, 'mediatype': 'video'
        })
        if type_ == 'movie':
            _add_movie_synopsis_context(li, base_title, item.get('description', ''))
        xbmcplugin.addDirectoryItem(handle=ADDON_HANDLE, url=url, listitem=li, isFolder=True)

    if has_next:
        next_page_url = build_url({'action': 'search', 'query': query, 'page': str(page + 1)})
        next_li = xbmcgui.ListItem('[Próxima Página]')
        icon = TRANSLATE(os.path.join(homeDir, 'resources', 'images', 'proximo.png'))
        next_li.setArt({'icon': icon, 'thumb': icon, 'poster': icon, 'fanart': addonFanart})
        _set_listitem_info(next_li, 'video', {'mediatype': 'video'})
        xbmcplugin.addDirectoryItem(handle=ADDON_HANDLE, url=next_page_url, listitem=next_li, isFolder=True)

    _log_debug('Lupa unificada: {} resultado(s) na página {}; próxima={}.'.format(
        len(items), page, 'sim' if has_next else 'não'))
    xbmcplugin.endOfDirectory(ADDON_HANDLE)
    xbmcplugin.setContent(ADDON_HANDLE, 'movies')
    setview('List')

# Somente ações que podem abrir catálogo, M3U, EPG, Pluto ou reprodução
# precisam preparar o DNS opcional. A Home é 100% local.
_NETWORK_ROUTER_ACTIONS = frozenset((
    'tv', 'openm3u', 'pluto_entry', 'channels_pluto',
    'channels_pluto_categories', 'channels_pluto_category', 'play_pluto',
    'pluto_movies', 'pluto_series', 'pluto_vod_category',
    'pluto_vod_seasons', 'pluto_vod_episodes', 'play_pluto_vod',
    'opengroup', 'play_proxy', 'show_full_epg', 'list',
    'details', 'search', 'open_tv_adult_groups', 'list_seasons',
    'list_episodes', 'list_streams', 'select_vod_provider', 'play_item_with_subtitles',
    'play_torrent_with_puretorrent',
))


def _router_impl(paramstring):
    params = dict(parse_qsl(paramstring, keep_blank_values=True))
    action = params.get('action')

    # A sincronização DNS era feita no import de todo clique. Isso incluía
    # Home, configurações e manutenção local. Agora ocorre antes da primeira
    # rota que realmente pode usar rede, preservando o DNS opcional no player.
    if action in _NETWORK_ROUTER_ACTIONS:
        ensure_customdns()

    ## MODIFICADO: Atualização do roteador para as novas ações
    if action is None:
        start_mode = _setting_text('paginainicial', '0')
        if start_mode in ('1', '2', '3'):
            ensure_customdns()
        if start_mode == '1':
            menu_tv()
        elif start_mode == '2':
            menu_movies()
        elif start_mode == '3':
            menu_series()
        else:
            home()
    elif action == 'menu_movies':
        menu_movies()
    elif action == 'tv':
        menu_tv()
    elif action == 'openm3u':
        openm3u(params)
    elif action == 'pluto_entry':
        pluto_entry(params)
    elif action == 'channels_pluto':
        channels_pluto(params, psid=(params.get('psid') or ''))
    elif action == 'channels_pluto_categories':
        channels_pluto_categories(params, psid=(params.get('psid') or ''))
    elif action == 'channels_pluto_category':
        channels_pluto_category(params, psid=(params.get('psid') or ''))
    elif action == 'play_pluto':
        play_pluto(params)
    elif action == 'pluto_movies':
        pluto_movies(params)
    elif action == 'pluto_series':
        pluto_series(params)
    elif action == 'pluto_vod_category':
        pluto_vod_category(params)
    elif action == 'pluto_vod_seasons':
        pluto_vod_seasons(params)
    elif action == 'pluto_vod_episodes':
        pluto_vod_episodes(params)
    elif action == 'play_pluto_vod':
        play_pluto_vod(params)
    elif action == 'opengroup':
        opengroup(params)
    elif action == 'play_proxy':
        play_proxy(params)
    elif action == 'show_full_epg':
        show_full_epg(params)
    elif action == 'noop':
        _complete_plugin_action(succeeded=True)
    elif action == 'menu_series':
        menu_series()
    elif action == 'list':
        list_items(params)
    elif action == 'details':
        show_details(params)
    elif action == 'search':
        search(params)
    elif action == 'open_settings':
        open_settings()
    elif action == 'clear_debug_log':
        cleared = bool(common.clear_debug_log())
        _notify('Diagnóstico', 'Log de manutenção limpo.' if cleared else 'Não foi possível limpar o log de manutenção.',
                xbmcgui.NOTIFICATION_INFO if cleared else xbmcgui.NOTIFICATION_ERROR, 3000)
        _complete_plugin_action(succeeded=cleared)
    elif action == 'clear_subtitles_cache':
        clear_subtitles_cache(notify=True)
        _complete_plugin_action(succeeded=True)
    elif action == 'clear_epg_cache':
        clear_epg_cache(notify=True)
        _complete_plugin_action(succeeded=True)
    elif action == 'clear_navigation_cache':
        clear_navigation_cache(notify=True)
        _complete_plugin_action(succeeded=True)
    elif action == 'clear_catalog_cache':
        clear_catalog_cache(notify=True)
        _complete_plugin_action(succeeded=True)
    elif action == 'clear_tv_history':
        clear_tv_history(notify=True)
        _complete_plugin_action(succeeded=True)
    elif action == 'change_tv_adult_pin':
        change_tv_adult_pin()
        _complete_plugin_action(succeeded=True)
    elif action == 'open_tv_adult_groups':
        open_tv_adult_groups(params)
    elif action == 'show_text_dialog':
        _show_text_dialog(params.get('title', 'OnePlay'), params.get('text', ''))
        _complete_plugin_action(succeeded=True)
    elif action == 'list_seasons':
        list_seasons(params)
    elif action == 'list_episodes':
        list_episodes(params)
    elif action == 'select_vod_provider':
        select_vod_provider(params)
    elif action == 'list_streams': # Rota antiga 'audio' agora é 'list_streams'
        list_streams(params)
    elif action == 'play_item_with_subtitles': # Rota nova para reprodução com legendas
        play_item_with_subtitles(params)
    elif action == 'play_torrent_with_puretorrent':
        play_torrent_with_puretorrent(params)
    elif action == 'donate':
        show_qr_code()
        _complete_plugin_action(succeeded=True)
    else:
        home()


def router(paramstring):
    try:
        diagnostic_params = dict(parse_qsl(paramstring or '', keep_blank_values=True))
        action = diagnostic_params.get('action') or 'home'
    except Exception:
        action = 'home'
    try:
        common.log_event('Routes', 'inicio', addon=ADDON, acao=action)
        result = _router_impl(paramstring)
        common.log_event('Routes', 'fim', addon=ADDON, acao=action)
        return result
    except Exception as exc:
        try:
            common.log_exception('Routes', 'Falha não tratada na ação {}'.format(action), exc, addon=ADDON)
        except Exception:
            _log_error('Falha não tratada na ação {}: {}'.format(action, exc.__class__.__name__))
        _show_error('Ocorreu um erro nesta operação. Consulte o log de manutenção se o problema persistir.', title='OnePlay', dialog=False)
        _complete_plugin_action(succeeded=False)


if __name__ == '__main__':
    router(sys.argv[2][1:])
