# -*- coding: utf-8 -*-
"""
Live Delay Player - Portable Edition
Start: python server_portable.py
Open: http://localhost:8090
"""
import json, os, re, io, subprocess, sys, time, threading, signal, shutil
import socket, struct, base64, hashlib, select
from pathlib import Path

BASE_DIR = Path(__file__).parent.parent
HLS_DIR = BASE_DIR / "hls"
WEB_DIR = BASE_DIR / "web"

def find_exe(name):
    p = shutil.which(name)
    if p:
        return p
    local = BASE_DIR / "tools" / name / (name + ".exe" if sys.platform == "win32" else name)
    if local.exists():
        return str(local)
    if sys.platform == "win32":
        for d in [os.environ.get("PROGRAMFILES", ""), os.environ.get("PROGRAMFILES(X86)", ""),
                  os.environ.get("LOCALAPPDATA", ""), str(Path.home())]:
            if not d:
                continue
            if name == "ffmpeg":
                candidates = ["FFmpeg\\bin\\ffmpeg.exe", "ffmpeg\\ffmpeg.exe"]
            else:
                candidates = ["nodejs\\node.exe"]
            for pat in candidates:
                c = Path(d) / pat
                if c.exists():
                    return str(c)
    return None

def check_deps():
    global FFMPEG_PATH, NODE_PATH
    ok = True
    FFMPEG_PATH = find_exe("ffmpeg")
    if FFMPEG_PATH:
        print("[OK] FFmpeg: %s" % FFMPEG_PATH)
    else:
        print("[!!] FFmpeg not found - required for Bilibili streams")
        print("     Fix: run install_deps.bat or put ffmpeg.exe in tools/ffmpeg/")
        ok = False
    NODE_PATH = find_exe("node")
    if NODE_PATH:
        print("[OK] Node.js: %s (optional, for Migu/Fszb)" % NODE_PATH)
    else:
        print("[~~] Node.js not found (optional)")
    try:
        import flask
        print("[OK] Flask installed")
    except ImportError:
        print("[!!] Flask not found - run: pip install flask")
        ok = False
    return ok

def detect_hw_encoder():
    """检测可用的硬件编码器：NVENC > QSV > AMF > libx264"""
    global HW_ENCODER, HW_DECODE_OPT
    HW_ENCODER = "libx264"
    HW_DECODE_OPT = []
    if not FFMPEG_PATH:
        return
    import subprocess as _sp
    for enc, name, tune, decode_accel in [
        ("h264_nvenc", "NVIDIA NVENC", "ll", ["-hwaccel", "cuda"]),
        ("h264_qsv",   "Intel QSV",    None,  ["-hwaccel", "qsv"]),
        ("h264_amf",   "AMD AMF",      None,  ["-hwaccel", "d3d11va"]),
    ]:
        try:
            t = _sp.run([FFMPEG_PATH, "-f", "lavfi", "-i", "color=size=320x240:rate=1",
                         "-t", "1", "-c:v", enc, "-f", "null", "-"],
                        capture_output=True, timeout=8,
                        creationflags=_sp.CREATE_NO_WINDOW if sys.platform == "win32" else 0)
            if t.returncode == 0:
                HW_ENCODER = enc
                HW_DECODE_OPT = decode_accel
                print("[OK] 硬件编码器: %s (%s)" % (enc, name))
                return
        except Exception:
            pass
    print("[~~] 无硬件编码器，使用 libx264 软编码（4K 可能卡顿）")

from flask import Flask, request, jsonify, send_file, Response

app = Flask(__name__, static_folder=str(WEB_DIR), static_url_path='')
app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 0
active_streams = {}
lock = threading.RLock()

def bili_stream_url(rid, cookie=""):
    import urllib.request as ur
    def get(u, ref):
        req = ur.Request(u)
        req.add_header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
        req.add_header("Referer", ref)
        if cookie:
            req.add_header("Cookie", cookie)
        with ur.urlopen(req, timeout=10) as r:
            return json.loads(r.read())
    d = get("https://api.live.bilibili.com/room/v1/Room/get_info?id=%s" % rid,
           "https://live.bilibili.com/%s" % rid)
    if d["code"] != 0:
        raise Exception("Room info failed: %s" % d.get("message", "?"))
    real_rid = d["data"]["room_id"]
    urls = []
    for q in [4, 3, 2]:
        try:
            d2 = get(
                "https://api.live.bilibili.com/room/v1/Room/playUrl?cid=%s&quality=%s&platform=web&qn=10000" % (real_rid, q),
                "https://live.bilibili.com/%s" % rid)
            if d2["code"] == 0:
                for du in d2["data"].get("durl", []):
                    urls.append(du["url"])
                if urls:
                    break
        except Exception:
            continue
    if not urls:
        raise Exception("Stream not available")
    return urls, real_rid

def migu_url(cid):
    if not NODE_PATH:
        raise Exception("Node.js required for Migu source")
    scr = BASE_DIR / "scripts" / "migu_stream.js"
    r = subprocess.run([NODE_PATH, str(scr), str(cid), "3"], capture_output=True, text=True, timeout=30,
                       env=dict(os.environ, NODE_OPTIONS="", NODE_PATH=str(Path.home() / ".workbuddy/binaries/node/workspace/node_modules")))
    if r.returncode != 0:
        raise Exception("Migu failed: %s" % (r.stderr.strip() or "?"))
    u = r.stdout.strip()
    if not u.startswith("http"):
        raise Exception("Migu bad response: %s" % u[:80])
    return u, cid

def fszb_url(rid, cookie=""):
    if not NODE_PATH:
        raise Exception("Node.js required for Fszb source")
    scr = BASE_DIR / "scripts" / "fszb_stream.js"
    # 从 URL 中提取 room_id 和 sport_id
    m = re.match(r"https?://(?:www\.)?fszb\d*\.\w+/broadcast/details\?room_id=(\d+)", rid)
    room = m.group(1) if m else rid
    sport = "1"
    sm = re.search(r"sport_id=(\d+)", rid)
    if sm:
        sport = sm.group(1)
    args = [NODE_PATH, str(scr), room, sport]
    if cookie:
        args.append(cookie)
    r = subprocess.run(args, capture_output=True, text=True, timeout=60,
                       env=dict(os.environ, NODE_OPTIONS="", NODE_PATH=str(Path.home() / ".workbuddy/binaries/node/workspace/node_modules")))
    if r.returncode != 0:
        err_lines = r.stderr.strip() if r.stderr else "?"
        err_last = err_lines.split("\n")[-1] if isinstance(err_lines, str) else "?"
        # 打印完整 stderr 到控制台，方便排查
        if r.stderr:
            print("[fszb stderr]", r.stderr[:500])
        raise Exception("Fszb failed: %s" % err_last)
    out = r.stdout.strip()
    # 尝试解析 JSON（新版返回 {"streams": [...]}）
    try:
        data = json.loads(out)
        streams = data.get("streams", [])
        if streams:
            return streams, room
    except (json.JSONDecodeError, Exception):
        pass
    # 兼容旧版单 URL 输出
    if out.startswith("http"):
        return [{"url": out, "quality": "默认", "label": "默认"}], room
    raise Exception("Fszb bad response: %s" % out[:80])

def haixing2_url(rid):
    """从海星体育直播页面提取 m3u8 流地址"""
    import urllib.request as ur
    url = "https://haixing2.com/live/%s" % rid
    req = ur.Request(url)
    req.add_header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
    req.add_header("Referer", "https://haixing2.com/")
    with ur.urlopen(req, timeout=15) as r:
        html = r.read().decode("utf-8", errors="ignore")
    m = re.search(r'__NEXT_DATA__[^>]*>\s*({.*?})\s*</script>', html, re.DOTALL)
    if not m:
        raise Exception("Haixing2: 未找到页面数据")
    data = json.loads(m.group(1))
    live_detail = data.get("props", {}).get("pageProps", {}).get("liveDetailProp")
    if not live_detail:
        raise Exception("Haixing2: 直播间不存在或已结束")
    if live_detail.get("status") == 0:
        raise Exception("Haixing2: 主播未开播或直播已结束")
    play_addr = live_detail.get("playAddr") or {}
    # 优先原画 HLS，其次高清、标清 HLS
    m3u8 = play_addr.get("ori_m3u8") or play_addr.get("hd_m3u8") or play_addr.get("sd_m3u8")
    if not m3u8:
        raise Exception("Haixing2: 未找到直播流地址")
    return m3u8, rid

def douyin_url(rid):
    """从抖音直播页面提取直播流地址"""
    import urllib.request as ur
    import random, string

    # 从 URL 中提取房间号
    m = re.match(r'https?://live\.douyin\.com/(\d+)', rid)
    room_id = m.group(1) if m else rid

    url = 'https://live.douyin.com/%s' % room_id
    ms_token = ''.join(random.choices(string.ascii_letters + string.digits, k=107))
    cookie = 'msToken=%s; Odysseyssid=help' % ms_token

    req = ur.Request(url)
    req.add_header('User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36')
    req.add_header('Cookie', cookie)
    req.add_header('Referer', 'https://live.douyin.com/')

    with ur.urlopen(req, timeout=15) as resp:
        html = resp.read().decode('utf-8', errors='ignore')

    # 质量等级优先级：or4(原画) > md(高清) > sd(标清) > ld(流畅)
    quality_map = {'or4': '原画', 'md': '高清', 'Stage0T000sd': '标清', 'Stage0T000ld': '流畅'}
    streams = []
    seen = set()

    # 优先提取 FLV 格式（更稳定），其次 m3u8
    for pattern, fmt in [(r'(https?://pull[^"\'<>\s]+\.flv[^"\'<>\s]*)', 'flv'),
                          (r'(https?://pull[^"\'<>\s]+\.m3u8[^"\'<>\s]*)', 'm3u8')]:
        for m in re.finditer(pattern, html):
            raw = m.group(1).replace('&amp;', '&').replace('\\u0026', '&')
            if 'sign=' not in raw:
                continue
            # 提取质量标识
            qmatch = re.search(r'stream-[\w]+_([a-zA-Z0-9]+)(?:\.flv|/index\.m3u8|\.m3u8)', raw)
            qid = qmatch.group(1) if qmatch else ''
            # 跳过备线（带数字后缀的如 sd5/ld5）
            if re.match(r'(Stage0T000(sd|ld))\d', qid):
                continue
            # 跳过 only_audio=1 的纯音频流
            if 'only_audio=1' in raw:
                continue
            qname = quality_map.get(qid, '')
            if not qname:
                # 无质量标识的裸流（stream-xxx.flv），当作高清
                if qid == '' and 'stream-' in raw:
                    qname = '高清'
                    qid = 'md'
                else:
                    continue
            key = raw[:80]  # 用URL前缀去重
            if key in seen:
                continue
            seen.add(key)
            streams.append({'url': raw, 'quality': qname, 'label': '%s %s' % (qname, fmt.upper())})

    # 按质量排序，同质量内 FLV 优先
    quality_rank = {'原画': 0, '高清': 1, '标清': 2, '流畅': 3}
    streams.sort(key=lambda s: (quality_rank.get(s['quality'], 99), 0 if 'FLV' in s['label'] else 1))

    if not streams:
        if '未开播' in html or '直播已结束' in html:
            raise Exception('Douyin: 主播未开播或直播已结束')
        raise Exception('Douyin: 未找到直播流地址（可能未开播或需要登录）')

    urls = [s['url'] for s in streams]
    return urls, room_id, streams

def detect(txt):
    t = txt.strip()
    m = re.match(r"https?://(?:www\.)?fszb\d*\.\w+/broadcast/details\?room_id=(\d+)", t)
    if m:
        return "fszb", m.group(1)
    m = re.match(r"https?://(?:www\.)?miguvideo\.com/p/(?:live|detail)/(\d+)", t)
    if m:
        return "migu", m.group(1)
    if re.match(r"^\d{10,20}$", t) and t.startswith(("120", "1008", "700")):
        return "migu", t
    m = re.match(r"https?://(?:www\.)?live\.bilibili\.com/(\d+)", t)
    if m:
        return "bilibili", m.group(1)
    if re.match(r"https?://b23\.tv/", t):
        raise Exception("B23 short links not supported")
    m = re.match(r"https?://(?:www\.)?haixing2?\.\w+/live/(\d+)", t)
    if m:
        return "haixing2", m.group(1)
    m = re.match(r"https?://live\.douyin\.com/(\d+)", t)
    if m:
        return "douyin", m.group(1)  # room_id only, query params ignored by re.match
    if t.startswith("http") and (".m3u8" in t or ".flv" in t):
        return "raw", t
    if t.isdigit():
        return "bilibili", t
    raise Exception("Unknown source: %s" % t[:50])

def start_ff(rid, delay, max_cache=1800):
    src, sid = detect(rid)
    safe = str(abs(hash(sid))) if src == "raw" else sid
    sdir = HLS_DIR / safe
    sdir.mkdir(parents=True, exist_ok=True)
    # ★ 不手动删旧文件：ffmpeg 的 -hls_flags delete_segments 自动管理分片，
    #   且 ffmpeg 以 O_TRUNC 模式打开文件，会覆盖旧 seg_*.ts 和 live.m3u8。
    #   手动 unlink 在沙箱环境中可能被拦截，导致 Flask 线程挂起。
    print("[%s] Getting stream (%s)..." % (safe, src))
    quality_options = []
    if src == "migu":
        urls = [migu_url(sid)[0]]
    elif src == "fszb":
        streams, _ = fszb_url(rid)
        quality_options = streams
        urls = [streams[0]["url"]] if streams else [sid]
        if streams:
            print("[%s] Quality: %s (1/%d)" % (safe, streams[0].get("quality", "?"), len(streams)))
    elif src == "raw":
        urls = [sid]
    elif src == "haixing2":
        url, _ = haixing2_url(sid)
        urls = [url]
        print("[%s] 海星体育: %s..." % (safe, url[:80]))
    elif src == "douyin":
        urls, _, quality_options = douyin_url(rid)
        if quality_options:
            print("[%s] 抖音直播: %d 质量档位" % (safe, len(quality_options)))
    else:
        urls, _ = bili_stream_url(sid)
        print("[%s] Got %d URLs" % (safe, len(urls)))
    print("[%s] URL: %s..." % (safe, urls[0][:100]))
    # ★ 根据源类型选择合适的切片时长：
    #   - HLS 源（haixing2/migu/fszb）：原始码流关键帧间隔约 4s，用 seg_t=4 避免不规则切片
    #   - 直连/B站：FLV 流关键帧间隔通常 2s，用 seg_t=2
    seg_t = 4 if src in ("haixing2", "migu", "fszb", "douyin") else 2
    min_list = max(int(delay / seg_t) + 10, 30)
    list_sz = max(int(max_cache / seg_t), min_list)
    print("[%s] Cache: %ds, hls_list_size=%d" % (safe, max_cache, list_sz))
    m3u8_p = sdir / "live.m3u8"
    seg_p = sdir / "seg_%08d.ts"
    proc = None
    used = urls[0] if urls else ""
    for idx, url in enumerate(urls):
        if idx > 0:
            print("[%s] Trying URL %d/%d..." % (safe, idx + 1, len(urls)))
        hdrs = {"-user_agent": "Mozilla/5.0"}
        if src == "fszb":
            hdrs["-headers"] = "Referer: https://www.fszb30.com/\r\n"
        elif src == "bilibili":
            hdrs["-headers"] = "Referer: https://live.bilibili.com/\r\nUser-Agent: Mozilla/5.0"
        elif src == "haixing2":
            hdrs["-headers"] = "Referer: https://haixing2.com/\r\nUser-Agent: Mozilla/5.0"
        elif src == "douyin":
            hdrs["-headers"] = "Referer: https://live.douyin.com/\r\nUser-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
        base_args = [FFMPEG_PATH, "-loglevel", "warning",
                     "-reconnect", "1", "-reconnect_streamed", "1",
                     "-reconnect_delay_max", "5"]
        for k, v in hdrs.items():
            base_args += [k, v]
        # ★ HLS 源：重编码音频（AAC 128k），确保浏览器兼容和无缝播放；
        #   视频保持 -c copy（不重编码，省 CPU）。
        #   纯直连 raw 源也保持 -c copy 以支持各种格式。
        if src in ("haixing2", "migu", "fszb", "douyin"):
            args = base_args + ["-i", url,
                                "-c:v", "copy", "-c:a", "aac", "-b:a", "128k",
                                "-f", "hls",
                                "-hls_time", str(seg_t),
                                "-hls_list_size", str(list_sz),
                                "-hls_flags", "delete_segments+omit_endlist",
                                "-hls_segment_filename", str(seg_p), str(m3u8_p)]
        else:
            args = base_args + ["-i", url, "-c", "copy", "-f", "hls",
                                "-hls_time", str(seg_t),
                                "-hls_list_size", str(list_sz),
                                "-hls_flags", "delete_segments+omit_endlist",
                                "-hls_segment_filename", str(seg_p), str(m3u8_p)]
        cf = subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
        proc = subprocess.Popen(args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=cf)
        time.sleep(3)
        if proc.poll() is None:
            used = url
            print("[%s] FFmpeg OK (URL %d)" % (safe, idx + 1))
            break
    if proc is None or proc.poll() is not None:
        raise Exception("All stream URLs failed")
    with lock:
        active_streams[safe] = {
            "process": proc,
            "delay": delay,
            "max_cache": max_cache,
            "seg_t": seg_t,
            "source": src,
            "source_id": sid,
            "started": time.time(),
            "url": used,
            "quality_options": quality_options
        }
    return {
        "room_id": rid,
        "safe_id": safe,
        "source": src,
        "source_id": sid,
        "delay": delay,
        "hls_url": "/hls/%s/delayed_%d.m3u8" % (safe, delay),
        "started": time.time(),
        "quality_options": quality_options
    }

def stop_ff(rid):
    with lock:
        info = active_streams.pop(rid, None)
    if not info:
        return {"error": "not_found"}
    try:
        info["process"].terminate()
        info["process"].wait(timeout=5)
    except Exception:
        info["process"].kill()
    print("[%s] Stopped" % rid)
    return {"status": "stopped", "room_id": rid}

@app.route("/")
def index():
    return send_file(WEB_DIR / "index.html")

@app.route("/api/health")
def health():
    return jsonify({"status": "ok"})

@app.route("/api/live-status")
def api_live_status():
    """批量查询B站主播直播状态。?ids=123,456,789"""
    import urllib.request
    ids = request.args.get("ids", "").strip()
    if not ids:
        return jsonify({"error": "need ids param"}), 400
    room_ids = [s.strip() for s in ids.split(",") if s.strip()]
    results = {}
    for rid in room_ids:
        try:
            url = "https://api.live.bilibili.com/room/v1/Room/get_info?room_id=%s" % rid
            req = urllib.request.Request(url, headers={
                "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
            })
            with urllib.request.urlopen(req, timeout=5) as resp:
                data = json.loads(resp.read().decode("utf-8"))
                if data.get("code") == 0:
                    d = data["data"]
                    results[rid] = {
                        "live_status": d.get("live_status", 0),
                        "title": d.get("title", ""),
                        "online": d.get("online", 0),
                        "room_id": d.get("room_id", 0),
                        "uid": d.get("uid", 0),
                        "cover": d.get("user_cover", ""),
                        "keyframe": d.get("keyframe", ""),
                        "live_time": d.get("live_time", ""),
                        "area_name": d.get("area_name", ""),
                        "tags": d.get("tags", "")
                    }
                else:
                    results[rid] = {"live_status": -1, "error": data.get("message", "")}
        except Exception as e:
            results[rid] = {"live_status": -1, "error": str(e)}
    return jsonify({"results": results})

@app.route("/api/anchors")
def api_anchors():
    """返回主播列表JSON"""
    anchors_file = WEB_DIR / "anchors.json"
    if anchors_file.exists():
        return send_file(anchors_file, mimetype="application/json")
    return jsonify([])

@app.route("/nav")
def nav_page():
    """足球解说导航页"""
    return send_file(WEB_DIR / "nav.html")

@app.route("/api/start", methods=["POST"])
def api_start():
    d = request.get_json(force=True)
    rid = str(d.get("room_id", "")).strip()
    delay = int(d.get("delay_seconds", 60))
    max_cache = int(d.get("max_cache_seconds", 1800))
    if not rid:
        return jsonify({"error": "need room_id"}), 400
    if delay < 1:
        return jsonify({"error": "min delay 1s"}), 400
    if delay > 3600:
        return jsonify({"error": "max delay 3600s"}), 400
    if max_cache < 60:
        return jsonify({"error": "min cache 60s"}), 400
    if max_cache > 86400:
        return jsonify({"error": "max cache 86400s (24h)"}), 400
    with lock:
        try:
            _, sid = detect(rid)
        except Exception as e:
            return jsonify({"error": str(e)}), 400
        if sid in active_streams:
            stop_ff(sid)
    try:
        return jsonify({"status": "running", **start_ff(rid, delay, max_cache)})
    except Exception as e:
        import traceback
        traceback.print_exc()
        return jsonify({"error": str(e)}), 500

@app.route("/api/stop", methods=["POST"])
def api_stop():
    d = request.get_json(force=True)
    _, sid = detect(str(d.get("room_id", "")).strip())
    return jsonify(stop_ff(sid))

@app.route("/api/status")
def api_status():
    with lock:
        streams = {}
        for sid, info in active_streams.items():
            alive = info["process"].poll() is None
            streams[sid] = {
                "alive": alive,
                "delay": info["delay"],
                "source": info.get("source", "bili"),
                "source_id": info.get("source_id", sid),
                "started": info["started"],
                "uptime": time.time() - info["started"] if alive else 0
            }
    return jsonify({"streams": streams})

@app.route("/hls/<path:fp>")
def serve_hls(fp):
    full = HLS_DIR / fp
    if not full.exists():
        m = re.match(r"([\w-]+)/delayed_(\d+)\.m3u8", fp)
        if m:
            return delayed_m3u8(m.group(1), int(m.group(2)))
        return "not found", 404
    resp = send_file(str(full), conditional=True)
    if fp.endswith(".m3u8"):
        resp.headers["Cache-Control"] = "no-cache,no-store,must-revalidate"
        resp.headers["Pragma"] = "no-cache"
        resp.headers["Expires"] = "0"
    elif fp.endswith(".ts"):
        resp.headers["Cache-Control"] = "public,max-age=5"
    resp.headers["Access-Control-Allow-Origin"] = "*"
    return resp

def delayed_m3u8(rid, delay):
    sdir = HLS_DIR / str(rid)
    live = sdir / "live.m3u8"
    if not live.exists():
        return "not ready", 503
    lines = live.read_text(encoding="utf-8", errors="ignore").splitlines()
    # ★ 过滤掉 ffmpeg 写入的 #EXT-X-DISCONTINUITY 标签
    lines = [l for l in lines if l.strip() != "#EXT-X-DISCONTINUITY"]
    # 提取 FFmpeg 写入的 MEDIA-SEQUENCE
    media_seq = 0
    target_dur = 2
    for l in lines:
        if l.startswith("#EXT-X-MEDIA-SEQUENCE:"):
            try: media_seq = int(l.split(":")[1].strip())
            except: pass
        if l.startswith("#EXT-X-TARGETDURATION:"):
            try: target_dur = int(l.split(":")[1].strip())
            except: pass

    # ★ 保留原始 EXTINF 时长（ffmpeg 写的实际值，如 1.666667 / 2.500000）
    #   而不是全部硬编码为 2.0 —— hls.js 用它做缓冲计算，错误值会导致追帧/卡顿
    seg_entries = []  # [(duration_str, filename), ...]
    i = 0
    while i < len(lines):
        l = lines[i].strip()
        if l.startswith("#EXTINF:"):
            dur_part = l.split(":")[1].split(",")[0].strip()
            i += 1
            while i < len(lines) and (lines[i].strip().startswith("#") or not lines[i].strip()):
                i += 1
            if i < len(lines):
                seg_name = lines[i].strip()
                if seg_name and not seg_name.startswith("#"):
                    seg_entries.append((dur_part, seg_name))
        i += 1

    if not seg_entries:
        return "no segments", 503

    avg_seg_t = sum(float(d) for d, _ in seg_entries) / max(len(seg_entries), 1)
    # ★ 限制返回给 hls.js 的窗口大小：延迟 + 60 片余量即可
    #   窗口太大 → m3u8 文件巨大 → hls.js 解析慢 → 卡顿
    window = max(30, int(delay / avg_seg_t) + 30)
    # ★ 硬上限：不超过 120 片（4分钟），避免极端情况
    window = min(window, 120)
    total = len(seg_entries)
    seek = int(request.args.get("seek", "0"))
    seek_segs = max(0, int(seek / avg_seg_t))
    start = max(0, min(total - window, total - window - seek_segs))

    buf = io.StringIO()
    buf.write("#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:%d\n" % target_dur)
    buf.write("#EXT-X-MEDIA-SEQUENCE:%d\n" % (media_seq + start))
    for dur_s, seg_name in seg_entries[start:]:
        buf.write("#EXTINF:%s,\n%s\n" % (dur_s, seg_name))
    result = buf.getvalue()
    r = Response(result, mimetype="application/vnd.apple.mpegurl")
    r.headers["Cache-Control"] = "no-cache,no-store,must-revalidate"
    r.headers["Access-Control-Allow-Origin"] = "*"
    return r

# ============================================================================
#  浏览器捕获：WebSocket 接收端（纯标准库实现，无第三方依赖）
#  扩展把"已登录浏览器里抓到的媒体字节流"通过 WS 推过来，
#  这里用 ffmpeg 从 stdin 读流、转成 HLS，再复用 delayed_m3u8 做延迟重排。
# ============================================================================
WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"

def ws_send_frame(conn, opcode, data):
    """服务端发帧（不加掩码）。"""
    b0 = 0x80 | opcode
    length = len(data)
    if length < 126:
        header = bytes([b0, length])
    elif length < 65536:
        header = bytes([b0, 126]) + struct.pack(">H", length)
    else:
        header = bytes([b0, 127]) + struct.pack(">Q", length)
    try:
        conn.sendall(header + data)
    except Exception:
        pass

def ws_safe_cid(s):
    return re.sub(r"[^A-Za-z0-9_-]", "", s or "")[:48] or "cap_unnamed"

class CaptureManager:
    """把 WS 帧路由到 ffmpeg 捕获会话（复用 active_streams / delayed_m3u8）。

    关键设计：捕获会话的生命周期与 WS 连接解耦。
    - WS 断开时不立即删除捕获（避免扩展短暂断线导致播放页丢失源）
    - 仅当收到显式 stop、或 ffmpeg 进程死亡时才清理
    - on_frame 写入数据时更新 last_data 时间戳
    """
    def __init__(self):
        self.sessions = {}   # conn -> {"captureId": cid}

    def on_connect(self, conn):
        print("[WS] client connected")
        return {"conn": conn, "captureId": None}

    def on_disconnect(self, sess):
        # ★ 不停止捕获：WS 断线可能是扩展短暂重连，捕获由 ffmpeg 进程保活
        cid = sess.get("captureId")
        print("[WS] client disconnected (capture '%s' kept alive)" % (cid or "-"))

    def on_frame(self, sess, opcode, data):
        if opcode == 0x1:  # 文本 = JSON 控制消息
            try:
                msg = json.loads(data.decode("utf-8", errors="ignore"))
            except Exception:
                return
            print("[WS] text frame: %s" % msg.get("type"))
            self._handle_json(sess, msg)
        elif opcode == 0x2:  # 二进制 = 媒体字节
            cid = sess.get("captureId")
            if not cid:
                return
            with lock:
                info = active_streams.get(cid)
            if not info:
                return
            info["last_data"] = time.time()
            # 诊断：记录每路捕获收到的首批字节（hex），确认是不是合法 FLV（应以 46 4C 56 开头）
            if not info.get("_first_logged"):
                info["_first_logged"] = True
                try:
                    head = data[:32]
                    hexs = " ".join("%02x" % b for b in head)
                    asci = "".join(chr(b) if 32 <= b < 127 else "." for b in head)
                    with open(str(HLS_DIR / cid / "recv.log"), "w", encoding="utf-8") as f:
                        f.write("首包字节数=%d\nhex: %s\nascii: %s\n" % (len(data), hexs, asci))
                except Exception:
                    pass
            # ★ 续接片段：重连后新 FLV 片段含独立 header，需跳过以免 ffmpeg 解析混乱
            if info.get("_skip_header"):
                pending = (info.get("_pending") or b"") + data
                if len(pending) < 13:
                    info["_pending"] = pending
                    return
                header_size = struct.unpack(">I", pending[5:9])[0]  # FLV header 第5-9字节为 header 长度
                skip = header_size + 4  # + 首个 prevTagSize(4字节)
                if len(pending) <= skip:
                    info["_pending"] = pending
                    return
                data = pending[skip:]
                info["_pending"] = b""
                info["_skip_header"] = False
            proc = info.get("process")
            if proc and proc.stdin is not None:
                # ★ Windows 上 proc.poll() 在进程死后可能仍返回 None（句柄过期）
                #   用 try-write + catch 做真实的存活检测
                try:
                    proc.stdin.write(data)
                except (BrokenPipeError, ValueError, OSError, AttributeError):
                    # 管道已断开 → 标记进程死亡，下次 init 将重建
                    info["_pipe_dead"] = True
            elif info.get("_pipe_dead"):
                pass  # 已标记死亡，等下次 init 重建

    def _handle_json(self, sess, msg):
        t = msg.get("type")
        if t == "init":
            print("[WS] init captureId=%s url=%s" % (msg.get("captureId"), str(msg.get("url"))[:60]))
            cid = ws_safe_cid(msg.get("captureId") or msg.get("url") or ("cap_" + str(time.time())))
            sess["captureId"] = cid
            self.start_capture(cid, msg, sess["conn"])
        elif t == "stop":
            cid = ws_safe_cid(msg.get("captureId") or sess.get("captureId"))
            if cid:
                self.stop_capture(cid)
        elif t == "seg_start":
            # 续接片段：通知服务端跳过新 FLV header，实现单 ffmpeg 无缝续接
            cid = ws_safe_cid(msg.get("captureId") or sess.get("captureId"))
            if cid:
                with lock:
                    info = active_streams.get(cid)
                    if info:
                        info["_skip_header"] = True
                        info["_pending"] = b""
        elif t == "meta":
            pass  # 预留：质量/标题更新

    def start_capture(self, cid, msg, conn):
        with lock:
            if cid in active_streams:
                old = active_streams[cid]
                proc = old.get("process")
                # ★ 检测旧 ffmpeg 是否真还活着：Windows 上 poll() 不可靠，
                #   必须同时检查 _pipe_dead 标记（由 on_frame 在写失败时设置）
                truly_alive = (proc is not None
                               and not old.get("_pipe_dead")
                               and old.get("last_data", 0) > time.time() - 120
                               and proc.poll() is None)
                if truly_alive:
                    old["last_data"] = time.time()
                    ws_send_frame(conn, 0x1, json.dumps({
                        "type": "ready", "captureId": cid,
                        "message": "复用已有捕获会话"
                    }).encode("utf-8"))
                    return
                # 进程已死 → 停掉旧会话，重建新 ffmpeg
                if proc is not None:
                    print("[Capture] 旧 ffmpeg 已死（pipe_dead=%s），重建 %s" % (old.get("_pipe_dead"), cid))
                self.stop_capture(cid)
        sdir = HLS_DIR / cid
        sdir.mkdir(parents=True, exist_ok=True)
        for f in sdir.iterdir():
            try: f.unlink()
            except Exception: pass
        delay = int(msg.get("delay_seconds", 60))
        max_cache = int(msg.get("max_cache_seconds", 1800))
        if delay < 1: delay = 1
        if max_cache < 60: max_cache = 60
        seg_t = 2
        # ★ 限制 hls_list_size：不需要保留整个 max_cache 的分片
        #   只需延迟窗口 + 足够余量即可，旧分片由 delete_segments 清理
        #   这样 live.m3u8 不会无限增长，hls.js 解析更快
        list_sz = max(int((delay + 120) / seg_t), 30)  # 延迟+2分钟余量
        list_sz = min(list_sz, 900)  # 上限不变
        m3u8_p = sdir / "live.m3u8"
        seg_p = sdir / "seg_%08d.ts"

        # ★ 画质：orig=保持原始分辨率；1080p/720p=降分辨率省资源
        quality = (msg.get("quality") or "orig").lower()
        vf_scale = ""
        v_bitrate = "12000k"
        v_maxrate = "15000k"
        v_bufsize = "30000k"
        v_crf = "18"
        if quality == "1080p":
            vf_scale = "scale=1920:1080"
            v_bitrate = "4000k"; v_maxrate = "5000k"; v_bufsize = "10000k"; v_crf = "20"
        elif quality == "720p":
            vf_scale = "scale=1280:720"
            v_bitrate = "1800k"; v_maxrate = "2000k"; v_bufsize = "4000k"; v_crf = "23"
        # 捕获路径采用「实时重编码」而非 -c copy：
        # 实时直播流经管道喂入时，分片边界不一定落在关键帧上，copy 无法插入
        # 关键帧会导致首片不可解码 → 黑屏。重编码可强制每 seg_t 秒一个关键帧，
        # 保证每个分片都能独立解码。
        # ★ 画质由 quality 参数控制：orig 保持原始分辨率（4K 原画），高码率不花屏。
        # ★ HLS 源：服务端直接从 URL 拉流（-i "url"），不走管道；
        #   FLV 源：扩展推流到管道（-i pipe:0）。
        fmt = (msg.get("format") or "flv").lower()
        src_url = msg.get("url", "")
        ffmpeg_log = sdir / "ffmpeg.log"

        # ★ 构建 ffmpeg 参数（支持硬件解码 + 硬件编码的全 GPU 流水线）
        def build_args(use_cuvid):
            a = [FFMPEG_PATH, "-loglevel", "info"]
            if fmt == "hls" and src_url:
                a += ["-re", "-i", src_url]
            else:
                # FLV 管道输入：小红书 FLV 是 HEVC，用 hevc_cuvid 硬解
                if use_cuvid and HW_ENCODER == "h264_nvenc":
                    a += ["-c:v", "hevc_cuvid"]
                a += ["-i", "pipe:0"]
            # 编码器
            if HW_ENCODER == "h264_nvenc":
                a += ["-c:v", "h264_nvenc", "-preset", "p4", "-tune", "ll",
                      "-rc", "cbr", "-pix_fmt", "yuv420p"]
            elif HW_ENCODER == "h264_qsv":
                a += ["-c:v", "h264_qsv", "-preset", "veryfast", "-pix_fmt", "yuv420p"]
            elif HW_ENCODER == "h264_amf":
                a += ["-c:v", "h264_amf", "-quality", "speed", "-pix_fmt", "yuv420p"]
            else:
                a += ["-c:v", "libx264", "-preset", "veryfast", "-tune", "zerolatency",
                      "-pix_fmt", "yuv420p"]
            if vf_scale:
                a += ["-vf", vf_scale]
            if HW_ENCODER in ("h264_nvenc", "h264_qsv", "h264_amf"):
                a += ["-b:v", v_bitrate, "-maxrate", v_maxrate, "-bufsize", v_bufsize]
            else:
                a += ["-crf", v_crf, "-b:v", v_bitrate, "-maxrate", v_maxrate, "-bufsize", v_bufsize]
            a += ["-g", str(int(seg_t * 25)), "-keyint_min", str(int(seg_t * 25)),
                  "-sc_threshold", "0",
                  "-c:a", "aac", "-b:a", "128k",
                  "-f", "hls",
                  "-hls_time", str(seg_t), "-hls_list_size", str(list_sz),
                  "-hls_flags", "delete_segments+omit_endlist",
                  "-hls_segment_filename", str(seg_p), str(m3u8_p)]
            return a

        stdin_mode = subprocess.DEVNULL if (fmt == "hls" and src_url) else subprocess.PIPE

        # ★ 先尝试 hevc_cuvid 硬解（全 GPU 流水线），失败回退到软解+NVENC
        use_cuvid = (HW_ENCODER == "h264_nvenc") and (fmt != "hls")
        args = build_args(use_cuvid)
        cf = subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
        try:
            proc = subprocess.Popen(args, stdin=stdin_mode, stdout=subprocess.DEVNULL,
                                    stderr=open(str(ffmpeg_log), "wb"),
                                    creationflags=cf)
        except Exception as e:
            ws_send_frame(conn, 0x1, json.dumps({"type": "error", "captureId": cid,
                                                 "message": "ffmpeg 启动失败: %s" % e}).encode("utf-8"))
            return
        time.sleep(1.5)
        if proc.poll() is not None and use_cuvid:
            # cuvid 不支持当前流 → 回退到软解 + NVENC 编码
            print("[Capture] hevc_cuvid 失败，回退到软解 + %s" % HW_ENCODER)
            args = build_args(False)
            try:
                proc = subprocess.Popen(args, stdin=stdin_mode, stdout=subprocess.DEVNULL,
                                        stderr=open(str(ffmpeg_log), "wb"),
                                        creationflags=cf)
            except Exception as e:
                ws_send_frame(conn, 0x1, json.dumps({"type": "error", "captureId": cid,
                                                     "message": "ffmpeg 启动失败: %s" % e}).encode("utf-8"))
                return
            time.sleep(1.5)
        if proc.poll() is not None:
            # ffmpeg 启动后立即退出 → 读日志找原因
            try:
                err_tail = ffmpeg_log.read_text(encoding="utf-8", errors="ignore")[-500:]
            except Exception:
                err_tail = ""
            ws_send_frame(conn, 0x1, json.dumps({"type": "error", "captureId": cid,
                                                 "message": "ffmpeg 进程异常退出（流格式可能不被支持）",
                                                 "log_tail": err_tail}).encode("utf-8"))
            return
        with lock:
            active_streams[cid] = {
                "process": proc, "delay": delay, "max_cache": max_cache, "seg_t": seg_t,
                "source": "capture",
                "source_id": cid, "started": time.time(), "last_data": time.time(),
                "url": msg.get("url", ""), "title": msg.get("title", ""),
                "format": fmt, "quality": quality,
                "quality_options": []
            }
        ws_send_frame(conn, 0x1, json.dumps({
            "type": "ready", "captureId": cid,
            "hls_url": "/hls/%s/delayed_%d.m3u8" % (cid, delay)
        }).encode("utf-8"))
        print("[capture] started: %s (from %s)" % (cid, msg.get("url", "?")[:80]))

    def stop_capture(self, cid):
        with lock:
            info = active_streams.pop(cid, None)
        if info:
            try:
                info["process"].terminate()
                info["process"].wait(timeout=5)
            except Exception:
                try: info["process"].kill()
                except Exception: pass
            print("[capture] stopped: %s" % cid)

class WSCaptureServer(threading.Thread):
    def __init__(self, host="127.0.0.1", port=8091, handler=None):
        super().__init__(daemon=True)
        self.host = host
        self.port = port
        self.handler = handler
        self._stop = False
        self.sock = None

    def run(self):
        try:
            self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
            self.sock.bind((self.host, self.port))
            self.sock.listen(16)
            self.sock.settimeout(1.0)
            print("[WS] 捕获服务已启动: ws://%s:%d/ws/capture" % (self.host, self.port))
            while not self._stop:
                try:
                    conn, addr = self.sock.accept()
                except socket.timeout:
                    continue
                threading.Thread(target=self._client, args=(conn, addr), daemon=True).start()
        except Exception as e:
            print("[WS] 服务错误: %s" % e)

    def _client(self, conn, addr):
        sess = None
        try:
            req = b""
            while b"\r\n\r\n" not in req:
                b = conn.recv(1)
                if not b:
                    return
                req += b
                if len(req) > 65536:
                    return
            key = None
            for line in req.decode("latin1").split("\r\n"):
                if line.lower().startswith("sec-websocket-key:"):
                    key = line.split(":", 1)[1].strip()
            if not key:
                return
            accept = base64.b64encode(hashlib.sha1((key + WS_GUID).encode()).digest()).decode()
            resp = ("HTTP/1.1 101 Switching Protocols\r\n"
                    "Upgrade: websocket\r\nConnection: Upgrade\r\n"
                    "Sec-WebSocket-Accept: %s\r\n\r\n" % accept)
            conn.sendall(resp.encode("latin1"))
            sess = self.handler.on_connect(conn) if self.handler else {"conn": conn}
            self._readloop(conn, sess)
        except Exception as e:
            print("[WS] client error: %s" % e)
        finally:
            try: conn.close()
            except Exception: pass
            if self.handler:
                try: self.handler.on_disconnect(sess)
                except Exception: pass

    def _readloop(self, conn, sess):
        frag_type = None
        frag_buf = b""
        while True:
            try:
                hdr = conn.recv(2)
            except Exception:
                break
            if len(hdr) < 2:
                break
            fin = hdr[0] & 0x80
            opcode = hdr[0] & 0x0F
            masked = hdr[1] & 0x80
            length = hdr[1] & 0x7F
            if length == 126:
                length = struct.unpack(">H", conn.recv(2))[0]
            elif length == 127:
                length = struct.unpack(">Q", conn.recv(8))[0]
            mask = None
            if masked:
                mask = conn.recv(4)
            data = b""
            while len(data) < length:
                chunk = conn.recv(length - len(data))
                if not chunk:
                    return
                data += chunk
            if masked and mask:
                data = bytes(data[i] ^ mask[i % 4] for i in range(len(data)))
            if opcode == 0x8:        # close
                break
            elif opcode == 0x9:      # ping -> pong
                ws_send_frame(conn, 0xA, data)
            elif opcode == 0x0:      # continuation
                frag_buf += data
                if fin:
                    if frag_type and self.handler:
                        self.handler.on_frame(sess, frag_type, frag_buf)
                    frag_type = None
                    frag_buf = b""
            elif opcode in (0x1, 0x2):
                if fin:
                    if self.handler:
                        self.handler.on_frame(sess, opcode, data)
                else:
                    frag_type = opcode
                    frag_buf = data

    def stop(self):
        self._stop = True
        try:
            if self.sock:
                self.sock.close()
        except Exception:
            pass

capture_mgr = CaptureManager()
ws_server = None

@app.route("/api/capture/list")
def api_capture_list():
    now = time.time()
    with lock:
        items = []
        for cid, info in active_streams.items():
            if info.get("source") != "capture":
                continue
            alive = info["process"].poll() is None
            # 若 ffmpeg 存活但 30s 内无新数据，标记为 stale（WS 可能短暂断开）
            stale = alive and (now - info.get("last_data", 0)) > 30
            items.append({
                "captureId": cid,
                "title": info.get("title", ""),
                "url": info.get("url", ""),
                "format": info.get("format", ""),
                "delay": info.get("delay", 0),
                "alive": alive,
                "stale": stale,
                "last_data_ago": round(now - info.get("last_data", 0), 1),
                "started": info["started"],
                "hls_url": "/hls/%s/delayed_%d.m3u8" % (cid, info.get("delay", 60))
            })
    return jsonify({"captures": items, "ws": "ws://127.0.0.1:8091/ws/capture"})

@app.route("/api/capture/stop", methods=["POST"])
def api_capture_stop():
    d = request.get_json(force=True, silent=True) or {}
    cid = ws_safe_cid(d.get("captureId", ""))
    if not cid:
        return jsonify({"error": "need captureId"}), 400
    capture_mgr.stop_capture(cid)
    return jsonify({"status": "stopped", "captureId": cid})

@app.route("/api/capture/<cid>/debug")
def api_capture_debug(cid):
    """诊断接口：直观看出捕获链路到底断在哪。"""
    cid = ws_safe_cid(cid)
    sdir = HLS_DIR / cid
    info = {"captureId": cid}
    with lock:
        st = active_streams.get(cid)
        if st:
            info["ffmpeg_alive"] = st["process"].poll() is None
            info["last_data_ago"] = round(time.time() - st.get("last_data", 0), 1)
            info["started_ago"] = round(time.time() - st.get("started", 0), 1)
            info["title"] = st.get("title", "")
            info["url"] = st.get("url", "")
        else:
            info["ffmpeg_alive"] = False
            info["registered"] = False
    if sdir.exists():
        info["dir_exists"] = True
        segs = sorted(sdir.glob("*.ts"))
        info["seg_count"] = len(segs)
        info["seg_total_bytes"] = sum(f.stat().st_size for f in segs)
        if segs:
            info["newest_seg_mtime_ago"] = round(time.time() - segs[-1].stat().st_mtime, 1)
            info["newest_seg_bytes"] = segs[-1].stat().st_size
        live = sdir / "live.m3u8"
        if live.exists():
            info["live_m3u8_head"] = live.read_text(errors="ignore").splitlines()[:14]
        flog = sdir / "ffmpeg.log"
        if flog.exists():
            lines = [l for l in flog.read_text(errors="ignore").splitlines() if l.strip()]
            info["ffmpeg_log_tail"] = lines[-25:]
    else:
        info["dir_exists"] = False
    return jsonify(info)

def cleanup():
    print("\nStopping all streams...")
    global ws_server
    if ws_server:
        try: ws_server.stop()
        except Exception: pass
    with lock:
        for rid in list(active_streams.keys()):
            stop_ff(rid)

signal.signal(signal.SIGINT, lambda s, f: (cleanup(), sys.exit(0)))
signal.signal(signal.SIGTERM, lambda s, f: (cleanup(), sys.exit(0)))

if __name__ == "__main__":
    print("=" * 55)
    print("  Live Delay Player (Portable)")
    print("  http://localhost:8090")
    print("=" * 55)
    if not check_deps():
        print("\nFix above issues first. Run install_deps.bat for auto-fix.")
        input("Press Enter to exit...")
        sys.exit(1)
    detect_hw_encoder()
    HLS_DIR.mkdir(parents=True, exist_ok=True)
    (BASE_DIR / "recordings").mkdir(exist_ok=True)
    ws_server = WSCaptureServer(port=8091, handler=capture_mgr)
    ws_server.start()

    # ★ 后台清理线程：每 30 秒扫描并清理已死/卡住的会话 + 活跃会话的超期缓存
    def sweep_loop():
        while True:
            time.sleep(30)
            now = time.time()
            with lock:
                dead = [cid for cid, info in list(active_streams.items())
                        if info.get("_pipe_dead")
                        or (info.get("last_data", 0) < now - 300  # 5 分钟无数据
                            and info.get("process")
                            and info["process"].poll() is not None)]
                for cid in dead:
                    print("[Sweep] 清理死会话: %s" % cid)
                    stop_ff(cid)  # stop_ff 已 pop，无需再 del
                # ★ 清理活跃会话的超期 ts 文件
                # ffmpeg delete_segments 有时不可靠（尤其 FLV→HLS），
                # 这里根据 max_cache 时长主动删除旧 ts
                for cid, info in list(active_streams.items()):
                    sdir = HLS_DIR / cid
                    if not sdir.is_dir():
                        continue
                    max_cache = info.get("max_cache", 1800)
                    # 保留窗口 = max_cache + 60s 余量
                    retention = max_cache + 60
                    removed = 0
                    freed = 0
                    try:
                        for f in sdir.glob("seg_*.ts"):
                            age = now - f.stat().st_mtime
                            if age > retention:
                                sz = f.stat().st_size
                                f.unlink(missing_ok=True)
                                removed += 1
                                freed += sz
                    except Exception:
                        pass
                    if removed > 0:
                        print("[Sweep] %s: 清理 %d 个超期切片, 释放 %.1f MB" % (cid, removed, freed / 1048576))
                # 清理残留的死会话目录
                if dead:
                    for d in HLS_DIR.iterdir():
                        if d.is_dir() and d.name not in active_streams:
                            try:
                                for f in d.iterdir():
                                    try: f.unlink()
                                    except: pass
                            except: pass
    threading.Thread(target=sweep_loop, daemon=True).start()

    print("\nStarting...\n")
    app.run(host="0.0.0.0", port=8090, debug=False, threaded=True)
