#!/usr/bin/env python3
"""
Script untuk mendownload video dari URL dan upload ke DigitalOcean Spaces dalam format HLS.

Input: file CSV/TXT dengan format: Judul;Player_URL
       atau langsung via chat Discord dengan format yang sama: Judul;URL

Usage:
    # Mode file input
    python3 download_upload_dc.py --input semprotaja.txt
    python3 download_upload_dc.py --input streamhg.txt --prefix videos/hls

    # Mode single URL
    python3 download_upload_dc.py --url "https://example.com/video.mp4" --title "Video Saya"

    # Mode Discord bot (terima pesan langsung dari Discord)
    python3 download_upload_dc.py --discord --token "BOT_TOKEN_DISINI"
    python3 download_upload_dc.py --discord  # (gunakan DISCORD_TOKEN dari konfigurasi di atas)

    # Di Discord, kirim pesan dengan format:
    #   Judul Video;https://streamhg.com/xxx
    #   Judul1;URL1
    #   Judul2;URL2
    #   (bisa multi-line sekaligus)
"""

import subprocess
import os
import sys
import tempfile
import shutil
import argparse
import json
import re
import logging
import time
import requests
import hashlib
from datetime import datetime
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor, as_completed
from urllib.parse import urlparse, urljoin, quote, parse_qs, unquote
import base64
try:
    from curl_cffi import requests as cf_requests
except ImportError:
    cf_requests = None
try:
    from Crypto.Cipher import AES
    from Crypto.Util.Counter import new as new_counter
except ImportError:
    AES = None
    new_counter = None
try:
    import discord
    from discord.ext import commands
    DISCORD_AVAILABLE = True
except ImportError:
    DISCORD_AVAILABLE = False

# ============================================================
# KONFIGURASI - Ubah sesuai kebutuhan
# ============================================================

# Discord Bot
# ⚠️ ISI TOKEN BOT DISCORD KAMU DI SINI (dari Discord Developer Portal > Bot > Token)
DISCORD_TOKEN = "MTQ3MTc0NTQwOTEyOTkwNjI0Nw.GS_-Yl.2LoxG2oEi-Uyu8GWpnkQgG9POf9tAMr4SiKI6Q"                       # Token bot Discord (wajib diisi jika mode --discord)
# Opsional: batasi channel tertentu saja (ambil channel ID dari Discord, klik kanan channel > Copy Channel ID)
# Contoh: DISCORD_CHANNEL_IDS = [123456789012345678, 987654321098765432]
DISCORD_CHANNEL_IDS = []                 # Kosong = bot aktif di SEMUA channel
DISCORD_PREFIX = "!"                     # Command prefix untuk perintah (misal: !proses, !status, !file)

# S3 DigitalOcean Spaces
BUCKET_NAME = "ytube"                    # Nama bucket DO Spaces
PREFIX_PATH = "Semprot"                   # Prefix/folder di S3 untuk video HLS
IMAGE_PREFIX = "image"                   # Prefix/folder di S3 untuk thumbnail/gambar
S3CFG_FILE  = "data.s3cfg"               # Path ke file konfigurasi s3cmd

# Log & Deduplicate
SUCCESS_LOG_FILE = "success_log.txt"     # File log URL yang sudah berhasil diproses
AUTO_DELETE_LOCAL = True                 # True = hapus file lokal setelah upload berhasil

# Watermark
WATERMARK_ENABLED  = True                # True = aktifkan watermark, False = tanpa watermark
WATERMARK_TEXT     = "Bokeplah.com"       # Teks watermark
WATERMARK_POSITION = "bottom-center"            # Posisi: top-left, top-center, top-right,
                                         #         center-left, center, center-right,
                                         #         bottom-left, bottom-center, bottom-right
WATERMARK_FONT     = "Arial"             # Font (Arial, Sans, dll)
WATERMARK_COLOR    = "white"             # Warna teks (white, yellow, #FFFFFF, dll)
WATERMARK_SHADOW   = ""                  # Warna shadow (kosong = tanpa shadow, misal: #303030)
WATERMARK_OPACITY  = 0.80                # Opacity 0.0 - 1.0 (80% = 0.80)
WATERMARK_SIZE     = 13                  # Ukuran font fallback (pixel) - dipakai kalau AUTO_SIZE=False
WATERMARK_AUTO_SIZE = True               # True = ukuran font otomatis proporsional resolusi video
WATERMARK_SIZE_RATIO = 0.017             # Rasio font terhadap tinggi video (0.018 = 1.8%)
WATERMARK_PADDING  = 10                  # Padding fallback (pixel) - dipakai kalau AUTO_SIZE=False
WATERMARK_PADDING_RATIO = 0.014          # Rasio padding terhadap tinggi video

# Floating/Moving watermark
WATERMARK_FLOATING = True                # True = watermark bergerak random, False = posisi tetap
WATERMARK_FLOAT_SPEED = 30               # Kecepatan gerak (pixel per detik), 20-60 recommended
WATERMARK_FLOAT_MODE = "bounce"           # Mode gerakan:
                                         #   "bounce"  = memantul di tepi layar (paling umum)
                                         #   "random"  = pindah posisi random tiap interval
                                         #   "smooth"  = gerakan melingkar halus (sin/cos)
WATERMARK_FLOAT_INTERVAL = 8             # Interval pindah posisi (detik) — hanya untuk mode "random"

# HLS
HLS_TIME       = 10                      # Durasi per segment HLS dalam detik
COPY_CODEC     = True                    # True = copy codec (cepat), False = re-encode
WORKERS        = 2                       # Jumlah parallel workers

# ============================================================
# AKHIR KONFIGURASI
# ============================================================

# Setup logging
log_filename = f'download_upload_hls_{datetime.now().strftime("%Y%m%d_%H%M%S")}.log'
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(levelname)s - %(message)s',
    handlers=[
        logging.FileHandler(log_filename),
        logging.StreamHandler()
    ]
)
logger = logging.getLogger(__name__)


def check_dependencies():
    """Cek apakah ffmpeg, yt-dlp, dan s3cmd terinstall"""
    deps = {
        'ffmpeg': False,
        'yt-dlp': False,
        's3cmd': False
    }

    for dep in deps:
        result = subprocess.run(f"which {dep}", shell=True, capture_output=True)
        deps[dep] = result.returncode == 0

    missing = [k for k, v in deps.items() if not v]
    if missing:
        logger.error(f"Dependency tidak ditemukan: {', '.join(missing)}")
        logger.info("Install dengan:")
        if 'ffmpeg' in missing:
            logger.info("  sudo apt install ffmpeg")
        if 'yt-dlp' in missing:
            logger.info("  pip install yt-dlp")
        if 's3cmd' in missing:
            logger.info("  pip install s3cmd")
        return False

    # Cek python library
    try:
        import requests
    except ImportError:
        logger.error("Library 'requests' tidak ditemukan. Install: pip install requests")
        return False

    return True


def sanitize_filename(name):
    """Bersihkan nama file dari karakter tidak valid"""
    name = re.sub(r'[<>:"/\\|?*]', '_', name)
    name = re.sub(r'\s+', '_', name)
    name = re.sub(r'_+', '_', name)
    name = name.strip('_. ')
    return name[:200] if name else 'untitled'


def parse_s3cfg(s3cfg_path):
    """Parse s3cfg file untuk mendapatkan info koneksi"""
    config = {}
    with open(s3cfg_path, 'r') as f:
        for line in f:
            line = line.strip()
            if '=' in line and not line.startswith('['):
                key, _, value = line.partition('=')
                config[key.strip()] = value.strip()
    return config


# ============================================================
# SUCCESS LOG - untuk menghindari duplikasi
# ============================================================

def load_success_log(log_file=None):
    """Baca daftar URL yang sudah berhasil diproses"""
    log_file = log_file or SUCCESS_LOG_FILE
    if not os.path.exists(log_file):
        return set()
    with open(log_file, 'r', encoding='utf-8') as f:
        return set(line.strip() for line in f if line.strip())


def append_success_log(url, title, playlist_url, log_file=None):
    """Tambahkan entry ke success log setelah berhasil upload"""
    log_file = log_file or SUCCESS_LOG_FILE
    timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
    with open(log_file, 'a', encoding='utf-8') as f:
        f.write(f"{url}|{title}|{playlist_url}|{timestamp}\n")
    logger.info(f"Success log updated: {log_file}")


def is_already_processed(url, log_file=None):
    """Cek apakah URL sudah pernah berhasil diproses"""
    success_urls = load_success_log(log_file)
    # Cek URL exact match (bagian pertama sebelum |)
    for entry in success_urls:
        entry_url = entry.split('|')[0]
        if entry_url == url:
            return True
    return False


def unpack_js(packed_code):
    """Decode JavaScript yang di-pack dengan Dean Edwards packer (p,a,c,k,e,d)"""
    match = re.search(
        r"eval\(function\(p,a,c,k,e,d\)\{.*?\}\('(.*?)',(\d+),(\d+),'(.*?)'\.split\('\|'\)",
        packed_code, re.DOTALL
    )
    if not match:
        return None

    p_code = match.group(1)
    a_val = int(match.group(2))
    c_val = int(match.group(3))
    k_words = match.group(4).split('|')

    def base_n(num, base):
        chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
        if num < base:
            return chars[num]
        return base_n(num // base, base) + chars[num % base]

    decoded = p_code
    for i in range(c_val - 1, -1, -1):
        word = k_words[i] if i < len(k_words) and k_words[i] else base_n(i, a_val)
        pattern = r'\b' + re.escape(base_n(i, a_val)) + r'\b'
        decoded = re.sub(pattern, word, decoded)

    return decoded


def extract_streamhg_url(page_url):
    """
    Extract video URL dari halaman streamhg/hgplaycdn/futurohope.
    Mendecode packed JS untuk mendapatkan m3u8 URL, lalu fetch index playlist
    yang berisi segment URL lengkap dengan token.
    Beberapa domain membatasi embed ke referer tertentu, jadi kita coba
    beberapa referer umum jika yang default gagal.
    """
    logger.info(f"[streamhg] Extracting video URL dari: {page_url}")

    ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'

    # Daftar referer yang umum dipakai situs bokep Indo sebagai source embed
    referer_list = [
        'https://streamhg.com/',
        'https://rexbokep.com/',
        'https://yobokep.com/',
        'https://bokep.fyi/',
        'https://nekopoi.care/',
        'https://yobokep.net/',
        'https://indoxxi.bz/',
        'https://lk21.fyi/',
        None,  # tanpa referer
    ]

    html = None
    used_referer = None

    for ref in referer_list:
        headers = {'User-Agent': ua}
        if ref:
            headers['Referer'] = ref

        try:
            resp = requests.get(page_url, headers=headers, timeout=30)
            resp.raise_for_status()
            page_html = resp.text

            # Cek apakah kena "embed restricted" atau halaman kosong
            if 'embed restricted' in page_html.lower() or 'not allowed' in page_html.lower():
                logger.debug(f"[streamhg] Embed restricted dengan referer: {ref}")
                continue

            # Cek ada packed JS = halaman berhasil dimuat
            if 'eval(function(p,a,c,k,e,d)' in page_html:
                html = page_html
                used_referer = ref
                logger.info(f"[streamhg] Halaman berhasil dimuat dengan referer: {ref}")
                break

            # Mungkin halaman valid tapi tanpa packer (cari m3u8 langsung)
            if '.m3u8' in page_html or 'jwplayer' in page_html.lower():
                html = page_html
                used_referer = ref
                logger.info(f"[streamhg] Halaman valid (tanpa packer) dengan referer: {ref}")
                break

        except Exception as e:
            logger.debug(f"[streamhg] Error dengan referer {ref}: {e}")
            continue

    if not html:
        logger.error("[streamhg] Gagal fetch halaman dengan semua referer")
        return None, None, None

    # Extract thumbnail dari halaman
    thumbnail = None
    img_match = re.search(r'<img[^>]*src=["\']([^"\'>]+\.(?:jpg|jpeg|png|webp))', html)
    if img_match:
        thumbnail = img_match.group(1)
        if not thumbnail.startswith('http'):
            parsed_page = urlparse(page_url)
            thumbnail = f"{parsed_page.scheme}://{parsed_page.netloc}{thumbnail}"
        logger.info(f"[streamhg] Thumbnail: {thumbnail}")

    # Decode packed JavaScript
    decoded = unpack_js(html)
    if not decoded:
        logger.warning("[streamhg] Tidak ditemukan packed JS di halaman")
        # Fallback: cari URL langsung di HTML (izinkan koma di URL karena CDN pakai format _,l,n,.urlset/)
        m3u8_direct = re.findall(r'["\']\s*(https?://[^"\'>\s]+\.m3u8[^"\'>\s]*)', html)
        if not m3u8_direct:
            m3u8_direct = re.findall(r'https?://[^\s"\';<>\\]+\.m3u8[^\s"\';<>\\]*', html)
        if m3u8_direct:
            return m3u8_direct[0], page_url, thumbnail
        return None, None, None

    # Extract m3u8/txt URL dari decoded JS
    # Prioritas 1: extract dari string yang di-quote (lebih akurat, izinkan koma di URL)
    m3u8_urls = re.findall(r'["\']\s*(https?://[^"\'>\s]+\.m3u8[^"\'>\s]*)', decoded)
    txt_urls = re.findall(r'["\']\s*(https?://[^"\'>\s]+master\.txt[^"\'>\s]*)', decoded)
    # Fallback: regex tanpa quote delimiter
    if not m3u8_urls:
        m3u8_urls = re.findall(r'https?://[^\s"\';<>\\]+\.m3u8[^\s"\';<>\\]*', decoded)
    if not txt_urls:
        txt_urls = re.findall(r'https?://[^\s"\';<>\\]+master\.txt[^\s"\';<>\\]*', decoded)

    if not m3u8_urls and not txt_urls:
        logger.error("[streamhg] Tidak ditemukan URL video di halaman")
        return None, None, None

    # Prioritas: m3u8 (hls2) karena berisi full URL segment dengan token
    m3u8_url = m3u8_urls[0] if m3u8_urls else None

    if m3u8_url:
        logger.info(f"[streamhg] Master m3u8 URL: {m3u8_url[:100]}...")

        # Fetch master m3u8 untuk mendapatkan index playlist URL
        try:
            resp2 = requests.get(m3u8_url, headers={
                'User-Agent': ua,
                'Referer': page_url,
                'Origin': urlparse(page_url).scheme + '://' + urlparse(page_url).netloc
            }, timeout=30)

            if resp2.status_code == 200:
                master_content = resp2.text
                # Cari index playlist (index-v1-a1.m3u8?token...)
                index_lines = [
                    line.strip() for line in master_content.split('\n')
                    if line.strip() and not line.startswith('#')
                ]

                if index_lines:
                    index_path = index_lines[0]  # Biasanya index-v1-a1.m3u8?token...

                    # Build full URL untuk index playlist
                    if index_path.startswith('http'):
                        index_url = index_path
                    else:
                        base = m3u8_url.rsplit('/', 1)[0] + '/'
                        # Jika index_path tidak punya token, tambahkan dari master URL
                        if '?' not in index_path and '?' in m3u8_url:
                            token = m3u8_url.split('?', 1)[1]
                            index_path = index_path + '?' + token
                        index_url = base + index_path

                    logger.info(f"[streamhg] Index playlist URL: {index_url[:100]}...")

                    # Verifikasi index playlist bisa diakses dan segment URL-nya punya token
                    resp3 = requests.get(index_url, headers={
                        'User-Agent': ua,
                        'Referer': page_url,
                    }, timeout=30)

                    if resp3.status_code == 200 and '#EXTINF' in resp3.text:
                        # Cek apakah segment URL sudah absolute dan punya token
                        seg_urls = [
                            l.strip() for l in resp3.text.split('\n')
                            if l.strip() and not l.startswith('#')
                        ]
                        if seg_urls and seg_urls[0].startswith('http') and '?' in seg_urls[0]:
                            logger.info(f"[streamhg] Index playlist valid dengan {len(seg_urls)} segments (full URL + token)")
                            return index_url, page_url, thumbnail
                        else:
                            logger.info("[streamhg] Segment URL tidak lengkap, gunakan master URL")

        except Exception as e:
            logger.warning(f"[streamhg] Gagal fetch master m3u8: {e}")

        return m3u8_url, page_url, thumbnail

    # Fallback ke txt URL (hls3)
    if txt_urls:
        logger.info(f"[streamhg] Menggunakan hls3 txt URL: {txt_urls[0][:100]}...")
        return txt_urls[0], page_url, thumbnail

    return None, None, None


# ============================================================
# SITE DETECTION
# ============================================================

SUPPORTED_SITES = {
    'streamhg':     ['hgplaycdn.com', 'streamhg.com', 'hglink.to', 'futurohope.online',
                     'streamwish.to', 'streamwish.com', 'iplayerhls.com', 'cybervynx.com'],
    'vidara':       ['vidara.to', 'vidara.so'],
    'strmup':       ['strmup.cc', 'strmup.to', 'streamup.cc'],
    'putarvid':     ['putarvid.com', 'streamruby.com'],
    'berbagi':      ['videb.lol', 'berbagi.app'],
    'pooptv':       ['pooptv.me', 'streamkithmc.com', 'vidcloudmv.com', 'lixstreamingcaio.com'],
    'indovidplus':  ['indovidplus.org', 'indovidplus.com'],
    'luluvid':      ['luluvid.com', 'lulustream.com'],
    'abysscdn':     ['abysscdn.com', 'short.icu', 'abyss.to'],
    'streamhls':    ['streamhls.to', 'savefiles.com'],
    'voe':          ['voe.sx', 'lauradaydo.com', 'fifrfrse.com', 'frfrfrerse.com'],
    'vidhide':      ['minochinos.com', 'vidhide.com', 'vidhidepro.com', 'pixibay.cc',
                     'luluvdo.com', 'vid2hide.com', 'alions.pro', 'vid2a10.com',
                     'hfrfrse.com', 'ahacdn.me', 'movearnpre.com'],
    'havenfile':    ['havenfile.cc', 'havenfile.com'],
    'filemoon':     ['filemoon.sx', 'filemoon.in', 'filemoon.to', 'filemoon.wf'],
    'doodstream':   ['pemersatu.link', 'doodstream.com', 'do0od.com', 'myvidplay.com',
                     'd0000d.com', 'dood.to', 'dood.la', 'dood.so', 'dood.pm',
                     'dood.ws', 'dood.re', 'doodcdn.co'],
    'bigwarp':      ['bigwarp.io', 'bigwarp.pro'],
    'youvid':       ['youvid.org'],
    'vidkeyx':      ['vidkeyx.com'],
    'streamtape':   ['streamtape.to', 'streamtape.com', 'streamtape.net', 'streamtape.xyz',
                     'streamtape.site', 'streamtape.club', 'streamta.pe', 'strtape.cloud',
                     'strtpe.link', 'stape.fun', 'advertape.net', 'tapecontent.net'],
    'vidnest':      ['vidnest.io', 'vidnest.live', 'vidnest.com'],
}


def detect_site(url):
    """Deteksi situs dari URL. Return nama situs atau None"""
    parsed = urlparse(url)
    for site_name, domains in SUPPORTED_SITES.items():
        if any(d in parsed.netloc for d in domains):
            return site_name
    return None


# ============================================================
# VIDARA.TO / VIDARA.SO EXTRACTOR
# ============================================================

def extract_vidara_url(page_url):
    """
    Extract video URL dari vidara.to.
    vidara.to -> iframe ke vidara.so/e/CODE -> API /api/stream?filecode=CODE
    """
    logger.info(f"[vidara] Extracting video URL dari: {page_url}")

    headers = {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
    }

    try:
        # Extract filecode dari URL
        # Format: vidara.to/v/CODE atau vidara.so/e/CODE
        parsed = urlparse(page_url)
        path_parts = [p for p in parsed.path.strip('/').split('/') if p]

        filecode = None
        if len(path_parts) >= 2:
            filecode = path_parts[-1]
        elif len(path_parts) == 1:
            filecode = path_parts[0]

        if not filecode:
            # Fallback: fetch halaman dan cari iframe
            resp = requests.get(page_url, headers=headers, timeout=30)
            iframe_match = re.search(r'<iframe[^>]*src=["\']([^"\']*\/e\/([^"\'/]+))', resp.text)
            if iframe_match:
                filecode = iframe_match.group(2)
            else:
                logger.error("[vidara] Tidak bisa menemukan filecode")
                return None, None, None

        logger.info(f"[vidara] Filecode: {filecode}")

        # Coba beberapa domain API
        api_domains = ['https://vidara.so', 'https://vidara.to']
        for api_domain in api_domains:
            api_url = f"{api_domain}/api/stream?filecode={filecode}"
            try:
                resp = requests.get(api_url, headers={
                    **headers,
                    'Referer': f"{api_domain}/e/{filecode}"
                }, timeout=30)

                if resp.status_code == 200:
                    data = resp.json()
                    streaming_url = data.get('streaming_url')
                    if streaming_url:
                        title = data.get('title', filecode)
                        thumbnail = data.get('thumbnail')
                        logger.info(f"[vidara] Streaming URL ditemukan: {streaming_url[:100]}...")
                        logger.info(f"[vidara] Title: {title}")
                        if thumbnail:
                            logger.info(f"[vidara] Thumbnail: {thumbnail}")
                        return streaming_url, api_domain, thumbnail
            except Exception as e:
                logger.warning(f"[vidara] API {api_domain} gagal: {e}")
                continue

        logger.error("[vidara] Gagal mendapatkan streaming URL dari semua API")
        return None, None, None

    except Exception as e:
        logger.error(f"[vidara] Error: {e}")
        return None, None, None


# ============================================================
# STRMUP.CC / STREAMUP EXTRACTOR
# ============================================================

def extract_strmup_url(page_url):
    """
    Extract video URL dari strmup.cc.
    strmup.cc/v/CODE -> iframe ke strmup.to/CODE -> API /ajax/stream?filecode=CODE
    """
    logger.info(f"[strmup] Extracting video URL dari: {page_url}")

    headers = {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
    }

    try:
        # Extract filecode dari URL
        parsed = urlparse(page_url)
        path_parts = [p for p in parsed.path.strip('/').split('/') if p]

        filecode = None
        if len(path_parts) >= 1:
            filecode = path_parts[-1]

        if not filecode:
            # Fallback: fetch halaman dan cari iframe
            resp = requests.get(page_url, headers=headers, timeout=30)
            iframe_match = re.search(r'<iframe[^>]*src=["\']([^"\']*\/([a-f0-9]+))', resp.text)
            if iframe_match:
                filecode = iframe_match.group(2)
            else:
                logger.error("[strmup] Tidak bisa menemukan filecode")
                return None, None, None

        logger.info(f"[strmup] Filecode: {filecode}")

        # API call ke strmup.to
        api_domains = ['https://strmup.to', 'https://streamup.cc']
        for api_domain in api_domains:
            api_url = f"{api_domain}/ajax/stream?filecode={filecode}"
            try:
                resp = requests.get(api_url, headers={
                    **headers,
                    'Referer': f"{api_domain}/{filecode}"
                }, timeout=30)

                if resp.status_code == 200:
                    data = resp.json()
                    streaming_url = data.get('streaming_url')
                    if streaming_url:
                        title = data.get('title', filecode)
                        thumbnail = data.get('thumbnail')
                        logger.info(f"[strmup] Streaming URL ditemukan: {streaming_url[:100]}...")
                        logger.info(f"[strmup] Title: {title}")
                        if thumbnail:
                            logger.info(f"[strmup] Thumbnail: {thumbnail}")
                        return streaming_url, api_domain, thumbnail
            except Exception as e:
                logger.warning(f"[strmup] API {api_domain} gagal: {e}")
                continue

        logger.error("[strmup] Gagal mendapatkan streaming URL dari semua API")
        return None, None, None

    except Exception as e:
        logger.error(f"[strmup] Error: {e}")
        return None, None, None


# ============================================================
# PUTARVID.COM / STREAMRUBY EXTRACTOR
# ============================================================

def extract_putarvid_url(page_url):
    """
    Extract video URL dari putarvid.com (streamruby).
    Menggunakan packed JS decode sama seperti streamhg.
    Format embed: putarvid.com/embed-XXXX.html
    Return: (streaming_url, referer, thumbnail_url)
    """
    logger.info(f"[putarvid] Extracting video URL dari: {page_url}")

    headers = {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
    }

    try:
        resp = requests.get(page_url, headers=headers, timeout=30)
        resp.raise_for_status()
        html = resp.text
    except Exception as e:
        logger.error(f"[putarvid] Gagal fetch halaman: {e}")
        return None, None, None

    # Cari thumbnail dari halaman HTML
    thumbnail = None
    img_match = re.search(r'<img[^>]*src=["\']([^"\'>]+\.(?:jpg|jpeg|png|webp))', html)
    if img_match:
        thumbnail = img_match.group(1)

    # Decode packed JavaScript (sama seperti streamhg)
    decoded = unpack_js(html)
    if not decoded:
        logger.warning("[putarvid] Tidak ditemukan packed JS")
        m3u8_direct = re.findall(r'["\']\s*(https?://[^"\'>\s]+\.m3u8[^"\'>\s]*)', html)
        if not m3u8_direct:
            m3u8_direct = re.findall(r'https?://[^\s"\';<>\\]+\.m3u8[^\s"\';<>\\]*', html)
        if m3u8_direct:
            return m3u8_direct[0], page_url, thumbnail
        return None, None, None

    # Cari thumbnail dari decoded JS jika belum ketemu
    if not thumbnail:
        thumb_match = re.search(r'image\s*[:=]\s*["\']?(https?://[^"\']+(?: jpg|jpeg|png|webp)[^"\' ]*)', decoded)
        if thumb_match:
            thumbnail = thumb_match.group(1)
        else:
            thumb_match2 = re.findall(r'https?://[^\s"\',;\\]+\.(?:jpg|jpeg|png|webp)', decoded)
            if thumb_match2:
                thumbnail = thumb_match2[0]

    if thumbnail:
        logger.info(f"[putarvid] Thumbnail: {thumbnail}")

    # Extract file URL dari decoded JS
    file_match = re.search(r'file\s*:\s*"(https?://[^"]+)"', decoded)
    if not file_match:
        file_match = re.search(r'"file"\s*:\s*"(https?://[^"]+)"', decoded)

    if not file_match:
        # Cari m3u8 URL langsung (izinkan koma di URL)
        m3u8_urls = re.findall(r'["\']\s*(https?://[^"\'>\s]+\.m3u8[^"\'>\s]*)', decoded)
        if not m3u8_urls:
            m3u8_urls = re.findall(r'https?://[^\s"\';<>\\]+\.m3u8[^\s"\';<>\\]*', decoded)
        if m3u8_urls:
            file_match_url = m3u8_urls[0]
        else:
            logger.error("[putarvid] Tidak ditemukan URL video")
            return None, None, None
    else:
        file_match_url = file_match.group(1)

    logger.info(f"[putarvid] Master URL: {file_match_url[:100]}...")

    # Fetch master m3u8 untuk mendapatkan index playlist dengan quality terbaik
    try:
        resp2 = requests.get(file_match_url, headers={
            **headers,
            'Referer': page_url,
        }, timeout=30)

        if resp2.status_code == 200 and '#EXTM3U' in resp2.text:
            # Ambil quality tertinggi (baris terakhir yang bukan comment)
            index_lines = [
                line.strip() for line in resp2.text.split('\n')
                if line.strip() and not line.startswith('#')
            ]
            if index_lines:
                best_quality = index_lines[-1]  # Quality tertinggi biasanya di bawah

                if best_quality.startswith('http'):
                    index_url = best_quality
                else:
                    base = file_match_url.rsplit('/', 1)[0] + '/'
                    index_url = base + best_quality

                # Verifikasi index playlist
                resp3 = requests.get(index_url, headers={**headers, 'Referer': page_url}, timeout=30)
                if resp3.status_code == 200 and '#EXTINF' in resp3.text:
                    seg_urls = [
                        l.strip() for l in resp3.text.split('\n')
                        if l.strip() and not l.startswith('#')
                    ]
                    if seg_urls and seg_urls[0].startswith('http'):
                        logger.info(f"[putarvid] Index playlist valid dengan {len(seg_urls)} segments")
                        return index_url, page_url, thumbnail

                # Jika index gagal, gunakan master
                logger.info("[putarvid] Menggunakan master URL")
                return file_match_url, page_url, thumbnail

    except Exception as e:
        logger.warning(f"[putarvid] Gagal fetch master m3u8: {e}")

    return file_match_url, page_url, thumbnail


# ============================================================
# VIDEB.LOL / BERBAGI.APP EXTRACTOR
# ============================================================

def extract_berbagi_url(page_url):
    """
    Extract video URL dari videb.lol / berbagi.app.
    Flow: videb.lol/e/CODE -> redirect ke berbagi.app/e/CODE
          -> iframe base64-reversed -> /player/CODE
          -> obfuscated JS array -> /stream/CODE?token=TIMESTAMP.HASH
          -> 302 redirect ke direct MP4 CDN
    Return: (stream_url, referer, thumbnail_url)
    """
    import base64

    logger.info(f"[berbagi] Extracting video URL dari: {page_url}")

    headers = {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
    }

    try:
        # Step 1: Fetch embed page (follows redirect from videb.lol to berbagi.app)
        resp = requests.get(page_url, headers=headers, timeout=30, allow_redirects=True)
        if resp.status_code != 200:
            logger.error(f"[berbagi] Gagal fetch halaman: HTTP {resp.status_code}")
            return None, None, None

        final_url = resp.url
        parsed = urlparse(final_url)
        base_domain = f"{parsed.scheme}://{parsed.netloc}"
        logger.info(f"[berbagi] Redirect ke: {final_url}")

        # Step 2: Extract video ID from base64-reversed encoded string
        # Pattern: var e='XXXX',d=atob(e.split('').reverse().join(''))
        enc_match = re.search(r"var\s+e='([^']+)',d=atob\(e\.split\(''\)\.reverse\(\)\.join\(''\)\)", resp.text)
        video_id = None
        if enc_match:
            encoded = enc_match.group(1)
            video_id = base64.b64decode(encoded[::-1]).decode()
            logger.info(f"[berbagi] Video ID (decoded): {video_id}")
        else:
            # Fallback: extract from URL path
            path_parts = [p for p in parsed.path.strip('/').split('/') if p]
            if len(path_parts) >= 2:
                video_id = path_parts[-1]
                logger.info(f"[berbagi] Video ID (from URL): {video_id}")

        if not video_id:
            logger.error("[berbagi] Tidak bisa menemukan video ID")
            return None, None, None

        # Step 3: Fetch player page
        player_url = f"{base_domain}/player/{video_id}"
        resp2 = requests.get(player_url, headers={**headers, 'Referer': final_url}, timeout=30)
        if resp2.status_code != 200:
            logger.error(f"[berbagi] Gagal fetch player page: HTTP {resp2.status_code}")
            return None, None, None

        logger.info(f"[berbagi] Player page OK: {player_url}")

        # Step 4: Extract stream URL from obfuscated JS array
        # Pattern: var _0x1=['/stream/XX','XXXX?toke','n=XXXX.XXXX']...join('')
        stream_url = None
        stream_match = re.search(r"var\s+_0x1=\[([^\]]+)\]", resp2.text)
        if stream_match:
            parts_raw = stream_match.group(1)
            parts = re.findall(r"'([^']*?)'", parts_raw)
            stream_path = ''.join(parts)
            if stream_path.startswith('/stream/'):
                stream_url = f"{base_domain}{stream_path}"
                logger.info(f"[berbagi] Stream URL: {stream_url}")

        # Fallback: extract token and construct stream URL manually
        if not stream_url:
            token_match = re.search(r"token['\"]?\s*[:=]\s*['\"]([^'\"]+)['\"]", resp2.text)
            if token_match:
                token = token_match.group(1)
                stream_url = f"{base_domain}/stream/{video_id}?token={token}"
                logger.info(f"[berbagi] Stream URL (from token): {stream_url}")

        if not stream_url:
            logger.error("[berbagi] Tidak bisa menemukan stream URL")
            return None, None, None

        # Step 5: Extract thumbnail from poster attribute
        thumbnail = None
        poster_match = re.search(r'poster="([^"]+)"', resp2.text)
        if poster_match:
            thumbnail = poster_match.group(1)
            logger.info(f"[berbagi] Thumbnail: {thumbnail}")

        # Step 6: Verify stream URL (follow redirect to get actual MP4 URL)
        try:
            resp3 = requests.head(stream_url, headers={**headers, 'Referer': player_url}, timeout=30, allow_redirects=True)
            if resp3.status_code in (200, 206):
                actual_url = resp3.url
                logger.info(f"[berbagi] Video MP4 terdeteksi: {actual_url[:100]}...")
                # Return the actual redirected MP4 URL for direct download
                return actual_url, player_url, thumbnail
            else:
                logger.warning(f"[berbagi] Stream status: {resp3.status_code}, mencoba stream URL langsung")
                return stream_url, player_url, thumbnail
        except Exception as e:
            logger.warning(f"[berbagi] Gagal verify stream: {e}, menggunakan stream URL langsung")
            return stream_url, player_url, thumbnail

    except Exception as e:
        logger.error(f"[berbagi] Error extracting URL: {e}")
        return None, None, None


# ============================================================
# POOPTV.ME / STREAMKIT / LIXBOX EXTRACTOR
# ============================================================

def extract_pooptv_url(page_url):
    """
    Extract video URL dari pooptv.me / streamkithmc.com / vidcloudmv.com.
    Flow: pooptv.me/s/CODE -> redirect ke streamkithmc.com
          -> API /v2/s/home/resources/CODE -> file_id + uid
          -> API /v2/s/assets/f?id=FILE_ID&uid=UID -> encrypted URL
          -> AES-256-CBC decrypt -> direct MP4 URL
    Return: (video_url, referer, thumbnail_url)
    """
    logger.info(f"[pooptv] Extracting video URL dari: {page_url}")

    headers = {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
        'Content-Type': 'application/json',
    }

    # AES decryption constants (from streamkit JS bundle)
    AES_KEY = "GNgN1lHXIFCQd8hSEZIeqozKInQTFNXj"  # 32 chars = AES-256
    AES_IV  = "2Xk4dLo38c9Z2Q2a"                    # 16 chars = 128-bit IV

    try:
        from Crypto.Cipher import AES as AES_Cipher
        from Crypto.Util.Padding import unpad as aes_unpad
    except ImportError:
        logger.error("[pooptv] pycryptodome belum terinstall. Jalankan: pip3 install pycryptodome")
        return None, None, None

    try:
        # Step 1: Extract link code dari URL
        # Format yang didukung: /s/CODE, /e/CODE, /v/CODE, atau langsung /CODE
        parsed = urlparse(page_url)
        path_parts = [p for p in parsed.path.strip('/').split('/') if p]
        link_code = None
        if len(path_parts) >= 2 and path_parts[0] in ('s', 'e', 'v', 'embed'):
            link_code = path_parts[1]
        elif len(path_parts) == 1:
            link_code = path_parts[0]
        elif len(path_parts) >= 2:
            # Fallback: ambil bagian terakhir path
            link_code = path_parts[-1]

        if not link_code:
            # Fallback: follow redirect dan extract dari final URL
            resp = requests.get(page_url, headers={'User-Agent': headers['User-Agent']}, timeout=30, allow_redirects=True)
            final_parsed = urlparse(resp.url)
            final_parts = [p for p in final_parsed.path.strip('/').split('/') if p]
            if len(final_parts) >= 2 and final_parts[0] in ('s', 'e', 'v', 'embed'):
                link_code = final_parts[1]
            elif len(final_parts) >= 1:
                link_code = final_parts[-1]

        if not link_code:
            logger.error("[pooptv] Tidak bisa menemukan link code")
            return None, None, None

        logger.info(f"[pooptv] Link code: {link_code}")

        # Step 2: Get resource info via API (dengan retry untuk rate limiting)
        api_base = "https://api.lixstreamingcaio.com"
        # Extract lv1 domain for header
        lv1_domain = parsed.netloc or 'pooptv.me'

        data = None
        max_retries = 3
        for attempt in range(max_retries):
            resp = requests.post(
                f"{api_base}/v2/s/home/resources/{link_code}",
                headers={**headers, 'X-Stream-L1': lv1_domain},
                json={},
                timeout=30
            )

            if resp.status_code == 200:
                data = resp.json()
                break
            elif resp.status_code == 400 and attempt < max_retries - 1:
                wait_time = 3 * (attempt + 1)  # 3s, 6s
                logger.warning(f"[pooptv] API rate limited (400), retry {attempt + 1}/{max_retries} dalam {wait_time}s...")
                time.sleep(wait_time)
            else:
                logger.error(f"[pooptv] API resources gagal: HTTP {resp.status_code} (attempt {attempt + 1})")
                if attempt == max_retries - 1:
                    return None, None, None

        if not data:
            logger.error("[pooptv] API resources gagal setelah semua retry")
            return None, None, None
        files = data.get('files', [])
        if not files:
            logger.error("[pooptv] Tidak ada file dalam response")
            return None, None, None

        file_info = files[0]
        file_id = file_info.get('id', '')
        uid = data.get('suid', '')
        title = file_info.get('display_name', '')
        thumbnail = file_info.get('thumbnail', '')
        # Prefer collage screenshot jika ada
        collage = file_info.get('collage_screenshots', [])
        if collage:
            thumbnail = collage[0]

        logger.info(f"[pooptv] File: {title} (ID: {file_id})")
        logger.info(f"[pooptv] Thumbnail: {thumbnail}")

        if not file_id or not uid:
            logger.error("[pooptv] file_id atau uid kosong")
            return None, None, None

        # Step 3: Get encrypted video URL
        asset_resp = requests.get(
            f"{api_base}/v2/s/assets/f?id={file_id}&uid={uid}",
            headers=headers,
            timeout=30
        )

        if asset_resp.status_code != 200:
            logger.error(f"[pooptv] API assets gagal: HTTP {asset_resp.status_code}")
            return None, None, None

        enc_url = asset_resp.json().get('url', '')
        if not enc_url:
            logger.error("[pooptv] Encrypted URL kosong")
            return None, None, None

        logger.info(f"[pooptv] Encrypted URL ditemukan ({len(enc_url)} chars)")

        # Step 4: AES-256-CBC decrypt
        try:
            key_bytes = AES_KEY.encode('utf-8')
            iv_bytes = AES_IV.encode('utf-8')
            encrypted_bytes = base64.b64decode(enc_url)
            cipher = AES_Cipher.new(key_bytes, AES_Cipher.MODE_CBC, iv_bytes)
            decrypted = aes_unpad(cipher.decrypt(encrypted_bytes), AES_Cipher.block_size)
            video_url = decrypted.decode('utf-8')
            logger.info(f"[pooptv] Video URL berhasil didecrypt: {video_url[:100]}...")
        except Exception as e:
            logger.error(f"[pooptv] Gagal decrypt URL: {e}")
            return None, None, None

        # Step 5: Verify video URL
        try:
            resp3 = requests.head(video_url, headers={
                'User-Agent': headers['User-Agent']
            }, timeout=30, allow_redirects=True)
            if resp3.status_code in (200, 206):
                logger.info(f"[pooptv] Video terverifikasi: {resp3.headers.get('content-type', '')} "
                           f"{int(resp3.headers.get('content-length', 0)) / 1024 / 1024:.1f} MB")
            else:
                logger.warning(f"[pooptv] Video status: {resp3.status_code}")
        except Exception as e:
            logger.warning(f"[pooptv] Gagal verify video: {e}")

        return video_url, page_url, thumbnail

    except Exception as e:
        logger.error(f"[pooptv] Error extracting URL: {e}")
        return None, None, None


# ============================================================
# INDOVIDPLUS.ORG EXTRACTOR
# ============================================================

def extract_indovidplus_url(page_url):
    """
    Extract video URL dari indovidplus.org.
    URL player-x.php?q=BASE64 -> decode base64 -> URL-decode -> extract src MP4 & poster.
    Return: (video_url, referer, thumbnail_url)
    """
    logger.info(f"[indovidplus] Extracting video URL dari: {page_url}")

    try:
        parsed = urlparse(page_url)
        params = parse_qs(parsed.query)
        q_param = params.get('q', [None])[0]

        if not q_param:
            logger.error("[indovidplus] Parameter 'q' tidak ditemukan di URL")
            return None, None, None

        # Decode base64
        try:
            decoded = base64.b64decode(q_param).decode('utf-8')
        except Exception as e:
            logger.error(f"[indovidplus] Gagal decode base64: {e}")
            return None, None, None

        # URL-decode
        unquoted = unquote(decoded)
        logger.info(f"[indovidplus] Decoded tag: {unquoted[:200]}...")

        # Extract video source URL (direct MP4)
        src_match = re.search(r'<source\s+src=["\']([^"\']+)["\']', unquoted)
        if not src_match:
            # Fallback: cari URL mp4 langsung
            mp4_match = re.findall(r'https?://[^\s"\'<>]+\.mp4', unquoted)
            if mp4_match:
                video_url = mp4_match[0]
            else:
                logger.error("[indovidplus] Tidak ditemukan URL video di decoded tag")
                return None, None, None
        else:
            video_url = src_match.group(1)

        logger.info(f"[indovidplus] Video URL: {video_url}")

        # Extract thumbnail/poster URL
        thumbnail = None
        poster_match = re.search(r'poster=["\']([^"\']+)["\']', unquoted)
        if poster_match:
            thumbnail = poster_match.group(1)
            logger.info(f"[indovidplus] Thumbnail: {thumbnail}")

        referer = 'https://indovidplus.org/'
        return video_url, referer, thumbnail

    except Exception as e:
        logger.error(f"[indovidplus] Error: {e}")
        return None, None, None


# ============================================================
# LULUVID.COM / LULUSTREAM.COM EXTRACTOR
# ============================================================

def extract_luluvid_url(page_url):
    """
    Extract video URL dari luluvid.com / lulustream.com.
    Halaman berisi JWPlayer setup dengan m3u8 source dan thumbnail.
    Return: (video_url, referer, thumbnail_url)
    """
    logger.info(f"[luluvid] Extracting video URL dari: {page_url}")

    try:
        headers = {
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
            'Referer': page_url,
        }
        resp = requests.get(page_url, headers=headers, timeout=30)
        if resp.status_code != 200:
            logger.error(f"[luluvid] HTTP {resp.status_code}")
            return None, None, None

        html = resp.text

        # Extract m3u8 URL dari JWPlayer setup: sources: [{file:"https://...master.m3u8?..."}]
        m3u8_match = re.search(r'sources:\s*\[\s*\{[^}]*file\s*:\s*"([^"]+\.m3u8[^"]*)"', html)
        if not m3u8_match:
            # Fallback: cari m3u8 URL langsung di HTML
            m3u8_match = re.search(r'(https?://[^\s"\'<>]+\.m3u8[^\s"\'<>]*)', html)

        if not m3u8_match:
            logger.error("[luluvid] m3u8 URL tidak ditemukan")
            return None, None, None

        video_url = m3u8_match.group(1)
        logger.info(f"[luluvid] Video URL: {video_url}")

        # Extract thumbnail dari JWPlayer image atau og:image meta tag
        thumbnail = None
        img_match = re.search(r'image\s*:\s*"([^"]+)"', html)
        if img_match:
            thumbnail = img_match.group(1)
        else:
            og_match = re.search(r'<meta[^>]*property=["\']og:image["\'][^>]*content=["\']([^"\']+)', html)
            if og_match:
                thumbnail = og_match.group(1)

        if thumbnail:
            logger.info(f"[luluvid] Thumbnail: {thumbnail}")

        referer = page_url
        return video_url, referer, thumbnail

    except Exception as e:
        logger.error(f"[luluvid] Error: {e}")
        return None, None, None


# ============================================================
# ABYSSCDN.COM / SHORT.ICU EXTRACTOR
# ============================================================

def _process_json_escapes(raw_bytes):
    """Process JSON string escape sequences in raw bytes. Return bytearray."""
    result = bytearray()
    i = 0
    while i < len(raw_bytes):
        if raw_bytes[i] == ord('\\') and i + 1 < len(raw_bytes):
            nc = raw_bytes[i + 1]
            if nc == ord('u') and i + 5 < len(raw_bytes):
                try:
                    hex_s = raw_bytes[i + 2:i + 6].decode('ascii')
                    result.append(int(hex_s, 16) & 0xFF)
                    i += 6
                    continue
                except (ValueError, UnicodeDecodeError):
                    pass
            escape_map = {
                ord('\\'): ord('\\'), ord('"'): ord('"'), ord('/'): ord('/'),
                ord('n'): 0x0A, ord('t'): 0x09, ord('r'): 0x0D,
                ord('f'): 0x0C, ord('b'): 0x08,
            }
            if nc in escape_map:
                result.append(escape_map[nc])
                i += 2
                continue
        result.append(raw_bytes[i])
        i += 1
    return result


def extract_abysscdn_url(page_url):
    """
    Extract metadata dari abysscdn.com / short.icu / abyss.to.
    Video di-serve via Service Worker browser (sssrr.org chunks di-decrypt client-side),
    sehingga tidak ada downloadable URL. Return (None, ref, None) -> sinyal browser download.
    Thumbnail akan di-generate dari video frame oleh process_single_video.
    Return: (None, referer, None)
    """
    logger.info(f"[abysscdn] Site terdeteksi, video URL tidak bisa di-extract (butuh browser download)")
    # Return None video URL -> download_video akan memanggil download_abysscdn_video()
    return None, 'https://abysscdn.com/', None


def download_abysscdn_video(page_url, output_path, preferred_quality='720p'):
    """
    Download video dari abysscdn/short.icu/abyss.to menggunakan Playwright.
    Site ini menggunakan Service Worker yang men-decrypt chunk terenkripsi dari sssrr.org
    secara client-side. Satu-satunya cara download adalah via browser fetch dari dalam iframe.

    preferred_quality: '360p', '720p', '1080p', '1440p', '2160p', atau 'best'
    Return: True jika berhasil, False jika gagal
    """
    logger.info(f"[abysscdn] Browser download: {page_url}")
    logger.info(f"[abysscdn] Preferred quality: {preferred_quality}")

    try:
        from playwright.sync_api import sync_playwright
        import time as _time
    except ImportError:
        logger.error("[abysscdn] playwright belum terinstall")
        return False

    try:
        with sync_playwright() as pw:
            br = pw.chromium.launch(headless=True)
            ctx = br.new_context(
                user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
                extra_http_headers={'Referer': 'https://rexbokep.com/'}
            )
            pg = ctx.new_page()

            logger.info("[abysscdn] Loading page...")
            pg.goto(page_url, wait_until='networkidle', timeout=60000)
            _time.sleep(5)

            # Cari iframe abysscdn
            abyss_frame = None
            for frame in pg.frames:
                if 'abysscdn.com' in frame.url:
                    abyss_frame = frame
                    break

            if not abyss_frame:
                logger.error("[abysscdn] Iframe abysscdn tidak ditemukan")
                br.close()
                return False

            # Ambil semua quality sources dari JWPlayer
            playlist_info = abyss_frame.evaluate('''() => {
                if (typeof jwplayer === 'undefined') return null;
                const jw = jwplayer();
                const pl = jw.getPlaylist();
                if (!pl || !pl[0]) return null;
                const sources = (pl[0].allSources || pl[0].sources || []).map(s => ({
                    label: s.label || '',
                    file: s.file || '',
                    type: s.type || ''
                }));
                return {sources: sources, image: pl[0].image || null};
            }''')

            if not playlist_info or not playlist_info.get('sources'):
                # Fallback: coba langsung dari video element
                video_src = abyss_frame.evaluate('() => { const v = document.querySelector("video"); return v ? v.src : null; }')
                if video_src:
                    playlist_info = {'sources': [{'label': 'default', 'file': video_src}]}
                else:
                    logger.error("[abysscdn] Tidak ada sources di JWPlayer")
                    br.close()
                    return False

            sources = playlist_info['sources']
            logger.info(f"[abysscdn] Available qualities: {[s['label'] for s in sources]}")

            # Pilih quality
            selected = None
            if preferred_quality == 'best':
                # Pilih resolusi tertinggi
                res_order = ['2160p', '1440p', '1080p', '720p', '480p', '360p']
                for q in res_order:
                    for s in sources:
                        if s['label'] == q:
                            selected = s
                            break
                    if selected:
                        break
            else:
                # Cari quality yang diminta
                for s in sources:
                    if s['label'] == preferred_quality:
                        selected = s
                        break
                # Fallback ke 720p -> 1080p -> 480p -> 360p -> apapun
                if not selected:
                    for fallback_q in ['720p', '1080p', '480p', '360p']:
                        for s in sources:
                            if s['label'] == fallback_q:
                                selected = s
                                break
                        if selected:
                            break

            if not selected:
                selected = sources[0]

            logger.info(f"[abysscdn] Selected quality: {selected['label']}")
            video_file_url = selected['file']

            # Dapatkan total size via Range request menggunakan URL source dari playlist
            # (bukan video.src karena quality ditentukan oleh URL fragment)
            size_info = abyss_frame.evaluate('''async (url) => {
                try {
                    const resp = await fetch(url, {headers: {'Range': 'bytes=0-0'}});
                    const cr = resp.headers.get('content-range') || '';
                    return {contentRange: cr, status: resp.status};
                } catch(e) {
                    return {error: e.message};
                }
            }''', video_file_url)

            total_size = 0
            if size_info and size_info.get('contentRange'):
                try:
                    total_size = int(size_info['contentRange'].split('/')[-1])
                except (ValueError, IndexError):
                    pass

            if total_size <= 0:
                logger.error(f"[abysscdn] Gagal mendapatkan total size: {size_info}")
                br.close()
                return False

            logger.info(f"[abysscdn] Total size: {total_size} bytes ({total_size / 1024 / 1024:.1f} MB)")

            # Download dalam chunk via fetch dari dalam iframe
            chunk_size = 10 * 1024 * 1024  # 10MB per chunk
            downloaded = 0

            with open(output_path, 'wb') as f:
                while downloaded < total_size:
                    end = min(downloaded + chunk_size - 1, total_size - 1)
                    pct = downloaded * 100 // total_size
                    logger.info(f"[abysscdn] Downloading {pct}% ({downloaded / 1024 / 1024:.1f} MB / {total_size / 1024 / 1024:.1f} MB)...")

                    chunk_b64 = abyss_frame.evaluate(f'''async (fileUrl) => {{
                        const resp = await fetch(fileUrl, {{
                            headers: {{'Range': 'bytes={downloaded}-{end}'}}
                        }});
                        const blob = await resp.blob();
                        return await new Promise(resolve => {{
                            const r = new FileReader();
                            r.onloadend = () => resolve(r.result);
                            r.readAsDataURL(blob);
                        }});
                    }}''', video_file_url)

                    if not chunk_b64 or ',' not in chunk_b64:
                        logger.error(f"[abysscdn] Gagal download chunk at byte {downloaded}")
                        br.close()
                        return False

                    raw = base64.b64decode(chunk_b64.split(',', 1)[1])
                    f.write(raw)
                    downloaded += len(raw)

            br.close()

            # Verifikasi file
            if os.path.exists(output_path):
                actual_size = os.path.getsize(output_path)
                logger.info(f"[abysscdn] Download selesai: {actual_size / 1024 / 1024:.1f} MB")
                if actual_size >= total_size * 0.95:  # Toleransi 5%
                    return True
                else:
                    logger.error(f"[abysscdn] File size mismatch: {actual_size} vs {total_size}")
                    return False
            return False

    except Exception as e:
        logger.error(f"[abysscdn] Browser download error: {e}")
        import traceback
        logger.error(traceback.format_exc())
        return False


# ============================================================
# STREAMHLS.TO EXTRACTOR
# ============================================================

def extract_streamhls_url(page_url):
    """
    Extract video URL dari streamhls.to.
    Flow: POST form data ke /dl endpoint -> parse Clappr player sources.
    Return: (video_url, referer, thumbnail_url)
    """
    logger.info(f"[streamhls] Extracting video URL dari: {page_url}")

    try:
        headers = {
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
            'Referer': page_url,
        }

        # Extract file_code dari URL path (segment terakhir)
        parsed = urlparse(page_url)
        path_parts = [p for p in parsed.path.strip('/').split('/') if p]
        file_code = path_parts[-1] if path_parts else None

        if not file_code:
            logger.error("[streamhls] Tidak bisa extract file_code dari URL")
            return None, None, None

        logger.info(f"[streamhls] File code: {file_code}")

        # Tentukan base domain dari URL
        base_domain = f"{parsed.scheme}://{parsed.netloc}"

        # POST form data ke /dl endpoint untuk mendapatkan halaman player
        form_data = {
            'op': 'embed',
            'file_code': file_code,
            'auto': '1',
            'referer': '',
        }

        dl_url = f"{base_domain}/dl"
        resp = requests.post(dl_url, data=form_data, headers=headers, timeout=30)

        if resp.status_code != 200:
            logger.error(f"[streamhls] POST /dl HTTP {resp.status_code}")
            return None, None, None

        html = resp.text
        logger.info(f"[streamhls] Player page length: {len(html)}")

        # Extract m3u8 URL dari Clappr player: sources: ["https://...master.m3u8?..."]
        m3u8_match = re.search(r'sources:\s*\["([^"]+\.m3u8[^"]*)"\]', html)
        if not m3u8_match:
            # Fallback: cari m3u8 URL langsung di HTML
            m3u8_match = re.search(r'(https?://[^\s"\'<>]+\.m3u8[^\s"\'<>]*)', html)

        if not m3u8_match:
            logger.error("[streamhls] m3u8 URL tidak ditemukan di player page")
            return None, None, None

        video_url = m3u8_match.group(1)
        logger.info(f"[streamhls] Video URL: {video_url}")

        # Extract thumbnail dari Clappr poster atau fallback
        thumbnail = None
        poster_match = re.search(r'poster:\s*"([^"]+)"', html)
        if poster_match:
            thumbnail = poster_match.group(1)
        else:
            # Fallback: coba pattern thumbnail dari savefiles.com
            thumb_match = re.search(r'(https?://img\.savefiles\.com/[^\s"\'<>]+)', html)
            if thumb_match:
                thumbnail = thumb_match.group(1)

        if thumbnail:
            logger.info(f"[streamhls] Thumbnail: {thumbnail}")

        referer = base_domain + '/'
        return video_url, referer, thumbnail

    except Exception as e:
        logger.error(f"[streamhls] Error: {e}")
        import traceback
        logger.error(traceback.format_exc())
        return None, None, None


# ============================================================
# VOE.SX EXTRACTOR
# ============================================================

def extract_voe_url(page_url):
    """
    Extract video URL dari voe.sx dan mirror domain-nya.
    voe.sx menggunakan DDoS-Guard + obfuscated JS + WASM untuk menyembunyikan video URL.
    Strategy utama: Playwright headless browser untuk menjalankan JS/WASM dan
    mengambil URL dari JWPlayer setelah decoded.
    Return: (video_url, referer, thumbnail_url)
    """
    logger.info(f"[voe] Extracting video URL dari: {page_url}")

    try:
        # Extract video code dari URL
        parsed = urlparse(page_url)
        path_parts = [p for p in parsed.path.strip('/').split('/') if p]
        video_code = path_parts[-1] if path_parts else None

        if not video_code:
            logger.error("[voe] Tidak bisa extract video code dari URL")
            return None, None, None

        logger.info(f"[voe] Video code: {video_code}")

        # List mirror domains yang bisa diakses tanpa DDoS-Guard
        # Mirror yang reliable (tanpa DDoS-Guard) ditaruh duluan untuk kecepatan
        voe_mirrors = [
            'lauradaydo.com',
            'fifrfrse.com',
            'frfrfrerse.com',
            parsed.netloc,  # domain asli dari input (biasanya voe.sx, sering DDoS-Guard)
        ]
        # Deduplicate sambil pertahankan urutan
        seen = set()
        unique_mirrors = []
        for d in voe_mirrors:
            if d and d not in seen:
                seen.add(d)
                unique_mirrors.append(d)
        voe_mirrors = unique_mirrors

        # ====== Strategy 1: Playwright headless browser ======
        # Ini cara paling reliable karena voe.sx menggunakan WASM decoder
        # yang tidak bisa di-replicate di Python
        try:
            from playwright.sync_api import sync_playwright
            logger.info("[voe] Menggunakan Playwright headless browser...")

            with sync_playwright() as p:
                browser = p.chromium.launch(headless=True)
                context = browser.new_context(
                    user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
                )
                page = context.new_page()

                # Blokir resource yang tidak perlu (gambar, font, css iklan)
                def route_handler(route):
                    if route.request.resource_type in ['image', 'font', 'stylesheet']:
                        route.abort()
                    else:
                        route.continue_()
                page.route("**/*", route_handler)

                video_url = None
                thumbnail = None
                used_domain = None

                for domain in voe_mirrors:
                    try:
                        target_url = f"https://{domain}/e/{video_code}"
                        logger.info(f"[voe] Playwright trying: {target_url}")

                        try:
                            page.goto(target_url, timeout=25000, wait_until='domcontentloaded')
                        except Exception as nav_err:
                            logger.warning(f"[voe] Playwright navigation warning for {domain}: {nav_err}")
                            continue

                        # Tunggu WASM decode selesai (JWPlayer ter-init)
                        # Coba tunggu jwplayer ready dengan polling
                        for wait_ms in [2000, 3000, 5000]:
                            page.wait_for_timeout(wait_ms)
                            try:
                                jw_ready = page.evaluate("typeof jwplayer !== 'undefined' && jwplayer() && typeof jwplayer().getPlaylistItem === 'function'")
                                if jw_ready:
                                    break
                            except Exception:
                                continue

                        # Extract dari JWPlayer
                        try:
                            jw_data = page.evaluate("""
                                (() => {
                                    try {
                                        if (typeof jwplayer !== 'undefined') {
                                            var p = jwplayer();
                                            if (p && p.getPlaylistItem) {
                                                var item = p.getPlaylistItem();
                                                return {
                                                    file: item.file || '',
                                                    image: item.image || '',
                                                    title: item.title || '',
                                                    sources: (item.sources || []).map(s => ({file: s.file, type: s.type, label: s.label}))
                                                };
                                            }
                                        }
                                    } catch(e) {}
                                    return null;
                                })()
                            """)

                            if jw_data and jw_data.get('file'):
                                file_url = jw_data['file']
                                # Pastikan bukan test URL
                                if 'test-videos' not in file_url and 'bigbuckbunny' not in file_url:
                                    video_url = file_url
                                    thumbnail = jw_data.get('image')
                                    used_domain = domain
                                    logger.info(f"[voe] JWPlayer URL: {video_url[:150]}...")
                                    if jw_data.get('title'):
                                        logger.info(f"[voe] Title: {jw_data['title']}")
                                    if thumbnail:
                                        logger.info(f"[voe] Thumbnail: {thumbnail}")
                                    break

                            # Fallback: cek source variable (mungkin sudah di-overwrite oleh WASM)
                            if not video_url:
                                source_val = page.evaluate("window.source || ''")
                                if source_val and 'test-videos' not in source_val and 'bigbuckbunny' not in source_val:
                                    video_url = source_val
                                    used_domain = domain
                                    logger.info(f"[voe] window.source: {video_url}")
                                    break

                        except Exception as eval_err:
                            logger.warning(f"[voe] Playwright eval error for {domain}: {eval_err}")
                            continue

                    except Exception as e:
                        logger.warning(f"[voe] Playwright {domain} error: {e}")
                        continue

                # Extract thumbnail dari og:image jika belum dapat
                if video_url and not thumbnail:
                    try:
                        thumbnail = page.evaluate("""
                            (() => {
                                var og = document.querySelector('meta[property="og:image"]');
                                return og ? og.getAttribute('content') : null;
                            })()
                        """)
                    except Exception:
                        pass

                browser.close()

                if video_url:
                    referer = f"https://{used_domain}/"
                    return video_url, referer, thumbnail

                logger.warning("[voe] Playwright tidak berhasil extract URL dari semua domain")

        except ImportError:
            logger.warning("[voe] Playwright tidak terinstall, skip Playwright strategy")
        except Exception as e:
            logger.warning(f"[voe] Playwright error: {e}")

        # ====== Strategy 2: curl_cffi + regex (fallback) ======
        logger.info("[voe] Fallback ke curl_cffi + regex...")
        headers = {
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
        }

        html = None
        used_domain = None

        if cf_requests:
            session = cf_requests.Session(impersonate="chrome120")
            for domain in voe_mirrors:
                try:
                    test_url = f"https://{domain}/e/{video_code}"
                    resp = session.get(test_url, timeout=20, allow_redirects=True)
                    if resp.status_code == 200 and 'DDoS-Guard' not in resp.text and len(resp.text) > 5000:
                        html = resp.text
                        used_domain = domain
                        break
                except Exception:
                    continue

        if not html:
            for domain in voe_mirrors:
                try:
                    test_url = f"https://{domain}/e/{video_code}"
                    resp = requests.get(test_url, headers=headers, timeout=20, allow_redirects=True)
                    if resp.status_code == 200 and 'DDoS-Guard' not in resp.text and len(resp.text) > 5000:
                        html = resp.text
                        used_domain = domain
                        break
                except Exception:
                    continue

        if not html:
            logger.error("[voe] Tidak bisa mengakses halaman voe (semua domain gagal)")
            return None, None, None

        # Cari var source yang bukan test URL
        video_url = None
        source_match = re.search(r"var\s+source\s*=\s*['\"]([^'\"]+)['\"]", html)
        if source_match:
            source_url = source_match.group(1)
            if 'test-videos' not in source_url and 'bigbuckbunny' not in source_url:
                video_url = source_url
                logger.info(f"[voe] Source URL (regex): {video_url}")

        # Cari m3u8 URL
        if not video_url:
            m3u8_match = re.search(r'(https?://[^\s"\'<>]+\.m3u8[^\s"\'<>]*)', html)
            if m3u8_match:
                video_url = m3u8_match.group(1)
                logger.info(f"[voe] m3u8 URL found: {video_url}")

        # Cari MP4 URL (bukan test URL)
        if not video_url:
            mp4_matches = re.findall(r'(https?://[^\s"\'<>]+\.mp4[^\s"\'<>]*)', html)
            for mp4 in mp4_matches:
                if 'test-videos' not in mp4 and 'bigbuckbunny' not in mp4:
                    video_url = mp4
                    logger.info(f"[voe] MP4 URL found: {video_url}")
                    break

        # Extract thumbnail
        thumbnail = None
        og_match = re.search(r'<meta[^>]*property=["\']og:image["\'][^>]*content=["\']([^"\']+)', html)
        if og_match:
            thumbnail = og_match.group(1)

        if video_url:
            if thumbnail:
                logger.info(f"[voe] Thumbnail: {thumbnail}")
            return video_url, f"https://{used_domain}/", thumbnail

        logger.warning("[voe] Tidak bisa extract video URL. Akan fallback ke yt-dlp.")
        return None, f"https://{used_domain}/" if used_domain else None, thumbnail

    except Exception as e:
        logger.error(f"[voe] Error: {e}")
        import traceback
        logger.error(traceback.format_exc())
        return None, None, None


# ============================================================
# AUTO TITLE FETCH - ambil judul video dari API/metadata situs
# ============================================================

def fetch_title_from_url(page_url):
    """
    Ambil judul video otomatis dari URL.
    Coba beberapa metode: API situs, og:title, <title> tag, fallback ke video code.
    Return: string title
    """
    site = detect_site(page_url)
    parsed = urlparse(page_url)
    path_parts = [p for p in parsed.path.strip('/').split('/') if p]
    video_code = path_parts[-1] if path_parts else 'video'

    headers = {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
    }

    try:
        if site == 'pooptv':
            # Pooptv: fetch halaman embed untuk ambil og:title (tanpa panggil API, hindari rate limit)
            try:
                resp = requests.get(page_url, headers=headers, timeout=15, allow_redirects=True)
                if resp.status_code == 200:
                    html = resp.text[:5000]
                    og_match = re.search(r'<meta[^>]*property=["\']og:title["\'][^>]*content=["\']([^"\'>]+)', html)
                    if og_match:
                        name = og_match.group(1).strip()
                        name = re.sub(r'\.(mp4|mkv|avi|mov|wmv|flv|webm|ts)$', '', name, flags=re.IGNORECASE)
                        if name and len(name) > 2:
                            logger.info(f"[auto-title] pooptv og:title: {name}")
                            return name.strip()
                    title_match = re.search(r'<title[^>]*>([^<]+)</title>', html, re.IGNORECASE)
                    if title_match:
                        name = title_match.group(1).strip()
                        name = re.sub(r'\.(mp4|mkv|avi|mov|wmv|flv|webm|ts)$', '', name, flags=re.IGNORECASE)
                        if name and len(name) > 2:
                            logger.info(f"[auto-title] pooptv <title>: {name}")
                            return name.strip()
            except Exception as e:
                logger.warning(f"[auto-title] pooptv fetch gagal: {e}")

        elif site == 'vidara':
            # Vidara: gunakan API stream untuk ambil title
            filecode = video_code
            for api_domain in ['https://vidara.so', 'https://vidara.to']:
                try:
                    resp = requests.get(f"{api_domain}/api/stream?filecode={filecode}",
                                       headers={**headers, 'Referer': f"{api_domain}/e/{filecode}"}, timeout=15)
                    if resp.status_code == 200:
                        title = resp.json().get('title', '')
                        if title and title != filecode:
                            logger.info(f"[auto-title] vidara title: {title}")
                            return title.strip()
                except Exception:
                    continue

        elif site == 'strmup':
            # Strmup: gunakan API ajax/stream
            filecode = video_code
            for api_domain in ['https://strmup.to', 'https://streamup.cc']:
                try:
                    resp = requests.get(f"{api_domain}/ajax/stream?filecode={filecode}",
                                       headers={**headers, 'Referer': f"{api_domain}/{filecode}"}, timeout=15)
                    if resp.status_code == 200:
                        title = resp.json().get('title', '')
                        if title and title != filecode:
                            logger.info(f"[auto-title] strmup title: {title}")
                            return title.strip()
                except Exception:
                    continue

        elif site == 'bigwarp':
            # Bigwarp: dilindungi Cloudflare, gunakan curl_cffi untuk fetch halaman utama
            try:
                fetch_fn = cf_requests if cf_requests else requests
                fetch_kwargs = dict(headers=headers, timeout=15, allow_redirects=True)
                if cf_requests:
                    fetch_kwargs['impersonate'] = 'chrome'
                for domain in ['bigwarp.pro', 'bigwarp.io']:
                    try:
                        resp = fetch_fn.get(f"https://{domain}/{video_code}", **fetch_kwargs)
                        if resp.status_code == 200:
                            html = resp.text[:8000]
                            # Cari <h2> tag (judul video di halaman bigwarp)
                            h2_match = re.search(r'<h2[^>]*>\s*([^<]+?)\s*</h2>', html)
                            if h2_match:
                                name = h2_match.group(1).strip()
                                name = re.sub(r'\.(mp4|mkv|avi|mov|wmv|flv|webm|ts)$', '', name, flags=re.IGNORECASE)
                                if name and len(name) > 2 and name.lower() not in ['watch', 'video']:
                                    logger.info(f"[auto-title] bigwarp h2: {name}")
                                    return name.strip()
                            # Fallback: og:title atau <title>
                            og_match = re.search(r'<meta[^>]*property=["\']og:title["\'][^>]*content=["\']([^"\'>]+)', html)
                            if og_match:
                                name = og_match.group(1).strip()
                                name = re.sub(r'^\s*Watch\s+', '', name, flags=re.IGNORECASE)
                                name = re.sub(r'\s*[-|]\s*bigwarp.*$', '', name, flags=re.IGNORECASE)
                                name = re.sub(r'\.(mp4|mkv|avi|mov|wmv|flv|webm|ts)$', '', name, flags=re.IGNORECASE)
                                if name and len(name) > 2:
                                    logger.info(f"[auto-title] bigwarp og:title: {name}")
                                    return name.strip()
                            title_match = re.search(r'<title[^>]*>([^<]+)</title>', html, re.IGNORECASE)
                            if title_match:
                                name = title_match.group(1).strip()
                                name = re.sub(r'^\s*Watch\s+', '', name, flags=re.IGNORECASE)
                                name = re.sub(r'\s*[-|]\s*bigwarp.*$', '', name, flags=re.IGNORECASE)
                                name = re.sub(r'\.(mp4|mkv|avi|mov|wmv|flv|webm|ts)$', '', name, flags=re.IGNORECASE)
                                if name and len(name) > 2:
                                    logger.info(f"[auto-title] bigwarp <title>: {name}")
                                    return name.strip()
                    except Exception:
                        continue
            except Exception as e:
                logger.warning(f"[auto-title] bigwarp fetch gagal: {e}")

        # Fallback umum: fetch halaman dan cari og:title atau <title>
        if site not in ['abysscdn', 'bigwarp']:  # skip site yang butuh curl_cffi / Cloudflare
            resp = requests.get(page_url, headers=headers, timeout=15, allow_redirects=True)
            if resp.status_code == 200:
                html = resp.text[:5000]  # Cukup baca awal saja
                # og:title
                og_match = re.search(r'<meta[^>]*property=["\']og:title["\'][^>]*content=["\']([^"\'>]+)', html)
                if og_match:
                    title = og_match.group(1).strip()
                    if title and len(title) > 3 and title.lower() not in ['watch', 'video', 'embed']:
                        title = re.sub(r'\.(mp4|mkv|avi|mov|wmv|flv|webm|ts)$', '', title, flags=re.IGNORECASE)
                        logger.info(f"[auto-title] og:title: {title}")
                        return title.strip()
                # <title> tag
                title_match = re.search(r'<title[^>]*>([^<]+)</title>', html, re.IGNORECASE)
                if title_match:
                    title = title_match.group(1).strip()
                    # Filter title generik
                    if title and len(title) > 3 and title.lower() not in ['watch', 'video', 'embed', 'untitled']:
                        title = re.sub(r'\s*[-|]\s*(streamhg|vidara|strmup|pooptv|lulustream|luluvid|voe|streamhls|abysscdn|vidhide|minochinos|bigwarp).*$', '', title, flags=re.IGNORECASE)
                        title = re.sub(r'\.(mp4|mkv|avi|mov|wmv|flv|webm|ts)$', '', title, flags=re.IGNORECASE)
                        if title.strip():
                            logger.info(f"[auto-title] <title>: {title.strip()}")
                            return title.strip()

    except Exception as e:
        logger.warning(f"[auto-title] Gagal fetch title: {e}")

    # Fallback terakhir: gunakan video code dari URL
    logger.info(f"[auto-title] Fallback ke video code: {video_code}")
    return video_code


# ============================================================
# HAVENFILE.CC EXTRACTOR
# ============================================================

def extract_havenfile_url(page_url):
    """
    Extract video URL dari havenfile.cc.
    Flow: GET /api/get-video-token/{fileId} -> token
          GET /api/video-url/{fileId}?token=TOKEN -> direct MP4 URL
    Return: (video_url, referer, thumbnail_url)
    """
    logger.info(f"[havenfile] Extracting video URL dari: {page_url}")

    headers = {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
    }

    try:
        # Extract file ID dari URL
        parsed = urlparse(page_url)
        path_parts = [p for p in parsed.path.strip('/').split('/') if p]
        file_id = None
        if len(path_parts) >= 2:
            file_id = path_parts[-1]
        elif len(path_parts) == 1:
            file_id = path_parts[0]

        if not file_id:
            logger.error("[havenfile] Tidak bisa menemukan file ID dari URL")
            return None, None, None

        logger.info(f"[havenfile] File ID: {file_id}")
        base_domain = f"{parsed.scheme}://{parsed.netloc}"

        # Step 1: Ambil thumbnail dari halaman embed
        thumbnail = None
        try:
            resp = requests.get(page_url, headers=headers, timeout=15)
            if resp.status_code == 200:
                og_match = re.search(r'<meta[^>]*(?:property|name)=["\']og:image["\'][^>]*content=["\']([^"\'>]+)', resp.text)
                if og_match:
                    thumbnail = og_match.group(1)
                    logger.info(f"[havenfile] Thumbnail: {thumbnail}")
        except Exception:
            pass

        # Step 2: Get video token via API
        token_url = f"{base_domain}/api/get-video-token/{file_id}"
        token_resp = requests.get(token_url, headers={
            **headers,
            'Referer': page_url,
        }, timeout=15)

        if token_resp.status_code != 200:
            logger.error(f"[havenfile] Token API gagal: HTTP {token_resp.status_code}")
            return None, None, None

        token_data = token_resp.json()
        if not token_data.get('success'):
            logger.error(f"[havenfile] Token API error: {token_data}")
            return None, None, None

        token = token_data['token']
        logger.info(f"[havenfile] Token diperoleh ({len(token)} chars)")

        # Step 3: Get video URL with token
        video_api_url = f"{base_domain}/api/video-url/{file_id}?token={token}"
        url_resp = requests.get(video_api_url, headers={
            **headers,
            'Referer': page_url,
        }, timeout=15)

        if url_resp.status_code != 200:
            logger.error(f"[havenfile] Video URL API gagal: HTTP {url_resp.status_code}")
            return None, None, None

        url_data = url_resp.json()
        if not url_data.get('success') or not url_data.get('video_url'):
            logger.error(f"[havenfile] Video URL API error: {url_data}")
            return None, None, None

        video_url = url_data['video_url']
        logger.info(f"[havenfile] Video URL: {video_url[:100]}...")

        # Step 4: Verify video URL
        try:
            head_resp = requests.head(video_url, headers=headers, timeout=15, allow_redirects=True)
            if head_resp.status_code in (200, 206):
                size_mb = int(head_resp.headers.get('content-length', 0)) / 1024 / 1024
                logger.info(f"[havenfile] Video terverifikasi: {size_mb:.1f} MB")
        except Exception as e:
            logger.warning(f"[havenfile] Gagal verify: {e}")

        return video_url, page_url, thumbnail

    except Exception as e:
        logger.error(f"[havenfile] Error: {e}")
        return None, None, None


# ============================================================
# FILEMOON.SX EXTRACTOR (Playwright headless browser)
# ============================================================

def extract_filemoon_url(page_url):
    """
    Extract video URL dari filemoon.sx (Byse SPA).
    Filemoon adalah SPA React+Vite yang menggunakan JWPlayer.
    Video sources di-encrypt dengan AES-GCM dan didecrypt client-side.
    Strategy: Playwright headless browser + intercept API response.
    Return: (video_url, referer, thumbnail_url)
    """
    logger.info(f"[filemoon] Extracting video URL dari: {page_url}")

    # Extract file code dari URL
    parsed = urlparse(page_url)
    path_parts = [p for p in parsed.path.strip('/').split('/') if p]
    file_code = path_parts[-1] if path_parts else None

    if not file_code:
        logger.error("[filemoon] Tidak bisa extract file code dari URL")
        return None, None, None

    logger.info(f"[filemoon] File code: {file_code}")

    # Strategy 1: API langsung (cek dulu apakah file masih ada)
    headers = {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
    }

    # Coba beberapa domain
    domains = [parsed.netloc, 'filemoon.sx', 'filemoon.in', 'filemoon.to']
    seen = set()
    unique_domains = []
    for d in domains:
        if d not in seen:
            seen.add(d)
            unique_domains.append(d)

    # Strategy 2: Playwright headless browser
    try:
        from playwright.sync_api import sync_playwright
        logger.info("[filemoon] Menggunakan Playwright headless browser...")

        with sync_playwright() as p:
            browser = p.chromium.launch(headless=True)
            context = browser.new_context(
                user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
            )

            captured_urls = []
            api_responses = []
            page = context.new_page()

            def on_response(response):
                url = response.url
                # Capture m3u8/video URLs
                if any(x in url for x in ['.m3u8', 'master.m3u8']):
                    captured_urls.append(url)
                    logger.info(f"[filemoon] Network capture: {url[:150]}")
                # Capture API embed details response
                if '/api/videos/' in url and '/embed/' in url:
                    try:
                        body = response.json()
                        api_responses.append(body)
                        logger.info(f"[filemoon] API response captured")
                    except Exception:
                        pass

            page.on("response", on_response)

            video_url = None
            thumbnail = None

            for domain in unique_domains:
                try:
                    target_url = f"https://{domain}/e/{file_code}"
                    logger.info(f"[filemoon] Playwright trying: {target_url}")

                    page.goto(target_url, wait_until='networkidle', timeout=30000)

                    # Tunggu SPA load + JWPlayer init
                    for wait_ms in [3000, 5000, 8000]:
                        page.wait_for_timeout(wait_ms)

                        # Check JWPlayer
                        result = page.evaluate("""() => {
                            try {
                                if (window.jwplayer) {
                                    const p = jwplayer();
                                    const item = p.getPlaylistItem();
                                    if (item && item.sources) {
                                        const src = item.sources.find(s => s.file && s.file.includes('.m3u8'));
                                        if (src) return {url: src.file, type: 'hls'};
                                        const mp4src = item.sources.find(s => s.file);
                                        if (mp4src) return {url: mp4src.file, type: 'mp4'};
                                    }
                                    const config = p.getConfig();
                                    if (config && config.file) return {url: config.file, type: 'config'};
                                }
                            } catch(e) {}
                            // Check video elements
                            const vids = document.querySelectorAll('video');
                            for (const v of vids) {
                                if (v.src && v.src.startsWith('http')) return {url: v.src, type: 'video'};
                                const sources = v.querySelectorAll('source');
                                for (const s of sources) {
                                    if (s.src && s.src.startsWith('http')) return {url: s.src, type: 'source'};
                                }
                            }
                            return null;
                        }""")

                        if result and result.get('url'):
                            video_url = result['url']
                            logger.info(f"[filemoon] JWPlayer URL ({result['type']}): {video_url[:100]}")
                            break

                    if video_url:
                        # Extract thumbnail
                        try:
                            thumbnail = page.evaluate("""() => {
                                try {
                                    const p = jwplayer();
                                    const item = p.getPlaylistItem();
                                    return item ? item.image : null;
                                } catch(e) {}
                                const og = document.querySelector('meta[property="og:image"]');
                                return og ? og.content : null;
                            }""")
                        except Exception:
                            pass
                        break

                    # Fallback: check captured URLs
                    if captured_urls:
                        video_url = captured_urls[-1]
                        logger.info(f"[filemoon] URL dari network capture: {video_url[:100]}")
                        break

                    # Check if page shows 404/error
                    body_text = page.evaluate("document.body.innerText.substring(0, 200)")
                    if '404' in body_text or 'not found' in body_text.lower():
                        logger.warning(f"[filemoon] File tidak ditemukan di {domain}")
                        continue

                except Exception as e:
                    logger.warning(f"[filemoon] Playwright error di {domain}: {e}")
                    continue

            browser.close()

            if video_url:
                referer = f"https://{unique_domains[0]}/"
                if thumbnail:
                    logger.info(f"[filemoon] Thumbnail: {thumbnail}")
                logger.info(f"[filemoon] \u2705 Video URL: {video_url[:100]}")
                return video_url, referer, thumbnail

            logger.warning("[filemoon] Playwright tidak berhasil extract URL")

    except ImportError:
        logger.warning("[filemoon] Playwright tidak terinstall. Install: pip install playwright && playwright install chromium")
    except Exception as e:
        logger.error(f"[filemoon] Playwright error: {e}")

    logger.error("[filemoon] Gagal extract video URL")
    return None, None, None


# ============================================================
# DOODSTREAM / PEMERSATU EXTRACTOR (Playwright headless browser)
# ============================================================

def extract_doodstream_url(page_url):
    """
    Extract video URL dari DoodStream (pemersatu.link, myvidplay.com, dll).
    DoodStream menggunakan Turnstile CAPTCHA + pass_md5 token generation.
    Strategy: Playwright headless + intercept pass_md5 network request.
    Return: (video_url, referer, thumbnail_url)
    """
    logger.info(f"[doodstream] Extracting video URL dari: {page_url}")

    # Cek apakah URL sudah merupakan direct video playback endpoint
    parsed_check = urlparse(page_url)
    if 'videoplayback' in parsed_check.path.lower():
        logger.info(f"[doodstream] URL adalah direct video playback endpoint, return langsung")
        return page_url, page_url, None

    headers = {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
    }

    try:
        # Step 1: Follow redirects to get final DoodStream URL
        resp = requests.get(page_url, headers=headers, timeout=15, allow_redirects=True, verify=False)
        final_url = resp.url
        final_parsed = urlparse(final_url)
        base_domain = f"{final_parsed.scheme}://{final_parsed.netloc}"
        logger.info(f"[doodstream] Redirect ke: {final_url}")

        # Extract thumbnail dari og:image
        thumbnail = None
        og_match = re.search(r'<meta[^>]*(?:property|name)=["\']og:image["\'][^>]*content=["\']([^"\'>]+)', resp.text)
        if og_match:
            thumbnail = og_match.group(1)
            logger.info(f"[doodstream] Thumbnail: {thumbnail}")

        # Strategy 1: cari pass_md5 URL langsung di HTML
        pass_md5_match = re.search(r"(https?://[^\s\"<>]*/pass_md5/[^\s\"<>]+)", resp.text)
        if pass_md5_match:
            pass_md5_url = pass_md5_match.group(1)
            logger.info(f"[doodstream] pass_md5 URL ditemukan: {pass_md5_url[:100]}")
            # Fetch pass_md5 untuk mendapatkan direct URL
            try:
                md5_resp = requests.get(pass_md5_url, headers={
                    **headers, 'Referer': final_url
                }, timeout=15)
                if md5_resp.status_code == 200:
                    direct_url = md5_resp.text.strip()
                    if direct_url.startswith('http'):
                        # DoodStream memerlukan token random di akhir URL
                        import string
                        rand = ''.join(__import__('random').choices(string.ascii_letters + string.digits, k=10))
                        video_url = f"{direct_url}{rand}?token={pass_md5_url.split('/')[-1]}&expiry={int(time.time() * 1000)}"
                        logger.info(f"[doodstream] \u2705 Video URL: {video_url[:100]}")
                        return video_url, final_url, thumbnail
            except Exception as e:
                logger.warning(f"[doodstream] pass_md5 fetch gagal: {e}")

        # Strategy 2: Playwright headless browser
        try:
            from playwright.sync_api import sync_playwright
            logger.info("[doodstream] Menggunakan Playwright headless browser...")

            with sync_playwright() as p:
                browser = p.chromium.launch(headless=True)
                context = browser.new_context(
                    user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
                )

                captured_urls = []
                pw_page = context.new_page()

                def on_response(response):
                    url = response.url
                    if any(x in url for x in ['pass_md5', '.mp4', '.m3u8', '/dl/', '/video/']):
                        captured_urls.append(url)
                        logger.info(f"[doodstream] Network capture: {url[:150]}")

                pw_page.on("response", on_response)

                try:
                    pw_page.goto(final_url, wait_until='networkidle', timeout=30000)

                    # Klik play button jika ada
                    play_btn = pw_page.query_selector('.vjs-big-play-button, .captcha_l, button[title*="Play"]')
                    if play_btn:
                        logger.info("[doodstream] Klik play button...")
                        play_btn.click()
                        pw_page.wait_for_timeout(15000)

                    # Check captured URLs
                    for cap_url in captured_urls:
                        if '.mp4' in cap_url or '.m3u8' in cap_url:
                            logger.info(f"[doodstream] \u2705 Video URL dari Playwright: {cap_url[:100]}")
                            browser.close()
                            return cap_url, final_url, thumbnail

                    # Check video element
                    result = pw_page.evaluate("""() => {
                        const vids = document.querySelectorAll('video');
                        for (const v of vids) {
                            if (v.src && v.src.startsWith('http')) return v.src;
                        }
                        return null;
                    }""")
                    if result:
                        logger.info(f"[doodstream] \u2705 Video URL dari video element: {result[:100]}")
                        browser.close()
                        return result, final_url, thumbnail

                except Exception as e:
                    logger.warning(f"[doodstream] Playwright error: {e}")

                browser.close()

        except ImportError:
            logger.warning("[doodstream] Playwright tidak terinstall")
        except Exception as e:
            logger.error(f"[doodstream] Playwright error: {e}")

        logger.warning("[doodstream] Gagal extract video URL. DoodStream menggunakan Turnstile CAPTCHA.")
        return None, final_url, thumbnail

    except Exception as e:
        logger.error(f"[doodstream] Error: {e}")
        return None, None, None


# ============================================================
# VIDHIDE / MINOCHINOS EXTRACTOR (VidHide family)
# Uses Dean Edwards JS packer deobfuscation
# ============================================================

def _unpack_js_packer(html):
    """Deobfuscate Dean Edwards packer: eval(function(p,a,c,k,e,d){...})"""
    match = re.search(
        r"eval\(function\(p,a,c,k,e,d\)\{.*?\}\('(.*?)',\s*(\d+),\s*(\d+),\s*'(.*?)'\s*\.split\('\|'\)",
        html, re.DOTALL
    )
    if not match:
        return None

    p_code = match.group(1)
    base = int(match.group(2))
    count = int(match.group(3))
    keywords = match.group(4).split('|')

    def base_convert(num, b):
        chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
        if num < b:
            return chars[num]
        return base_convert(num // b, b) + chars[num % b]

    # Replace dari index tertinggi ke terendah agar tidak partial match
    for i in range(count - 1, -1, -1):
        if keywords[i]:
            token = base_convert(i, base)
            p_code = re.sub(r'\b' + re.escape(token) + r'\b', keywords[i], p_code)

    return p_code


def extract_vidhide_url(page_url):
    """Extract video dari VidHide family (minochinos, vidhide, dll).
    Menggunakan Dean Edwards packer deobfuscation untuk extract m3u8 URLs."""
    try:
        parsed = urlparse(page_url)
        site_domain = parsed.netloc
        headers = {
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
            'Referer': f'{parsed.scheme}://{site_domain}/',
        }

        resp = requests.get(page_url, headers=headers, timeout=15)
        if resp.status_code != 200:
            logger.warning(f"[vidhide] HTTP {resp.status_code}")
            return None, None, None

        # Step 1: Deobfuscate packed JS
        unpacked = _unpack_js_packer(resp.text)
        if not unpacked:
            logger.warning(f"[vidhide] Packed JS tidak ditemukan")
            return None, None, None

        logger.info(f"[vidhide] JS berhasil di-unpack ({len(unpacked)} chars)")

        # Step 2: Extract links object (var links={"hls2":"...", "hls3":"...", "hls4":"..."})
        links_match = re.search(r'var\s+links\s*=\s*\{([^}]+)\}', unpacked)
        m3u8_url = None
        thumbnail = None

        if links_match:
            links_str = links_match.group(1)
            logger.info(f"[vidhide] Links object ditemukan")

            # Extract hls URLs - prioritas: hls2 (CDN) > hls3 (backup) > hls4 (stream)
            hls_urls = {}
            for hls_key in ['hls4', 'hls2', 'hls3']:
                url_match = re.search(rf'"{hls_key}"\s*:\s*"([^"]+)"', links_str)
                if url_match:
                    hls_urls[hls_key] = url_match.group(1)
                    logger.info(f"[vidhide] {hls_key}: {hls_urls[hls_key][:80]}...")

            # Pilih URL terbaik: hls2 (CDN langsung) lebih reliable daripada hls4 (stream)
            if 'hls2' in hls_urls:
                m3u8_url = hls_urls['hls2']
                logger.info(f"[vidhide] Menggunakan hls2 (CDN)")
            elif 'hls3' in hls_urls:
                m3u8_url = hls_urls['hls3']
                logger.info(f"[vidhide] Menggunakan hls3 (backup)")
            elif 'hls4' in hls_urls:
                m3u8_url = hls_urls['hls4']
                # hls4 biasanya relative path
                if m3u8_url.startswith('/'):
                    m3u8_url = f"{parsed.scheme}://{site_domain}{m3u8_url}"
                logger.info(f"[vidhide] Menggunakan hls4 (stream)")

        # Step 3: Fallback - cari m3u8 langsung di unpacked code (izinkan koma di URL)
        if not m3u8_url:
            m3u8_matches = re.findall(r'["\']\s*(https?://[^"\'>\s]+\.m3u8[^"\'>\s]*)', unpacked)
            if not m3u8_matches:
                m3u8_matches = re.findall(r'https?://[^\s"\';<>\\]+\.m3u8[^\s"\';<>\\]*', unpacked)
            if m3u8_matches:
                m3u8_url = m3u8_matches[0]
                logger.info(f"[vidhide] M3U8 dari fallback: {m3u8_url[:80]}...")

        # Step 4: Extract thumbnail
        thumb_match = re.search(r'image\s*:\s*"(https?://[^"]+\.jpg)"', unpacked)
        if thumb_match:
            thumbnail = thumb_match.group(1)
            logger.info(f"[vidhide] Thumbnail: {thumbnail}")

        if m3u8_url:
            # Step 5: Resolve master m3u8 ke index m3u8 (sub-playlist)
            # FFmpeg 4.x punya bug dimana relative URL di HLS kehilangan query params
            # Solusi: fetch master, extract sub-playlist URL lengkap dengan token
            try:
                m3u8_resp = requests.get(m3u8_url, headers=headers, timeout=15)
                if m3u8_resp.status_code == 200:
                    m3u8_content = m3u8_resp.text
                    # Cari sub-playlist (index-v1-a1.m3u8?token=...)
                    sub_match = re.search(r'^(index-[^\s]+\.m3u8\?[^\s]+)$', m3u8_content, re.MULTILINE)
                    if sub_match:
                        sub_path = sub_match.group(1)
                        # Construct full URL dari base URL master
                        base_url = m3u8_url.rsplit('/', 1)[0]
                        resolved_url = f"{base_url}/{sub_path}"
                        logger.info(f"[vidhide] Resolved ke sub-playlist: {resolved_url[:100]}...")
                        m3u8_url = resolved_url
                    else:
                        logger.info(f"[vidhide] Tidak ada sub-playlist, gunakan master langsung")
            except Exception as e:
                logger.warning(f"[vidhide] Gagal resolve sub-playlist: {e}")

            logger.info(f"[vidhide] ✅ Video URL: {m3u8_url[:100]}")
            return m3u8_url, page_url, thumbnail
        else:
            logger.warning(f"[vidhide] Tidak menemukan M3U8 URL")
            return None, None, None

    except Exception as e:
        logger.error(f"[vidhide] Error: {e}")
        return None, None, None


# ============================================================
# BIGWARP.IO / BIGWARP.PRO EXTRACTOR
# ============================================================

def extract_bigwarp_url(page_url):
    """
    Extract video URL dari bigwarp.io / bigwarp.pro.
    Flow: bigwarp.io/CODE -> redirect ke bigwarp.pro/CODE
          -> embed page /embed-CODE.html berisi JWPlayer sources (direct MP4)
    Return: (video_url, referer, thumbnail_url)
    """
    logger.info(f"[bigwarp] Extracting video URL dari: {page_url}")

    headers = {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
    }

    try:
        # Extract video code dari URL
        parsed = urlparse(page_url)
        path_parts = [p for p in parsed.path.strip('/').split('/') if p]
        # Hapus prefix seperti 'e', 'embed', 'v' dll
        video_code = path_parts[-1] if path_parts else None
        if video_code:
            video_code = re.sub(r'^(embed-|e-)', '', video_code)
            video_code = re.sub(r'\.(html|htm)$', '', video_code)

        if not video_code:
            logger.error("[bigwarp] Tidak bisa extract video code dari URL")
            return None, None, None

        logger.info(f"[bigwarp] Video code: {video_code}")

        # Bigwarp dilindungi Cloudflare, gunakan curl_cffi jika tersedia
        use_cffi = cf_requests is not None
        fetch_fn = cf_requests if use_cffi else requests

        # Coba beberapa domain
        domains = ['bigwarp.pro', 'bigwarp.io']
        html = None
        used_domain = None

        for domain in domains:
            embed_url = f"https://{domain}/embed-{video_code}.html"
            logger.info(f"[bigwarp] Mencoba embed URL: {embed_url}")
            try:
                fetch_kwargs = dict(
                    headers={**headers, 'Referer': f'https://{domain}/'},
                    timeout=20,
                    allow_redirects=True,
                )
                if use_cffi:
                    fetch_kwargs['impersonate'] = 'chrome'
                resp = fetch_fn.get(embed_url, **fetch_kwargs)
                if resp.status_code == 200 and ('jwplayer' in resp.text.lower() or 'sources' in resp.text.lower()):
                    html = resp.text
                    used_domain = domain
                    break
                else:
                    logger.warning(f"[bigwarp] Status {resp.status_code} dari {embed_url}")
            except Exception as e:
                logger.warning(f"[bigwarp] Gagal fetch {embed_url}: {e}")
                continue

        if not html:
            # Fallback: coba fetch halaman utama (mungkin ada redirect)
            try:
                fetch_kwargs = dict(headers=headers, timeout=20, allow_redirects=True)
                if use_cffi:
                    fetch_kwargs['impersonate'] = 'chrome'
                resp = fetch_fn.get(page_url, **fetch_kwargs)
                if resp.status_code == 200:
                    html = resp.text
                    used_domain = urlparse(resp.url).netloc
            except Exception as e:
                logger.error(f"[bigwarp] Gagal fetch halaman: {e}")
                return None, None, None

        if not html:
            logger.error("[bigwarp] Gagal mendapatkan HTML dari semua domain")
            return None, None, None

        # Extract JWPlayer sources - cari file URL (MP4/m3u8)
        # Pattern: sources: [{file:"URL",label:"..."}]
        video_url = None
        best_quality = 0

        # Cari semua source file URLs
        sources_match = re.findall(r'file\s*:\s*["\']([^"\'>]+)["\']', html)
        # Filter hanya video URLs (bukan thumbnail tracks)
        video_sources = []
        for src in sources_match:
            if any(ext in src.lower() for ext in ['.mp4', '.m3u8', '.mkv', '.webm', '/v/', 'videoplayback']):
                # Coba extract label/quality
                label_match = re.search(re.escape(src) + r'["\'][^}]*label\s*:\s*["\']([^"\'>]+)', html)
                quality = 0
                if label_match:
                    q_match = re.search(r'(\d+)', label_match.group(1))
                    if q_match:
                        quality = int(q_match.group(1))
                video_sources.append((src, quality))

        if video_sources:
            # Pilih quality tertinggi
            video_sources.sort(key=lambda x: x[1], reverse=True)
            video_url = video_sources[0][0]
            logger.info(f"[bigwarp] Found {len(video_sources)} source(s), best quality: {video_sources[0][1]}")

        if not video_url:
            # Fallback: cari URL dengan pattern langsung
            mp4_match = re.search(r'["\']\s*(https?://[^"\'>]+\.mp4[^"\'>]*)', html)
            if mp4_match:
                video_url = mp4_match.group(1)
            else:
                m3u8_match = re.search(r'["\']\s*(https?://[^"\'>]+\.m3u8[^"\'>]*)', html)
                if m3u8_match:
                    video_url = m3u8_match.group(1)

        if not video_url:
            logger.error("[bigwarp] Tidak menemukan video URL di JWPlayer sources")
            return None, None, None

        logger.info(f"[bigwarp] Video URL: {video_url[:100]}...")

        # Extract thumbnail
        thumbnail = None
        img_match = re.search(r'image\s*:\s*["\']([^"\'>]+)["\']', html)
        if img_match:
            thumbnail = img_match.group(1)
            if thumbnail and not thumbnail.startswith('http'):
                thumbnail = f"https://{used_domain or 'bigwarp.pro'}{thumbnail}"
            logger.info(f"[bigwarp] Thumbnail: {thumbnail}")

        referer = f"https://{used_domain or 'bigwarp.pro'}/"
        return video_url, referer, thumbnail

    except Exception as e:
        logger.error(f"[bigwarp] Error: {e}")
        import traceback
        logger.error(traceback.format_exc())
        return None, None, None


# ============================================================
# GENERIC VIDEO EXTRACTOR (routing ke extractor yang tepat)
# ============================================================

def _resolve_youvid_url(page_url):
    """
    Resolve youvid.org wrapper ke URL embed asli (biasanya hgplaycdn/streamhg).
    youvid.org meng-embed player via base64-encoded server links di HTML.

    Mendukung beberapa format halaman youvid.org:
    1. embed.youvid.org/player.php?token=...  -> let servers = [{name, link}]
    2. cdn2.youvid.org/play2.php?encrypted_id=... -> serverList = ["base64_url", ...]
    3. youvid.org/... (format lama)           -> let servers = [{name, link}]

    Return: resolved URL atau None
    """
    logger.info(f"[youvid] Resolving wrapper URL: {page_url}")
    try:
        resp = requests.get(page_url, headers={
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
        }, timeout=15)
        if resp.status_code != 200:
            logger.error(f"[youvid] HTTP {resp.status_code}")
            return None

        html = resp.text
        resolved = None

        # Pattern 1: let servers = [{"name":"...","link":"BASE64"}]
        #   Digunakan oleh embed.youvid.org/player.php dan format lama
        m = re.search(r'let\s+servers\s*=\s*(\[.*?\]);', html)
        if m:
            import json as _json
            servers = _json.loads(m.group(1))
            if servers:
                link_b64 = servers[0].get('link', '')
                resolved = base64.b64decode(link_b64).decode('utf-8')
                logger.info(f"[youvid] Resolved (servers pattern) ke: {resolved}")
                return resolved

        # Pattern 2: serverList = ["base64_url1", "base64_url2", ...]
        #   Digunakan oleh cdn2.youvid.org/play2.php
        m2 = re.search(r'serverList\s*=\s*(\[.*?\])', html)
        if m2:
            import json as _json
            server_list = _json.loads(m2.group(1))
            if server_list:
                resolved = base64.b64decode(server_list[0]).decode('utf-8')
                logger.info(f"[youvid] Resolved (serverList pattern) ke: {resolved}")
                return resolved

        # Pattern 3: changeServer('BASE64_URL') inline di button onclick
        #   Fallback jika pattern 1 & 2 tidak ditemukan
        m3 = re.search(r"changeServer\(['\"]([A-Za-z0-9+/=]+)['\"]", html)
        if m3:
            resolved = base64.b64decode(m3.group(1)).decode('utf-8')
            logger.info(f"[youvid] Resolved (changeServer pattern) ke: {resolved}")
            return resolved

        logger.error("[youvid] Server list tidak ditemukan (tidak ada pattern yang cocok)")
        return None

    except Exception as e:
        logger.error(f"[youvid] Error resolving: {e}")
        return None


# Domain alias: ganti domain yang tidak bisa di-curl langsung ke equivalent-nya
_DOMAIN_ALIASES = {
    'streamwish.to': 'hgplaycdn.com',
    'streamwish.com': 'hgplaycdn.com',
}


def _apply_domain_alias(url):
    """Ganti domain di URL jika ada alias yang diketahui (misal streamwish.to -> hgplaycdn.com)"""
    if not url:
        return url
    parsed = urlparse(url)
    for old_domain, new_domain in _DOMAIN_ALIASES.items():
        if old_domain in parsed.netloc:
            new_url = url.replace(parsed.netloc, parsed.netloc.replace(old_domain, new_domain))
            logger.info(f"[alias] Domain rewrite: {parsed.netloc} -> {new_domain} | {new_url}")
            return new_url
    return url


# ============================================================
# VIDKEYX.COM EXTRACTOR
# ============================================================

def extract_vidkeyx_url(page_url):
    """
    Extract video URL dari vidkeyx.com.
    vidkeyx.com/e/CODE -> iframe embed.php?bucket=temporary&id=CODE -> <source src="..."> mp4
    """
    logger.info(f"[vidkeyx] Extracting video URL dari: {page_url}")

    headers = {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
    }

    try:
        # Extract video ID dari URL: /e/CODE
        parsed = urlparse(page_url)
        path_parts = [p for p in parsed.path.strip('/').split('/') if p]
        video_id = None
        if len(path_parts) >= 2 and path_parts[0] == 'e':
            video_id = path_parts[1]
        elif len(path_parts) == 1:
            video_id = path_parts[0]

        if not video_id:
            # Fallback: fetch halaman dan cari iframeId
            resp = requests.get(page_url, headers=headers, timeout=30)
            id_match = re.search(r"var\s+iframeId\s*=\s*['\"]([^'\"]+)", resp.text)
            if id_match:
                # iframeId is hex-encoded video_id, but we need the raw id
                pass
            # Coba cari id dari AJAX call
            id_match2 = re.search(r"var\s+id\s*=\s*['\"]([^'\"]+)", resp.text)
            if id_match2:
                video_id = id_match2.group(1)
            else:
                logger.error("[vidkeyx] Tidak bisa menemukan video ID")
                return None, None, None

        logger.info(f"[vidkeyx] Video ID: {video_id}")

        # Fetch embed page: embed.php?bucket=temporary&id=VIDEO_ID
        base_url = f"{parsed.scheme}://{parsed.netloc}"
        embed_url = f"{base_url}/embed.php?bucket=temporary&id={video_id}"
        embed_headers = {
            **headers,
            'Referer': page_url,
        }

        resp = requests.get(embed_url, headers=embed_headers, timeout=30)
        if resp.status_code != 200:
            logger.warning(f"[vidkeyx] Embed page HTTP {resp.status_code}")
            return None, None, None

        html = resp.text

        # Extract video source URL dari <source src="..."> atau <video src="...">
        source_match = re.search(r'<source\s+src=["\']([^"\']+)["\']', html)
        if not source_match:
            source_match = re.search(r'<video[^>]+src=["\']([^"\']+)["\']', html)
        if not source_match:
            # Coba cari URL .mp4 atau .m3u8 di dalam script
            source_match = re.search(r'["\']\s*(https?://[^\s"\']+\.(?:mp4|m3u8)[^\s"\']*)\s*["\']', html)

        if not source_match:
            logger.error("[vidkeyx] Tidak bisa menemukan video source URL")
            return None, None, None

        video_url = source_match.group(1)
        logger.info(f"[vidkeyx] Video URL ditemukan: {video_url}")

        # Extract thumbnail dari poster attribute
        thumbnail = None
        poster_match = re.search(r'poster=["\']([^"\']+)["\']', html)
        if poster_match:
            thumbnail = poster_match.group(1)
            logger.info(f"[vidkeyx] Thumbnail: {thumbnail}")

        return video_url, base_url, thumbnail

    except Exception as e:
        logger.error(f"[vidkeyx] Error: {e}")
        return None, None, None


# ============================================================
# STREAMTAPE.TO EXTRACTOR
# ============================================================

def extract_streamtape_url(page_url):
    """
    Extract video URL dari streamtape.to.
    Streamtape menyembunyikan link video di beberapa elemen HTML (#ideoooolink, #norobotlink)
    dengan obfuscation JS yang meng-concat substring dari string.
    Link akhir: //streamtape.to/get_video?id=XXX&expires=YYY&ip=ZZZ&token=TTT -> redirect ke CDN mp4
    Return: (video_url, referer, thumbnail_url)
    """
    logger.info(f"[streamtape] Extracting video URL dari: {page_url}")

    headers = {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
    }

    try:
        resp = requests.get(page_url, headers=headers, timeout=30)
        if resp.status_code != 200:
            logger.warning(f"[streamtape] HTTP {resp.status_code}")
            return None, None, None

        html = resp.text

        # Extract thumbnail dari vidconfig
        thumbnail = None
        thumb_match = re.search(r'"cors"\s*:\s*"(https?://[^"]+)"', html)
        if thumb_match:
            thumbnail = thumb_match.group(1)

        # Metode 1: Cari JS yang membangun link di #ideoooolink dan #norobotlink
        # Pattern: document.getElementById('norobotlink').innerHTML = '//streamt' + ('xcdape.to/get_video?...token=XXXX').substring(1).substring(2);
        # Kita perlu execute logic JS: concat base + substr
        video_url = None

        # Cari semua assignment ke norobotlink/ideoooolink
        # Pattern: getElementById('norobotlink').innerHTML = <expr>
        for target_id in ['norobotlink', 'ideoooolink']:
            pat_str = r"getElementById\(['\"]" + target_id + r"['\"]\)\.innerHTML\s*=\s*(.+?);"
            pattern = re.compile(pat_str, re.DOTALL)
            match = pattern.search(html)
            if match:
                expr = match.group(1).strip()
                # Parse JS string concatenation: 'str1' + ('str2').substring(N).substring(M)
                # Simplified parser for this specific pattern
                result = _eval_streamtape_js_concat(expr)
                if result and '/get_video?' in result:
                    video_url = result
                    break

        # Metode 2: Fallback - cari langsung dari hidden div content
        if not video_url:
            for target_id in ['norobotlink', 'ideoooolink']:
                div_pat = r'id=["\x27]' + target_id + r'["\x27][^>]*>([^<]+)</(?:div|span)>'
                div_match = re.search(div_pat, html)
                if div_match:
                    link_text = div_match.group(1).strip()
                    if '/get_video?' in link_text:
                        video_url = link_text
                        break

        if not video_url:
            logger.error("[streamtape] Tidak bisa menemukan video URL")
            return None, None, None

        # Pastikan URL lengkap
        if video_url.startswith('//'):
            video_url = 'https:' + video_url
        elif video_url.startswith('/'):
            parsed = urlparse(page_url)
            video_url = f"{parsed.scheme}://{parsed.netloc}{video_url}"

        # Tambah &dl=1 untuk force download
        if '&dl=' not in video_url:
            video_url += '&dl=1'

        logger.info(f"[streamtape] Video URL: {video_url[:120]}...")

        # Follow redirect untuk mendapatkan direct CDN URL
        try:
            dl_resp = requests.head(video_url, headers={**headers, 'Referer': page_url}, timeout=30, allow_redirects=True)
            if dl_resp.status_code == 200 and dl_resp.url != video_url:
                video_url = dl_resp.url
                logger.info(f"[streamtape] Direct CDN URL: {video_url[:120]}...")
        except Exception as e:
            logger.warning(f"[streamtape] Follow redirect gagal: {e}, pakai URL asli")

        return video_url, page_url, thumbnail

    except Exception as e:
        logger.error(f"[streamtape] Error: {e}")
        return None, None, None


def _eval_streamtape_js_concat(expr):
    """
    Parse dan evaluasi ekspresi JS concat sederhana dari streamtape.
    Contoh:
      '//streamt' + ('xcdape.to/get_video?id=XX&token=YY').substring(1).substring(2)
    Return: string hasil concat, atau None jika gagal parse.
    """
    try:
        # Split by + untuk concat parts
        parts = []
        # Regex untuk extract string literal dan .substring() calls
        # Match: 'string' atau ("string").substring(N).substring(M) dll
        token_pattern = re.compile(
            r"(?:"
            r"'([^']*)'"         # single-quoted string
            r'|"([^"]*)"'       # double-quoted string  
            r"|\('([^']*)'\)"   # ('string') in parens single
            r'|\("([^"]*)"\)'  # ("string") in parens double
            r")"
            r"((?:\.substring\(\d+\))*)"  # optional .substring(N) chains
        )

        pos = 0
        result = ''
        for m in token_pattern.finditer(expr):
            s = m.group(1) or m.group(2) or m.group(3) or m.group(4) or ''
            substr_chain = m.group(5) or ''

            # Apply .substring() calls
            if substr_chain:
                for sub_m in re.finditer(r'\.substring\((\d+)\)', substr_chain):
                    idx = int(sub_m.group(1))
                    s = s[idx:]

            result += s

        if result and len(result) > 10:
            return result
        return None
    except Exception:
        return None


# ============================================================
# VIDNEST.IO / VIDNEST.LIVE EXTRACTOR
# ============================================================

def extract_vidnest_url(page_url):
    """
    Extract video URL dari vidnest.io / vidnest.live.
    Flow: vidnest.io/CODE -> redirect ke vidnest.live/CODE (halaman download)
          POST /dl dengan op=embed&file_code=CODE -> JWPlayer page dengan sources[{file:"..."}]
    Return: (video_url, referer, thumbnail_url)
    """
    logger.info(f"[vidnest] Extracting video URL dari: {page_url}")

    headers = {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
    }

    try:
        # Extract file code dari URL
        parsed = urlparse(page_url)
        path_parts = [p for p in parsed.path.strip('/').split('/') if p]
        file_code = None

        # Format: vidnest.io/CODE atau vidnest.live/e/CODE atau vidnest.live/CODE
        if path_parts:
            file_code = path_parts[-1]
            # Hapus extension jika ada
            if '.' in file_code:
                file_code = file_code.rsplit('.', 1)[0]

        if not file_code:
            logger.error("[vidnest] Tidak bisa menemukan file code")
            return None, None, None

        logger.info(f"[vidnest] File code: {file_code}")

        # Coba beberapa domain
        domains = ['https://vidnest.live', 'https://vidnest.io']
        video_url = None
        thumbnail = None

        for base_domain in domains:
            try:
                # POST ke /dl untuk mendapatkan halaman player
                post_url = f"{base_domain}/dl"
                post_data = {
                    'op': 'embed',
                    'file_code': file_code,
                    'auto': '1',
                    'referer': '',
                }

                resp = requests.post(post_url, data=post_data, headers={
                    **headers,
                    'Referer': f"{base_domain}/e/{file_code}",
                    'Content-Type': 'application/x-www-form-urlencoded',
                }, timeout=30)

                if resp.status_code != 200:
                    continue

                html = resp.text

                # Extract video URL dari JWPlayer sources
                # Pattern: sources: [{file:"https://...",label:"..."}]
                file_match = re.search(r'sources:\s*\[\{file:\s*"(https?://[^"]+)"', html)
                if not file_match:
                    # Fallback: cari file: "url" standalone
                    file_match = re.search(r'file:\s*"(https?://[^"]+\.mp4[^"]*)"', html)
                if not file_match:
                    file_match = re.search(r'file:\s*"(https?://[^"]+\.m3u8[^"]*)"', html)

                if file_match:
                    video_url = file_match.group(1)
                    logger.info(f"[vidnest] Video URL ditemukan: {video_url[:120]}...")

                    # Extract thumbnail
                    thumb_match = re.search(r'<img\s+src="(https?://[^"]+\.jpg)"', html)
                    if thumb_match:
                        thumbnail = thumb_match.group(1)
                        logger.info(f"[vidnest] Thumbnail: {thumbnail}")

                    return video_url, base_domain, thumbnail

            except Exception as e:
                logger.warning(f"[vidnest] {base_domain} gagal: {e}")
                continue

        logger.error("[vidnest] Gagal extract video URL dari semua domain")
        return None, None, None

    except Exception as e:
        logger.error(f"[vidnest] Error: {e}")
        return None, None, None


def extract_video_url(page_url):
    """
    Router utama: deteksi situs dan panggil extractor yang sesuai.
    Return: (streaming_url, referer, thumbnail_url) atau (None, None, None)
    """
    site = detect_site(page_url)

    # Wrapper sites: resolve dulu ke URL asli, lalu re-detect
    if site == 'youvid':
        resolved = _resolve_youvid_url(page_url)
        if resolved:
            resolved = _apply_domain_alias(resolved)
            page_url = resolved
            site = detect_site(resolved)
            if not site:
                logger.warning(f"[youvid] Resolved URL tidak dikenali: {resolved}")
                return None, None, None
        else:
            return None, None, None

    if site == 'streamhg':
        return extract_streamhg_url(page_url)
    elif site == 'vidara':
        return extract_vidara_url(page_url)
    elif site == 'strmup':
        return extract_strmup_url(page_url)
    elif site == 'putarvid':
        return extract_putarvid_url(page_url)
    elif site == 'berbagi':
        return extract_berbagi_url(page_url)
    elif site == 'pooptv':
        return extract_pooptv_url(page_url)
    elif site == 'indovidplus':
        return extract_indovidplus_url(page_url)
    elif site == 'luluvid':
        return extract_luluvid_url(page_url)
    elif site == 'abysscdn':
        return extract_abysscdn_url(page_url)
    elif site == 'streamhls':
        return extract_streamhls_url(page_url)
    elif site == 'voe':
        return extract_voe_url(page_url)
    elif site == 'vidhide':
        return extract_vidhide_url(page_url)
    elif site == 'havenfile':
        return extract_havenfile_url(page_url)
    elif site == 'filemoon':
        return extract_filemoon_url(page_url)
    elif site == 'doodstream':
        return extract_doodstream_url(page_url)
    elif site == 'bigwarp':
        return extract_bigwarp_url(page_url)
    elif site == 'vidkeyx':
        return extract_vidkeyx_url(page_url)
    elif site == 'streamtape':
        return extract_streamtape_url(page_url)
    elif site == 'vidnest':
        return extract_vidnest_url(page_url)
    else:
        return None, None, None


# ============================================================
# DOWNLOAD VIDEO
# ============================================================

def download_with_ffmpeg(m3u8_url, output_path, referer):
    """Download video dari m3u8/stream URL menggunakan ffmpeg"""
    parsed = urlparse(referer)
    origin = f"{parsed.scheme}://{parsed.netloc}"

    ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
    ffmpeg_cmd = [
        "ffmpeg", "-y",
        "-headers", f"Referer: {origin}/\r\nOrigin: {origin}\r\nUser-Agent: {ua}\r\n",
        "-i", m3u8_url,
        "-c", "copy",
        output_path
    ]

    try:
        result = subprocess.run(ffmpeg_cmd, capture_output=True, text=True, timeout=1800)
        if result.returncode == 0 and os.path.exists(output_path):
            size = os.path.getsize(output_path)
            if size > 10000:  # Minimal 10KB
                logger.info(f"Download berhasil (ffmpeg): {size / 1024 / 1024:.2f} MB")
                return True
            else:
                logger.warning(f"File terlalu kecil: {size} bytes")
        else:
            logger.warning(f"ffmpeg gagal: {result.stderr[:300] if result.stderr else 'unknown'}")
    except subprocess.TimeoutExpired:
        logger.warning("ffmpeg download timeout (30 menit)")

    return False


def download_video(url, output_path, title="video", cached_extraction=None):
    """Download video menggunakan berbagai metode (site extractor, yt-dlp, ffmpeg)
    cached_extraction: tuple (extracted_url, referer) dari extract sebelumnya, supaya tidak extract ulang"""
    logger.info(f"Mendownload: {title}")
    logger.info(f"URL: {url}")

    # Fast path: Deteksi direct video/playlist URLs
    parsed = urlparse(url)
    path_lower = parsed.path.lower()

    # Jangan treat sebagai direct video jika URL milik situs yang punya extractor khusus
    site = detect_site(url)
    is_known_site = site is not None

    is_direct_video = not is_known_site and (
        path_lower.endswith(('.mp4', '.mkv', '.avi', '.mov', '.flv', '.webm', '.ts', '.m4v')) or
        path_lower.endswith('.m3u8') or
        '/cdn.' in url.lower() or  # CDN URLs (videy.co/cdn, etc)
        'cdn-' in url.lower() or
        'videoplayback.php' in path_lower or  # Direct video playback endpoints (pemersatu, etc)
        'videoplayback?' in url.lower() or
        '/dl/' in path_lower and path_lower.endswith(('.mp4', '.m3u8', '.mkv'))
    )

    if is_direct_video:
        logger.info(f"Terdeteksi direct video URL, skip extractor...")
        # Langsung download dengan yt-dlp dulu (handle banyak format)
        logger.info("Mencoba download dengan yt-dlp...")
        ytdlp_cmd = [
            "yt-dlp",
            "--no-check-certificates",
            "--no-warnings",
            "-o", output_path,
            "--merge-output-format", "mp4",
            "--retries", "3",
            "--fragment-retries", "3",
            url
        ]

        try:
            result = subprocess.run(ytdlp_cmd, capture_output=True, text=True, timeout=600)
            if result.returncode == 0 and os.path.exists(output_path):
                size = os.path.getsize(output_path)
                logger.info(f"Download berhasil (yt-dlp): {size / 1024 / 1024:.2f} MB")
                return True
        except subprocess.TimeoutExpired:
            logger.warning("yt-dlp timeout")

        # Fallback ffmpeg untuk direct URL
        logger.info("yt-dlp gagal, mencoba ffmpeg...")
        if download_with_ffmpeg(url, output_path, url):
            return True

        logger.error(f"Download direct video gagal: {url}")
        return False

    # site sudah di-detect di atas (is_known_site check)

    # Special case: abysscdn menggunakan browser download (Service Worker + encrypted chunks)
    if site == 'abysscdn':
        logger.info(f"[abysscdn] Menggunakan browser download untuk: {url}")
        if download_abysscdn_video(url, output_path):
            return True
        logger.warning("[abysscdn] Browser download gagal, mencoba yt-dlp sebagai fallback...")
        # Fall through ke yt-dlp fallback di bawah

    # Step 0: Jika URL dikenali, gunakan cached result atau extract video URL
    extracted_url = None
    ref = None
    if cached_extraction and cached_extraction[0]:
        extracted_url, ref = cached_extraction
        logger.info(f"Menggunakan cached video URL dari extraction sebelumnya")
    elif site:
        logger.info(f"Terdeteksi situs: {site}, mengextract video URL...")
        extracted_url, ref, _thumb = extract_video_url(url)

    if extracted_url:
        logger.info(f"Video URL berhasil diextract dari {site}")

        # Download dengan ffmpeg dari m3u8
        logger.info("Mendownload dengan ffmpeg dari m3u8...")
        if download_with_ffmpeg(extracted_url, output_path, ref or url):
            return True
        else:
            logger.warning("ffmpeg download gagal, mencoba metode lain...")
    elif site:
        logger.warning(f"Gagal extract video URL dari {site}")

    # Metode 1: yt-dlp
    logger.info("Mencoba download dengan yt-dlp...")
    ytdlp_cmd = [
        "yt-dlp",
        "--no-check-certificates",
        "--no-warnings",
        "-o", output_path,
        "--merge-output-format", "mp4",
        "--retries", "3",
        "--fragment-retries", "3",
        url
    ]

    try:
        result = subprocess.run(ytdlp_cmd, capture_output=True, text=True, timeout=600)
        if result.returncode == 0 and os.path.exists(output_path):
            size = os.path.getsize(output_path)
            logger.info(f"Download berhasil (yt-dlp): {size / 1024 / 1024:.2f} MB")
            return True
    except subprocess.TimeoutExpired:
        logger.warning("yt-dlp timeout")

    # Cek apakah yt-dlp menyimpan dengan ekstensi berbeda
    base = os.path.splitext(output_path)[0]
    for ext in ['.mp4', '.mkv', '.webm', '.ts', '.flv']:
        alt_path = base + ext
        if os.path.exists(alt_path) and alt_path != output_path:
            os.rename(alt_path, output_path)
            size = os.path.getsize(output_path)
            logger.info(f"Download berhasil (yt-dlp, renamed): {size / 1024 / 1024:.2f} MB")
            return True

    logger.warning("yt-dlp gagal")

    # Metode 2: ffmpeg langsung (jika belum dicoba di atas)
    if not site:
        logger.info("Mencoba download dengan ffmpeg...")
        if download_with_ffmpeg(url, output_path, url):
            return True

    logger.error(f"Semua metode download gagal untuk: {url}")
    return False


def build_watermark_filter(font_size=None, padding=None):
    """Buat ffmpeg drawtext filter berdasarkan konfigurasi watermark.
    font_size & padding bisa di-override (untuk proporsional resolusi).
    Jika WATERMARK_FLOATING=True, posisi x/y menggunakan ekspresi dinamis."""
    if not WATERMARK_ENABLED:
        return None

    fs = font_size or WATERMARK_SIZE
    pad = padding or WATERMARK_PADDING

    # Konversi opacity ke format alpha ffmpeg
    alpha = WATERMARK_OPACITY
    color = WATERMARK_COLOR
    if color.startswith('#') and len(color) == 7:
        color_with_alpha = f"{color}@{alpha}"
    else:
        color_with_alpha = f"{color}@{alpha}"

    if WATERMARK_FLOATING:
        # === FLOATING/MOVING WATERMARK ===
        x_expr, y_expr = _build_floating_xy(pad, WATERMARK_FLOAT_MODE, WATERMARK_FLOAT_SPEED, WATERMARK_FLOAT_INTERVAL)
    else:
        # === STATIC WATERMARK ===
        position_map = {
            'top-left':      (f'{pad}', f'{pad}'),
            'top-center':    ('(w-text_w)/2', f'{pad}'),
            'top-right':     (f'w-text_w-{pad}', f'{pad}'),
            'center-left':   (f'{pad}', '(h-text_h)/2'),
            'center':        ('(w-text_w)/2', '(h-text_h)/2'),
            'center-right':  (f'w-text_w-{pad}', '(h-text_h)/2'),
            'bottom-left':   (f'{pad}', f'h-text_h-{pad}'),
            'bottom-center': ('(w-text_w)/2', f'h-text_h-{pad}'),
            'bottom-right':  (f'w-text_w-{pad}', f'h-text_h-{pad}'),
        }
        pos = WATERMARK_POSITION.lower().strip()
        x_expr, y_expr = position_map.get(pos, position_map['center'])

    # Bangun filter drawtext
    # Escape single quotes in text for ffmpeg
    wm_text = WATERMARK_TEXT.replace("'", "'\\\\\''")
    filter_parts = [
        f"drawtext=text='{wm_text}'",
        f"fontsize={fs}",
        f"fontcolor={color_with_alpha}",
        f"x={x_expr}",
        f"y={y_expr}",
    ]

    # Font
    if WATERMARK_FONT:
        filter_parts.append(f"font='{WATERMARK_FONT}'")

    # Shadow
    if WATERMARK_SHADOW:
        shadow_color = WATERMARK_SHADOW
        filter_parts.append(f"shadowcolor={shadow_color}@{alpha}")
        filter_parts.append("shadowx=1")
        filter_parts.append("shadowy=1")

    vf = ":".join(filter_parts)
    mode_label = f"floating-{WATERMARK_FLOAT_MODE}" if WATERMARK_FLOATING else f"static-{WATERMARK_POSITION}"
    logger.info(f"Watermark filter ({mode_label}): {vf[:200]}{'...' if len(vf) > 200 else ''}")
    return vf


def _build_floating_xy(pad, mode, speed, interval):
    """
    Bangun ekspresi x/y ffmpeg untuk floating watermark.
    
    Mode:
      bounce  - memantul di tepi layar seperti DVD screensaver
      random  - pindah ke posisi random tiap N detik
      smooth  - gerakan melingkar halus menggunakan sin/cos
    
    Return: (x_expr, y_expr) string untuk ffmpeg drawtext
    """
    if mode == 'bounce':
        # Bounce mode: teks memantul di tepi layar
        # Menggunakan mod + abs untuk efek bounce
        # x bergerak bolak-balik antara pad sampai (w - text_w - pad)
        # y bergerak bolak-balik antara pad sampai (h - text_h - pad)
        # Kecepatan x dan y sedikit berbeda agar tidak monoton
        sx = speed
        sy = int(speed * 0.73)  # Rasio golden-ish agar tidak sinkron
        # abs(mod(t*speed, 2*(range)) - range) -> triangle wave 0..range
        x_expr = f"{pad}+abs(mod(t*{sx}\,2*(w-text_w-{pad*2}))-w+text_w+{pad*2})"
        y_expr = f"{pad}+abs(mod(t*{sy}\,2*(h-text_h-{pad*2}))-h+text_h+{pad*2})"
        return x_expr, y_expr

    elif mode == 'random':
        # Random mode: pindah posisi random tiap interval detik
        # Menggunakan pseudo-random dari ekspresi ffmpeg
        # mod(floor(t/interval)*PRIME, range) untuk pseudo-random
        inv = interval
        # Dua prime numbers berbeda untuk x dan y agar tidak terkait
        x_expr = f"{pad}+mod(floor(t/{inv})*1237\,(w-text_w-{pad*2}))"
        y_expr = f"{pad}+mod(floor(t/{inv})*859\,(h-text_h-{pad*2}))"
        return x_expr, y_expr

    elif mode == 'smooth':
        # Smooth mode: gerakan melingkar menggunakan sin/cos
        # Watermark bergerak dalam pola lissajous (elips)
        # center + amplitude * sin(t * freq)
        # Frekuensi x dan y berbeda agar pola tidak terlalu reguler
        freq_x = speed / 100.0
        freq_y = freq_x * 0.71  # Rasio irasional untuk pola non-repeating
        x_expr = f"(w-text_w)/2 + ((w-text_w)/2-{pad})*sin(t*{freq_x:.4f})"
        y_expr = f"(h-text_h)/2 + ((h-text_h)/2-{pad})*sin(t*{freq_y:.4f})"
        return x_expr, y_expr

    else:
        # Fallback ke bounce
        logger.warning(f"Mode floating '{mode}' tidak dikenal, fallback ke bounce")
        return _build_floating_xy(pad, 'bounce', speed, interval)


def _probe_video_height(input_file):
    """Probe video height untuk auto-size watermark. Returns height atau None."""
    try:
        probe_cmd = [
            'ffprobe', '-v', 'error',
            '-select_streams', 'v:0',
            '-show_entries', 'stream=height',
            '-of', 'csv=p=0',
            input_file
        ]
        result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=10)
        if result.returncode == 0 and result.stdout.strip():
            return int(result.stdout.strip())
    except Exception:
        pass
    return None


def _calc_watermark_size(vid_height):
    """Hitung font_size dan padding proporsional ke tinggi video."""
    if not WATERMARK_AUTO_SIZE or not vid_height:
        return WATERMARK_SIZE, WATERMARK_PADDING
    font_size = max(10, int(vid_height * WATERMARK_SIZE_RATIO))
    padding = max(5, int(vid_height * WATERMARK_PADDING_RATIO))
    return font_size, padding


def apply_watermark(input_file, output_file):
    """Tambahkan watermark ke video menggunakan ffmpeg drawtext.
    Font size & padding otomatis proporsional ke resolusi video."""
    if not WATERMARK_ENABLED:
        return input_file

    # Auto-size berdasarkan resolusi video
    font_size, padding = WATERMARK_SIZE, WATERMARK_PADDING
    if WATERMARK_AUTO_SIZE:
        vid_height = _probe_video_height(input_file)
        if vid_height:
            font_size, padding = _calc_watermark_size(vid_height)
            logger.info(f"Video height: {vid_height}px → font={font_size}px, padding={padding}px")

    logger.info(f"Menambahkan watermark: '{WATERMARK_TEXT}' posisi={WATERMARK_POSITION} size={font_size}px")

    vf = build_watermark_filter(font_size=font_size, padding=padding)
    if not vf:
        return input_file

    ffmpeg_cmd = [
        "ffmpeg", "-y",
        "-i", input_file,
        "-vf", vf,
        "-c:v", "libx264",
        "-preset", "fast",
        "-crf", "23",
        "-c:a", "copy",
        output_file
    ]

    logger.info(f"FFmpeg watermark: {' '.join(ffmpeg_cmd)}")
    result = subprocess.run(ffmpeg_cmd, capture_output=True, text=True)

    if result.returncode != 0:
        logger.error(f"Watermark gagal: {result.stderr[:500]}")
        return None

    size = os.path.getsize(output_file)
    logger.info(f"Watermark berhasil ditambahkan: {size / 1024 / 1024:.2f} MB")
    return output_file


def convert_to_hls(input_file, output_dir, hls_time=10, copy_codec=False):
    """Konversi video ke format HLS (m3u8 + ts segments)"""
    logger.info(f"Mengkonversi ke HLS: {os.path.basename(input_file)}")

    os.makedirs(output_dir, exist_ok=True)
    playlist_path = os.path.join(output_dir, "playlist.m3u8")
    segment_pattern = os.path.join(output_dir, "seg_%04d.ts")

    # Jika watermark aktif, harus re-encode (tidak bisa copy codec)
    # Auto-size watermark berdasarkan resolusi video
    font_size, wm_padding = WATERMARK_SIZE, WATERMARK_PADDING
    if WATERMARK_AUTO_SIZE:
        vid_height = _probe_video_height(input_file)
        if vid_height:
            font_size, wm_padding = _calc_watermark_size(vid_height)
            logger.info(f"HLS watermark auto-size: height={vid_height}px → font={font_size}px")
    vf = build_watermark_filter(font_size=font_size, padding=wm_padding)
    if vf:
        logger.info("Watermark aktif -> re-encode dengan watermark langsung ke HLS")
        ffmpeg_cmd = [
            "ffmpeg", "-y",
            "-i", input_file,
            "-vf", vf,
            "-c:v", "libx264",
            "-preset", "fast",
            "-crf", "23",
            "-c:a", "aac",
            "-b:a", "128k",
            "-ac", "2",
            "-hls_time", str(hls_time),
            "-hls_playlist_type", "vod",
            "-hls_segment_filename", segment_pattern,
            "-hls_list_size", "0",
            "-f", "hls",
            playlist_path
        ]
    elif copy_codec:
        # Copy codec tanpa re-encode (lebih cepat)
        ffmpeg_cmd = [
            "ffmpeg", "-y",
            "-i", input_file,
            "-c:v", "copy",
            "-c:a", "copy",
            "-hls_time", str(hls_time),
            "-hls_playlist_type", "vod",
            "-hls_segment_filename", segment_pattern,
            "-hls_list_size", "0",
            "-f", "hls",
            playlist_path
        ]
    else:
        # Re-encode untuk kompatibilitas maksimal
        ffmpeg_cmd = [
            "ffmpeg", "-y",
            "-i", input_file,
            "-c:v", "libx264",
            "-preset", "fast",
            "-crf", "23",
            "-c:a", "aac",
            "-b:a", "128k",
            "-ac", "2",
            "-hls_time", str(hls_time),
            "-hls_playlist_type", "vod",
            "-hls_segment_filename", segment_pattern,
            "-hls_list_size", "0",
            "-f", "hls",
            playlist_path
        ]

    logger.info(f"FFmpeg command: {' '.join(ffmpeg_cmd)}")
    result = subprocess.run(ffmpeg_cmd, capture_output=True, text=True)

    if result.returncode != 0:
        logger.error(f"Konversi HLS gagal: {result.stderr[:500]}")

        # Fallback: coba tanpa watermark jika gagal
        if vf:
            logger.info("Mencoba ulang tanpa watermark...")
            ffmpeg_cmd_fallback = [
                "ffmpeg", "-y",
                "-i", input_file,
                "-c:v", "copy",
                "-c:a", "copy",
                "-hls_time", str(hls_time),
                "-hls_playlist_type", "vod",
                "-hls_segment_filename", segment_pattern,
                "-hls_list_size", "0",
                "-f", "hls",
                playlist_path
            ]
            result = subprocess.run(ffmpeg_cmd_fallback, capture_output=True, text=True)
            if result.returncode == 0:
                segments = [f for f in os.listdir(output_dir) if f.endswith('.ts')]
                logger.info(f"Konversi HLS berhasil (tanpa watermark): {len(segments)} segments")
                return output_dir
        elif not copy_codec:
            logger.info("Mencoba ulang dengan copy codec...")
            return convert_to_hls(input_file, output_dir, hls_time, copy_codec=True)
        return None

    # Hitung total segments
    segments = [f for f in os.listdir(output_dir) if f.endswith('.ts')]
    wm_status = "+ watermark" if vf else "tanpa watermark"
    logger.info(f"Konversi HLS berhasil ({wm_status}): {len(segments)} segments, playlist: {playlist_path}")

    return output_dir


def upload_hls_to_s3(hls_dir, s3cfg_path, bucket, prefix, title_slug):
    """Upload semua file HLS (m3u8 + ts) ke S3"""
    s3_base_path = f"s3://{bucket}/{prefix}/{title_slug}" if prefix else f"s3://{bucket}/{title_slug}"
    logger.info(f"Mengupload HLS ke: {s3_base_path}/")

    files = sorted(os.listdir(hls_dir))
    total_files = len(files)
    uploaded = 0
    failed = 0
    total_size = 0

    for filename in files:
        local_file = os.path.join(hls_dir, filename)
        if not os.path.isfile(local_file):
            continue

        file_size = os.path.getsize(local_file)
        total_size += file_size

        # Set content type
        if filename.endswith('.m3u8'):
            content_type = "application/vnd.apple.mpegurl"
        elif filename.endswith('.ts'):
            content_type = "video/MP2T"
        else:
            content_type = "binary/octet-stream"

        s3_dest = f"{s3_base_path}/{filename}"
        cmd = (
            f's3cmd -c {s3cfg_path} put "{local_file}" "{s3_dest}" '
            f'--acl-public '
            f'--mime-type="{content_type}" '
            f'--no-progress'
        )

        result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
        if result.returncode == 0:
            uploaded += 1
            logger.debug(f"  Uploaded: {filename} ({file_size / 1024:.1f} KB)")
        else:
            failed += 1
            logger.error(f"  Gagal upload {filename}: {result.stderr[:200]}")

    logger.info(
        f"Upload selesai: {uploaded}/{total_files} file berhasil, "
        f"{failed} gagal, total size: {total_size / 1024 / 1024:.2f} MB"
    )

    # Generate public URL untuk playlist
    s3_config = parse_s3cfg(s3cfg_path)
    host_bucket = s3_config.get('host_bucket', '%(bucket)s.sgp1.digitaloceanspaces.com')
    host = host_bucket.replace('%(bucket)s', bucket)
    use_https = s3_config.get('use_https', 'True').lower() == 'true'
    protocol = 'https' if use_https else 'http'

    playlist_key = f"{prefix}/{title_slug}/playlist.m3u8" if prefix else f"{title_slug}/playlist.m3u8"
    playlist_url = f"{protocol}://{host}/{playlist_key}"

    # CDN URL (jika menggunakan CDN DigitalOcean)
    cdn_url = f"{protocol}://{bucket}.sgp1.cdn.digitaloceanspaces.com/{playlist_key}"

    return {
        'uploaded': uploaded,
        'failed': failed,
        'total_size': total_size,
        'playlist_url': playlist_url,
        'cdn_url': cdn_url,
        's3_path': f"{s3_base_path}/playlist.m3u8"
    }


def download_thumbnail(thumbnail_url, output_path, referer=None):
    """Download thumbnail/gambar dari URL"""
    if not thumbnail_url:
        return False

    logger.info(f"Mendownload thumbnail: {thumbnail_url[:100]}...")
    headers = {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
    }
    if referer:
        headers['Referer'] = referer

    try:
        resp = requests.get(thumbnail_url, headers=headers, timeout=60, stream=True)
        resp.raise_for_status()

        with open(output_path, 'wb') as f:
            for chunk in resp.iter_content(chunk_size=8192):
                f.write(chunk)

        size = os.path.getsize(output_path)
        if size > 100:  # Minimal 100 bytes
            logger.info(f"Thumbnail berhasil didownload: {size / 1024:.1f} KB")
            return True
        else:
            logger.warning(f"Thumbnail terlalu kecil: {size} bytes")
            return False

    except Exception as e:
        logger.warning(f"Gagal download thumbnail: {e}")
        return False


def upload_image_to_s3(image_path, s3cfg_path, bucket, image_prefix, title_slug):
    """Upload gambar/thumbnail ke S3 dengan prefix image"""
    # Deteksi ekstensi file
    ext = os.path.splitext(image_path)[1].lower()
    if ext not in ['.jpg', '.jpeg', '.png', '.webp', '.gif']:
        ext = '.jpg'  # Default

    # Content type mapping
    content_types = {
        '.jpg': 'image/jpeg',
        '.jpeg': 'image/jpeg',
        '.png': 'image/png',
        '.webp': 'image/webp',
        '.gif': 'image/gif',
    }
    content_type = content_types.get(ext, 'image/jpeg')

    filename = f"{title_slug}{ext}"
    s3_dest = f"s3://{bucket}/{image_prefix}/{filename}"

    logger.info(f"Mengupload thumbnail ke: {s3_dest}")

    cmd = (
        f's3cmd -c {s3cfg_path} put "{image_path}" "{s3_dest}" '
        f'--acl-public '
        f'--mime-type="{content_type}" '
        f'--no-progress'
    )

    result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
    if result.returncode == 0:
        # Generate public URL
        s3_config = parse_s3cfg(s3cfg_path)
        host_bucket = s3_config.get('host_bucket', '%(bucket)s.sgp1.digitaloceanspaces.com')
        host = host_bucket.replace('%(bucket)s', bucket)
        use_https = s3_config.get('use_https', 'True').lower() == 'true'
        protocol = 'https' if use_https else 'http'

        image_url = f"{protocol}://{host}/{image_prefix}/{filename}"
        cdn_url = f"{protocol}://{bucket}.sgp1.cdn.digitaloceanspaces.com/{image_prefix}/{filename}"

        logger.info(f"Thumbnail berhasil diupload: {image_url}")
        return {
            'image_url': image_url,
            'cdn_image_url': cdn_url,
            's3_path': s3_dest,
        }
    else:
        logger.error(f"Gagal upload thumbnail: {result.stderr[:200]}")
        return None


def process_single_video(url, title, s3cfg_path, bucket, prefix, hls_time, copy_codec, temp_base, image_prefix=IMAGE_PREFIX):
    """Proses satu video: download -> konversi HLS -> upload S3"""
    title_slug = sanitize_filename(title)
    temp_dir = tempfile.mkdtemp(prefix=f'hls_{title_slug}_', dir=temp_base)

    result = {
        'title': title,
        'url': url,
        'status': 'failed',
        'playlist_url': None,
        'cdn_url': None,
        'image_url': None,
        'cdn_image_url': None,
        'error': None,
        'timestamp': datetime.now().isoformat()
    }

    try:
        # Step 0: Cek duplikasi - skip jika sudah pernah berhasil
        if is_already_processed(url):
            logger.info(f"⏭️  SKIP (sudah pernah berhasil): {title}")
            result['status'] = 'skipped'
            result['error'] = 'already_processed'
            return result

        # Step 1: Extract video URL + thumbnail (sekali saja, hasilnya di-cache)
        thumbnail_url = None
        extracted_url = None
        extracted_ref = None
        site = detect_site(url)
        if site:
            extracted_url, extracted_ref, thumbnail_url = extract_video_url(url)

        # Step 2: Download video (pass hasil extraction supaya tidak extract ulang)
        video_path = os.path.join(temp_dir, f"{title_slug}.mp4")
        if not download_video(url, video_path, title, cached_extraction=(extracted_url, extracted_ref)):
            result['error'] = 'download_failed'
            return result

        # Step 3: Download thumbnail (jika ada)
        thumb_path = None
        if thumbnail_url:
            # Tentukan ekstensi dari URL thumbnail
            thumb_ext = os.path.splitext(urlparse(thumbnail_url).path)[1].lower()
            if thumb_ext not in ['.jpg', '.jpeg', '.png', '.webp', '.gif']:
                thumb_ext = '.jpg'
            thumb_path = os.path.join(temp_dir, f"{title_slug}{thumb_ext}")
            if not download_thumbnail(thumbnail_url, thumb_path, url):
                thumb_path = None
                logger.warning("Thumbnail gagal didownload, lanjut tanpa thumbnail")
        else:
            # Fallback: generate thumbnail dari video dengan ffmpeg
            logger.info("Tidak ada thumbnail URL, generate dari video frame...")
            thumb_path = os.path.join(temp_dir, f"{title_slug}.jpg")
            ffmpeg_thumb = [
                "ffmpeg", "-y",
                "-i", video_path,
                "-ss", "00:00:05",  # Ambil frame di detik ke-5
                "-vframes", "1",
                "-q:v", "2",
                thumb_path
            ]
            try:
                r = subprocess.run(ffmpeg_thumb, capture_output=True, text=True, timeout=30)
                if r.returncode != 0 or not os.path.exists(thumb_path) or os.path.getsize(thumb_path) < 100:
                    thumb_path = None
                    logger.warning("Generate thumbnail dari video gagal")
                else:
                    logger.info(f"Thumbnail generated dari video: {os.path.getsize(thumb_path) / 1024:.1f} KB")
            except Exception:
                thumb_path = None

        # Step 4: Konversi ke HLS
        hls_output_dir = os.path.join(temp_dir, "hls")
        hls_dir = convert_to_hls(video_path, hls_output_dir, hls_time, copy_codec)
        if not hls_dir:
            result['error'] = 'conversion_failed'
            return result

        # Hapus file video asli untuk hemat disk (setelah konversi HLS)
        if AUTO_DELETE_LOCAL:
            try:
                os.remove(video_path)
                logger.info("File video asli dihapus untuk hemat disk")
            except Exception:
                pass

        # Step 5: Upload HLS ke S3
        upload_result = upload_hls_to_s3(hls_dir, s3cfg_path, bucket, prefix, title_slug)

        if upload_result['failed'] > 0:
            result['status'] = 'partial'
            result['error'] = f"{upload_result['failed']} file gagal upload"
        else:
            result['status'] = 'success'

        result['playlist_url'] = upload_result['playlist_url']
        result['cdn_url'] = upload_result['cdn_url']
        result['s3_path'] = upload_result['s3_path']
        result['total_size_mb'] = round(upload_result['total_size'] / 1024 / 1024, 2)
        result['uploaded_files'] = upload_result['uploaded']

        # Step 6: Upload thumbnail ke S3 (prefix image)
        if thumb_path and os.path.exists(thumb_path):
            img_result = upload_image_to_s3(thumb_path, s3cfg_path, bucket, image_prefix, title_slug)
            if img_result:
                result['image_url'] = img_result['image_url']
                result['cdn_image_url'] = img_result['cdn_image_url']
                result['s3_image_path'] = img_result['s3_path']

        # Step 7: Tulis ke success log (untuk menghindari duplikasi)
        if result['status'] == 'success':
            append_success_log(url, title, result['playlist_url'])

    except Exception as e:
        logger.error(f"Error saat memproses '{title}': {str(e)}")
        result['error'] = str(e)

    finally:
        # Cleanup: auto-delete semua temp file setelah upload
        if AUTO_DELETE_LOCAL:
            try:
                shutil.rmtree(temp_dir)
                logger.debug(f"Temp dir dihapus: {temp_dir}")
            except Exception:
                pass
        else:
            try:
                shutil.rmtree(temp_dir)
                logger.debug(f"Temp dir dihapus: {temp_dir}")
            except Exception:
                pass

    return result


def read_input_file(input_path):
    """Baca file input dengan format Judul;URL"""
    entries = []
    with open(input_path, 'r', encoding='utf-8') as f:
        lines = f.readlines()

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

        # Deteksi header
        if i == 0 and ('judul' in line.lower() or 'url' in line.lower() or 'player' in line.lower()):
            logger.info(f"Header terdeteksi, skip: {line}")
            continue

        # Parse dengan separator ; atau ,
        parts = None
        for sep in [';', ',', '\t']:
            if sep in line:
                parts = line.split(sep, 1)
                break

        if parts and len(parts) == 2:
            title = parts[0].strip()
            url = parts[1].strip()
            if url.startswith('http'):
                entries.append({'title': title, 'url': url})
            else:
                logger.warning(f"URL tidak valid di baris {i + 1}: {url}")
        elif line.startswith('http'):
            # Hanya URL tanpa judul — tandai untuk auto-fetch title nanti
            entries.append({'title': None, 'url': line, 'auto_title': True})
        else:
            logger.warning(f"Format tidak dikenali di baris {i + 1}: {line[:80]}")

    logger.info(f"Total {len(entries)} video ditemukan dari file input")
    return entries


def run_discord_bot(args):
    """Jalankan Discord bot yang menerima pesan dengan format title;url"""
    if not DISCORD_AVAILABLE:
        logger.error("Library 'discord.py' belum terinstall. Jalankan: pip3 install discord.py")
        sys.exit(1)

    token = args.token or DISCORD_TOKEN
    if not token:
        logger.error("Discord token belum diset! Gunakan --token atau isi DISCORD_TOKEN di konfigurasi.")
        sys.exit(1)

    # Cek dependencies sekali di awal
    if not check_dependencies():
        sys.exit(1)
    if not os.path.exists(args.s3cfg):
        logger.error(f"File s3cfg tidak ditemukan: {args.s3cfg}")
        sys.exit(1)

    intents = discord.Intents.default()
    intents.message_content = True
    bot = commands.Bot(command_prefix=DISCORD_PREFIX, intents=intents)

    # Queue system — pesan yang masuk saat bot sibuk akan masuk antrian
    import asyncio
    task_queue = asyncio.Queue()
    is_processing = False

    async def queue_worker():
        """Worker yang memproses antrian secara berurutan"""
        nonlocal is_processing
        while True:
            entries, message = await task_queue.get()
            try:
                is_processing = True
                await process_entries_async(entries, message)
            except Exception as e:
                logger.error(f"Queue worker error: {e}")
                try:
                    await message.channel.send(f"❌ Error saat memproses batch: {str(e)[:200]}")
                except Exception:
                    pass
            finally:
                is_processing = False
                task_queue.task_done()
                # Beritahu jika masih ada antrian
                if not task_queue.empty():
                    try:
                        await message.channel.send(
                            f"📋 Batch selesai. Masih ada **{task_queue.qsize()}** antrian, lanjut memproses..."
                        )
                    except Exception:
                        pass

    async def add_to_queue(entries, message):
        """Tambahkan entries ke antrian dan beritahu posisi antrian"""
        position = task_queue.qsize()  # posisi sebelum ditambahkan
        await task_queue.put((entries, message))
        if position > 0:
            await message.channel.send(
                f"📋 Antrian #{position + 1} — {len(entries)} video ditambahkan ke antrian.\n"
                f"⏳ Menunggu {position} batch sebelumnya selesai..."
            )
        else:
            total_label = f"{len(entries)} video" if len(entries) > 1 else "1 video"
            await message.channel.send(f"🚀 Memulai proses {total_label}...")

    def parse_message_entries(content):
        """Parse pesan Discord menjadi list entries [{title, url}]
        Support format:
          - Judul;URL
          - Judul,URL  
          - URL saja (title otomatis diambil dari situs)
        """
        entries = []
        for line in content.strip().split('\n'):
            line = line.strip()
            if not line or line.startswith('#'):
                continue

            parts = None
            for sep in [';', ',', '\t']:
                if sep in line:
                    parts = line.split(sep, 1)
                    break

            if parts and len(parts) == 2:
                title = parts[0].strip()
                url = parts[1].strip()
                if url.startswith('http'):
                    entries.append({'title': title, 'url': url, 'auto_title': False})
            elif line.startswith('http'):
                # URL tanpa title — tandai untuk auto-fetch title nanti
                url = line.strip()
                entries.append({'title': None, 'url': url, 'auto_title': True})
        return entries

    async def process_entries_async(entries, message):
        """Proses list entries dan kirim hasilnya ke Discord"""
        loop = asyncio.get_event_loop()
        temp_base = tempfile.mkdtemp(prefix='hls_dc_')
        all_results = []
        start_time = time.time()

        for idx, entry in enumerate(entries, 1):
            # Auto-fetch title jika belum ada
            if entry.get('auto_title') and not entry.get('title'):
                fetch_msg = await message.channel.send(
                    f"🔍 [{idx}/{len(entries)}] Mengambil judul dari URL...\n"
                    f"🔗 `{entry['url'][:80]}{'...' if len(entry['url']) > 80 else ''}`"
                )
                try:
                    fetched_title = await loop.run_in_executor(None, fetch_title_from_url, entry['url'])
                    entry['title'] = fetched_title or 'video'
                    await fetch_msg.edit(content=
                        f"🔍 [{idx}/{len(entries)}] Judul ditemukan: **{entry['title']}**"
                    )
                except Exception as e:
                    logger.warning(f"Auto-title gagal: {e}")
                    parsed_u = urlparse(entry['url'])
                    entry['title'] = os.path.basename(parsed_u.path) or 'video'
                    await fetch_msg.edit(content=
                        f"🔍 [{idx}/{len(entries)}] Judul: **{entry['title']}** (fallback)"
                    )

            url_display = entry['url'][:80] + ('...' if len(entry['url']) > 80 else '')
            status_msg = await message.channel.send(
                f"⏳ [{idx}/{len(entries)}] Memproses: **{entry['title']}**\n"
                f"🔗 `{url_display}`"
            )

            try:
                result = await loop.run_in_executor(None, lambda e=entry: process_single_video(
                    url=e['url'],
                    title=e['title'],
                    s3cfg_path=args.s3cfg,
                    bucket=args.bucket,
                    prefix=args.prefix,
                    hls_time=args.hls_time,
                    copy_codec=args.copy_codec,
                    temp_base=temp_base
                ))
                all_results.append(result)

                # Kirim hasil per video
                if result['status'] == 'success':
                    result_text = (
                        f"✅ **{entry['title']}** — Berhasil!\n"
                        f"📺 Video: `{result.get('playlist_url', '-')}`\n"
                        f"🌐 CDN: `{result.get('cdn_url', '-')}`"
                    )
                    if result.get('image_url'):
                        result_text += f"\n🖼️ Thumbnail: `{result['image_url']}`"
                    if result.get('cdn_image_url'):
                        result_text += f"\n🖼️ Thumb CDN: `{result['cdn_image_url']}`"
                elif result['status'] == 'skipped':
                    result_text = f"⏭️ **{entry['title']}** — Sudah pernah diproses (skip)"
                else:
                    result_text = f"❌ **{entry['title']}** — Gagal: {result.get('error', 'unknown')}"

                await status_msg.edit(content=result_text)

            except Exception as e:
                logger.error(f"Error memproses '{entry['title']}': {e}")
                await status_msg.edit(content=f"❌ **{entry['title']}** — Error: {str(e)[:200]}")
                all_results.append({'title': entry['title'], 'url': entry['url'], 'status': 'failed', 'error': str(e)})

        # Cleanup
        try:
            shutil.rmtree(temp_base)
        except Exception:
            pass

        # Kirim ringkasan
        elapsed = time.time() - start_time
        success_count = sum(1 for r in all_results if r['status'] == 'success')
        failed_count = sum(1 for r in all_results if r['status'] == 'failed')
        skipped_count = sum(1 for r in all_results if r['status'] == 'skipped')

        summary = (
            f"\n📊 **RINGKASAN**\n"
            f"━━━━━━━━━━━━━━━━━━━━\n"
            f"📁 Total: {len(entries)}\n"
            f"✅ Berhasil: {success_count}\n"
            f"❌ Gagal: {failed_count}\n"
            f"⏭️ Skip: {skipped_count}\n"
            f"⏱️ Waktu: {elapsed:.0f}s ({elapsed/60:.1f} menit)"
        )
        await message.channel.send(summary)

        # Simpan hasil JSON
        output_json = f'hls_results_{datetime.now().strftime("%Y%m%d_%H%M%S")}.json'
        with open(output_json, 'w', encoding='utf-8') as f:
            json.dump({
                'timestamp': datetime.now().isoformat(),
                'source': 'discord',
                'summary': {
                    'total': len(entries), 'success': success_count,
                    'failed': failed_count, 'skipped': skipped_count,
                    'elapsed_seconds': round(elapsed, 1)
                },
                'results': all_results
            }, f, indent=2, ensure_ascii=False)
        logger.info(f"Hasil disimpan ke: {output_json}")

    @bot.event
    async def on_ready():
        # Start queue worker
        bot.loop.create_task(queue_worker())
        logger.info(f"🤖 Discord bot aktif sebagai: {bot.user}")
        logger.info(f"   Bot ID: {bot.user.id}")
        logger.info(f"   Channel filter: {DISCORD_CHANNEL_IDS if DISCORD_CHANNEL_IDS else 'Semua channel'}")
        logger.info(f"   Kirim pesan dengan format: Judul;URL atau URL langsung")
        logger.info(f"   Atau ketik {DISCORD_PREFIX}help untuk bantuan")

    @bot.command(name='proses', aliases=['p', 'download', 'dl'])
    async def cmd_proses(ctx, *, content: str = None):
        """Proses video dari pesan. Format: !proses Judul;URL (bisa multi-line)"""
        if DISCORD_CHANNEL_IDS and ctx.channel.id not in DISCORD_CHANNEL_IDS:
            return

        if not content:
            await ctx.send(
                "📋 **Format penggunaan:**\n"
                f"`{DISCORD_PREFIX}proses Judul Video;https://url-video.com/xxx`\n\n"
                "Bisa multi-line:\n"
                f"```\n{DISCORD_PREFIX}proses\n"
                "Judul1;https://url1.com\n"
                "Judul2;https://url2.com\n```"
            )
            return

        entries = parse_message_entries(content)
        if not entries:
            await ctx.send("⚠️ Tidak ditemukan format yang valid. Gunakan format: `Judul;URL` atau URL langsung")
            return

        await add_to_queue(entries, ctx.message)

    @bot.command(name='status')
    async def cmd_status(ctx):
        """Cek status bot dan jumlah video yang sudah diproses"""
        if DISCORD_CHANNEL_IDS and ctx.channel.id not in DISCORD_CHANNEL_IDS:
            return

        success_log = load_success_log()
        if is_processing:
            status_text = "🔴 Sedang memproses"
            if task_queue.qsize() > 0:
                status_text += f" (+{task_queue.qsize()} antrian)"
        else:
            status_text = "🟢 Idle (siap menerima)"
        await ctx.send(
            f"📊 **Status Bot**\n"
            f"Status: {status_text}\n"
            f"Video sudah diproses: {len(success_log)}\n"
            f"Bucket: {args.bucket}\n"
            f"Video prefix: {args.prefix}\n"
            f"Watermark: {'ON - ' + WATERMARK_TEXT if WATERMARK_ENABLED else 'OFF'}"
        )

    @bot.command(name='file', aliases=['f'])
    async def cmd_file(ctx, filename: str = None):
        """Proses dari file txt yang ada di server. Format: !file namafile.txt"""
        if DISCORD_CHANNEL_IDS and ctx.channel.id not in DISCORD_CHANNEL_IDS:
            return

        if not filename:
            await ctx.send(f"📋 Gunakan: `{DISCORD_PREFIX}file namafile.txt`")
            return

        if not os.path.exists(filename):
            await ctx.send(f"⚠️ File tidak ditemukan: `{filename}`")
            return

        entries = read_input_file(filename)
        if not entries:
            await ctx.send(f"⚠️ Tidak ada video valid di file `{filename}`")
            return

        await ctx.send(f"📂 Menambahkan {len(entries)} video dari file `{filename}` ke antrian...")
        await add_to_queue(entries, ctx.message)

    @bot.event
    async def on_message(message):
        # Jangan proses pesan dari bot sendiri
        if message.author == bot.user:
            return

        # Proses commands dulu
        await bot.process_commands(message)

        # Jika bukan command, cek apakah pesan berisi format title;url
        content = message.content.strip()
        if content.startswith(DISCORD_PREFIX):
            return  # Sudah dihandle oleh command handler

        # Filter channel
        if DISCORD_CHANNEL_IDS and message.channel.id not in DISCORD_CHANNEL_IDS:
            return

        # Cek apakah pesan berisi format title;url ATAU URL polos
        has_valid_format = False
        for line in content.split('\n'):
            line = line.strip()
            if not line:
                continue
            # Cek format title;url
            for sep in [';', ',', '\t']:
                if sep in line:
                    parts = line.split(sep, 1)
                    if len(parts) == 2 and parts[1].strip().startswith('http'):
                        has_valid_format = True
                        break
            if has_valid_format:
                break
            # Cek URL polos (tanpa title)
            if line.startswith('http://') or line.startswith('https://'):
                # Terima semua URL valid (yt-dlp bisa handle banyak situs)
                has_valid_format = True
                break

        if not has_valid_format:
            return  # Abaikan pesan yang bukan format yang dikenali

        entries = parse_message_entries(content)
        if not entries:
            return

        await add_to_queue(entries, message)

    logger.info("🤖 Memulai Discord bot...")
    bot.run(token)


def main():
    parser = argparse.ArgumentParser(
        description='Download video dan upload ke DigitalOcean Spaces dalam format HLS',
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Contoh penggunaan:
  # Dari file input (streamhg.txt)
  python3 download_upload_dc.py --input streamhg.txt

  # Dari single URL
  python3 download_upload_dc.py --url "https://example.com/video.mp4" --title "My Video"

  # Mode Discord bot (terima pesan langsung dari Discord chat)
  python3 download_upload_dc.py --discord --token "BOT_TOKEN"

  # Dengan custom prefix dan HLS segment time
  python3 download_upload_dc.py --input streamhg.txt --prefix videos/hls --hls-time 6

  # Copy codec (lebih cepat, tanpa re-encode)
  python3 download_upload_dc.py --input streamhg.txt --copy-codec

  # Parallel processing
  python3 download_upload_dc.py --input streamhg.txt --workers 4
        """
    )

    # Input source
    input_group = parser.add_mutually_exclusive_group(required=True)
    input_group.add_argument('--input', '-i', help='File input dengan format Judul;URL')
    input_group.add_argument('--url', '-u', help='Single URL video untuk download')
    input_group.add_argument('--discord', '-d', action='store_true', help='Jalankan sebagai Discord bot')

    # Discord options
    parser.add_argument('--token', default=None, help='Discord bot token (atau isi DISCORD_TOKEN di konfigurasi)')

    # S3 config (default dari konfigurasi hardcoded di atas)
    parser.add_argument('--s3cfg', '-c', default=S3CFG_FILE, help=f'Path ke file s3cfg (default: {S3CFG_FILE})')
    parser.add_argument('--bucket', '-b', default=BUCKET_NAME, help=f'Nama bucket DO Spaces (default: {BUCKET_NAME})')
    parser.add_argument('--prefix', '-p', default=PREFIX_PATH, help=f'Prefix/folder di S3 (default: {PREFIX_PATH})')

    # Video options
    parser.add_argument('--title', '-t', default=None, help='Judul video (untuk single URL mode)')
    parser.add_argument('--hls-time', type=int, default=HLS_TIME, help=f'Durasi per segment HLS dalam detik (default: {HLS_TIME})')
    parser.add_argument('--copy-codec', action='store_true', default=COPY_CODEC, help=f'Copy codec tanpa re-encode (default: {COPY_CODEC})')

    # Processing options
    parser.add_argument('--workers', '-w', type=int, default=WORKERS, help=f'Jumlah parallel workers (default: {WORKERS})')
    parser.add_argument('--output-json', '-o', default=None, help='File output JSON hasil proses')

    args = parser.parse_args()

    # Mode Discord bot
    if args.discord:
        run_discord_bot(args)
        return

    # Banner
    logger.info("=" * 60)
    logger.info("  Download & Upload HLS ke DigitalOcean Spaces")
    logger.info("=" * 60)

    # Cek dependencies
    if not check_dependencies():
        sys.exit(1)

    # Validasi s3cfg
    if not os.path.exists(args.s3cfg):
        logger.error(f"File s3cfg tidak ditemukan: {args.s3cfg}")
        sys.exit(1)

    # Baca input
    entries = []
    if args.input:
        if not os.path.exists(args.input):
            logger.error(f"File input tidak ditemukan: {args.input}")
            sys.exit(1)
        entries = read_input_file(args.input)
    elif args.url:
        if args.title:
            entries = [{'title': args.title, 'url': args.url}]
        else:
            entries = [{'title': None, 'url': args.url, 'auto_title': True}]

    if not entries:
        logger.error("Tidak ada video yang ditemukan untuk diproses")
        sys.exit(1)

    # Auto-fetch title untuk entry yang hanya berisi URL tanpa judul
    auto_title_entries = [e for e in entries if e.get('auto_title') and not e.get('title')]
    if auto_title_entries:
        logger.info(f"🔍 Mengambil judul otomatis untuk {len(auto_title_entries)} video...")
        for idx, entry in enumerate(auto_title_entries, 1):
            try:
                fetched_title = fetch_title_from_url(entry['url'])
                if fetched_title and len(fetched_title) > 2:
                    entry['title'] = fetched_title
                    logger.info(f"  [{idx}/{len(auto_title_entries)}] ✅ {fetched_title}")
                else:
                    parsed_u = urlparse(entry['url'])
                    entry['title'] = os.path.basename(parsed_u.path) or f"video_{idx}"
                    logger.info(f"  [{idx}/{len(auto_title_entries)}] ⚠️  Fallback: {entry['title']}")
            except Exception as e:
                parsed_u = urlparse(entry['url'])
                entry['title'] = os.path.basename(parsed_u.path) or f"video_{idx}"
                logger.warning(f"  [{idx}/{len(auto_title_entries)}] ❌ Gagal fetch title: {e}, fallback: {entry['title']}")
        logger.info(f"🔍 Auto-title selesai")
        logger.info("-" * 60)

    # Cek duplikasi dan filter
    success_log = load_success_log()
    already_done = sum(1 for e in entries if e['url'] in {s.split('|')[0] for s in success_log})

    logger.info(f"Total video     : {len(entries)}")
    if already_done > 0:
        logger.info(f"Sudah diproses  : {already_done} (akan di-skip)")
    logger.info(f"Bucket          : {args.bucket}")
    logger.info(f"Video prefix    : {args.prefix}")
    logger.info(f"Image prefix    : {IMAGE_PREFIX}")
    logger.info(f"S3cfg           : {args.s3cfg}")
    logger.info(f"Success log     : {SUCCESS_LOG_FILE}")
    logger.info(f"Auto-delete     : {'ON' if AUTO_DELETE_LOCAL else 'OFF'}")
    logger.info(f"Watermark       : {'ON - ' + WATERMARK_TEXT if WATERMARK_ENABLED else 'OFF'}")
    logger.info(f"HLS segment time: {args.hls_time}s")
    logger.info(f"Copy codec      : {args.copy_codec} {'(override: re-encode karena watermark)' if WATERMARK_ENABLED else ''}")
    logger.info(f"Workers         : {args.workers}")
    logger.info("-" * 60)

    # Buat temp directory utama
    temp_base = tempfile.mkdtemp(prefix='hls_main_')

    # Proses semua video
    all_results = []
    start_time = time.time()

    if args.workers <= 1:
        # Sequential processing
        for idx, entry in enumerate(entries, 1):
            logger.info(f"\n[{idx}/{len(entries)}] Memproses: {entry['title']}")
            result = process_single_video(
                url=entry['url'],
                title=entry['title'],
                s3cfg_path=args.s3cfg,
                bucket=args.bucket,
                prefix=args.prefix,
                hls_time=args.hls_time,
                copy_codec=args.copy_codec,
                temp_base=temp_base
            )
            all_results.append(result)

            status_emoji = "✅" if result['status'] == 'success' else "❌"
            logger.info(f"{status_emoji} {entry['title']}: {result['status']}")
            if result.get('playlist_url'):
                logger.info(f"   Playlist: {result['playlist_url']}")
    else:
        # Parallel processing
        with ThreadPoolExecutor(max_workers=args.workers) as executor:
            futures = {}
            for entry in entries:
                future = executor.submit(
                    process_single_video,
                    url=entry['url'],
                    title=entry['title'],
                    s3cfg_path=args.s3cfg,
                    bucket=args.bucket,
                    prefix=args.prefix,
                    hls_time=args.hls_time,
                    copy_codec=args.copy_codec,
                    temp_base=temp_base
                )
                futures[future] = entry

            for future in as_completed(futures):
                entry = futures[future]
                result = future.result()
                all_results.append(result)

                completed = len(all_results)
                status_emoji = "✅" if result['status'] == 'success' else "❌"
                logger.info(f"{status_emoji} [{completed}/{len(entries)}] {entry['title']}: {result['status']}")
                if result.get('playlist_url'):
                    logger.info(f"   Playlist: {result['playlist_url']}")

    # Cleanup temp base
    try:
        shutil.rmtree(temp_base)
    except Exception:
        pass

    elapsed = time.time() - start_time

    # Summary
    success_count = sum(1 for r in all_results if r['status'] == 'success')
    failed_count = sum(1 for r in all_results if r['status'] == 'failed')
    partial_count = sum(1 for r in all_results if r['status'] == 'partial')
    skipped_count = sum(1 for r in all_results if r['status'] == 'skipped')

    logger.info("\n" + "=" * 60)
    logger.info("  RINGKASAN HASIL")
    logger.info("=" * 60)
    logger.info(f"Total video     : {len(entries)}")
    logger.info(f"Berhasil        : {success_count}")
    logger.info(f"Parsial         : {partial_count}")
    logger.info(f"Gagal           : {failed_count}")
    logger.info(f"Di-skip (dupli) : {skipped_count}")
    logger.info(f"Waktu total     : {elapsed:.1f} detik ({elapsed / 60:.1f} menit)")
    logger.info("-" * 60)

    # Tampilkan semua playlist URL
    if any(r.get('playlist_url') for r in all_results):
        logger.info("\nPlaylist URLs:")
        for r in all_results:
            if r.get('playlist_url'):
                logger.info(f"  {r['title']}")
                logger.info(f"    Video URL : {r['playlist_url']}")
                logger.info(f"    Video CDN : {r['cdn_url']}")
                if r.get('image_url'):
                    logger.info(f"    Image URL : {r['image_url']}")
                    logger.info(f"    Image CDN : {r['cdn_image_url']}")

    # Tampilkan yang gagal
    if failed_count > 0:
        logger.info("\nVideo yang gagal:")
        for r in all_results:
            if r['status'] == 'failed':
                logger.info(f"  ❌ {r['title']}: {r.get('error', 'unknown')}")

    # Simpan hasil ke JSON
    output_json = args.output_json or f'hls_results_{datetime.now().strftime("%Y%m%d_%H%M%S")}.json'
    with open(output_json, 'w', encoding='utf-8') as f:
        json.dump({
            'timestamp': datetime.now().isoformat(),
            'config': {
                'bucket': args.bucket,
                'video_prefix': args.prefix,
                'image_prefix': IMAGE_PREFIX,
                'hls_time': args.hls_time,
                'copy_codec': args.copy_codec,
                's3cfg': args.s3cfg,
                'watermark_enabled': WATERMARK_ENABLED,
                'watermark_text': WATERMARK_TEXT if WATERMARK_ENABLED else None,
                'watermark_position': WATERMARK_POSITION if WATERMARK_ENABLED else None,
                'auto_delete_local': AUTO_DELETE_LOCAL,
                'success_log_file': SUCCESS_LOG_FILE,
            },
            'summary': {
                'total': len(entries),
                'success': success_count,
                'partial': partial_count,
                'failed': failed_count,
                'skipped': skipped_count,
                'elapsed_seconds': round(elapsed, 1)
            },
            'results': all_results
        }, f, indent=2, ensure_ascii=False)

    logger.info(f"\nHasil detail disimpan ke: {output_json}")
    logger.info(f"Log disimpan ke: {log_filename}")

    # Exit code
    if failed_count == len(entries):
        sys.exit(1)
    elif failed_count > 0:
        sys.exit(2)  # partial success


if __name__ == '__main__':
    main()
