#!/usr/bin/env python3
"""
AdBlock-Maxxer YouTube Companion
--------------------------------
A tiny local server (127.0.0.1 only) that extracts ad-free YouTube streams via
yt-dlp's app InnerTube clients (android_vr / tv) and range-proxies the media
bytes back to the browser. The AdBlock-Maxxer extension talks to this to replace
youtube.com's own (ad-enforced) player with our own player.

Why this exists: on accounts/IPs in YouTube's strict server-side ad-enforcement
tier, googlevideo.com refuses media bytes (403) unless an ad plays. No in-page
blocker (MV3 or MV2) can beat that. Extracting anonymously through a NON-web
client sidesteps it. See project_adblockmaxxer memory.

No third-party deps beyond yt-dlp. Pure stdlib otherwise.

This build is self-maintaining so the frozen .exe never goes stale:
  * Single instance only      — a named OS lock; a 2nd launch exits immediately.
  * Self-updating yt-dlp       — newest yt-dlp is pulled into a writable cache and
                                 loaded over the bundled (frozen) copy; if extraction
                                 ever returns nothing, it force-updates and retries.
  * Light on resources         — runs at below-normal process priority so it never
                                 steals CPU from a game while a podcast streams.
"""

import json
import os
import re
import shutil
import struct
import sys
import time
import tempfile
import threading
import zipfile
import urllib.parse
import urllib.request
import urllib.error
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

VERSION = "0.4.0"
HOST = "127.0.0.1"
PORT = 47823

# Native-messaging integration. The browser can launch this companion on demand
# (chrome.runtime.connectNative) so the user never has to start it manually — it
# auto-starts the moment a browser with YouTube Reclaim enabled opens. The host is
# locked to our extension's stable ID (manifest "key" pins it across machines).
NATIVE_HOST_NAME = "com.maxxtopia.abmcompanion"
EXTENSION_ID = "ooiobdhdkaoikfodnmhaljkffoeakldp"
_ALLOWED_ORIGIN = "chrome-extension://%s/" % EXTENSION_ID

_RECLAIM_LOG = os.path.join(tempfile.gettempdir(), "abm-reclaim.log")

_LOGFILE = os.path.join(tempfile.gettempdir(), "abm-companion.log")


def log(*a):
    msg = "[abm-companion] " + " ".join(str(x) for x in a)
    # When frozen with --noconsole, sys.stderr is None — guard it.
    try:
        if sys.stderr:
            print(msg, file=sys.stderr, flush=True)
    except Exception:
        pass
    # Always also append to a temp logfile so the windowless .exe is still debuggable.
    try:
        with open(_LOGFILE, "a", encoding="utf-8") as fh:
            fh.write(msg + "\n")
    except Exception:
        pass


# ---------------------------------------------------------------------------
# Be a good neighbour: drop our process priority so we never compete with a game.
# The work we do (a little extraction + byte proxying) is fine at below-normal.
# ---------------------------------------------------------------------------
def _lower_priority():
    try:
        if os.name == "nt":
            import ctypes
            from ctypes import wintypes
            BELOW_NORMAL_PRIORITY_CLASS = 0x00004000
            k32 = ctypes.WinDLL("kernel32", use_last_error=True)
            k32.GetCurrentProcess.restype = wintypes.HANDLE
            k32.SetPriorityClass.argtypes = [wintypes.HANDLE, wintypes.DWORD]
            k32.SetPriorityClass.restype = wintypes.BOOL
            ok = k32.SetPriorityClass(k32.GetCurrentProcess(), BELOW_NORMAL_PRIORITY_CLASS)
            log("priority -> below-normal" if ok else "priority set failed")
        else:
            os.nice(10)
    except Exception as e:
        log("priority set error", repr(e))


# ---------------------------------------------------------------------------
# Single instance — Windows lets multiple sockets bind the same port (unlike
# Linux), so the old "port in use -> exit" guard never fired and copies piled up.
# A named mutex is authoritative: the 2nd launch sees it already exists and bails
# before doing any heavy work (importing yt-dlp).
# ---------------------------------------------------------------------------
_SINGLETON_HANDLE = None  # keep the mutex handle alive for the process lifetime


def _acquire_single_instance():
    if os.name != "nt":
        return True  # other OSes fall back to the bind check below
    try:
        import ctypes
        global _SINGLETON_HANDLE
        ERROR_ALREADY_EXISTS = 183
        k32 = ctypes.windll.kernel32
        handle = k32.CreateMutexW(None, False, "AdBlockMaxxerCompanionSingleton")
        if not handle or k32.GetLastError() == ERROR_ALREADY_EXISTS:
            return False
        _SINGLETON_HANDLE = handle
        return True
    except Exception:
        return True  # never block startup on a lock failure


# ---------------------------------------------------------------------------
# Native-messaging auto-launch
# The extension can ask the browser to start us (connectNative). The browser runs
# this exe as a "native host" wired to its stdin/stdout. We do NOT stay on that pipe:
# an MV3 service worker sleeps after ~30s, which closes the port — and if we were the
# server, the browser would then kill us mid-video. So the native-host invocation just
# spawns the REAL server detached (browser-independent) and exits. The detached server
# is deduped by the single-instance mutex, so repeated launches are harmless.
# ---------------------------------------------------------------------------
def _abm_base_dir():
    base = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA") or tempfile.gettempdir()
    d = os.path.join(base, "abm-companion")
    try:
        os.makedirs(d, exist_ok=True)
    except Exception:
        pass
    return d


def _pythonw_path():
    exe = sys.executable or "python"
    d, name = os.path.split(exe)
    if name.lower() == "python.exe":
        cand = os.path.join(d, "pythonw.exe")
        if os.path.isfile(cand):
            return cand
    return exe


def _is_server_running():
    import socket
    try:
        with socket.create_connection((HOST, PORT), timeout=0.6):
            return True
    except Exception:
        return False


def _spawn_detached_server():
    """Start a normal companion server as an independent, browser-outliving process.
    Note: DETACHED_PROCESS already makes our windowless app console-less — do NOT also
    pass CREATE_NO_WINDOW (the two contradict and the PyInstaller onefile bootloader dies
    on the combo). CREATE_BREAKAWAY_FROM_JOB lets us escape the browser's job object so
    closing the browser can't take the server down; we retry without it if it's refused."""
    import subprocess
    if getattr(sys, "frozen", False):
        args = [sys.executable]
    else:
        args = [_pythonw_path(), os.path.abspath(__file__)]
    base = 0
    if os.name == "nt":
        DETACHED_PROCESS = 0x00000008
        CREATE_NEW_PROCESS_GROUP = 0x00000200
        base = DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP
    CREATE_BREAKAWAY_FROM_JOB = 0x01000000 if os.name == "nt" else 0
    kw = dict(close_fds=True, stdin=subprocess.DEVNULL,
              stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    for extra in (CREATE_BREAKAWAY_FROM_JOB, 0):
        try:
            subprocess.Popen(args, creationflags=base | extra, **kw)
            log("spawned detached server", args[0], "(breakaway)" if extra else "")
            return
        except Exception as e:
            log("detached spawn attempt failed", repr(e))
    log("detached spawn failed")


def _handle_native_host():
    """Invoked by the browser via connectNative. Ensure the real server is up, then exit."""
    try:
        if not _is_server_running():
            _spawn_detached_server()
    except Exception as e:
        log("native host error", repr(e))
    # Nothing to say on the native pipe — exit cleanly. The extension ignores the
    # resulting port disconnect; the detached server is what it actually talks to (HTTP).


def _native_launcher_path():
    """The file the browser will execute as the native host. Frozen -> the exe itself;
    dev -> a tiny .bat that runs this script under pythonw (forwarding the native args)."""
    if getattr(sys, "frozen", False):
        return sys.executable
    bat = os.path.join(_abm_base_dir(), "abm-native-launch.bat")
    content = '@echo off\r\n"%s" "%s" %%*\r\n' % (_pythonw_path(), os.path.abspath(__file__))
    try:
        with open(bat, "w", encoding="ascii") as fh:
            fh.write(content)
    except Exception as e:
        log("native launcher write failed", repr(e))
    return bat


_NATIVE_REG_KEYS = [
    r"Software\Google\Chrome\NativeMessagingHosts",
    r"Software\BraveSoftware\Brave-Browser\NativeMessagingHosts",
    r"Software\Microsoft\Edge\NativeMessagingHosts",
    r"Software\Chromium\NativeMessagingHosts",
]


def _register_native_host():
    """Write the native-host manifest + point each Chromium browser's HKCU key at it.
    Idempotent; safe to call on every startup. No admin needed (HKCU only)."""
    base = _abm_base_dir()
    manifest_path = os.path.join(base, NATIVE_HOST_NAME + ".json")
    manifest = {
        "name": NATIVE_HOST_NAME,
        "description": "AdBlock-Maxxer YouTube companion launcher",
        "path": _native_launcher_path(),
        "type": "stdio",
        "allowed_origins": [_ALLOWED_ORIGIN],
    }
    try:
        with open(manifest_path, "w", encoding="utf-8") as fh:
            json.dump(manifest, fh, indent=2)
    except Exception as e:
        log("native manifest write failed", repr(e))
        return False
    if os.name != "nt":
        return True
    import winreg
    ok = False
    for sk in _NATIVE_REG_KEYS:
        try:
            key = winreg.CreateKey(winreg.HKEY_CURRENT_USER, sk + "\\" + NATIVE_HOST_NAME)
            winreg.SetValueEx(key, None, 0, winreg.REG_SZ, manifest_path)
            winreg.CloseKey(key)
            ok = True
        except Exception as e:
            log("registry write failed", sk, repr(e))
    return ok


def _unregister_native_host():
    if os.name == "nt":
        import winreg
        for sk in _NATIVE_REG_KEYS:
            try:
                winreg.DeleteKey(winreg.HKEY_CURRENT_USER, sk + "\\" + NATIVE_HOST_NAME)
            except Exception:
                pass
    try:
        os.remove(os.path.join(_abm_base_dir(), NATIVE_HOST_NAME + ".json"))
    except Exception:
        pass


def _out(s):
    try:
        if sys.stdout:
            sys.stdout.write(s + "\n")
            sys.stdout.flush()
    except Exception:
        pass
    log(s)


# ---------------------------------------------------------------------------
# yt-dlp loading + self-update
# A frozen exe ships a pinned yt-dlp that goes stale whenever YouTube changes.
# We keep a writable copy of the newest yt-dlp under LOCALAPPDATA and load it in
# front of the bundled one, so the .exe self-heals exactly like the pip version.
# ---------------------------------------------------------------------------
def _ytdlp_cache_dir():
    base = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA") or tempfile.gettempdir()
    d = os.path.join(base, "abm-companion", "ytdlp")
    try:
        os.makedirs(d, exist_ok=True)
    except Exception:
        pass
    return d


_YTDLP_DIR = _ytdlp_cache_dir()
_UPDATE_STAMP = os.path.join(_YTDLP_DIR, ".last-check")
_UPDATE_INTERVAL = 12 * 3600  # re-check PyPI at most twice a day

# Load the cached (fresh) yt-dlp ahead of the bundled one, if present + healthy.
_have_cached = os.path.isdir(os.path.join(_YTDLP_DIR, "yt_dlp"))
if _have_cached:
    sys.path.insert(0, _YTDLP_DIR)
try:
    from yt_dlp import YoutubeDL
except Exception as e:
    if _have_cached:
        # Cached copy is broken — drop it and fall back to the bundled yt-dlp.
        try:
            sys.path.remove(_YTDLP_DIR)
        except ValueError:
            pass
        for _m in [m for m in sys.modules if m == "yt_dlp" or m.startswith("yt_dlp.")]:
            del sys.modules[_m]
        try:
            from yt_dlp import YoutubeDL
        except Exception as e2:
            print("FATAL: yt-dlp not importable:", e2, file=sys.stderr)
            sys.exit(1)
    else:
        print("FATAL: yt-dlp not importable:", e, file=sys.stderr)
        sys.exit(1)


def _ver_tuple(v):
    out = []
    for p in str(v).split("."):
        try:
            out.append(int(p))
        except ValueError:
            out.append(0)
    return tuple(out)


def _installed_version():
    try:
        import yt_dlp.version as v
        return v.__version__
    except Exception:
        return "0"


def _update_ytdlp(force=False):
    """If PyPI has a newer yt-dlp than the one currently loaded, download it into
    the writable cache dir (atomic swap). Returns True if a newer copy was staged."""
    try:
        req = urllib.request.Request("https://pypi.org/pypi/yt-dlp/json",
                                     headers={"User-Agent": "abm-companion"})
        with urllib.request.urlopen(req, timeout=20) as r:
            data = json.load(r)
        latest = data["info"]["version"]
        if not force and _ver_tuple(latest) <= _ver_tuple(_installed_version()):
            return False
        files = data.get("releases", {}).get(latest) or []
        wheel = (next((f for f in files if f["filename"].endswith("-py3-none-any.whl")), None)
                 or next((f for f in files if f["filename"].endswith(".whl")), None))
        if not wheel:
            return False
        wreq = urllib.request.Request(wheel["url"], headers={"User-Agent": "abm-companion"})
        with urllib.request.urlopen(wreq, timeout=120) as r:
            blob = r.read()
        tmp = os.path.join(_YTDLP_DIR, "_dl.whl")
        with open(tmp, "wb") as fh:
            fh.write(blob)
        staging = os.path.join(_YTDLP_DIR, "_staging")
        shutil.rmtree(staging, ignore_errors=True)
        os.makedirs(staging, exist_ok=True)
        with zipfile.ZipFile(tmp) as z:
            for name in z.namelist():
                if name.startswith("yt_dlp/"):
                    z.extract(name, staging)
        src = os.path.join(staging, "yt_dlp")
        if not os.path.isdir(src):
            return False
        dst = os.path.join(_YTDLP_DIR, "yt_dlp")
        old = os.path.join(_YTDLP_DIR, "yt_dlp_old")
        shutil.rmtree(old, ignore_errors=True)
        if os.path.isdir(dst):
            os.rename(dst, old)
        os.rename(src, dst)
        shutil.rmtree(old, ignore_errors=True)
        shutil.rmtree(staging, ignore_errors=True)
        try:
            os.remove(tmp)
        except OSError:
            pass
        log("staged newer yt-dlp", latest, "(was", _installed_version() + ")")
        return True
    except Exception as e:
        log("yt-dlp update check failed", repr(e))
        return False


def _reload_ytdlp():
    """Hot-swap the running process onto the freshly-cached yt-dlp."""
    try:
        for m in [k for k in sys.modules if k == "yt_dlp" or k.startswith("yt_dlp.")]:
            del sys.modules[m]
        if _YTDLP_DIR not in sys.path:
            sys.path.insert(0, _YTDLP_DIR)
        global YoutubeDL
        from yt_dlp import YoutubeDL as _Y
        YoutubeDL = _Y
        log("reloaded yt-dlp", _installed_version())
        return True
    except Exception as e:
        log("yt-dlp reload failed", repr(e))
        return False


def _background_update():
    """Once-a-launch (throttled) check so the cache stays current without polling."""
    try:
        last = 0.0
        try:
            with open(_UPDATE_STAMP) as fh:
                last = float(fh.read().strip())
        except Exception:
            last = 0.0
        if time.time() - last < _UPDATE_INTERVAL:
            return
        if _update_ytdlp(force=False):
            _reload_ytdlp()
        try:
            with open(_UPDATE_STAMP, "w") as fh:
                fh.write(str(time.time()))
        except Exception:
            pass
    except Exception as e:
        log("bg update error", repr(e))


# Clients to try. android_vr is tokenless + fast (June 2026); we hit it FIRST on its own so the
# common case is a single InnerTube round-trip, and only fall back if it yields nothing.
PRIMARY_CLIENTS = ["android_vr"]
FALLBACK_CLIENTS = ["tv", "web_safari", "ios"]

# Only ever proxy Google's own hosts — never become an open relay. googlevideo = media,
# youtube.com = caption (timedtext) tracks.
ALLOWED_PROXY_SUFFIXES = (".googlevideo.com", ".youtube.com")

_CACHE = {}            # videoId -> (expires_ts, payload dict)
_CACHE_TTL = 300       # seconds
_CACHE_LOCK = threading.Lock()


# ---------------------------------------------------------------------------
# Extraction
# ---------------------------------------------------------------------------
def _ydl_opts(clients):
    return {
        "quiet": True,
        "no_warnings": True,
        "noplaylist": True,
        "skip_download": True,
        "extractor_args": {"youtube": {"player_client": clients}},
    }


def _is_https(f):
    return (f.get("protocol") or "").startswith("https") and f.get("url")


def _fmt(f, with_url=True):
    if not f:
        return None
    out = {
        "ext": f.get("ext"),
        "height": f.get("height"),
        "fps": f.get("fps"),
        "vcodec": None if f.get("vcodec") == "none" else f.get("vcodec"),
        "acodec": None if f.get("acodec") == "none" else f.get("acodec"),
        "abr": f.get("abr"),
        "filesize": f.get("filesize") or f.get("filesize_approx"),
    }
    if with_url:
        out["url"] = f["url"]
    return out


def _pick_formats(info):
    """Pick a default progressive + DASH video/audio pair, and also return the FULL ladders
    (one entry per quality) so the extension can offer a quality picker, plus caption tracks."""
    formats = info.get("formats") or []

    progressive = [
        f for f in formats
        if _is_https(f) and f.get("vcodec") not in (None, "none")
        and f.get("acodec") not in (None, "none") and (f.get("height") or 0) <= 1080
    ]
    progressive.sort(key=lambda f: (f.get("height") or 0, f.get("tbr") or 0))
    prog = progressive[-1] if progressive else None

    vonly = [
        f for f in formats
        if _is_https(f) and f.get("vcodec") not in (None, "none")
        and f.get("acodec") in (None, "none") and (f.get("height") or 0) <= 2160
    ]

    # prefer avc1 (broadest MSE support), then vp9, then av01 at a given height
    def vcodec_pref(f):
        vc = (f.get("vcodec") or "")
        return 3 if vc.startswith("avc1") else (2 if vc.startswith("vp9") else (1 if vc.startswith("av01") else 0))

    # one best format per height for the picker ladder
    by_h = {}
    for f in vonly:
        h = f.get("height") or 0
        if not h:
            continue
        cur = by_h.get(h)
        if cur is None or (vcodec_pref(f), f.get("tbr") or 0) > (vcodec_pref(cur), cur.get("tbr") or 0):
            by_h[h] = f
    ladder = [by_h[h] for h in sorted(by_h.keys(), reverse=True)]
    # Default = best quality up to 1080p (prefer avc1 → clean mp4 MSE, light decode). 1440p/4K
    # stay in the ladder for the picker but aren't the default (huge files, heavy decode).
    le1080 = [h for h in by_h if h <= 1080]
    vid = by_h[max(le1080)] if le1080 else (ladder[0] if ladder else None)

    audios = [
        f for f in formats
        if _is_https(f) and f.get("acodec") not in (None, "none")
        and f.get("vcodec") in (None, "none")
    ]
    def arank(f):
        ac = (f.get("acodec") or "")
        return (1 if ac.startswith("mp4a") else 0, f.get("abr") or 0)  # m4a pairs cleanly in MSE
    audios.sort(key=arank)
    aud = audios[-1] if audios else None

    # caption tracks: manual first, then auto-generated; English-ish to the front
    captions = []
    def add_caps(d, auto):
        for lang, tracks in (d or {}).items():
            best = None
            for t in tracks:
                if (t.get("ext") in ("vtt", "json3")) or best is None:
                    best = t
            if best and best.get("url"):
                captions.append({
                    "lang": lang,
                    "name": (best.get("name") or lang) + (" (auto)" if auto else ""),
                    "url": best["url"] + ("&fmt=vtt" if "fmt=" not in best["url"] else ""),
                    "auto": auto,
                })
    add_caps(info.get("subtitles"), False)
    add_caps(info.get("automatic_captions"), True)
    captions.sort(key=lambda c: (0 if c["lang"].startswith("en") else 1, c["auto"]))
    captions = captions[:12]

    return prog, vid, aud, ladder, audios, captions


def _extract(video_id, clients):
    url = f"https://www.youtube.com/watch?v={video_id}"
    with YoutubeDL(_ydl_opts(clients)) as ydl:
        return ydl.extract_info(url, download=False)


# ---------------------------------------------------------------------------
# Live / just-ended streams: YouTube serves these as HLS (m3u8) or segmented DASH,
# never as a single-URL file. We hand the browser's hls.js the HLS master manifest,
# proxying the manifest + every segment through ourselves so it stays ad-free + CORS-clean.
# ---------------------------------------------------------------------------
def _hls_master(info):
    """Find the HLS master (hls_variant) manifest URL, if this is a live/HLS stream."""
    m = info.get("manifest_url")
    if m and ("m3u8" in m.lower() or "hls" in m.lower()):
        return m
    for f in (info.get("formats") or []):
        if (f.get("protocol") or "") in ("m3u8_native", "m3u8"):
            mu = f.get("manifest_url") or f.get("url")
            if mu:
                return mu
    return None


def _local(kind, full_url):
    return f"http://{HOST}:{PORT}/{kind}?u=" + urllib.parse.quote(full_url, safe="")


def _hls_local(manifest_url):
    return _local("hls", manifest_url) if manifest_url else None


_URI_ATTR = re.compile(r'URI="([^"]+)"')


def _rewrite_hls_uri(u, base):
    """Route a playlist URI back through /hls (recursive rewrite) and a segment URI
    through /proxy, so every byte the player fetches comes from us (ad-free, CORS-ok)."""
    full = urllib.parse.urljoin(base, u)
    low = full.split("?", 1)[0].lower()
    is_playlist = ("hls_playlist" in full) or ("hls_variant" in full) or low.endswith(".m3u8")
    return _local("hls" if is_playlist else "proxy", full)


def _rewrite_hls(text, base):
    out = []
    for line in text.splitlines():
        s = line.strip()
        if not s:
            out.append(line)
        elif s.startswith("#"):
            if "URI=" in s:
                s = _URI_ATTR.sub(lambda m: 'URI="' + _rewrite_hls_uri(m.group(1), base) + '"', s)
            out.append(s)
        else:
            out.append(_rewrite_hls_uri(s, base))
    return "\n".join(out)


# ---------------------------------------------------------------------------
# HD seeking: YouTube's DASH fMP4 streams carry a segment index (sidx) at the front
# mapping presentation-time -> byte-offset. android_vr doesn't expose it in metadata,
# so we read it straight from the file (first ~1.5 MB = ftyp+moov+sidx) and hand the
# browser a time->byte map. The player then jumps to any timestamp inside the SAME HD
# stream (instant, stays HD) instead of reloading a separate low-res stream to seek.
# ---------------------------------------------------------------------------
_INDEX_CACHE = {}        # url -> (expires, payload)
_INDEX_LOCK = threading.Lock()


def _segment_index(url):
    now = time.time()
    with _INDEX_LOCK:
        hit = _INDEX_CACHE.get(url)
        if hit and hit[0] > now:
            return hit[1]
    try:
        req = urllib.request.Request(
            url, headers={"User-Agent": "Mozilla/5.0", "Range": "bytes=0-1500000"})
        buf = urllib.request.urlopen(req, timeout=30).read()
    except Exception as e:
        log("index fetch error", repr(e))
        return None
    try:
        sidx = None
        off, n = 0, len(buf)
        while off + 8 <= n:
            size = struct.unpack(">I", buf[off:off + 4])[0]
            typ = buf[off + 4:off + 8]
            hdr = 8
            if size == 1:
                size = struct.unpack(">Q", buf[off + 8:off + 16])[0]
                hdr = 16
            elif size == 0:
                break
            if typ == b"sidx":
                sidx = (off, size, hdr)
                break
            if size <= 0:
                break
            off += size
        if not sidx:
            return None
        so, ss, sh = sidx
        p = so + sh
        ver = buf[p]
        p += 4
        p += 4  # reference_ID
        timescale = struct.unpack(">I", buf[p:p + 4])[0]
        p += 4
        if ver == 0:
            ept = struct.unpack(">I", buf[p:p + 4])[0]; p += 4
            first_offset = struct.unpack(">I", buf[p:p + 4])[0]; p += 4
        else:
            ept = struct.unpack(">Q", buf[p:p + 8])[0]; p += 8
            first_offset = struct.unpack(">Q", buf[p:p + 8])[0]; p += 8
        p += 2  # reserved
        ref_count = struct.unpack(">H", buf[p:p + 2])[0]
        p += 2
        seg_start = so + ss + first_offset
        cum_b = seg_start
        cum_t = ept / timescale if timescale else 0.0
        segs = []
        for _ in range(ref_count):
            if p + 12 > n:
                break  # sidx ran past our 1.5MB window (extremely long stream) — partial map
            word = struct.unpack(">I", buf[p:p + 4])[0]; p += 4
            dur = struct.unpack(">I", buf[p:p + 4])[0]; p += 4
            p += 4  # SAP word
            segs.append([round(cum_t, 2), cum_b])
            cum_b += (word & 0x7FFFFFFF)
            cum_t += (dur / timescale) if timescale else 0.0
        if not segs:
            return None
        payload = {"ok": True, "initEnd": seg_start, "segments": segs}
        with _INDEX_LOCK:
            _INDEX_CACHE[url] = (now + _CACHE_TTL, payload)
        return payload
    except Exception as e:
        log("sidx parse error", repr(e))
        return None


def _resolve_once(video_id):
    info = None
    prog = vid = aud = None
    ladder = audios = captions = []
    for clients in (PRIMARY_CLIENTS, FALLBACK_CLIENTS):
        try:
            info = _extract(video_id, clients)
        except Exception as e:
            log("extract failed", clients, repr(e))
            continue
        prog, vid, aud, ladder, audios, captions = _pick_formats(info)
        if vid or prog or _hls_master(info):
            break  # got something playable: file formats OR a live/HLS stream
    return info, prog, vid, aud, ladder, audios, captions


def resolve(video_id):
    now = time.time()
    with _CACHE_LOCK:
        hit = _CACHE.get(video_id)
        if hit and hit[0] > now:
            return hit[1]

    info, prog, vid, aud, ladder, audios, captions = _resolve_once(video_id)
    hls = _hls_master(info) if info else None

    # Self-heal: if yt-dlp returns NOTHING playable - no file formats AND no HLS stream -
    # YouTube probably changed something. Force-pull the newest yt-dlp, reload, retry once.
    # (A live stream legitimately has no file formats but DOES have HLS - not a failure.)
    if not (vid or prog or audios or hls):
        log("no formats - force-updating yt-dlp and retrying", video_id)
        if _update_ytdlp(force=True) and _reload_ytdlp():
            info, prog, vid, aud, ladder, audios, captions = _resolve_once(video_id)
            hls = _hls_master(info) if info else None

    if info is None:
        raise RuntimeError("extraction failed on all clients")

    payload = {
        "ok": True,
        "id": video_id,
        "title": info.get("title"),
        "duration": info.get("duration"),
        "isLive": bool(info.get("is_live")),
        "hls": _hls_local(hls),  # live/just-ended streams play via hls.js; null for normal videos
        "progressive": _fmt(prog),
        "video": _fmt(vid),
        "audio": _fmt(aud),
        "videos": [_fmt(f) for f in ladder],
        "audios": [_fmt(f) for f in audios],
        "captions": captions,
    }
    with _CACHE_LOCK:
        _CACHE[video_id] = (now + _CACHE_TTL, payload)
    return payload


# ---------------------------------------------------------------------------
# HTTP handler
# ---------------------------------------------------------------------------
class Handler(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"
    server_version = "abm-companion/" + VERSION

    def log_message(self, *a):  # silence default access logging
        pass

    # -- shared header helpers -------------------------------------------------
    def _cors(self):
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Access-Control-Allow-Private-Network", "true")
        self.send_header("Access-Control-Allow-Headers", "range, content-type")
        self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
        self.send_header("Access-Control-Expose-Headers",
                         "content-length, content-range, accept-ranges")

    def _json(self, obj, status=200):
        body = json.dumps(obj).encode("utf-8")
        self.send_response(status)
        self._cors()
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Cache-Control", "no-store")
        self.end_headers()
        self.wfile.write(body)

    def do_OPTIONS(self):
        self.send_response(204)
        self._cors()
        self.send_header("Content-Length", "0")
        self.end_headers()

    def do_GET(self):
        parsed = urllib.parse.urlparse(self.path)
        path = parsed.path
        qs = urllib.parse.parse_qs(parsed.query)
        try:
            if path == "/health":
                return self._json({"ok": True, "app": "abm-companion",
                                   "version": VERSION, "ytdlp": _installed_version()})
            if path == "/resolve":
                vid = (qs.get("v") or [""])[0]
                if not vid:
                    return self._json({"ok": False, "error": "missing v"}, 400)
                try:
                    return self._json(resolve(vid))
                except Exception as e:
                    log("resolve error", vid, repr(e))
                    return self._json({"ok": False, "error": str(e)}, 502)
            if path == "/hls":
                return self._hls((qs.get("u") or [""])[0])
            if path == "/log":
                # The extension's Reclaim player posts its console lines here so they land in a
                # file on disk (abm-reclaim.log) — lets us read diagnostics without copy-paste.
                msg = (qs.get("m") or [""])[0]
                if msg:
                    try:
                        with open(_RECLAIM_LOG, "a", encoding="utf-8") as fh:
                            fh.write(time.strftime("%H:%M:%S ") + msg + "\n")
                    except Exception:
                        pass
                self.send_response(204)
                self._cors()
                self.send_header("Content-Length", "0")
                self.end_headers()
                return
            if path == "/index":
                u = (qs.get("u") or [""])[0]
                host = urllib.parse.urlparse(u).hostname or ""
                if not host.endswith(ALLOWED_PROXY_SUFFIXES):
                    return self._json({"ok": False, "error": "host not allowed"}, 403)
                idx = _segment_index(u) if u else None
                return self._json(idx or {"ok": False, "error": "no index"},
                                  200 if idx else 502)
            if path == "/proxy":
                return self._proxy((qs.get("u") or [""])[0])
            return self._json({"ok": False, "error": "not found"}, 404)
        except BrokenPipeError:
            pass
        except Exception as e:  # pragma: no cover
            log("handler error", repr(e))
            try:
                self._json({"ok": False, "error": str(e)}, 500)
            except Exception:
                pass

    # -- HLS manifest (live / just-ended streams) ------------------------------
    def _hls(self, upstream):
        if not upstream:
            return self._json({"ok": False, "error": "missing u"}, 400)
        host = urllib.parse.urlparse(upstream).hostname or ""
        if not host.endswith(ALLOWED_PROXY_SUFFIXES):
            return self._json({"ok": False, "error": "host not allowed"}, 403)
        req = urllib.request.Request(upstream, headers={"User-Agent": "Mozilla/5.0", "Accept": "*/*"})
        try:
            up = urllib.request.urlopen(req, timeout=30)
            raw = up.read().decode("utf-8", "replace")
        except Exception as e:
            log("hls fetch error", repr(e))
            return self._json({"ok": False, "error": str(e)}, 502)
        body = _rewrite_hls(raw, upstream).encode("utf-8")
        self.send_response(200)
        self._cors()
        self.send_header("Content-Type", "application/vnd.apple.mpegurl")
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Cache-Control", "no-store")
        self.end_headers()
        try:
            self.wfile.write(body)
        except (BrokenPipeError, ConnectionAbortedError):
            pass

    # -- range proxy -----------------------------------------------------------
    def _proxy(self, upstream):
        if not upstream:
            return self._json({"ok": False, "error": "missing u"}, 400)
        host = urllib.parse.urlparse(upstream).hostname or ""
        if not host.endswith(ALLOWED_PROXY_SUFFIXES):
            return self._json({"ok": False, "error": "host not allowed"}, 403)

        req_headers = {
            "User-Agent": "Mozilla/5.0",
            "Accept": "*/*",
        }
        rng = self.headers.get("Range")
        if rng:
            req_headers["Range"] = rng

        req = urllib.request.Request(upstream, headers=req_headers)
        try:
            up = urllib.request.urlopen(req, timeout=30)
        except urllib.error.HTTPError as e:
            up = e  # still stream the body/headers (e.g. 403/416)
        except Exception as e:
            log("proxy open error", repr(e))
            return self._json({"ok": False, "error": str(e)}, 502)

        status = getattr(up, "status", None) or up.getcode() or 200
        self.send_response(status)
        self._cors()
        passthru = ("Content-Type", "Content-Length", "Content-Range",
                    "Accept-Ranges")
        for h in passthru:
            v = up.headers.get(h)
            if v:
                self.send_header(h, v)
        if not up.headers.get("Accept-Ranges"):
            self.send_header("Accept-Ranges", "bytes")
        self.send_header("Cache-Control", "no-store")
        self.end_headers()

        try:
            while True:
                chunk = up.read(65536)
                if not chunk:
                    break
                self.wfile.write(chunk)
        except (BrokenPipeError, ConnectionAbortedError):
            pass
        finally:
            try:
                up.close()
            except Exception:
                pass


class _Server(ThreadingHTTPServer):
    # Windows' default SO_REUSEADDR lets a 2nd process bind the SAME port silently
    # (that's how copies piled up). Turn it off so a duplicate bind actually fails —
    # belt-and-suspenders behind the named-mutex single-instance lock.
    allow_reuse_address = False
    daemon_threads = True


def main():
    global PORT
    argv = sys.argv[1:]

    # One-time native-host registration (also done automatically on every normal
    # startup, so a single run of the companion is enough to wire up auto-launch).
    if argv and argv[0] == "--register":
        _out("registered" if _register_native_host() else "registration failed")
        return
    if argv and argv[0] == "--unregister":
        _unregister_native_host()
        _out("unregistered")
        return

    # Launched by the browser as a native-messaging host -> boot the detached server
    # and exit (see _handle_native_host). Detected by the chrome-extension origin arg.
    if any(a.startswith("chrome-extension://") for a in argv):
        _handle_native_host()
        return

    # Legacy: a numeric first arg overrides the port.
    if argv:
        try:
            PORT = int(argv[0])
        except ValueError:
            pass

    _lower_priority()

    # Register the native-host manifest FIRST, before the single-instance check. This makes
    # version cutover self-healing: launching a new build always re-points the browser at
    # THIS exe, even if an older instance is still serving and we're about to bail as a
    # duplicate below. So a user can just run the new build once -- no need to stop the old
    # one by hand -- and the browser will use the new exe from then on. (Idempotent; HKCU only.)
    try:
        _register_native_host()
    except Exception as e:
        log("self-register failed", repr(e))

    if not _acquire_single_instance():
        log("another companion instance is already running - exiting (native host now points here).")
        return

    try:
        httpd = _Server((HOST, PORT), Handler)
    except OSError as e:
        log(f"could not bind {HOST}:{PORT} ({e}) - another companion is probably already "
            f"running. Exiting.")
        return

    # Keep the bundled yt-dlp current without blocking startup or polling.
    threading.Thread(target=_background_update, daemon=True).start()

    log(f"v{VERSION} listening on http://{HOST}:{PORT}  "
        f"(yt-dlp {_installed_version()}, clients={PRIMARY_CLIENTS}+fallback)")
    try:
        httpd.serve_forever()
    except KeyboardInterrupt:
        log("shutting down")
        httpd.shutdown()


if __name__ == "__main__":
    main()
