# -*- coding: utf-8 -*-
# @tvbox-role manager
# @version v7.1
# @release-format plain-release-v1
# @dual-app-loader WebHTV,OK影视
# @author 江 晚枫
# @signature 秋色正好，江 晚枫来过。
"""
TVBox 本地影仓 v7.1
===================


"""

import base64
import gzip
import concurrent.futures
import copy
import hashlib
import ipaddress
import json
import math
import os
import re
import shutil
import socket
import threading
import time
import urllib.parse
import urllib.error
import urllib.request
import xml.etree.ElementTree as ElementTree
import zipfile

from base.spider import Spider as BaseSpider


def _detect_storage_root():
    candidates = []
    external = str(os.environ.get("EXTERNAL_STORAGE", "")).strip()
    if external:
        candidates.append(external)
    candidates.extend(("/sdcard", "/storage/emulated/0", os.path.expanduser("~/storage/shared")))
    seen = set()
    for candidate in candidates:
        path = os.path.abspath(os.path.expanduser(candidate))
        real = os.path.realpath(path)
        if real in seen:
            continue
        seen.add(real)
        if os.path.isdir(path):
            return real
    return os.path.abspath(external or "/sdcard")


def _detect_local_base(storage_root):
    candidates = []
    configured = str(os.environ.get("TVBOX_HOME", "")).strip()
    if configured:
        candidates.append(configured)
    candidates.extend(
        (
            os.path.join(storage_root, "tvbox"),
            os.path.join(storage_root, "TVBox"),
        )
    )
    for candidate in candidates:
        path = os.path.realpath(os.path.abspath(os.path.expanduser(candidate)))
        if os.path.isdir(path):
            return path
    return os.path.realpath(os.path.join(storage_root, "tvbox"))


def _detect_child_dir(base, *names):
    if os.path.isdir(base):
        try:
            entries = {
                name.lower(): name
                for name in os.listdir(base)
                if os.path.isdir(os.path.join(base, name))
            }
            for name in names:
                actual = entries.get(name.lower())
                if actual:
                    return os.path.join(base, actual)
        except Exception:
            pass
    return os.path.join(base, names[0])


DETECTED_STORAGE_ROOT = _detect_storage_root()
DETECTED_LOCAL_BASE = _detect_local_base(DETECTED_STORAGE_ROOT)

# 进程级自动补扫冷却，防止“补扫 -> 重载 -> 重建实例 -> 再补扫”循环。
_AUTO_SCAN_STATE = {"last": 0.0}
_MANUAL_SCAN_LOCK = threading.Lock()
_MANUAL_SCAN_STATE = {}


class RegistryChangedError(RuntimeError):
    pass


class SiteTestCancelled(RuntimeError):
    pass


class BackgroundTaskCancelled(RuntimeError):
    pass


class PackageCompatibilityError(RuntimeError):
    pass


class ReleaseAuthorizationError(RuntimeError):
    pass


class PackageServiceRequestError(ValueError):
    def __init__(self, message, status=0, code="", retry_after=0):
        super().__init__(message)
        self.status = int(status or 0)
        self.code = str(code or "").strip()
        self.retry_after = max(0, int(retry_after or 0))


class NoRedirectHandler(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        return None


# === UI_ICONS BEGIN (auto-generated, do not edit by hand) ===
_UI_ICONS = {
    'classify': 'data:image/png;base64,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',
    'add': 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAPAAAAFACAIAAAANimYEAAAtwklEQVR42u2deXRc1ZXu9z7nDjVJpVm2JUuWLc8jBjuAbQw4hARsQjqkm3SSJj2k+3U6SQ8vr9Od9MDqvPc6Sc95dPqtpJM0jwwkECYHAgSwzRA7wYAHPGuwLNmyJGtWDbfuvWe/P44khMFVZVtyVUn7W15aXkslqerWr/b99j7n7I1F930FWKzpIsGXgMVAs1gMNIvFQLNYDDSLgWaxGGgWi4FmsRhoFouBZjHQLBYDzWIx0CwWA81iMdAsBprFYqBZLAaaxWKgWSwGmsVAs1gMNIvFQLNYDDSLxUCzGGgWi4FmsRhoFouBZrEYaBYDzWIx0CwWA81iMdAsFgPNYqBZLAaaxWKgWSwGmsVAs1gMNIvFQLNYDDSLxUCzGGgWi4FmsRhoFouBZrEYaBYDzWIx0CwWA81iMdAsFgPNYqBZLAaaxWKgWSwGmsVioFkMNIvFQLNYDDSLxUCzWAw0i4FmsRhoFouBZrEYaBaLgWYx0CwWA81iMdAsFgPNYjHQLAaaxWKgWSwGmsVioFkzWAZfgqkTAl7gO0R8dRjofKUWEFF/1agSEAIoIlf57/ojUggDkcZ+FgCIiMa+shjoHBAsEDW7rlIpz/OUAlJABEIIFEQUMIyKYEhT+1ZkBkLAoZQz6DgC0ScCDb0QAoUtpSmEQIEIRKQAmHAGeqogFoiIqOOu4/q+7wOiKWV5MFhbVFwRCK2urI6Y5rzi0vriqKtUiW3Pj5a+62/risfOjAwbQgw4yf09XQTwWteZfifZPNDfl0wkPAeUAiEDhmEJIYUgAkVEwHBn8U4V3fcVvgoXvDqIEpEAXN9PeJ7yPcMwK4OhxtKy91TPWVU5a360ZFFJWXkwJBAv/8/1JZMnh/pPDQ/t7+na23Xm4LnurngsmUqBwKBhWlIKREWkOGwz0BcfjwUAJXzPcV0ALA+FVldUb6lr2DBn7vLyyopg6LwfGecMAQBxzJlcMCWk8f8SwFj4P+/hgymnqb9vd2fHro62PZ0dZ2IjyvdNwwiapkT0iS03A51JAlEgOr4fd1OI2BgtvWnuvA01dTfW1tcVRSdYYfCV0uwKGMvsLlsE2lgQ0egzmRC8E7s7O3a0n9zRfvJQb7fjebZpBqUJQD5jzUC/a0gmoJjrep5XHSnaVFN3z/LVm2vqiiz7bRAjikkDOCPfQESKaCLcnlJ7u848cOTgMyebmgf7ETBiWYYQvmKTzUADIIAUwlVqxHEMKa6qmv3RxSt+fdGymkiRfoBP6p3x8sqLgBQBERlidC1sOOVsbznxg6MHd3a0xVJO0LQChsE+ZOYCPQHlZEkgdHPdvE+vuuamufM0uLqqIFBgnj3tsXI1SRwl++C57v97YO+TLSfaBgeC1kzHeoYCLYXwlBpxklE7cPfiFZ9bu35ZWeX4PV2+S4aWdyIARUqMrupAdzz2zYOvf/Pg6+2D/UHLDhiGrxQx0NMfZUQCGEwmo7Z99+IVn7tq/bLySgDQqZXIf5DfITUhYE/AeiASCBhC+Eox0NP0pSJKxKGUQ4o+vmzlF9Zt0FHZJ4WQY4s8CQGbyB9z2D2J2Df27/2n1/YMO040ENDQz5SAZd/23pnwOg0hUr4/nExurKn/9vu2/dnV11UGwz4pAJA4VXGZxr+cb99xKj6uAlFjXWTZN9bOu7NxyXDK2Xv2jEcUNIwZ4qqnf4TWtA4kkzVFRV9av+lTK9caQqgxgzG5iZpmZtyAX+j309iCyuhaDAIC6q+TVRLx1Wi0fqat+S9ffuGNrjMRO2Cg0J9hBrqAA3PC85Ke+4llq7+6ccvscETbZXl5KE8kOE1FbyjlnHevRwAFUGoHLvSbx6uEl5+Wjn9oXaX+Ye8v/tevXk56btQOeNPaVU9noA0h+pOJecUlX9v03o8sWqZxGa92XWphgSYWg8fB7YyNtA8NvtHT5Sr/UG9PXzKBAEf6znmkzou7iqixpCxsmoYQ66rnIMDyiqq6omhDcUlpIPBOuC+z5DL+6d3bdeZPdz778um2aCA4jV319ARa7+0cSsa3NS751i1bq0MRn+iSV/ho9O1/q/TrkTrR33e8/9wrZzr29XSdGOjtTyYHHQeUAiAQQu/pCBjGu/49x/f1LwQdLIUIGmZZIDg/WrK0rGLDnLpl5RXLyipDpjn+GVBEl0O2p5QhhE/0xZef/8e9u23DCBjGtAzV0xBoiSLhuT7Rvddt/tL6jZo/45ICsyZpwuJcalfHyZ0dbTvaT54Y6BtOOUCEQtjSMIQwxoAjGs0F1bvnhDQO5vjjFSlXqZTv+8oHANs064uiN9bWb5hTt6WuYcKy5aXXFhWRtumPNB39zPNPnY3HSgLT0H5MN6ANIQYdZ25R8bdv2balruHSkr/z1iyGU86u06eeaD6+o721ebCffGUaRsAw5ISd+Je/Ff+tcwMASinH9x3PBYDqcNGmmrpt8xdtqZtXEykej7iXELD1dhRDiJbB/t97dvuO9tbSYGiarb9MK6BNIfpisY11DQ9vvas6FNb32UtAeeKq8g+PHnzoxJGmgX4gZZtmUBp4RTYl66q59idxNwVE1eHIbQ0LP7Zk5Y1z5+lv+UqJi8d63H788Y6n//31PdFQeDpZ6mkCtC57DcZjv79m/T9tviViWhdL80SUU8p/6PjhHx59c2dHW8xJBiwrIA3M0RZkvRMQEVK+H0s5hpRXVc2+e/Hyjy9ZWRUKX1q0VjRqe+7b/+qf7HjGktKWcnpsQ50OQOtzHHHX/e9XX/fVTVv0G3ZRNmO8FJBS/oNHD339jV++1nUGhYiYlg5m+bAqoXdTKdJ7XN250dJPrVz7ByvXaqx9Uhe1lUpnuhLxgSMH/3jH0wnPDRjmNKhSFzzQmuaRVOo7t97xyWWrfSJxMSsU4ybbU+oHRw9+/Y1fvdZ1xpRGxLL0qlt+vmSBmPS8RMrRWP+3VVdXBkNw8SV2VylTiNe7O2979If9TiJkWIXOdGEvfY/T/N1b7/jkstWuUoYQ2dOsy9KI+Pyp1t95dvu/vbb7XDJZHAiYOirn66seDa5ChCxrKOX8vPXET5qOltqBFRVVEoVPKnv/IRE9pWoixVvqGn5y4uiw69jSKOiDAgUM9ESa71m22lPKzNo0KyICkCiaBvr+ZOczf/7icx2xoZJAUK+KF8r7OY71gJN86NibL3S0zY+WzI+WIoCftekSo0wXbamfDkwXKtDvpDn7FHA8MP/Hgb2f+Nmju8+0RwMBnRUV4ts4jnXzQP/3jhzod5LXz6nV6yYzkOmCBBoBCS6F5vFCbPNg/z3PPP4ve3ejEBHL9gt/K5oiChiGKeXOUy1PtDYtLi1vLCnTd5tsDMi7MS2Jgb5i4Tnmut++9Y5PXgzNulYlER9pOvqRnz70eldnaSgEgNOmBEsABBC27M7YyPeOHjSEuKG2XlcbxUUxXdfw8IkjCc8zhCAGeqplCjEQj31h/abPX32dm7Vv1um/IvWXL7/wpzuf8YiKbHtabmZQRLaUhpA/azn2es/Zm+saiizLJ5Ul065SNZGixWUVDxw+EDAKr7FWgQFtCNEfi/3BVev/9cZbvdGaRmbpR3bFYx984sffP7yvOBCUQkzjdhb6hYUte39310Mnjlw3p3ZuUdRVKpuKnq57LC2rqC8u+fGxNwOmSQz0VD1XFMMpZ2Nt/QMfuFOiEAKzqdBpmt/oPrvl4QcOnOsqDYb8mXF4QxGFLWsgmfyvQ/tri4qvrprtE2VT0dPe4+rq2QOp1K721rBlFZArKxigBaLjeXMiRc/f9YmoHaDsthxpmh9tOvrRpx7pTsSj09RmpGHakpIAHm06ZgixubaeiCCLhSdE9JW6raHx9e6u/d1nC4jpwonQiI7nPXLHR5aVV2a5T1/TfP/h/R998hEFFJoWS7uXYD+EEJaUT5040ptybm9YqAgyHvca/9576xsePnFkIJk0C6ToURgjKQwhhhLxL2+46cbaeZ66OJp/+5knim3bkjInNCOARGGI0X856ZJARERUFim677U9n93xtERUBBnLzAJRAVQFw9+99Q6vcLaYFkCE1iepti1Yct/NH1CUVZFuIs0Ry9KrMLkw/egSDaecZCqVdN2k5wGiJWTOLLVtv9jW2uskb29YmE2BWpvp+dFSS8qnmo+FLTv/jUe+A617gc4JF//0zrvDppWN/8sHmnWD9EHHKQ0Ets5f/LGlK2+un7+otKI3Hu+Mj5i6jXmumda9JzMy7Su1qbb+tZ6zB3q6woal8nsFMd8LjQiQcN1/ve3WqlA4G+usaX6i5fgnn3682LYxRzQj4mAy+Ydr1v3V+o1zxg5QAcBwKnX/kf1/8eJz+nle+efmKVUWDt+39xcR0/r7jTdnsyylof/6Te/ffaYj6XsSRT6viud1hDaEGEgm71mx5i/Xbcjm0utdDa93d9795CMEJHNBDIzG5uQ3b9n6N9feUGTZPpE+4UJAAcNYP6vm5rqGHx075Pj+5RxBvxxLHbDsne0n64uja6tme5nWXBDRJ1UWCJYFgz85fjiU3xWP/E0KETDpeXMiRf+w6b1ElHEJRQEhYk8i/qEnftyXTFjSyJVvHnKcP1qz7lMr13pK6U30OiOUKAjAVf61s2u/fvP7PeXnpP0YARBByDR/+5kndna0GZh5jUmi8JX6vRVX3Tpv4UAymZPPYcEDLQUmUs6912+uDIYUgEhrnXXPF0Xq7id/0jEyFLGsHNU0MKVUZSj05etuVESjA63e7kZMIT2lPrZk5caa+uGUI3NS9wASiEHD+M2nHjk5NJBNmqHN9lc2bQkaRj5XP/MUaIk4knI2zW347WVrstlb4yuSiH/1ys4XTjbnsDmQQIi7qS1z55cEgpT2Q0hEH164RCmVq3anisiWxtnYyO88u12RIshQyNND6NZUVn96zbphx7nY08czHWgC8BT974036wuX/j33aTQR/Nqrr0RDoRyuBSIiKLW6qhrT9jXQjb7WVFYLIXPoRz1SJYHAjrbmv3plp0T0VcbKNCiiL63fOKeoOOl5U9FycnoCbaAYTCY/sWzVxjlzMx6SU0QCROtQ/+///Kd5sjssy231Xh6kVp5S0WDoa6++8njzMUNkaOWIgARQYgfuvfaGhOsKwUBnJ5dUkW3/zbWbMsZmfe9GhM/teLYrNmIbRm4TcCICFC0D/ZDFtvr8wcGS8nM7nu5JxBFQZTIeiui3l6+5qnp2LJXKw6baeQe0IcSIk/zYkpULomW+yuCefSIpxH+++cZPm46WBoN5sfEIsXVwIK94zWimg6Z5amjgf7z0nECktMYDAQjIEOLz11zn+h4DnUV4VqrYDvzZ1dfShQdXvmU2EE8ODfzFS8+HbdvPm+0GphBQUPKUKgmE7n9z3+PNx2Qm4yFQKKK7GpdeVV0zkn9BOr8uvSHESDL5m0tWLiwpy9gsRs+F/9vdu3oTMUvK/Fm+KsTN1gRkGfKLr+wYTCW1V04fpC0pP3/Nta7nIQOdPjyXBIOj4TlDZYMk4stn2h84fKAkEPTUjNsXOunGI2Jah7s7/2P/a3r/RjZB+prZtfnmpPMIaIk44jjvrV+QMTxr3FO+/8WXXzAEMo6TZTzCgeC/vLanZbBfpl1qwbGjA7+1bJXruQz0BTE1pfij1ddQ5nCiBOLDTUdeam/VTQgYx0m5/paU3bHhr+3djYjpr6kUSAB3L142N1qayKeadL4ArTsTrK2as3HOXEhbeya9wuz7/7h3j2WYimme1CBdFAj+8OjBEwN9IkOQRqWoMhi+s3FJ0k1JwUC/A2jPcz+2dKWR6Tz2eHh+o+t0YZ3fLAgZQgw5yX9+bQ9mzG4RCOCeZatsw8yfm2ReAI2Aju9XR4o/smipvp2lCc8CMOl7/7h3jykNpnkqgnTEDvzg6MHj/b3pg7REJKKrKmdtmDN3JJWS+eGk8wJogRBPpW6orZ8ViiiiNIZMH7LY3nL8ja7TEQ7PUyNTiKFk8v/sexVH58Wkq40IxA82LlZ+vtTv8shy/M6K1ZDpNqcT6vsPHxAoiGmeGvmKgpb1RPPx3mRcCExT4Nf30l9ftKw6Uuz4fj6khrkHGgETnje/pGzjnDodrdPHgyN953a2nwxbFhc3pqzcQQHDODXYv73lBAKkWYJFQEU0KxS5obY+4abyITPMPdBSoOO5W+oaIqblK5XmU64NxveOHIw5SaPQlpcLjGkiKeX9hw9kXK/Vb8odCxYRESBH6LEy3A21den9ht4TM5xK/fj4Ydu0fMXheSpdB1HYsnafaX+9u1Nv7b8gQAIB4NpZNcWj67U4o4FGQNf3y0Lhm2rr09c3NMC/6GxvGegLGoU9NqEgJBEdN/V483FIe1hBABLR/GjpmorquOvKXMfoHAMtEBKed1VldVWm+obOtx9rOqZIIfJy95RLKTIM86nWppTyZVqDp8/I3VBbr3w/529NriM0ovK9LfXz028e0D0JRtzUz0+12IbJfuNKAA0UMs03e7v393Th2FTmC72JAHDrvPlSypwXUnMMtCIyDPP62TWQ9oiHBvil06daB/rZb1xJ15Fy3cebj2VwHQgAsKyssraoOJXr4l0ugdYGuioUWl5eBWkLdvpq7uo4pZTPfuNKhhsh5Usd7Srt7hpdvCsNBBeXViQ9L7fFu5wCjZD0/cZoWdSyKZ2BJimEq/znT7UYhqnYb1w5oCFkmvvOnW0Z7EdMd9xQESHAmqpqynWGk0ugBaLyvffMrk2/IUmXglqHBo719wYNg3fyX0GRIcRQMrHn7GmdJqYLTgAb5swFwNzGm1wCTQCIYk1VdYaHEQDAsb7esT5DHKGvpC0EIDrY050pNgEALC2riAYCua1G5xJoRWRIY0G0JH1GqA30y2fagYgNdC5stLGns8MnldZGAwDMDkcqgiHP93P4JoncffTRU6o8GJgfLU2fEepNjG90n0XekHTl76IEASmbBvv6kknEC25U0nlhyDDnFZU4vsphXpg7oBFSvj83Ei1OmxHqtoJxz20Z6LcNyQnhFbeFZEjZHY+3DPbDWP30QjdSgbi4rJzIx5kINICnVHUobEsj7RYOAIAzI8PnknFDSOY5J7m77/tNA32Qthqtv1FXHAWimeihERFILa+oynCZCACgbXhg0HEMzghzFHqI1PH+vsxvKMCysorcdqDMaXdDomLbgvSb7IgAcV9PN+rOs5d3pRBgYsvmSXfkElEiZnmsH8cej1MTLrR8ost8mUQEQu7v6YIs+lBGbTu3dVUjdzATClFXFIUs+hrqeQ6XT1tKqXgqAbqLCiJMdid6ieg7yRE3lc2DPVKukxwgmoJ4RuOv0TbNoDTUZX92M+6f0W9hVSgctW2fSADmZIdC7oAGQBS6ZpfewAHAgZ4uEJde4tBTfAYcpyoU2jZ/0eqKKkBoHxpqHew3pJxEnATCiOteO7sW0rpI/a3yQGjLgsUhw5jETBcRPKXKAsEVFVVAdGp46Lm2luah/rBhXvLEGSKwpWwe7BtxU2HTInj3DF4HpdpIcakdOBuP5Wp6XS4tBxElPS+bR/Ynk5ecZ6SZSTXlSULaT+mqiurnPvzxqX4alz93S0+hHko5nlIZk5iU76eUyuHqRs6AVkQBw4jaAciEqr5z4WWApWdSfWrl2omeErMYeXg5lYFsQJmisvr4b0aEIsv6zOp111TNvv3RHyZ8zxSX2NUy/Q7S8b9rSRm17N5E3MxR/p6bz5JeVakMhRtLyuDCbXN1EXrISR3p67Ev6dacfiaVQJyif1lnqFPy1yd37pbuNXo2Hjva1wMXLkUjIBFFTKuxpMzxc1aKzulejuzyIQLyiPCSPjbpZ1LNkKLbpMzdUkRZztDI7R5/USjvyqWlaFnOpJoJuvy5WwVx+aZzM4AsZ1LNiDiNiIircz13i4GeHL8OrMIJsQx0htB0anhwhryX6f0GEZ0aHiI1zc/M5xjoKb35+Ypsw3y6tcnxvTRbH2eO63i06RgBwBRbjpl7YkUgZtPRiy41cSagoGG0DPZ/59A+gThj57DoJZW9XZ3PnmyO2Palrhdmu0Sf2y5tOVtYkUIMOs6ZkeHFpeUX2m6oS5tFptVYUvbS6VPWxS9TK4KwaX5+18+vrpqzftYcnxQRTPU9F7NeWJn6/IwIwBCi30n+1tOPxTw3Yl50c3IEdLNYNNA3gaTvnRkZNoWgGQU0AUnEASd5emRocWl5mg20+v0osmx1SaVo3aGGlP+BR7//T5vf98llq/PHTevddlcgD9zT2fF7P9/ePNAXMa30MwjThOeAlMWWnSYb0Xs84q57emTIFCJXpZRc7uWQiFlO576cBRFFZAiZ8LxP/fyn9x/e/6EFS1ZXVgNMSafM0XNlgeCqympK994DAgw4yTe6z0ohpsJ2ap7ah4cebz7681OtMTcVMe1LoxnGtnNkcz8xhbBkLs9hGDmMT4qoN5HIiKNAXFc9+4kThy95P7QiMqW0pNzV0bbzVKuYso1gEtF1klvmL37uro/ThY/06hX4N3rO3vzgdwzLnjrjoUgBQMSyw5camwFAIDiet7SsMmrbabrr6k/pYMpJeLkcmZw7oBFJ+QfOdW+bn7m1sLjsPEPvSIpaNmYXaS4Z6AGikGlmdekRDcsumsrBGroGr0hd5p/QsGLaLc7aNzYP9vUkYsVT+SnNX8sBWWRFYwd7yi9nP/S4fKIpLVrp206W7yWNPXgq33ualNADSi2rqBy/Yaa7wopmaG87fbDnje6z6S2y/kZDtCRo8AyKHL1TAIiwsKQs8xsKsK+nS/m5tBwih5dJIPYm4q5S6Q53IADAvKKSimDQSzuwgjV1oQeFXFJWDmlPLehvnI3FYKZGaLClbBkaiLup9B1MiChkWvVF0Rzusp2x0nWbskBgXnEJZCpCA8Ch3p5JMYeFGKHJEKI3kTg5NABpl2MVkSnE8ooqpXzBRF9poMHx/bqi6KxwhC6MCwEhYtxz24YGrEk9plkwQOuaQMJLnRwehAzjggAAMk4VYk0JHwI933vP7FpLSHXhLoy6DNKXTHTFYzlcJoRc94dGUGp/TzdkaBA/2sEkYFpKcTfdHNxMV+l+QGkNJAAc6+/tTcQNIXK4DyzHR7BQyFfPnqG0xSD9nYUl5aM2mvPCKyhfKds018+aA2mLUToe/ersGVJKzNiG54rANow3z3WPuCmRNi/0lQqb5ua58xzPlYKBvmJwYNzzlpdXriivokwdYgHgV52nc5sR5jpCA1lCdMVHmgb70+eF+jub2UbnwkCvn1VryfQGmgRizHWP9ffmNiOEnG/wl0IkUs6eztOQdtVQR+Utc+dVh4tS7DquoCcUKO5sXPSW87vAnRYATgz0tgz2B6RUMINPfesOdzvaT1KG9UJURNWhyA01+TIkfdoLAROe11BSdv3suZDFjLKdHW2O68pcz2DP+ZxCCBjm7s6OIScp0p6S0vH7g435MiR92ksKdDz3lrqGIsvy067R6kj0XFtrzg10HkRoIEvKzpHh3Wc7IG2DeO063lvXUMWu44rdPFHc2bg4vd/QBrozNvJ6d2fINHM+YiH3p74Fou97O9tPQdpqNAL6iqpDka3zF8ZTDtc6prq+EXPdFRXVN9TUa2TTG+jXujs7R4YtKXN+Ejn3QOsh6c+dakn5GYak66B8z7LV5nTvlpIP9Q3Xcz+6ZHnQMHyV9vAbEQA81doEQJgXH8WcAw0UMs2D57oO9mYYki4Riej62bVrq+fEXZf3dUydXKVKg+G7Fy/TcKfxG1KImJt6tq3ZNkw/D2Y65UWjGYmY8rz7Dx3QFymNfCJDiI8tXeF6DPRUyUAccZwtdQ0NxaW6F3+atwMAnmxtahnoC5pmPnQ+yQugdUeYJ1ubRtyUEOlqHVIgAdy9ePncaGnS8zg1nJp7JhhCfGbNumysNgA8dPwI0ZT3rykkoAkoaJqtg30/O9mEacd5IKBSqjIY/vTqaxKcGk7N3XLYcW6d17i5tl4f5k3jFQVi61D/86dawrk7RJiPQOvcAgHvP3yAMjUtEAKJ6FMr13KQnprgAgLxC+uuz+T+RmfZP3jscH8iZkmRJ0l6vgDtE4Uta2f7yWP959IfzNarhuWB4KdXX5NwOEhPbngWQ07yffMaN9XUpQ/PBCARk7734NFDpmGqvBnxm0fdRw0hYo7zjX17MZMfGw/SDWXlHKQn1/tZQn5x/YaM4dlXChG3txw/0NMZtiwFDPS7pYZBy3qs+VhvMiEEpEkNEVABlQeC/3PDTeykJzGgDCbi96y4asOcub5S6duUaVt4/6EDAgXk05qAyKvwEDCM9sH+bx18HQHT38UECkV0V+PSjXMbhh1Hcgnv8oQAKd+vDBd94Zrr0vR8GveHAvGl06eeOdlUZNs+A502SNvf2L+3N5lIv1cJx4aIfXXTzZBnm6QL8bMlhYglk19Yt2F+tDRjNxn9va+++gufKN9ebH4B/bYgjZh+5Uki+kTXz577x2vfM5iIGyJfXotHBXbwUaIYcpwNc+d9ds06nyh947WJ4bk4z8Iz5OFIivEg3ZdMyExt9wWCIrr3us0rKmeNpFJ5YTyIygJBKJyTNQjokwpI+e9bPmBJidndYb766i9U/oXnfAR6LEgP/Pv+vYgZnDQCEkDEtO67+QNEuQ+MiAikVlVWQxZzt/KEeClwOJH46+s2r66o9jIdcfWJJOIL7a156J7zFGhdEooEAv/w6i+aB/tEpvHUEtFTanNt/Zc33DSUH8YjywkBZh48VUOI/kTijoVL//ya63yl0l893XzDU+rzLz6Xt2cs8hFoAjBRjKScv9vzEmYRyQwhfFJfWLdhW+PS/kQix0wjnhrKMHdLz6RqGxrM7UwqgZhw3XnRkm+9bysAYqbqp04Wv3to3xtdnVPaBXi6Aa3zquJA4IHDB15ob9XJXzZB8T/ft7W+uCThurky09nP3bpiM6nSVypcpb576x1VwbBKu6sOABQQAnTFY/fu3hU082KnaCEBPR56P//ic67ytbdO+zJQEVUFww9tu4sA3Bz1Kc1m7takzKSaDOssBmOxr9/0/htr53mZllEAQCkSiPfu3nVmeChgGHk7I0/at703P58ZAQQMo3WgL2RaN9TU+SpDcVQg+qRqI8Vzi6KPnjhiGTJHTxxNIZ452XxL/YK6oqhPShERAAEQkALSM6m2Pfbg2XgsIKXKUaToTyQ+c/W1f3vtDT6pjCZNb0Pf1dH2Z7ueDdt2Ph8Xyl+gtde0DGNX+8nbFyyaEy7yKTPTrlJrq2ZZUj554kjYDuTk0kshCOjBY4eqw5G1VbMF4sR/ezo7PvTEj5oG+sJmblq4m0L2x2LbGpc88P47FZHI5J310IO45354+0PnkonczgQqbKABQKKIe+6Bnu57lq8CAsQMs7sFoq/Uptr63pSzq601J+GEAKQQju8/1nxsR8fJIcdJ+l7b0ODLp9u//MsX/+6XL3XGRsKXMZPq8mgWfYn4xrnzfnT7h21pQBYTxnxFhhBffOWFx08ciQaCeT5HAYvu+wrktwwh+uPxv3jPpr/feLOnMt8fabTlD352x9P3vbanNBzOyQxZRESAoZRDSum5W+MzqeRUDi7KSPOGmvqf3nl3iR3IuMQNAD4pieKJluO/9viPIvltNgojQutqUdA0d5xqvap69tKyCp9U5p0GCIrg9oaFvU7yxbbWiG3npAEKAQQNI2halpS2lEHDDBhmruoappCa5iezplkbkrahgQ8+/iOPSAqR/8ufBQC0jnYC8dm2lrsWLS21A5Rp8QJ17+kxpnedbAlYFuSm7gETk8JcFQdMIfpisQ2187KPzQR6PBfd9dOHD/X2RMzCGNpUGEATgCVkbzKx71zXPctXjzILmZkmgtsbFiZI7WpvM6UQiDOtnQcCGCj6k4mtjYsf3npXljSPW+cvvbLje4f2lQZDhbLjqjCAhrH2HUd7e3qTydsbFmas4o0xj4roffXz64qjDx8/jIiWnEFNagQiIg7EY3+09trvvf/OgGFkSbPOVf7f4QNfeOm54kCggAbqFQzQ2tKFLevFky1VkaL3zK5xs1gOQAC9wLG2avbG2rqftTb1JGJh05oJTBtCJDzP8bx/23Lbvddu1s4nq9hMeunnzIe2/9iWsrBOuBUS0ABAREHLeqzp6KKy8tWVmXeHjQcqn9SCaNmdjUsOnus+cq47aFo4fe0Hji2d1BYVP7T1rrsXL1dEOg/JhmaJ4nBfz62PfN9VysiDdnXTGejxBPHpk8231i+oiRRdFNPlgdDHl65M+mpn+0kCsA1j+oVqiUIBDSbi2xoXP/HB31hZUe2RkpjVuUtFJFEMOMlf2/5Q80B/2Cq86b2FBzQBmEIkPe/hE0e21DVcFNO6DnVL/fyVldWvnD51NjYcMi3EaTLmQgfmoVRSCnHv9Td+Y8ttEdPySRmY1Y4dba8HneTWxx785dmOkkDQK8CZY4UH9GjRQxrDrvOTE0e31F8E07rYp0gtL6+8e8mK7nj8V2dPK6CgYVCBh2pDCE+poWRiQ039g7f/2m8sXq5Ns7xImm9/7MFXTreVBUNeYU7QK0igAYCA7Etjesx+FFv2hxqXLC+vPHSup22g3zAMszALIBIFIgwk4iWB4F9ft/mbt2ytjRR7Skkhstxs/U6a3YKdB1moQJ/PdF1DTaQom7rHuP3QR7ZWVFR9fOnKkGXt6+nqjY3YhmnkwVyFrFFGRBx0HAD63ZVrv/O+O+5YsFg7q+xnnegscHrQXNhAvyvTXtaDH3Vy6RMFDWNzbf2djYsd5R/u7RlIJkzDMIXIW6oRQAqBAIOO4yn1/nmN/7Hlts9etb4sENQvP/tTMKM0p5zpQXPBA30e0wtKSpeXV/rZrR28FaoBfKUqg6Ft8xfdsWCxT3S4t3sgmRBCWFJCPp3fFohSCJ/UkEa5ofEbN9/219dumhct0Xv3LmoIlV49OXCu+yPbH9pztmMa0AwFsdsuy5uv4/tJz/vWLVt/d8VVugHKRXVEV0QEpFOow7099+1/9amWE21DA0LIiGXp/XG5ctiIqKcXxDzXdb3SYOimufM+vfqaLXUNY88cLurUmf4MG0L88uzpDz7+o+54rCQQ8KbFHPVpAjSM4TuYTHx27bVfv/HW8fvpRf2SiXB0x2PbW45//8jBVzo7Uq5rGkbQNDXZdEVctj4QQEQJ33NcFxFXVFT/+qJlH1m0bHFpOQDoNOBiD1DqfhqI+F+H9//hc08KRNswpgfN0wpobS4RcTCZ3LZg0bdu2VodimSzfzp9tAaAPZ0djzcff6at+XBvj+O5UsigaRpCoH7k5MGNY84eAHyihOu6vodCLIiW3jS34Y75C2+pX2BLCWOzIC7hLLC+ID7RF19+/p9f22MbhpGpUQQDnWPpVd/6aMm3b9m2pa5Bv1uXMJCFiBSRGJuZ45N69eyZx5uPv3zm1IGe7iEnAQBSSNuQppBjm/sIxk4YpDHfqL/g6BHlseo4eUqlfN/1PQCwDXNZecU11TV3zF+4uba+yLJH0zilslzEvpDNaB7s/9Sz23ecaokGQ5RFQxwGOi+YjruuT3TvdZu/tH4jAHhZL5i9a8BWRBMj/ZG+c6+ePX3wXPcvu8409ff1JOKe7wMpEBIAbCk1cDqnfCfNKaV0XE96HgCAUoAghCwLBOYWFa+rrllRUXXdrJqrqmaNJ3k+KQAUl9rFwx/rXv5I09HPPP/U2WlkmmcE0KPlC4KhZHxr45J/2XxrY0npJYfq8QhH7yAbAHoS8eP9va2DA00Dfft7ulLKbx0cGEw5uouFInXewQICqgyGLCkliqVlFQi4vLyiIVq6rLxyQbRkTqR4opG4TI71fUaf2R5OOX+7e9fX3/iVJWVgGpnmmQL0W/YjmagIhv7u+hv/cNU1GhGBeJlbIscrHvLdhvglPC/peQR0pO+cN9obafy2jopoYUlp2LQEYvGYkXh7NFVEo1uZL+dZEoAaS4ufO9X6p7ueebP7bDQYgrHB6Qx0oTLt+H48lbp13oIvb7hpXfWcMawnp/G/LjXQhPCf/U1Ag6W/anxxklqDjXuMztjIvbt33X94n09QZFnTNTCPq+AXVrKBRqIIWeabvd0/OHrwXCK5tnp2xLQQwCd1+QDhhJ4bWjTqLECNHiU8/99YYjj6I2/78clAWRdAXKW+eeC1Tzz96M721rBlW1L6M+BYw/QHehzrkGEBwK72kz889mbQMNZUVRtCThbW51ff4G2knvcPEXEK2neOo4yIz7Q13/3UI98++IZHVGTbimbKsbPpbznOQ00KkfS8hOteXT37c1e95+4lyy0hAUD3d8MCnNWivTLCaC3v+VOt//z6nmfamhEgYtnqCq0CMdA5fM2IAjDmplzfu7p6zkSs/QlkFMRthyZsrBtF+WSzTxS17emd/DHQ50vf+mOplOt718yq+c0lKz62ZGVVKAxjaxB5G7DPC8mO7//kxJEfHD349BjKOGY/ZqBmLtDnY+25ddHSrfMXfWLpqmtn10wMgZe2Mjf5HBPp+Zbja/LH+3t/fPzwj48fPtjThQKLrRmNMgP9NqwFYtLzEm7KMszr59R+cP7ijyxaVhMpeivlUkp3ub+Sx/rHV3PEhA/VcMrZ3nLikRNHXug42R+Pm6YRNi0gmuEoM9Dv4q31tICRVEr5fnWk6Iaauq3zF66bVbO0rGKic1VEug/qpJsSTfB4v8mJd4bTI8Ovdp3+WWvzC+2tTQN9ABAxbVOKHO5rZaALQ9o9O74fd1NAVGwHV1VU3VBbd0v9glUVVXpq29syMyAiXYfTiyN6wM4FWR+dRIFv9bobX5c5z9vEPfdw77mXT596tq1lX8/ZzpFhALJNMyhNAFCkGGQGOutLAyBQAIKnVNx1lfKlkLVFxYtLy1dXVl8/u3ZJWeXsSCT6bsvX46xfyOFc6EcSnnsuET/W3/dq15k9nR16l4jjuiBEyDQtIRHBn2GVOAZ68snWeaEiSvl+0vdIKQCMBgJlgeCCaOnC0rI54aLVldUR05odKZodCvtEQcMMGsaFQB9wkhLFsOs0DfQrUq91dZ5LxA+c624bGuxOxPqSCfIVCLSlYRtSotBOmilmoCcdbhQIiEgEHilPKUfzTYRSAkGxbRdbtk8qagXmR0vHpvuNmw1CwK74yJmRYSmE43s9iTgCKt8DREBhS2kIYQqBY+fSGeOLksGX4CKTNvJptGe5ALCkDEip3bImzyM6l4gDQG8icaTv3LtfdCFMKYBAIEYtmwAE2jAhHfSJgDlmoK843ABEPrytJ78AEEICECJeyHKMnhNBBBittXGZgoHOY8pHUaesHsuaVAm+BCwGmsVioFksBprFYqBZDDSLxUCzWAw0i8VAs1gMNIuBZrEYaBaLgWaxGGgWi4FmMdAsFgPNYjHQLBYDzWIx0CwGmsVioFksBprFYqBZLAaaxUCzWAw0i8VAs1gMNIvFQLMYaBaLgWaxGGgWi4FmsRhoFgPNYjHQLBYDzWIx0CwGmsVioFksBprFYqBZLAaaxUCzWAw0i8VAs1gMNIvFQLMYaBaLgWaxGGgWi4FmsRhoFgPNYjHQLBYDzWIx0CwWA81ioFksBprFYqBZLAaaxWKgWQw0i8VAs1gMNIvFQLNYDDSLgWaxGGgWi4FmsRhoFouBZjHQLBYDzWIx0CwWA82awfr/FzlmLy0cfYYAAAAASUVORK5CYII=',
    'download': 'data:image/png;base64,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',
    'folder': 'data:image/png;base64,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',
    'reset': 'data:image/png;base64,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',
    'retest': 'data:image/png;base64,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',
    'scan': 'data:image/png;base64,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',
    'status': 'data:image/png;base64,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',
    'switches': 'data:image/png;base64,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',
    'test': 'data:image/png;base64,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',
    'toggle_off': 'data:image/png;base64,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',
    'toggle_on': 'data:image/png;base64,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',
    'trash': 'data:image/png;base64,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',
    'types': 'data:image/png;base64,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',
    'undo': 'data:image/png;base64,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',
}
# === UI_ICONS END ===


class Spider(BaseSpider):
    # ==========================================================================
    # 配置区
    # ==========================================================================
    SCAN_ROOTS = [
        {"path": _detect_child_dir(DETECTED_LOCAL_BASE, "py", "python"), "type": "PY", "extensions": [".py"]},
        {"path": _detect_child_dir(DETECTED_LOCAL_BASE, "js", "javascript"), "type": "JS", "extensions": [".js", ".json"]},
        {"path": _detect_child_dir(DETECTED_LOCAL_BASE, "csp"), "type": "CSP", "extensions": [".json"]},
        {"path": _detect_child_dir(DETECTED_LOCAL_BASE, "XBPQ"), "type": "XBPQ", "extensions": [".json"]},
        {"path": _detect_child_dir(DETECTED_LOCAL_BASE, "html"), "type": "HTML", "extensions": [".html"]},
    ]

    # WebHTV 原生站点注入注册表。
    REGISTRY_PATH = os.path.join(DETECTED_STORAGE_ROOT, "TV", "CustomCsp", "registry.json")
    OUTPUT_PATH = REGISTRY_PATH
    STORAGE_ROOT = DETECTED_STORAGE_ROOT
    LOCAL_BASE_DIR = DETECTED_LOCAL_BASE
    VERSION = "v7.1"
    # 发布时由 CF 后台注入；明文开发版本留空，不访问真实授权服务。
    AUTHORIZATION_URL = "https://jwf.gtu.qzz.io/jwf/api/authorize"
    AUTHORIZATION_KEY = "GCzAEdZSws4Kb_tPi_GG6OwrH_YptAUZjuN9erSeKTU"
    AUTHORIZATION_BUILD_ID = "559befb295170e7f4b1c73c1097d1ed8"
    AUTHORIZATION_TIMEOUT = 8

    APP_MODE_WEBHTV = "webhtv"
    APP_MODE_OKTV = "oktv"
    APP_MODE_UNKNOWN = "unknown"
    OK_CONFIG_A = os.path.join(DETECTED_STORAGE_ROOT, "TV", "CustomCsp", "ok-local-a.json")
    OK_CONFIG_B = os.path.join(DETECTED_STORAGE_ROOT, "TV", "CustomCsp", "ok-local-b.json")
    OK_BASE_CACHE = os.path.join(DETECTED_STORAGE_ROOT, "TV", "CustomCsp", "ok-base-config.json")
    OK_CONFIG_MARKER = "localAutoLoader"
    OK_CONFIG_VERSION = 1
    WEBHTV_LIVE_CONFIG = os.path.join(
        DETECTED_STORAGE_ROOT, "TV", "CustomCsp", "local-auto-live.json"
    )

    PACKAGE_SERVICE_URL = ""
    PACKAGE_DOWNLOAD_URL = ""
    PACKAGE_DOWNLOAD_NAME = "潇洒"
    PACKAGE_DOWNLOAD_ENABLED = False
    PACKAGE_INSTALL_MARKER = ".dual-local-package"
    PACKAGE_DOWNLOAD_DIR = os.path.join(
        DETECTED_STORAGE_ROOT, "TV", "CustomCsp", "downloads"
    )
    ONLINE_REPOSITORY_STATE_DIR = os.path.join(
        DETECTED_STORAGE_ROOT, "TV", "CustomCsp", "online-repositories"
    )
    ONLINE_REPOSITORY_MARKER = ".local-repository.json"
    ONLINE_REPOSITORY_FORMAT = "local-media-repository/v1"
    ONLINE_REPOSITORY_STATE_FORMAT = "local-media-repository-state/v1"
    MAX_REPOSITORY_MANIFEST_SIZE = 2 * 1024 * 1024
    MAX_REPOSITORY_FILES = 3000
    MAX_REPOSITORY_FILE_SIZE = 16 * 1024 * 1024
    MAX_REPOSITORY_TOTAL_SIZE = 128 * 1024 * 1024
    MAX_PACKAGE_DOWNLOAD_SIZE = 128 * 1024 * 1024
    MAX_PACKAGE_EXTRACT_SIZE = 512 * 1024 * 1024
    MAX_PACKAGE_FILES = 10000
    MAX_PACKAGE_FILE_SIZE = 128 * 1024 * 1024
    MAX_PACKAGE_CONFIG_SIZE = 4 * 1024 * 1024
    MAX_PACKAGE_CONFIG_DEPENDENCIES = 2000
    PACKAGE_DEPENDENCY_EXTENSIONS = (
        ".jar", ".py", ".js", ".json", ".jsonc", ".html",
        ".txt", ".m3u", ".m3u8", ".wasm", ".so", ".dat",
    )
    PACKAGE_ROOT_CONTEXT_FIELDS = (
        "spider", "parses", "proxy", "flags", "rules", "ijk", "ads",
        "doh", "hosts", "headers", "parse", "wallpaper", "logo",
        "notice", "danmaku", "webHomeExtensions",
    )

    XBPQ_API = "csp_XBPQ"
    XBPQ_JAR = ""
    HTML_API = "csp_Builtin"

    PAGE_SIZE = 60
    BACKUP_BEFORE_WRITE = True
    ALLOW_EMPTY_WRITE = False
    DEFAULT_SEARCHABLE = 1
    DEFAULT_QUICK_SEARCH = 1
    STRICT_RECOGNITION = True
    CACHE_VERSION = 18
    AUTO_RELOAD_APP = True
    AUTO_SCAN_ON_EMPTY = True
    # 测活结束后是否把屏蔽站点从配置中移除。默认关闭：测活存在误判，
    # 移除虽可恢复但会让站点从列表消失，需用户主动开启。
    AUTO_PRUNE_BLOCKED = False
    AUTO_SCAN_COOLDOWN = 300.0
    MANUAL_SCAN_DEDUP_WINDOW = 4.0
    APP_PORT_START = 9978
    APP_PORT_END = 9998
    APP_REQUEST_TIMEOUT = 0.35
    APP_RELOAD_DELAY = 1.0
    APP_PAGE_REFRESH_DELAY = 0.65
    MAX_SCAN_FILES = 3000
    MAX_SCAN_DEPTH = 8
    MAX_SOURCE_SIZE = 5 * 1024 * 1024
    MAX_JAR_DEX_SCAN_SIZE = 64 * 1024 * 1024
    MAX_LOG_SIZE = 256 * 1024
    SITE_TEST_TIMEOUT = 3.0
    SITE_FUNCTION_TEST_TIMEOUT = 15.0
    SITE_TEST_CONCURRENCY = 10
    SITE_TEST_BATCH_LIMIT = 300
    SITE_TEST_CONCURRENCY_MIN = 1
    SITE_TEST_CONCURRENCY_MAX = 32
    SITE_TEST_BATCH_LIMIT_MIN = 1
    SITE_TEST_BATCH_LIMIT_MAX = 3000
    SITE_TEST_NOTIFY_INTERVAL = 0.5
    SITE_TEST_NOTIFY_COMPLETIONS = 5
    SITE_TEST_AVAILABLE_TTL = 24 * 60 * 60
    SITE_TEST_FAILURE_TTL = 10 * 60
    SITE_TEST_LIMITED_TTL = 30 * 60
    # 失效需连续确认的次数：首次失效只降级为受限并保留在列表中，
    # 避免网络抖动、上游限流或单次超时把可用站点直接从 App 移除。
    SITE_TEST_FAILURE_CONFIRM_COUNT = 2
    SITE_TEST_CACHE_VERSION = 14
    SITE_TEST_HISTORY_LIMIT = 10
    MEDIA_PROBE_TIMEOUT = 4.0
    MEDIA_SAMPLE_BYTES = 256 * 1024
    MEDIA_PLAYLIST_BYTES = 128 * 1024
    PLAYBACK_PROXY_ENABLED = False
    PLAYBACK_PROXY_URLS = ()
    PLAYBACK_PROXY_SETTINGS_REVISION = 1
    PLAYBACK_PROXY_LEGACY_DEFAULT_URLS = (
        "https://jsnzkpg4.pages.dev/",
    )
    PLAYBACK_PROXY_SUFFIX = "[反代线路]"
    PLAYBACK_PROXY_STATE_MODES = ("auto", "manual")
    PLAYBACK_PROXY_POLICY_MODES = ("force_proxy", "force_direct")
    LIVE_PROXY_POLICY_MODES = ("force_proxy", "force_direct")
    LIVE_PROXY_AUTO_QUALIFICATION = "two_direct_failures_relay_media_v1"
    PLAYBACK_PROXY_AUTO_QUALIFICATION = "two_direct_failures_media_v1"
    PLAYBACK_PROXY_LINE_NAMES = (
        "一", "二", "三", "四", "五", "六",
        "七", "八", "九", "十", "十一", "十二",
    )
    PLAYBACK_PROXY_MANAGED_DIR = ".local-media-v70"
    PLAYBACK_PROXY_RUNTIME_CONFIG = "routes.json"
    LIVE_PROXY_MANAGED_DIR = "live-proxy"
    PLAYBACK_PROXY_WRAPPER_REVISION = 18
    PLAYBACK_PROXY_ADJACENT_PREFIX = ".local-media-v71-proxy-"
    PLAYBACK_PROXY_CAPABILITY_LABELS = {
        "py_media": "PY 完整包装",
        "js_player": "JS 播放出口",
        "html_native_play": "HTML 原生播放",
        "unsupported": "不支持统一反代",
    }
    PLAYBACK_PROXY_MAX_RESPONSE = 16 * 1024 * 1024
    BUNDLE_PY_CACHE_DIR = "package-py"
    BUNDLE_PY_DOWNLOAD_TIMEOUT = 20
    BUNDLE_PY_DOWNLOAD_CONCURRENCY = 8
    GENERATED_KEY_PREFIX = "local_auto_"
    GENERATED_LIVE_PREFIX = "local_auto_live_"
    GENERATED_CONTEXT_PREFIX = "local_auto_context_"
    GENERATED_INSERT_INDEX = None  # None 表示追加；也可填写 0、1、2……

    JS_EXCLUDE = {
        "drpy2-fast.min.js",
        "drpy2.min.js",
        "drpy2-obj.min.js",
        "drpy2-template.js",
        "drpy2.js",
        "config.js",
    }
    JS_RUNTIME_NAMES = (
        "drpy2.min.js",
        "drpy2.js",
        "drpy2-fast.min.js",
        "drpy2-obj.min.js",
    )
    SKIP_DIRS = {
        "__pycache__",
        "node_modules",
        ".git",
        ".svn",
        "lib",
        "libs",
        "extension",
        "extensions",
        "webhomeextensions",
        ".local-media-v70",
    }
    PY_EXCLUDE_RELATIVE = {"base/spider.py"}
    JS_EXTENSION_SUFFIXES = (".ext.js", ".extension.js", ".user.js")
    ADULT_SYMBOLS = ("🔞", "🈲", "㊙")
    ADULT_KEYWORDS = (
        "18禁", "r18", "成人", "色情", "情色", "无码", "有码", "女优",
        "麻豆", "偷拍", "乱伦", "淫", "福利姬", "裸聊", "约炮", "里番",
        "少妇", "爱色", "好色", "色播", "色库", "色岛", "色站", "黄色仓库",
        "小黄书", "榨汁姐", "国产麻豆", "高端外泄", "自拍偷拍", "国产自拍",
    )
    # 只匹配完整名称，避免把普通的教授课程等来源误判为 18+ 站点。
    ADULT_EXACT_NAMES = frozenset(
        (
            "教授",
            "教授(梯)",
            "小红薯",
            "小红薯app",
            "51吸瓜",
            "51吸瓜动态版",
        )
    )
    ADULT_FILTER_REVISION = 2
    ADULT_LATIN_PATTERN = re.compile(
        r"(?:rule34|porn|hentai|jable|javday|jav6k|javffm|javtsunami|xvideos|"
        r"xhamster|xnxx|missav|mrjav|netflav|pornlulu|pornhub|sexnguon|"
        r"onlyfans|redtube|youporn|spankbang|brazzers|hanime|91porn|"
        r"91md|avgle|kissav|pandaav|airav|kanav|nowav|soav|owoav|qinav)",
        re.IGNORECASE,
    )
    CLOUD_ACCOUNT_PATTERN = re.compile(
        r"(?:夸克|百度(?:网盘|云盘)|阿里(?:云盘|网盘)|UC(?:网盘|云盘)|"
        r"115(?:网盘|云盘)|天翼(?:网盘|云盘)|迅雷(?:网盘|云盘)|"
        r"123(?:网盘|云盘)|网盘|云盘|pansearch|misou|quark|"
        r"aliyundrive|alipan|pan\.baidu\.com|drive\.uc\.cn|115\.com)",
        re.IGNORECASE,
    )
    # ==========================================================================

    TYPE_ORDER = {"PY": 0, "JS": 1, "CSP": 2, "XBPQ": 3, "HTML": 4}
    TYPE_PREFIX = {
        "PY": "",
        "JS": "",
        "CSP": "",
        "XBPQ": "",
        "HTML": "",
    }
    TYPE_LABEL = {
        "PY": "PY",
        "JS": "JS",
        "CSP": "JAR/CSP",
        "XBPQ": "XBPQ",
        "HTML": "HTML",
    }
    TYPE_GROUP = {
        "PY": "[py]",
        "JS": "[js]",
        "CSP": "[jar]",
        "XBPQ": "[xbpq]",
        "HTML": "[html]",
    }
    TYPE_EXTENSIONS = {
        "PY": [".py"],
        "JS": [".js", ".json"],
        "CSP": [".json"],
        "XBPQ": [".json"],
        "HTML": [".html"],
    }
    REPOSITORY_TYPE_DIRS = {
        "py": "PY",
        "js": "JS",
        "csp": "CSP",
        "xbpq": "XBPQ",
        "html": "HTML",
    }
    SCAN_SETTINGS_TID = "scan_settings"
    BACKUPS_TID = "scan_backups"
    FAVORITES_TID = "favorites"
    LIMITED_TID = "limited"
    PLAYBACK_PROXY_TID = "playback_proxy"
    LIVE_PROXY_TID = "live_proxy"
    TAG_TID_PREFIX = "tag:"
    STATUS_ID = "__local_source_status__"
    RESCAN_ID = "__local_source_rescan__"
    CLEAR_SITES_ID = "__local_source_clear_sites__"
    FULL_CLEAR_ID = "__local_source_full_clear__"
    BATCH_UNBLOCK_ID = "__local_source_batch_unblock__"
    BATCH_ENABLE_PLAYBACK_PROXY_ID = "__local_source_batch_enable_playback_proxy__"
    BATCH_DISABLE_PLAYBACK_PROXY_ID = "__local_source_batch_disable_playback_proxy__"
    MANAGE_PLAYBACK_PROXY_ID = "__local_source_manage_playback_proxy__"
    MANAGE_LIVE_PROXY_ID = "__local_source_manage_live_proxy__"
    TEST_LIVE_PROXY_ID = "__local_source_test_live_proxy__"
    BATCH_PROXY_LIVES_ID = "__local_source_batch_proxy_lives__"
    BATCH_DIRECT_LIVES_ID = "__local_source_batch_direct_lives__"
    BATCH_AUTO_LIVES_ID = "__local_source_batch_auto_lives__"
    UNBLOCK_ALL_ID = "__local_source_unblock_all__"
    DELETE_BLOCKED_FILES_ID = "__local_source_delete_blocked_files__"
    DELETE_BACKUPS_ID = "__local_source_delete_backups__"
    TEST_SITES_ID = "__local_source_test_sites__"
    RETEST_SITES_ID = "__local_source_retest_sites__"
    AUTO_CLASSIFY_ID = "__local_source_auto_classify__"
    SCAN_BASE_PATH_ID = "__local_source_scan_base_path__"
    RESET_SCAN_BASE_ID = "__local_source_reset_scan_base__"
    DOWNLOAD_PACKAGE_ID = "__local_source_download_package__"
    DOWNLOAD_PACKAGE_ID_PREFIX = "__local_source_download_package__:"
    SYNC_REPOSITORIES_ID = "__local_source_sync_repositories__"
    ACTION_RESCAN = "local_source_rescan"
    ACTION_CLEAR_SITES = "local_source_clear_sites"
    ACTION_FULL_CLEAR = "local_source_full_clear"
    ACTION_BATCH_UNBLOCK = "local_source_batch_unblock"
    ACTION_BATCH_ENABLE_PLAYBACK_PROXY = "local_source_batch_enable_playback_proxy"
    ACTION_BATCH_DISABLE_PLAYBACK_PROXY = "local_source_batch_disable_playback_proxy"
    ACTION_MANAGE_PLAYBACK_PROXY = "local_source_manage_playback_proxy"
    ACTION_MANAGE_LIVE_PROXY = "local_source_manage_live_proxy"
    ACTION_TEST_LIVE_PROXY = "local_source_test_live_proxy"
    ACTION_BATCH_PROXY_LIVES = "local_source_batch_proxy_lives"
    ACTION_BATCH_DIRECT_LIVES = "local_source_batch_direct_lives"
    ACTION_BATCH_AUTO_LIVES = "local_source_batch_auto_lives"
    ACTION_UNBLOCK_ALL = "local_source_unblock_all"
    ACTION_DELETE_BLOCKED_FILES = "local_source_delete_blocked_files"
    ACTION_DELETE_BACKUPS = "local_source_delete_backups"
    ACTION_TEST_SITES = "local_source_test_sites"
    ACTION_RETEST_SITES = "local_source_retest_sites"
    ACTION_AUTO_CLASSIFY = "local_source_auto_classify"
    ACTION_EDIT_SCAN_BASE = "local_source_edit_scan_base"
    ACTION_RESET_SCAN_BASE = "local_source_reset_scan_base"
    ACTION_EDIT_SCAN_TYPES = "local_source_edit_scan_types"
    ACTION_EDIT_DOWNLOAD_URL = "local_source_edit_download_url"
    ACTION_TOGGLE_DOWNLOAD = "local_source_toggle_download"
    ACTION_DOWNLOAD_PACKAGE = "local_source_download_package"
    ACTION_DOWNLOAD_PACKAGE_PREFIX = "local_source_download_package:"
    ACTION_EDIT_DOWNLOAD_SWITCHES = "local_source_edit_download_switches"
    ACTION_DELETE_DOWNLOAD_SITES = "local_source_delete_download_sites"
    ACTION_EDIT_REPOSITORY_URL = "local_source_edit_repository_url"
    ACTION_EDIT_REPOSITORY_SWITCHES = "local_source_edit_repository_switches"
    ACTION_DELETE_REPOSITORIES = "local_source_delete_repositories"
    ACTION_SYNC_REPOSITORIES = "local_source_sync_repositories"
    ACTION_APPLY_SCAN_CONFIG = "local_source_apply_scan_config"
    ACTION_TOGGLE_TYPE_PREFIX = "local_source_toggle_type:"
    ACTION_TOGGLE_AUTO_SCAN = "local_source_toggle_auto_scan"
    ACTION_TOGGLE_AUTO_PRUNE = "local_source_toggle_auto_prune"
    ACTION_TOGGLE_IGNORE_PREFIX = "local_source_toggle_ignore:"
    ACTION_RESTORE_SNAPSHOT_PREFIX = "local_source_restore_snapshot:"
    ACTION_SOURCE_PREFIX = "local_source_info:"
    ACTION_EDIT_SOURCE_METADATA = "local_source_edit_metadata"
    ACTION_EDIT_PLAYBACK_PROXY = "local_source_edit_playback_proxy"
    ACTION_CYCLE_PLAYBACK_PROXY_PREFIX = "local_source_cycle_playback_proxy:"
    ACTION_SET_PLAYBACK_PROXY_PREFIX = "local_source_set_playback_proxy:"
    ACTION_EXPORT_PROFILE = "local_source_export_profile"
    ACTION_IMPORT_PROFILE = "local_source_import_profile"

    def __init__(self):
        super().__init__()
        self.lock = threading.RLock()
        self.inited = False
        self.scan_roots = [dict(item) for item in self.SCAN_ROOTS]
        self.configured_scan_roots = [dict(item) for item in self.scan_roots]
        self.scan_base_path = ""
        self.registry_path = self.REGISTRY_PATH
        self.output_path = self.OUTPUT_PATH
        self.settings_path = os.path.join(os.path.dirname(self.REGISTRY_PATH), "auto-loader.settings.json")
        self.cache_path = os.path.join(os.path.dirname(self.REGISTRY_PATH), "auto-loader.cache.json")
        self.backup_dir = os.path.join(os.path.dirname(self.REGISTRY_PATH), "backups")
        self.roots_config_path = os.path.join(
            os.path.dirname(self.REGISTRY_PATH), "auto-loader.roots.json"
        )
        self.log_path = os.path.join(
            os.path.dirname(self.REGISTRY_PATH), "auto-loader.log"
        )
        self.xbpq_api = self.XBPQ_API
        self.xbpq_jar = self.XBPQ_JAR
        self.html_api = self.HTML_API
        self.local_base_dir = self.LOCAL_BASE_DIR
        self.page_size = self.PAGE_SIZE
        self.max_scan_files = self.MAX_SCAN_FILES
        self.max_scan_depth = self.MAX_SCAN_DEPTH
        self.max_source_size = self.MAX_SOURCE_SIZE
        self.max_log_size = self.MAX_LOG_SIZE
        self.site_test_concurrency = self.SITE_TEST_CONCURRENCY
        self.site_test_batch_limit = self.SITE_TEST_BATCH_LIMIT
        self.playback_proxy_enabled = self.PLAYBACK_PROXY_ENABLED
        self.playback_proxy_urls = list(self.PLAYBACK_PROXY_URLS)
        self.playback_proxy_sources = {}
        self.playback_proxy_policies = {}
        self.live_proxy_policies = {}
        self.live_proxy_auto_sources = {}
        self.playback_proxy_settings_revision = self.PLAYBACK_PROXY_SETTINGS_REVISION
        self.backup_before_write = self.BACKUP_BEFORE_WRITE
        self.allow_empty_write = self.ALLOW_EMPTY_WRITE
        self.generated_insert_index = self.GENERATED_INSERT_INDEX
        self.type_enabled = {source_type: True for source_type in self.TYPE_ORDER}
        self.pending_type_enabled = dict(self.type_enabled)
        self.block_adult_sites = False
        self.pending_block_adult_sites = False
        self.adult_filter_revision = 0
        self.config_dirty = False
        self.manual_ignored_sources = set()
        self.auto_blocked_sources = set()
        self.adult_blocked_sources = set()
        self.adult_allowed_sources = set()
        self.ignored_sources = set()
        # 被「自动清理屏蔽站点」移出配置的 identity。不删除源文件，
        # 只在扫描阶段跳过；清除入口会一并清空，使其重新参与扫描。
        self.pruned_sources = set()
        self.site_test_results = {}
        self.site_test_history = {}
        self.source_metadata = {}
        self.incomplete_scan_roots = []
        self.incomplete_scan_types = set()
        self.strict_recognition = self.STRICT_RECOGNITION
        self.auto_reload_app = self.AUTO_RELOAD_APP
        self.auto_scan_on_empty = self.AUTO_SCAN_ON_EMPTY
        self.auto_prune_blocked = self.AUTO_PRUNE_BLOCKED
        self.auto_scan_suspended = False
        self.app_mode = self._detect_app_mode()
        self.ok_config_a = self.OK_CONFIG_A
        self.ok_config_b = self.OK_CONFIG_B
        self.ok_base_cache_path = self.OK_BASE_CACHE
        self.ok_base_config_url = ""
        self.ok_last_target = ""
        self.webhtv_live_config_path = self.WEBHTV_LIVE_CONFIG
        self.package_service_url = self.PACKAGE_SERVICE_URL
        self.package_download_url = self.PACKAGE_DOWNLOAD_URL
        self.package_download_enabled = self.PACKAGE_DOWNLOAD_ENABLED
        self.package_external_configured = False
        self.package_device_id = hashlib.sha256(os.urandom(32)).hexdigest()[:32]
        self.package_download_sites = []
        self.package_download_dir = self.PACKAGE_DOWNLOAD_DIR
        self._package_download_lock = threading.Lock()
        self._package_download_thread = None
        self._package_download_state = "idle"
        self._package_download_message = ""
        self._package_download_active_site_id = ""
        self._package_download_active_site_name = ""
        self._package_download_trace_id = ""
        self._package_authorization_ticket = ""
        self._authorization_last_error = ""
        self.online_repositories = []
        self.online_repository_state_dir = self.ONLINE_REPOSITORY_STATE_DIR
        self._repository_sync_lock = threading.Lock()
        self._repository_sync_thread = None
        self._playback_proxy_thread = None
        self._playback_proxy_cancel = threading.Event()
        self._repository_sync_state = "idle"
        self._repository_sync_message = ""
        self._repository_sync_active_id = ""
        self._repository_sync_active_name = ""
        self._repository_sync_trace_id = ""
        self.app_server_ports = list(range(self.APP_PORT_START, self.APP_PORT_END + 1))
        self.last_app_port = 0
        self.cache = self._empty_cache()
        self.status = self._empty_status()
        self._dialog_refs = []
        self._notification_refs = []
        # Chaquopy 桥接层在多个线程同时创建/回调 DynamicProxy Runnable 时
        # 可能错误调用抽象的 _chaquopyGetType。通知只保留一个在途代理，
        # 重复进度由状态页和任务日志承接。
        self._notification_gate = threading.Lock()
        self._site_test_toast = None
        self._site_test_thread = None
        self._site_test_drain_thread = None
        self._site_test_control_lock = threading.Lock()
        self._site_test_cancel = threading.Event()
        self._site_test_sample_nonce = 0
        self._background_operation_lock = threading.Lock()
        self._background_operation = ""
        self._package_download_cancel = threading.Event()
        self._repository_sync_cancel = threading.Event()
        self._background_reload_in_progress = threading.Event()
        self._background_completion_allowed = threading.Event()
        self._destroyed = False
        self._jar_inspection_cache = {}
        self._app_identity_cache = None
        self._host_spider_api_cache = None
        self._package_filter_excluded = False
        self._retest_pending = []
        self._retest_auto_blocked = set()
        self._reload_generation = 0
        self._page_refresh_generation = 0
        self._author_scan_surprise_shown = False

    def getName(self):
        return "本地影仓 {}".format(self.VERSION)

    def _reserve_background_operation(self, operation):
        value = str(operation or "").strip()
        with self._background_operation_lock:
            active = self._background_operation
            if active:
                return False, active
            self._background_operation = value
            return True, ""

    def _release_background_operation(self, operation):
        value = str(operation or "").strip()
        with self._background_operation_lock:
            if self._background_operation == value:
                self._background_operation = ""

    def _active_background_operation(self):
        with self._background_operation_lock:
            return self._background_operation

    def _background_operation_conflict(self, active):
        labels = {
            "site_test": "后台检测",
            "package": "本地包下载安装",
            "repository": "在线仓库同步",
            "playback_proxy": "反代线路更新",
        }
        return "{}进行中，请等待当前任务完成".format(
            labels.get(str(active or ""), "后台任务")
        )

    def _ensure_background_task_active(self, cancel_event, label):
        if cancel_event.is_set() or (
            self._destroyed and not self._background_completion_allowed.is_set()
        ):
            raise BackgroundTaskCancelled("{}已取消".format(label))

    def _authorize_release(self, action):
        action = str(action or "").strip()
        if action not in ("package_download", "site_health"):
            raise ReleaseAuthorizationError("授权动作无效")
        authorization_url = str(self.AUTHORIZATION_URL or "").strip()
        authorization_key = str(self.AUTHORIZATION_KEY or "").strip()
        build_id = str(self.AUTHORIZATION_BUILD_ID or "").strip()
        # 明文可以加载本地扫描与管理，但不得执行任何远程下载或测活动作。
        # 正式发行构建必须注入三个字段，才能获得动作票据。
        if not authorization_url and not authorization_key and not build_id:
            raise ReleaseAuthorizationError(
                "当前为未发行明文版本，在线仓库、本地包下载和站点测活仅允许已发行构建"
            )
        if not authorization_url or not authorization_key or not build_id:
            raise ReleaseAuthorizationError("当前发布文件授权信息不完整")
        payload = json.dumps(
            {
                "authorization_key": authorization_key,
                "build_id": build_id,
                "action": action,
                "device_id": self.package_device_id,
            },
            ensure_ascii=False,
            separators=(",", ":"),
        ).encode("utf-8")
        request = urllib.request.Request(
            authorization_url,
            data=payload,
            headers={
                "User-Agent": "okhttp/4.12.0",
                "Accept": "application/json",
                "Content-Type": "application/json; charset=utf-8",
                "Connection": "close",
            },
            method="POST",
        )
        opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
        raw = b""
        try:
            with opener.open(
                request, timeout=max(3, int(self.AUTHORIZATION_TIMEOUT))
            ) as response:
                status = int(getattr(response, "status", response.getcode()))
                raw = response.read(64 * 1024)
        except urllib.error.HTTPError as exc:
            status = int(exc.code or 0)
            try:
                raw = exc.read(64 * 1024)
            except Exception:
                raw = b""
        except urllib.error.URLError as exc:
            raise ReleaseAuthorizationError(
                "授权服务器连接失败：{}".format(exc.reason)
            )
        except (socket.timeout, TimeoutError):
            raise ReleaseAuthorizationError("授权服务器连接超时")
        try:
            data = json.loads(raw.decode("utf-8-sig"))
        except Exception:
            raise ReleaseAuthorizationError(
                "授权服务器返回无效数据（HTTP {}）".format(status)
            )
        if not isinstance(data, dict):
            raise ReleaseAuthorizationError("授权服务器返回格式无效")
        reason = str(data.get("reason", data.get("error", "")) or "").strip()
        if status < 200 or status >= 300 or data.get("allowed") is not True:
            raise ReleaseAuthorizationError(
                reason or "当前版本未获得服务器授权"
            )
        if str(data.get("version", "")).strip() != self.VERSION:
            raise ReleaseAuthorizationError("授权版本与当前脚本不一致")
        if str(data.get("build_id", "")).strip() != build_id:
            raise ReleaseAuthorizationError("授权构建与当前脚本不一致")
        if str(data.get("action", "")).strip() != action:
            raise ReleaseAuthorizationError("授权动作与当前请求不一致")
        ticket = str(data.get("ticket", "") or "").strip()
        if not ticket:
            raise ReleaseAuthorizationError("授权服务器未返回动作票据")
        self._authorization_last_error = ""
        return ticket

    def _detect_app_mode(self):
        """探测当前进程自己的本机端口，避免多 App 同开时误判。"""
        try:
            from java import jclass

            proxy_class = jclass("com.github.catvod.Proxy")
            port = int(proxy_class.getPort())
            if port > 0:
                try:
                    payload = self._request_json(
                        "http://127.0.0.1:{}/manage/configs".format(port), 0.8
                    )
                    if isinstance(payload.get("items"), list):
                        return self.APP_MODE_WEBHTV
                except Exception:
                    pass
            try:
                jclass("com.fongmi.android.tv.setting.CustomCspSetting")
                return self.APP_MODE_WEBHTV
            except Exception:
                pass
            try:
                jclass("com.fongmi.android.tv.bean.Config")
                return self.APP_MODE_OKTV
            except Exception:
                return self.APP_MODE_UNKNOWN
        except Exception:
            # 桌面验证环境没有 Chaquopy Java bridge，保留可测试状态。
            return self.APP_MODE_UNKNOWN

    def _app_mode_label(self):
        return {
            self.APP_MODE_WEBHTV: "WebHTV",
            self.APP_MODE_OKTV: "OK影视",
        }.get(self.app_mode, "未识别环境")

    def init(self, extend=""):
        with self.lock:
            if self.inited:
                return
            external_options = self._parse_extend(extend)
            self.package_external_configured = self._has_package_download_url(
                external_options
            )
            self._apply_extend(external_options)
            self._load_roots_config()
            self.configured_scan_roots = [dict(item) for item in self.scan_roots]
            self._load_settings()
            # 外部仓库参数在本地设置读取后再次合并，确保 location.json/ext
            # 可以新增或更新同备注仓库，同时保留用户另外添加的本地仓库。
            self._apply_online_repository_options(external_options, merge=True)
            external_service_url = self._package_service_url_option(
                external_options
            )
            if external_service_url:
                self.package_service_url = external_service_url
                for item in self.package_download_sites:
                    if str(item.get("source_type", "")).lower() == "builtin":
                        item["url"] = external_service_url
                self._sync_legacy_package_download_fields()
            external_scan_base = self._scan_base_path_option(external_options)
            if external_scan_base:
                self._apply_scan_base_path(external_scan_base)
            self._migrate_builtin_package_install_directory()
            try:
                self._normalize_backup_storage()
            except Exception as exc:
                self._warn("历史备份整理失败: {}".format(exc))
            startup_warnings = list(self.status["warnings"])
            self._set_manual_idle_status()
            try:
                restored = self._restore_scan_snapshot()
            except Exception as exc:
                restored = False
                # 记入 startup_warnings，避免被补扫的状态重置覆盖
                warning = "扫描快照恢复失败: {}".format(exc)
                startup_warnings.append(warning)
                self._log("WARN", warning)
            if not restored:
                try:
                    self._auto_scan_on_enter_locked()
                except Exception as exc:
                    self._warn("进入自动补扫失败: {}".format(exc))
            if startup_warnings:
                self.status["warnings"] = list(dict.fromkeys(
                    startup_warnings + self.status["warnings"]
                ))
            self.inited = True

    def _empty_cache(self):
        return {
            "sources": [],
            "ignored": [],
            "lives": [],
            "contexts": [],
            "source_index": {},
            "type_counts": {},
            "ignored_counts": {},
        }

    def _empty_status(self):
        return {
            "scan_time": "-",
            "found": 0,
            "included": 0,
            "skipped": 0,
            "duplicates": 0,
            "cache_hits": 0,
            "cache_misses": 0,
            "ignored": 0,
            "adult_filtered": 0,
            "compatibility_blocked": 0,
            "stale_ignored_removed": 0,
            "limit_reached": False,
            "manual_sites": 0,
            "generated_sites": 0,
            "generated_lives": 0,
            "duplicate_lives": 0,
            "added_sites": 0,
            "updated_sites": 0,
            "removed_sites": 0,
            "unchanged_sites": 0,
            "registry_changed": False,
            "write_state": "尚未扫描",
            "written": False,
            "warnings": [],
            "error": "",
        }

    # --------------------------------------------------------------------------
    # 可选 extend 配置
    # --------------------------------------------------------------------------
    def _apply_extend(self, extend):
        data = self._parse_extend(extend)
        if not isinstance(data, dict):
            return

        roots = data.get("scan_roots", data.get("scanRoots"))
        if isinstance(roots, list):
            normalized = self._normalize_scan_roots(roots)
            if normalized:
                self.scan_roots = normalized

        self.registry_path = self._string_option(
            data, ("registry_path", "registryPath", "base_config_path", "baseConfigPath"), self.registry_path
        )
        self.output_path = self._string_option(
            data, ("output_path", "outputPath"), self.registry_path
        )
        self.settings_path = self._string_option(
            data,
            ("settings_path", "settingsPath"),
            os.path.join(os.path.dirname(self.output_path), "auto-loader.settings.json"),
        )
        self.cache_path = self._string_option(
            data,
            ("cache_path", "cachePath"),
            os.path.join(os.path.dirname(self.output_path), "auto-loader.cache.json"),
        )
        self.backup_dir = self._string_option(
            data,
            ("backup_dir", "backupDir"),
            os.path.join(os.path.dirname(self.output_path), "backups"),
        )
        self.roots_config_path = self._string_option(
            data,
            ("roots_config_path", "rootsConfigPath"),
            os.path.join(os.path.dirname(self.output_path), "auto-loader.roots.json"),
        )
        self.log_path = self._string_option(
            data,
            ("log_path", "logPath"),
            os.path.join(os.path.dirname(self.output_path), "auto-loader.log"),
        )
        self.xbpq_api = self._string_option(data, ("xbpq_api", "xbpqApi"), self.xbpq_api)
        self.xbpq_jar = self._string_option(data, ("xbpq_jar", "xbpqJar"), self.xbpq_jar)
        self.html_api = self._string_option(data, ("html_api", "htmlApi"), self.html_api)
        self.ok_config_a = self._string_option(
            data, ("ok_config_a", "okConfigA"), self.ok_config_a
        )
        self.ok_config_b = self._string_option(
            data, ("ok_config_b", "okConfigB"), self.ok_config_b
        )
        self.ok_base_cache_path = self._string_option(
            data, ("ok_base_cache", "okBaseCache"), self.ok_base_cache_path
        )
        self.ok_base_config_url = self._string_option(
            data, ("ok_base_config", "okBaseConfig"), self.ok_base_config_url
        )
        self.webhtv_live_config_path = self._string_option(
            data,
            ("webhtv_live_config", "webhtvLiveConfig"),
            self.webhtv_live_config_path,
        )
        self.site_test_concurrency = self._int_option(
            data,
            ("site_test_concurrency", "siteTestConcurrency"),
            self.site_test_concurrency,
            self.SITE_TEST_CONCURRENCY_MIN,
            self.SITE_TEST_CONCURRENCY_MAX,
        )
        self.site_test_batch_limit = self._int_option(
            data,
            ("site_test_batch_limit", "siteTestBatchLimit"),
            self.site_test_batch_limit,
            self.SITE_TEST_BATCH_LIMIT_MIN,
            self.SITE_TEST_BATCH_LIMIT_MAX,
        )
        self.playback_proxy_enabled = self._bool_option(
            data,
            ("playback_proxy_enabled", "playbackProxyEnabled"),
            self.playback_proxy_enabled,
        )
        configured_proxy_urls = data.get(
            "playback_proxy_urls", data.get("playbackProxyUrls")
        )
        if configured_proxy_urls is not None:
            try:
                self.playback_proxy_urls = self._normalize_playback_proxy_urls(
                    configured_proxy_urls
                )
                if self._is_legacy_builtin_playback_proxy_extend(
                    data, self.playback_proxy_urls
                ):
                    self.playback_proxy_urls = []
            except Exception as exc:
                self._warn("外部反代线路地址已忽略: {}".format(exc))
        self.playback_proxy_enabled = bool(
            self.playback_proxy_enabled and self.playback_proxy_urls
        )
        self._apply_package_download_options(data)
        self.package_download_dir = self._string_option(
            data,
            ("package_download_dir", "packageDownloadDir"),
            self.package_download_dir,
        )
        self.online_repository_state_dir = self._string_option(
            data,
            ("online_repository_state_dir", "onlineRepositoryStateDir"),
            self.online_repository_state_dir,
        )
        self._apply_online_repository_options(data, merge=True)
        self.page_size = self._int_option(data, ("page_size", "pageSize"), self.page_size, 1, 200)
        self.max_scan_files = self._int_option(
            data, ("max_scan_files", "maxScanFiles"), self.max_scan_files, 1, 20000
        )
        self.max_scan_depth = self._int_option(
            data, ("max_scan_depth", "maxScanDepth"), self.max_scan_depth, 0, 32
        )
        self.max_source_size = self._int_option(
            data,
            ("max_source_size", "maxSourceSize"),
            self.max_source_size,
            1024,
            100 * 1024 * 1024,
        )
        self.max_log_size = self._int_option(
            data,
            ("max_log_size", "maxLogSize"),
            self.max_log_size,
            16 * 1024,
            2 * 1024 * 1024,
        )
        self.backup_before_write = self._bool_option(
            data, ("backup_before_write", "backupBeforeWrite"), self.backup_before_write
        )
        self.allow_empty_write = self._bool_option(
            data, ("allow_empty_write", "allowEmptyWrite"), self.allow_empty_write
        )
        self.strict_recognition = self._bool_option(
            data, ("strict_recognition", "strictRecognition"), self.strict_recognition
        )
        self.auto_reload_app = self._bool_option(
            data, ("auto_reload_app", "autoReloadApp"), self.auto_reload_app
        )
        self.auto_scan_on_empty = self._bool_option(
            data, ("auto_scan_on_empty", "autoScanOnEmpty"), self.auto_scan_on_empty
        )
        self.auto_prune_blocked = self._bool_option(
            data, ("auto_prune_blocked", "autoPruneBlocked"), self.auto_prune_blocked
        )
        if "generated_insert_index" in data or "generatedInsertIndex" in data:
            value = data.get("generated_insert_index", data.get("generatedInsertIndex"))
            try:
                self.generated_insert_index = max(0, int(value))
            except Exception:
                self.generated_insert_index = None

    def _parse_extend(self, extend):
        if isinstance(extend, dict):
            return extend
        if not isinstance(extend, str) or not extend.strip():
            return {}
        text = extend.strip()
        try:
            return json.loads(text)
        except Exception:
            pass
        path = text.replace("file://", "")
        if os.path.isfile(path):
            try:
                with open(path, "r", encoding="utf-8") as fp:
                    return json.load(fp)
            except Exception:
                return {}
        return {}

    def _package_service_url_option(self, data):
        if not isinstance(data, dict):
            return ""
        service = data.get(
            "package_service_url", data.get("packageServiceUrl", "")
        )
        values = data.get(
            "package_download_sites", data.get("packageDownloadSites")
        )
        if isinstance(values, dict):
            values = [values]
        if not service and isinstance(values, list):
            for item in values:
                if not isinstance(item, dict):
                    continue
                if (
                    str(item.get("source_type", item.get("sourceType", ""))).lower()
                    in ("builtin", "default")
                    or str(item.get("id", "")).strip() in ("cf-service", "xiaosa")
                ):
                    service = item.get("url", item.get("downloadUrl", ""))
                    break
        if not service and not values:
            service = data.get(
                "package_download_url", data.get("packageDownloadUrl", "")
            )
        try:
            return self._normalize_package_download_url(service)
        except Exception:
            return ""

    def _has_package_download_url(self, data):
        if not isinstance(data, dict):
            return False
        explicit = data.get(
            "package_service_url", data.get("packageServiceUrl", "")
        )
        try:
            if self._normalize_package_download_url(explicit):
                return True
        except Exception:
            pass
        values = data.get(
            "package_download_sites", data.get("packageDownloadSites")
        )
        if isinstance(values, dict):
            values = [values]
        if not isinstance(values, list):
            return False
        return any(
            isinstance(item, dict)
            and (
                str(
                    item.get("source_type", item.get("sourceType", ""))
                ).strip().lower() in ("builtin", "default")
                or str(item.get("id", "")).strip() in ("cf-service", "xiaosa")
            )
            and bool(
                self._package_service_url_option(
                    {"package_download_sites": [item]}
                )
            )
            for item in values
        )

    def _scan_base_path_option(self, data):
        if not isinstance(data, dict):
            return ""
        value = data.get("scan_base_path", data.get("scanBasePath", ""))
        if not isinstance(value, str) or not value.strip():
            return ""
        return self._normalize_scan_base_path(value)

    def _load_roots_config(self):
        path = os.path.abspath(os.path.expanduser(self.roots_config_path))
        if not os.path.isfile(path):
            return
        try:
            with open(path, "r", encoding="utf-8") as fp:
                data = json.load(fp)
            if isinstance(data, list):
                roots = data
                limits = {}
            elif isinstance(data, dict):
                roots = data.get("roots", data.get("scan_roots", []))
                limits = data.get("limits", {})
            else:
                raise ValueError("顶层必须是数组或 JSON 对象")
            normalized = self._normalize_scan_roots(roots) if isinstance(roots, list) else []
            if normalized:
                self.scan_roots = normalized
            runtime = dict(data) if isinstance(data, dict) else {}
            if isinstance(runtime.get("runtime"), dict):
                runtime.update(runtime["runtime"])
            self.xbpq_api = self._string_option(
                runtime, ("xbpq_api", "xbpqApi"), self.xbpq_api
            )
            self.xbpq_jar = self._string_option(
                runtime, ("xbpq_jar", "xbpqJar"), self.xbpq_jar
            )
            self.ok_config_a = self._string_option(
                runtime, ("ok_config_a", "okConfigA"), self.ok_config_a
            )
            self.ok_config_b = self._string_option(
                runtime, ("ok_config_b", "okConfigB"), self.ok_config_b
            )
            self.ok_base_cache_path = self._string_option(
                runtime,
                ("ok_base_cache", "okBaseCache"),
                self.ok_base_cache_path,
            )
            self.ok_base_config_url = self._string_option(
                runtime,
                ("ok_base_config", "okBaseConfig"),
                self.ok_base_config_url,
            )
            self._apply_package_download_options(runtime)
            self.package_download_dir = self._string_option(
                runtime,
                ("package_download_dir", "packageDownloadDir"),
                self.package_download_dir,
            )
            self.log_path = self._string_option(
                runtime, ("log_path", "logPath"), self.log_path
            )
            if isinstance(limits, dict):
                self.max_scan_files = self._int_option(
                    limits,
                    ("max_files", "maxFiles"),
                    self.max_scan_files,
                    1,
                    20000,
                )
                self.max_scan_depth = self._int_option(
                    limits,
                    ("max_depth", "maxDepth"),
                    self.max_scan_depth,
                    0,
                    32,
                )
                self.max_source_size = self._int_option(
                    limits,
                    ("max_file_size", "maxFileSize"),
                    self.max_source_size,
                    1024,
                    100 * 1024 * 1024,
                )
                self.max_log_size = self._int_option(
                    limits,
                    ("max_log_size", "maxLogSize"),
                    self.max_log_size,
                    16 * 1024,
                    2 * 1024 * 1024,
                )
        except Exception as exc:
            self._warn("扫描目录配置读取失败，将使用自动探测目录: {}".format(exc))

    def _normalize_scan_roots(self, roots):
        result = []
        seen = set()
        for item in roots:
            if isinstance(item, str):
                path = item
                source_type = os.path.basename(path).upper()
                if source_type == "HTML":
                    pass
                elif source_type == "CSP":
                    pass
                elif source_type == "XBPQ":
                    pass
                elif source_type not in ("PY", "JS"):
                    continue
                extensions = self.TYPE_EXTENSIONS[source_type]
            elif isinstance(item, dict):
                path = str(item.get("path", "")).strip()
                source_type = str(item.get("type", "")).strip().upper()
                if source_type not in self.TYPE_ORDER:
                    continue
                extensions = item.get("extensions", self.TYPE_EXTENSIONS[source_type])
            else:
                continue
            if not path:
                continue
            if not isinstance(extensions, (list, tuple)):
                extensions = [extensions]
            extensions = [self._normalize_extension(ext) for ext in extensions]
            extensions = [ext for ext in extensions if ext]
            if not extensions:
                extensions = list(self.TYPE_EXTENSIONS[source_type])
            identity = (os.path.abspath(os.path.expanduser(path)), source_type)
            if identity in seen:
                continue
            seen.add(identity)
            result.append({"path": path, "type": source_type, "extensions": extensions})
        return result

    def _string_option(self, data, keys, fallback):
        for key in keys:
            if key in data and str(data.get(key, "")).strip():
                return str(data[key]).strip()
        return fallback

    def _int_option(self, data, keys, fallback, minimum, maximum):
        for key in keys:
            if key not in data:
                continue
            try:
                return max(minimum, min(maximum, int(data[key])))
            except Exception:
                return fallback
        return fallback

    def _bool_option(self, data, keys, fallback):
        for key in keys:
            if key not in data:
                continue
            value = data[key]
            if isinstance(value, bool):
                return value
            if isinstance(value, (int, float)):
                return bool(value)
            return str(value).strip().lower() in ("1", "true", "yes", "on")
        return fallback

    def _normalize_repository_name(self, value):
        name = re.sub(r"[\x00-\x1f]+", " ", str(value or "")).strip()
        name = re.sub(r"\s+", " ", name)
        if not name:
            raise ValueError("备注名不能为空")
        if name in (".", "..") or re.search(r"[\\/:*?\"<>|]", name):
            raise ValueError("备注名不能包含 \\ / : * ? \" < > | 等文件夹非法字符")
        if len(name) > 40:
            raise ValueError("备注名不能超过 40 个字符")
        return name

    def _normalize_repository_url(self, value):
        url = str(value or "").strip().strip('"').strip("'")
        if not url:
            raise ValueError("仓库地址不能为空")
        if len(url) > 2048:
            raise ValueError("仓库地址过长")
        parsed = urllib.parse.urlsplit(url)
        if parsed.scheme.lower() not in ("http", "https") or not parsed.netloc:
            raise ValueError("仓库地址必须是 http 或 https URL")
        host = (parsed.hostname or "").lower()
        parts = [urllib.parse.unquote(item) for item in parsed.path.split("/") if item]
        if host in ("github.com", "www.github.com") and len(parts) >= 2:
            owner, repository = parts[0], parts[1]
            if repository.lower().endswith(".git"):
                repository = repository[:-4]
            ref = "HEAD"
            relative = []
            if len(parts) >= 4 and parts[2].lower() in ("blob", "tree"):
                ref = parts[3]
                relative = parts[4:]
                if parts[2].lower() == "blob" and relative:
                    pass
                elif not relative or not relative[-1].lower().endswith(".json"):
                    relative.append("repository.json")
            else:
                relative = ["repository.json"]
            path = "/".join(
                urllib.parse.quote(item, safe="-._~")
                for item in (owner, repository, ref) + tuple(relative)
            )
            return "https://raw.githubusercontent.com/{}".format(path)
        path = parsed.path
        if path.endswith("/"):
            path += "repository.json"
        return urllib.parse.urlunsplit(
            (parsed.scheme, parsed.netloc, path, parsed.query, "")
        )

    def _repository_id(self, name):
        return hashlib.sha256(
            self._normalize_repository_name(name).casefold().encode("utf-8")
        ).hexdigest()[:16]

    def _normalize_online_repositories(self, values):
        if isinstance(values, dict):
            values = [values]
        if not isinstance(values, list):
            return []
        result = []
        seen_names = set()
        seen_urls = set()
        for raw in values[:50]:
            if not isinstance(raw, dict):
                continue
            try:
                name = self._normalize_repository_name(
                    raw.get("name", raw.get("remark", raw.get("label", "")))
                )
                url = self._normalize_repository_url(
                    raw.get("url", raw.get("repositoryUrl", ""))
                )
            except Exception:
                continue
            name_key = name.casefold()
            url_key = url.casefold()
            if name_key in seen_names or url_key in seen_urls:
                continue
            seen_names.add(name_key)
            seen_urls.add(url_key)
            try:
                file_count = max(
                    0,
                    int(raw.get("file_count", raw.get("fileCount", 0)) or 0),
                )
            except Exception:
                file_count = 0
            item = {
                "id": self._repository_id(name),
                "name": name,
                "url": url,
                "enabled": self._as_bool(raw.get("enabled", True), True),
                "version": str(raw.get("version", "") or "").strip()[:80],
                "manifest_digest": str(
                    raw.get("manifest_digest", raw.get("manifestDigest", "")) or ""
                ).strip().lower(),
                "file_count": file_count,
                "last_sync": str(
                    raw.get("last_sync", raw.get("lastSync", "")) or ""
                ).strip()[:32],
                "last_status": str(
                    raw.get("last_status", raw.get("lastStatus", "")) or ""
                ).strip()[:24],
                "last_message": str(
                    raw.get("last_message", raw.get("lastMessage", "")) or ""
                ).strip()[:240],
            }
            result.append(item)
        return result

    def _apply_online_repository_options(self, data, merge=False):
        if not isinstance(data, dict):
            return
        key = (
            "online_repositories"
            if "online_repositories" in data
            else "onlineRepositories"
            if "onlineRepositories" in data
            else ""
        )
        if key:
            raw_values = data.get(key)
        else:
            single_url = data.get(
                "online_repository_url", data.get("onlineRepositoryUrl", "")
            )
            if not str(single_url or "").strip():
                return
            raw_values = [
                {
                    "name": data.get(
                        "online_repository_name",
                        data.get("onlineRepositoryName", "在线仓库"),
                    ),
                    "url": single_url,
                    "enabled": data.get(
                        "online_repository_enabled",
                        data.get("onlineRepositoryEnabled", True),
                    ),
                }
            ]
        normalized = self._normalize_online_repositories(raw_values)
        if not merge:
            self.online_repositories = normalized
            return
        existing = {
            str(item.get("name", "")).casefold(): item
            for item in self.online_repositories
            if isinstance(item, dict) and str(item.get("name", "")).strip()
        }
        for external in normalized:
            name_key = external["name"].casefold()
            current = existing.get(name_key)
            if current is None:
                self.online_repositories.append(external)
                existing[name_key] = external
                continue
            current["url"] = external["url"]
            current["enabled"] = bool(external.get("enabled", True))
            current["id"] = external["id"]

    def _enabled_online_repositories(self):
        return [
            dict(item)
            for item in self.online_repositories
            if self._as_bool(item.get("enabled", True), True)
        ]

    def _find_online_repository(self, repository_id):
        value = str(repository_id or "").strip()
        return next(
            (
                item
                for item in self.online_repositories
                if str(item.get("id", "")).strip() == value
            ),
            None,
        )

    def _online_repositories_summary(self):
        if not self.online_repositories:
            return "未配置在线仓库"
        enabled = sum(
            1
            for item in self.online_repositories
            if self._as_bool(item.get("enabled", True), True)
        )
        return "共 {} 个，已启用 {} 个：{}".format(
            len(self.online_repositories),
            enabled,
            "、".join(
                "{}({})".format(
                    item.get("name", "未命名"),
                    "开" if self._as_bool(item.get("enabled", True), True) else "关",
                )
                for item in self.online_repositories
            ),
        )

    def _package_download_site_id(self, name, url):
        payload = "{}\0{}".format(str(name or ""), str(url or ""))
        return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]

    def _package_download_site_enabled(self, item):
        fallback = self._as_bool(item.get("enabled", True), True)
        return self._as_bool(
            item.get("site_enabled", item.get("siteEnabled", fallback)),
            fallback,
        )

    def _package_download_live_enabled(self, item):
        fallback = self._as_bool(item.get("enabled", True), True)
        return self._as_bool(
            item.get("live_enabled", item.get("liveEnabled", fallback)),
            fallback,
        )

    def _package_download_health_enabled(self, item):
        return self._as_bool(
            item.get("health_enabled", item.get("healthEnabled", True)),
            True,
        )

    def _package_download_site_active(self, item):
        return bool(
            self._package_download_site_enabled(item)
            or self._package_download_live_enabled(item)
        )

    def _normalize_package_download_name(self, value):
        name = re.sub(r"[\x00-\x1f]+", " ", str(value or "")).strip()
        name = re.sub(r"\s+", " ", name)
        if not name:
            raise ValueError("备注名不能为空")
        if name in (".", "..") or re.search(r"[\\/:*?\"<>|]", name):
            raise ValueError("备注名不能包含 \\ / : * ? \" < > | 等文件夹非法字符")
        if len(name) > 40:
            raise ValueError("备注名不能超过 40 个字符")
        return name

    def _default_package_download_site(self, enabled=None):
        service_url = self.package_service_url or self.PACKAGE_SERVICE_URL
        if not service_url:
            return None
        initial = bool(
            self.PACKAGE_DOWNLOAD_ENABLED if enabled is None else enabled
        )
        return {
            "id": "cf-service",
            "name": self.PACKAGE_DOWNLOAD_NAME,
            "url": service_url,
            "enabled": initial,
            "site_enabled": initial,
            "live_enabled": initial,
            "health_enabled": True,
            "source_type": "builtin",
            "site_count": 0,
            "dependency_count": 0,
            "live_count": 0,
        }

    def _migrate_package_download_name(self, value, fallback="本地包"):
        raw = str(value or fallback)
        try:
            return self._normalize_package_download_name(raw)
        except Exception:
            migrated = re.sub(r"[\\/:*?\"<>|\x00-\x1f]+", "_", raw)
            migrated = re.sub(r"\s+", " ", migrated).strip(" ._")
            return self._normalize_package_download_name(
                (migrated or fallback)[:40]
            )

    def _normalize_package_download_sites(self, values, default_if_empty=True):
        normalized = []
        seen_names = set()
        seen_urls = set()
        if isinstance(values, dict):
            values = [values]
        if isinstance(values, list):
            for raw in values[:50]:
                if not isinstance(raw, dict):
                    continue
                try:
                    url = self._normalize_package_download_url(
                        raw.get("url", raw.get("downloadUrl", ""))
                    )
                    name = self._migrate_package_download_name(
                        raw.get("name", raw.get("remark", raw.get("label", ""))),
                        fallback=self._package_name_from_url(url),
                    )
                except Exception:
                    continue
                site_id = re.sub(
                    r"[^A-Za-z0-9_-]+", "", str(raw.get("id", "")).strip()
                )[:40]
                if not site_id:
                    site_id = self._package_download_site_id(name, url)
                raw_source_type = str(
                    raw.get("source_type", raw.get("sourceType", ""))
                ).strip().lower()
                if raw_source_type in ("builtin", "default"):
                    source_type = "builtin"
                elif raw_source_type in ("config", "json", "tvbox"):
                    source_type = "config"
                elif (
                    site_id in ("cf-service", "xiaosa")
                    or name in (self.PACKAGE_DOWNLOAD_NAME, "CF本地包")
                ) and self.package_service_url and (
                    self._package_download_url_identity(url)
                    == self._package_download_url_identity(self.package_service_url)
                ):
                    # 兼容旧 v4：CF 服务曾被保存成普通 ZIP 下载站点。
                    source_type = "builtin"
                else:
                    source_type = "zip"
                if source_type == "builtin":
                    name = self.PACKAGE_DOWNLOAD_NAME
                name_key = name.casefold()
                url_key = self._package_download_url_identity(url)
                if name_key in seen_names or url_key in seen_urls:
                    continue
                try:
                    site_count = max(
                        0, int(raw.get("site_count", raw.get("siteCount", 0)) or 0)
                    )
                except Exception:
                    site_count = 0
                try:
                    dependency_count = max(
                        0,
                        int(
                            raw.get(
                                "dependency_count",
                                raw.get("dependencyCount", 0),
                            )
                            or 0
                        ),
                    )
                except Exception:
                    dependency_count = 0
                try:
                    live_count = max(
                        0, int(raw.get("live_count", raw.get("liveCount", 0)) or 0)
                    )
                except Exception:
                    live_count = 0
                legacy_enabled = self._as_bool(raw.get("enabled", True), True)
                site_enabled = self._as_bool(
                    raw.get(
                        "site_enabled",
                        raw.get("siteEnabled", legacy_enabled),
                    ),
                    legacy_enabled,
                )
                live_enabled = self._as_bool(
                    raw.get(
                        "live_enabled",
                        raw.get("liveEnabled", legacy_enabled),
                    ),
                    legacy_enabled,
                )
                health_enabled = self._as_bool(
                    raw.get(
                        "health_enabled",
                        raw.get("healthEnabled", True),
                    ),
                    True,
                )
                normalized.append(
                    {
                        "id": site_id,
                        "name": name,
                        "url": url,
                        "enabled": bool(site_enabled or live_enabled),
                        "site_enabled": bool(site_enabled),
                        "live_enabled": bool(live_enabled),
                        "health_enabled": bool(health_enabled),
                        "source_type": source_type,
                        "site_count": site_count,
                        "dependency_count": dependency_count,
                        "live_count": live_count,
                    }
                )
                seen_names.add(name_key)
                seen_urls.add(url_key)
        if not normalized and default_if_empty:
            default_site = self._default_package_download_site()
            normalized = [default_site] if default_site else []
        return normalized

    def _sync_legacy_package_download_fields(self):
        sites = self.package_download_sites
        if not isinstance(sites, list):
            sites = []
        self.package_download_sites = sites
        if not sites:
            self.package_download_url = self.package_service_url
            self.package_download_enabled = False
            return
        primary = next(
            (
                item
                for item in sites
                if str(item.get("source_type", "")).lower() == "builtin"
            ),
            sites[0],
        )
        self.package_download_url = self.package_service_url or str(
            primary.get("url", self.PACKAGE_DOWNLOAD_URL)
        )
        self.package_download_enabled = self._package_download_site_active(primary)

    def _apply_package_download_options(self, data):
        if not isinstance(data, dict):
            return
        raw_sites = data.get(
            "package_download_sites", data.get("packageDownloadSites")
        )
        service_value = self._package_service_url_option(data)
        if service_value:
            self.package_service_url = service_value
        if isinstance(raw_sites, (list, dict)):
            normalized = self._normalize_package_download_sites(
                raw_sites, default_if_empty=False
            )
            if normalized or isinstance(raw_sites, list):
                self.package_download_sites = normalized
                self._sync_legacy_package_download_fields()
                return
        has_url = "package_download_url" in data or "packageDownloadUrl" in data
        has_enabled = (
            "package_download_enabled" in data
            or "packageDownloadEnabled" in data
        )
        if not (has_url or has_enabled):
            return
        url = self._string_option(
            data,
            ("package_download_url", "packageDownloadUrl"),
            self.package_download_url,
        )
        enabled = self._bool_option(
            data,
            ("package_download_enabled", "packageDownloadEnabled"),
            self.package_download_enabled,
        )
        try:
            url = self._normalize_package_download_url(url)
        except Exception:
            return
        self.package_download_sites = [
            {
                "id": "xiaosa"
                if url == self.PACKAGE_DOWNLOAD_URL
                else self._package_download_site_id("自定义", url),
                "name": self.PACKAGE_DOWNLOAD_NAME
                if url == self.PACKAGE_DOWNLOAD_URL
                else "自定义",
                "url": url,
                "enabled": bool(enabled),
                "site_enabled": bool(enabled),
                "live_enabled": bool(enabled),
                "health_enabled": True,
                "source_type": (
                    "builtin"
                    if self.package_service_url
                    and self._package_download_url_identity(url)
                    == self._package_download_url_identity(self.package_service_url)
                    else "zip"
                ),
                "site_count": 0,
                "dependency_count": 0,
                "live_count": 0,
            }
        ]
        self._sync_legacy_package_download_fields()

    def _load_settings(self):
        path = os.path.abspath(os.path.expanduser(self.settings_path))
        if not os.path.isfile(path):
            return
        try:
            with open(path, "r", encoding="utf-8") as fp:
                data = json.load(fp)
            if not isinstance(data, dict):
                return
            type_enabled = data.get("type_enabled", data.get("typeEnabled", {}))
            if isinstance(type_enabled, dict):
                for source_type in self.TYPE_ORDER:
                    if source_type in type_enabled:
                        self.type_enabled[source_type] = self._as_bool(
                            type_enabled[source_type], True
                        )
            pending = data.get("pending_type_enabled", data.get("pendingTypeEnabled", {}))
            self.pending_type_enabled = dict(self.type_enabled)
            if isinstance(pending, dict):
                for source_type in self.TYPE_ORDER:
                    if source_type in pending:
                        self.pending_type_enabled[source_type] = self._as_bool(
                            pending[source_type], self.type_enabled[source_type]
                        )
            self.block_adult_sites = self._as_bool(
                data.get("block_adult_sites", data.get("blockAdultSites", False)),
                False,
            )
            self.pending_block_adult_sites = self._as_bool(
                data.get(
                    "pending_block_adult_sites",
                    data.get("pendingBlockAdultSites", self.block_adult_sites),
                ),
                self.block_adult_sites,
            )
            try:
                self.adult_filter_revision = max(
                    0, int(data.get("adult_filter_revision", 0) or 0)
                )
            except Exception:
                self.adult_filter_revision = 0
            self.config_dirty = any(
                self.pending_type_enabled[source_type] != self.type_enabled[source_type]
                for source_type in self.TYPE_ORDER
            ) or self.pending_block_adult_sites != self.block_adult_sites
            scan_base = data.get("scan_base_path", data.get("scanBasePath", ""))
            if str(scan_base or "").strip():
                self._apply_scan_base_path(str(scan_base))
            self.site_test_concurrency = self._load_health_setting_int(
                data,
                ("site_test_concurrency", "siteTestConcurrency"),
                self.site_test_concurrency,
                self.SITE_TEST_CONCURRENCY_MIN,
                self.SITE_TEST_CONCURRENCY_MAX,
            )
            self.site_test_batch_limit = self._load_health_setting_int(
                data,
                ("site_test_batch_limit", "siteTestBatchLimit"),
                self.site_test_batch_limit,
                self.SITE_TEST_BATCH_LIMIT_MIN,
                self.SITE_TEST_BATCH_LIMIT_MAX,
            )
            self.playback_proxy_enabled = self._as_bool(
                data.get(
                    "playback_proxy_enabled",
                    data.get("playbackProxyEnabled", self.playback_proxy_enabled),
                ),
                self.playback_proxy_enabled,
            )
            raw_proxy_urls = data.get(
                "playback_proxy_urls", data.get("playbackProxyUrls")
            )
            if raw_proxy_urls is not None:
                self.playback_proxy_urls = self._normalize_playback_proxy_urls(
                    raw_proxy_urls
                )
            try:
                saved_proxy_revision = int(
                    data.get("playback_proxy_settings_revision", 0) or 0
                )
            except Exception:
                saved_proxy_revision = 0
            if saved_proxy_revision < self.PLAYBACK_PROXY_SETTINGS_REVISION:
                legacy_defaults = {
                    self._playback_proxy_url_identity(
                        self._normalize_playback_proxy_url(value)
                    )
                    for value in self.PLAYBACK_PROXY_LEGACY_DEFAULT_URLS
                }
                self.playback_proxy_urls = [
                    value
                    for value in self.playback_proxy_urls
                    if self._playback_proxy_url_identity(value)
                    not in legacy_defaults
                ]
            self.playback_proxy_settings_revision = (
                self.PLAYBACK_PROXY_SETTINGS_REVISION
            )
            self.playback_proxy_enabled = bool(
                self.playback_proxy_enabled and self.playback_proxy_urls
            )
            self.playback_proxy_sources = self._normalize_playback_proxy_sources(
                data.get(
                    "playback_proxy_sources",
                    data.get("playbackProxySources", {}),
                )
            )
            self.playback_proxy_policies = self._normalize_playback_proxy_policies(
                data.get(
                    "playback_proxy_policies",
                    data.get("playbackProxyPolicies", {}),
                )
            )
            self._reconcile_playback_proxy_policies()
            self.live_proxy_policies = self._normalize_live_proxy_policies(
                data.get(
                    "live_proxy_policies",
                    data.get("liveProxyPolicies", {}),
                )
            )
            self.live_proxy_auto_sources = self._normalize_live_proxy_auto_sources(
                data.get(
                    "live_proxy_auto_sources",
                    data.get("liveProxyAutoSources", {}),
                )
            )
            test_results = data.get("site_test_results", data.get("siteTestResults", {}))
            if isinstance(test_results, dict):
                self.site_test_results = {
                    str(identity): result
                    for identity, result in test_results.items()
                    if str(identity).strip() and isinstance(result, dict)
                }
            test_history = data.get(
                "site_test_history", data.get("siteTestHistory", {})
            )
            if isinstance(test_history, dict):
                self.site_test_history = {
                    str(identity): [
                        dict(item)
                        for item in values[-self.SITE_TEST_HISTORY_LIMIT :]
                        if isinstance(item, dict)
                    ]
                    for identity, values in test_history.items()
                    if str(identity).strip() and isinstance(values, list)
                }
            self.source_metadata = self._normalize_source_metadata(
                data.get("source_metadata", data.get("sourceMetadata", {}))
            )
            self._retest_pending = list(dict.fromkeys(
                str(identity).strip()
                for identity in data.get(
                    "retest_pending", data.get("retestPending", [])
                )
                if str(identity).strip()
            )) if isinstance(
                data.get("retest_pending", data.get("retestPending", [])), list
            ) else []
            self._retest_auto_blocked = self._identity_set(
                data.get(
                    "retest_auto_blocked", data.get("retestAutoBlocked", [])
                )
            )
            manual_ignored = data.get(
                "manual_ignored_sources", data.get("manualIgnoredSources")
            )
            auto_blocked = data.get(
                "auto_blocked_sources", data.get("autoBlockedSources")
            )
            self.adult_blocked_sources = self._identity_set(
                data.get("adult_blocked_sources", data.get("adultBlockedSources", []))
            )
            self.adult_allowed_sources = self._identity_set(
                data.get("adult_allowed_sources", data.get("adultAllowedSources", []))
            )
            if isinstance(manual_ignored, list) or isinstance(auto_blocked, list):
                self.manual_ignored_sources = self._identity_set(manual_ignored)
                self.auto_blocked_sources = self._identity_set(auto_blocked)
            else:
                legacy_ignored = self._identity_set(
                    data.get("ignored_sources", data.get("ignoredSources", []))
                )
                # 旧版本没有记录忽略来源，无法可靠区分手动选择和测活屏蔽。
                # 按手动忽略迁移，避免升级后擅自恢复用户明确隐藏的站点。
                self.manual_ignored_sources = legacy_ignored
                self.auto_blocked_sources = set()
            # 历史版本曾把单章节媒体失败、加载器未就绪、整批等待
            # 超时和网络瞬时错误提升成整站失效。这些都不是可屏蔽证据，
            # 升级时恢复自动屏蔽项，不改动用户手工屏蔽。
            detector_recovered = 0
            detector_recovered_identities = set()
            for identity in list(self.auto_blocked_sources):
                result = self.site_test_results.get(identity, {})
                legacy_playback = str(result.get("failure_kind", "")) == "playback"
                loader_detector_failure = (
                    str(result.get("failure_kind", "")) == "runtime"
                    and "App BaseLoader 不可用" in str(result.get("detail", ""))
                )
                bridge_detector_failure = self._is_spider_bridge_failure(
                    result.get("detail", "")
                )
                result_detail = str(result.get("detail", "") or "")
                initialization_detector_failure = (
                    "App 真实 Spider 初始化失败" in result_detail
                    or "App 真实 Spider 初始化异常" in result_detail
                )
                batch_timeout_failure = (
                    str(result.get("failure_kind", "")) == "batch_timeout"
                    or "超过批次等待时间" in result_detail
                )
                transient_detector_failure = (
                    str(result.get("state", "")) == "unavailable"
                    and self._health_failure_is_limited(result_detail)
                )
                account_dependent_failure = self._source_requires_account_session(
                    {"identity": identity}, result.get("detail", "")
                )
                unconfirmed_limited = (
                    str(result.get("state", "")) == "limited"
                    and not bool(result.get("confirmed", False))
                )
                if (
                    not legacy_playback
                    and not loader_detector_failure
                    and not bridge_detector_failure
                    and not initialization_detector_failure
                    and not batch_timeout_failure
                    and not transient_detector_failure
                    and not account_dependent_failure
                    and not unconfirmed_limited
                ):
                    continue
                self.auto_blocked_sources.discard(identity)
                self._retest_auto_blocked.discard(identity)
                if (
                    legacy_playback
                    or loader_detector_failure
                    or bridge_detector_failure
                    or initialization_detector_failure
                    or batch_timeout_failure
                    or transient_detector_failure
                    or account_dependent_failure
                ):
                    original_detail = str(result.get("detail", "") or "")
                    result["confirmed"] = False
                    result["failure_count"] = 0
                    result.pop("failure_kind", None)
                    if account_dependent_failure:
                        detector_recovered += 1
                        detector_recovered_identities.add(identity)
                        result["state"] = "limited"
                        result["failure_kind"] = "account_required"
                        result["playback_state"] = "account_required"
                        result["detail"] = (
                            "站点依赖网盘账号、Cookie 或 App 登录状态，历史误屏蔽已撤销"
                        )
                    elif (
                        loader_detector_failure
                        or bridge_detector_failure
                        or initialization_detector_failure
                    ):
                        detector_recovered += 1
                        detector_recovered_identities.add(identity)
                        result["state"] = "limited"
                        result["failure_kind"] = "detector"
                        result["detail"] = (
                            "旧版检测器加载或 Java Spider 桥接受限，误屏蔽已自动撤销"
                        )
                    elif batch_timeout_failure:
                        detector_recovered += 1
                        detector_recovered_identities.add(identity)
                        result["state"] = "limited"
                        result["failure_kind"] = "batch_timeout"
                        result["detail"] = "旧版整批等待超时造成误屏蔽，已自动撤销"
                    elif transient_detector_failure:
                        detector_recovered += 1
                        detector_recovered_identities.add(identity)
                        result["state"] = "limited"
                        result["failure_kind"] = "network_limited"
                        result["detail"] = "旧版网络超时或连接异常造成误屏蔽，已自动撤销"
                    else:
                        result["state"] = "available"
                        result["detail"] = original_detail.split(
                            "；已确认失效", 1
                        )[0][:240]
            self._sync_ignored_sources()
            if detector_recovered:
                self._log(
                    "WARN",
                    "已自动恢复 {} 个由检测器、批次超时、网络或账号环境造成误屏蔽的站点".format(
                        detector_recovered
                    ),
                )
            self.strict_recognition = self._as_bool(
                data.get("strict_recognition", data.get("strictRecognition", self.strict_recognition)),
                self.strict_recognition,
            )
            self.auto_scan_on_empty = self._as_bool(
                data.get("auto_scan_on_empty", data.get("autoScanOnEmpty", self.auto_scan_on_empty)),
                self.auto_scan_on_empty,
            )
            self.auto_prune_blocked = self._as_bool(
                data.get(
                    "auto_prune_blocked",
                    data.get("autoPruneBlocked", self.auto_prune_blocked),
                ),
                self.auto_prune_blocked,
            )
            self.pruned_sources = self._identity_set(
                data.get("pruned_sources", data.get("prunedSources", []))
            )
            self.pruned_sources.difference_update(detector_recovered_identities)
            self.auto_scan_suspended = self._as_bool(
                data.get("auto_scan_suspended", data.get("autoScanSuspended", False)),
                False,
            )
            self._author_scan_surprise_shown = self._as_bool(
                data.get(
                    "author_scan_surprise_shown",
                    data.get("authorScanSurpriseShown", False),
                ),
                False,
            )
            saved_base_url = str(
                data.get("ok_base_config_url", data.get("okBaseConfigUrl", ""))
                or ""
            ).strip()
            if saved_base_url and not self.ok_base_config_url:
                self.ok_base_config_url = saved_base_url
            self.ok_last_target = str(
                data.get("ok_last_target", data.get("okLastTarget", "")) or ""
            ).strip()
            self._apply_package_download_options(data)
            self._apply_online_repository_options(data, merge=False)
            saved_device_id = str(
                data.get("package_device_id", data.get("packageDeviceId", "")) or ""
            ).strip()
            if re.fullmatch(r"[A-Za-z0-9._:-]{16,128}", saved_device_id):
                self.package_device_id = saved_device_id
            try:
                port = int(data.get("last_app_port", data.get("lastAppPort", 0)) or 0)
                self.last_app_port = port if self.APP_PORT_START <= port <= 65535 else 0
            except Exception:
                self.last_app_port = 0
        except Exception as exc:
            self._warn("扫描设置读取失败，将使用默认配置: {}".format(exc))

    def _identity_set(self, values):
        if not isinstance(values, (list, tuple, set)):
            return set()
        return {
            str(item).strip() for item in values if str(item).strip()
        }

    def _normalize_source_tags(self, values):
        if isinstance(values, str):
            values = re.split(r"[,，;；\n]+", values)
        if not isinstance(values, (list, tuple, set)):
            return []
        result = []
        seen = set()
        for value in values:
            tag = re.sub(r"\s+", " ", str(value or "")).strip()[:24]
            key = tag.casefold()
            if not tag or key in seen:
                continue
            seen.add(key)
            result.append(tag)
            if len(result) >= 8:
                break
        return result

    def _normalize_source_metadata(self, values):
        if not isinstance(values, dict):
            return {}
        result = {}
        for identity, raw in list(values.items())[:3000]:
            clean_identity = str(identity or "").strip()
            if not clean_identity or not isinstance(raw, dict):
                continue
            favorite = self._as_bool(raw.get("favorite", False), False)
            tags = self._normalize_source_tags(raw.get("tags", []))
            if favorite or tags:
                result[clean_identity] = {
                    "favorite": favorite,
                    "tags": tags,
                }
        return result

    def _source_metadata_for(self, source_or_identity):
        identity = (
            str(source_or_identity.get("identity", "")).strip()
            if isinstance(source_or_identity, dict)
            else str(source_or_identity or "").strip()
        )
        value = self.source_metadata.get(identity, {})
        return value if isinstance(value, dict) else {}

    def _set_source_metadata(self, identity, favorite=False, tags=None):
        identity = str(identity or "").strip()
        if not identity:
            raise ValueError("站点稳定标识为空")
        clean = {
            "favorite": bool(favorite),
            "tags": self._normalize_source_tags(tags or []),
        }
        if clean["favorite"] or clean["tags"]:
            self.source_metadata[identity] = clean
        else:
            self.source_metadata.pop(identity, None)
        self._save_settings()
        return clean

    def _management_profile_path(self):
        return os.path.join(
            os.path.dirname(os.path.abspath(os.path.expanduser(self.settings_path))),
            "local-media-profile.json",
        )

    def _management_profile_data(self):
        return {
            "format": "local-media-profile/v1",
            "exportedAt": time.strftime("%Y-%m-%d %H:%M:%S"),
            "scan_base_path": self.scan_base_path,
            "site_test_concurrency": int(self.site_test_concurrency),
            "site_test_batch_limit": int(self.site_test_batch_limit),
            "playback_proxy_enabled": bool(self.playback_proxy_enabled),
            "playback_proxy_urls": list(self.playback_proxy_urls),
            "playback_proxy_settings_revision": int(
                self.playback_proxy_settings_revision
            ),
            "playback_proxy_sources": copy.deepcopy(
                self.playback_proxy_sources
            ),
            "playback_proxy_policies": copy.deepcopy(
                self.playback_proxy_policies
            ),
            "live_proxy_policies": copy.deepcopy(
                self.live_proxy_policies
            ),
            "live_proxy_auto_sources": copy.deepcopy(
                self.live_proxy_auto_sources
            ),
            "type_enabled": {
                source_type: bool(self.type_enabled.get(source_type, True))
                for source_type in self.TYPE_ORDER
            },
            "block_adult_sites": bool(self.block_adult_sites),
            "manual_ignored_sources": sorted(self.manual_ignored_sources),
            "adult_allowed_sources": sorted(self.adult_allowed_sources),
            "source_metadata": self._normalize_source_metadata(
                self.source_metadata
            ),
            "package_download_states": [
                {
                    "id": str(item.get("id", "")),
                    "name": str(item.get("name", "")),
                    "site_enabled": self._package_download_site_enabled(item),
                    "live_enabled": self._package_download_live_enabled(item),
                    "health_enabled": self._package_download_health_enabled(item),
                }
                for item in self.package_download_sites
                if str(item.get("id", "")).strip()
            ],
            "online_repositories": [
                {
                    "name": str(item.get("name", "")),
                    "url": str(item.get("url", "")),
                    "enabled": self._as_bool(item.get("enabled", True), True),
                }
                for item in self.online_repositories
            ],
        }

    def _export_management_profile(self, path=None):
        target = os.path.abspath(
            os.path.expanduser(path or self._management_profile_path())
        )
        self._atomic_write_plain_json(target, self._management_profile_data())
        return target

    def _import_management_profile(self, path=None):
        source = os.path.abspath(
            os.path.expanduser(path or self._management_profile_path())
        )
        with open(source, "r", encoding="utf-8") as fp:
            data = json.load(fp)
        if not isinstance(data, dict) or data.get("format") != "local-media-profile/v1":
            raise ValueError("管理配置格式不受支持")
        type_enabled = data.get("type_enabled", {})
        if not isinstance(type_enabled, dict):
            raise ValueError("类型开关格式错误")
        manual_ignored = data.get("manual_ignored_sources", [])
        adult_allowed = data.get("adult_allowed_sources", [])
        metadata = data.get("source_metadata", {})
        states = data.get("package_download_states", [])
        repositories = data.get("online_repositories")
        if not isinstance(manual_ignored, list):
            raise ValueError("手工屏蔽列表格式错误")
        if not isinstance(adult_allowed, list):
            raise ValueError("18+ 放行列表格式错误")
        if not isinstance(metadata, dict):
            raise ValueError("收藏与标签格式错误")
        if states is not None and not isinstance(states, list):
            raise ValueError("接口开关格式错误")
        if repositories is not None and not isinstance(repositories, list):
            raise ValueError("在线仓库格式错误")
        next_types = dict(self.type_enabled)
        for source_type in self.TYPE_ORDER:
            if source_type in type_enabled:
                next_types[source_type] = self._as_bool(
                    type_enabled[source_type], next_types[source_type]
                )
        next_metadata = self._normalize_source_metadata(metadata)
        next_concurrency = self._parse_health_setting_input(
            data.get("site_test_concurrency", self.site_test_concurrency),
            self.SITE_TEST_CONCURRENCY,
            self.SITE_TEST_CONCURRENCY_MIN,
            self.SITE_TEST_CONCURRENCY_MAX,
            "测活线程数",
        )
        next_batch_limit = self._parse_health_setting_input(
            data.get("site_test_batch_limit", self.site_test_batch_limit),
            self.SITE_TEST_BATCH_LIMIT,
            self.SITE_TEST_BATCH_LIMIT_MIN,
            self.SITE_TEST_BATCH_LIMIT_MAX,
            "单批测活上限",
        )
        next_proxy_enabled = self._as_bool(
            data.get("playback_proxy_enabled", self.playback_proxy_enabled),
            self.playback_proxy_enabled,
        )
        next_proxy_urls = self._normalize_playback_proxy_urls(
            data.get("playback_proxy_urls", self.playback_proxy_urls)
        )
        next_proxy_enabled = bool(next_proxy_enabled and next_proxy_urls)
        next_proxy_sources = self._normalize_playback_proxy_sources(
            data.get("playback_proxy_sources", self.playback_proxy_sources),
            next_proxy_urls,
        )
        next_proxy_policies = self._normalize_playback_proxy_policies(
            data.get("playback_proxy_policies", self.playback_proxy_policies),
            next_proxy_urls,
        )
        next_live_proxy_policies = self._normalize_live_proxy_policies(
            data.get("live_proxy_policies", self.live_proxy_policies),
            next_proxy_urls,
        )
        next_live_proxy_auto_sources = self._normalize_live_proxy_auto_sources(
            data.get(
                "live_proxy_auto_sources", self.live_proxy_auto_sources
            ),
            next_proxy_urls,
        )
        state_map = {
            str(item.get("id", "")).strip(): item
            for item in states or []
            if isinstance(item, dict) and str(item.get("id", "")).strip()
        }
        next_sites = copy.deepcopy(self.package_download_sites)
        for item in next_sites:
            state = state_map.get(str(item.get("id", "")).strip())
            if state is None:
                continue
            item["site_enabled"] = self._as_bool(
                state.get("site_enabled", item.get("site_enabled", True)),
                True,
            )
            item["live_enabled"] = self._as_bool(
                state.get("live_enabled", item.get("live_enabled", True)),
                True,
            )
            item["health_enabled"] = self._as_bool(
                state.get("health_enabled", item.get("health_enabled", True)),
                True,
            )
            item["enabled"] = bool(
                item["site_enabled"] or item["live_enabled"]
            )
        next_repositories = (
            copy.deepcopy(self.online_repositories)
            if repositories is None
            else self._normalize_online_repositories(repositories)
        )
        backup = os.path.join(
            os.path.dirname(os.path.abspath(os.path.expanduser(self.settings_path))),
            "auto-loader.settings.before-import.json",
        )
        settings_path = os.path.abspath(os.path.expanduser(self.settings_path))
        if os.path.isfile(settings_path):
            with open(settings_path, "r", encoding="utf-8") as fp:
                current = json.load(fp)
            self._atomic_write_plain_json(backup, current)
        previous = {
            "type_enabled": dict(self.type_enabled),
            "pending_type_enabled": dict(self.pending_type_enabled),
            "block_adult_sites": self.block_adult_sites,
            "pending_block_adult_sites": self.pending_block_adult_sites,
            "config_dirty": self.config_dirty,
            "manual_ignored_sources": set(self.manual_ignored_sources),
            "adult_allowed_sources": set(self.adult_allowed_sources),
            "source_metadata": copy.deepcopy(self.source_metadata),
            "package_download_sites": copy.deepcopy(self.package_download_sites),
            "online_repositories": copy.deepcopy(self.online_repositories),
            "scan_base_path": self.scan_base_path,
            "site_test_concurrency": self.site_test_concurrency,
            "site_test_batch_limit": self.site_test_batch_limit,
            "playback_proxy_enabled": self.playback_proxy_enabled,
            "playback_proxy_urls": list(self.playback_proxy_urls),
            "playback_proxy_sources": copy.deepcopy(
                self.playback_proxy_sources
            ),
            "playback_proxy_policies": copy.deepcopy(
                self.playback_proxy_policies
            ),
            "live_proxy_policies": copy.deepcopy(
                self.live_proxy_policies
            ),
            "live_proxy_auto_sources": copy.deepcopy(
                self.live_proxy_auto_sources
            ),
        }
        try:
            self.type_enabled = next_types
            self.pending_type_enabled = dict(next_types)
            self.block_adult_sites = self._as_bool(
                data.get("block_adult_sites", self.block_adult_sites),
                self.block_adult_sites,
            )
            self.pending_block_adult_sites = self.block_adult_sites
            self.config_dirty = False
            self.manual_ignored_sources = self._identity_set(manual_ignored)
            self.adult_allowed_sources = self._identity_set(adult_allowed)
            self.source_metadata = next_metadata
            self.package_download_sites = next_sites
            self.online_repositories = next_repositories
            self.site_test_concurrency = next_concurrency
            self.site_test_batch_limit = next_batch_limit
            self.playback_proxy_enabled = next_proxy_enabled
            self.playback_proxy_urls = next_proxy_urls
            self.playback_proxy_sources = next_proxy_sources
            self.playback_proxy_policies = next_proxy_policies
            self._reconcile_playback_proxy_policies()
            self.live_proxy_policies = next_live_proxy_policies
            self.live_proxy_auto_sources = next_live_proxy_auto_sources
            self._apply_scan_base_path(
                str(data.get("scan_base_path", "") or "").strip()
            )
            self._sync_ignored_sources()
            self._save_settings()
        except Exception:
            self.type_enabled = previous["type_enabled"]
            self.pending_type_enabled = previous["pending_type_enabled"]
            self.block_adult_sites = previous["block_adult_sites"]
            self.pending_block_adult_sites = previous["pending_block_adult_sites"]
            self.config_dirty = previous["config_dirty"]
            self.manual_ignored_sources = previous["manual_ignored_sources"]
            self.adult_allowed_sources = previous["adult_allowed_sources"]
            self.source_metadata = previous["source_metadata"]
            self.package_download_sites = previous["package_download_sites"]
            self.online_repositories = previous["online_repositories"]
            self.site_test_concurrency = previous["site_test_concurrency"]
            self.site_test_batch_limit = previous["site_test_batch_limit"]
            self.playback_proxy_enabled = previous["playback_proxy_enabled"]
            self.playback_proxy_urls = previous["playback_proxy_urls"]
            self.playback_proxy_sources = previous["playback_proxy_sources"]
            self.playback_proxy_policies = previous[
                "playback_proxy_policies"
            ]
            self.live_proxy_policies = previous["live_proxy_policies"]
            self.live_proxy_auto_sources = previous[
                "live_proxy_auto_sources"
            ]
            self._apply_scan_base_path(previous["scan_base_path"])
            self._sync_ignored_sources()
            raise
        return source, backup if os.path.isfile(backup) else ""

    def _sync_ignored_sources(self):
        self.ignored_sources = set(self.manual_ignored_sources)
        self.ignored_sources.update(self.auto_blocked_sources)
        if self.block_adult_sites:
            self.ignored_sources.update(self.adult_blocked_sources)

    def _prune_blocked_sources_locked(self):
        """把测活屏蔽的站点移出配置，返回本次新增移除的数量。

        只处理 auto_blocked_sources：手工屏蔽属于用户的明确决策，被自动
        逻辑接管后用户将无法区分「我屏蔽的」和「系统清理的」。

        不删除任何源文件。整包类型多个站点共用同一个 api.json，删文件会
        因一个站点失效而波及整包；且用户目录里有手工放置的 PY/JS。移除
        仅作用于扫描阶段，清除入口可让其重新参与扫描。
        """
        if not self.auto_prune_blocked:
            return 0
        candidates = {
            identity
            for identity in self.auto_blocked_sources
            if identity and identity not in self.pruned_sources
        }
        if not candidates:
            return 0

        names = []
        for identity in candidates:
            source = next(
                (
                    item
                    for item in self.cache.get("ignored", [])
                    if item.get("identity") == identity
                ),
                None,
            )
            names.append(str((source or {}).get("name", "") or identity)[:60])

        self.pruned_sources.update(candidates)
        self.auto_blocked_sources.difference_update(candidates)
        self._sync_ignored_sources()
        self._log(
            "INFO",
            "自动清理屏蔽站点 {} 个（仅移出配置，源文件保留）: {}".format(
                len(candidates), "、".join(sorted(names)[:8])
            ),
        )
        return len(candidates)

    def _as_bool(self, value, fallback=False):
        if isinstance(value, bool):
            return value
        if isinstance(value, (int, float)):
            return bool(value)
        if value is None:
            return fallback
        return str(value).strip().lower() in ("1", "true", "yes", "on")

    @staticmethod
    def _normalize_playback_proxy_url(value):
        url = str(value or "").strip().strip('"').strip("'")
        if not url:
            raise ValueError("反代线路地址不能为空")
        if len(url) > 2048:
            raise ValueError("反代线路地址过长")
        parsed = urllib.parse.urlsplit(url)
        if parsed.scheme.lower() not in ("http", "https") or not parsed.netloc:
            raise ValueError("反代线路地址必须是 http 或 https URL")
        if parsed.fragment:
            raise ValueError("反代线路地址不能包含片段标识")
        if parsed.query and not url.endswith(("?", "=", "&")):
            raise ValueError("含查询参数的反代线路必须以 = 或 & 结尾")
        if not parsed.query and not url.endswith(("/", "?", "=", "&")):
            url += "/"
        return url

    @staticmethod
    def _playback_proxy_url_identity(value):
        parsed = urllib.parse.urlsplit(str(value or ""))
        return (
            parsed.scheme.lower(),
            str(parsed.hostname or "").lower(),
            parsed.port,
            str(parsed.username or ""),
            str(parsed.password or ""),
            parsed.path,
            parsed.query,
        )

    @staticmethod
    def _playback_proxy_response_target(prefix, response_url, fallback):
        value = str(response_url or "").strip()
        configured = str(prefix or "")
        candidate = ""
        if configured and value.startswith(configured):
            suffix = value[len(configured) :]
            if suffix.startswith("url="):
                candidate = urllib.parse.parse_qs(
                    suffix, keep_blank_values=True
                ).get("url", [""])[0]
            elif configured.endswith("="):
                candidate = urllib.parse.unquote(suffix)
            elif suffix.startswith(("http://", "https://")):
                candidate = suffix
        if not candidate:
            try:
                candidate = urllib.parse.parse_qs(
                    urllib.parse.urlsplit(value).query,
                    keep_blank_values=True,
                ).get("url", [""])[0]
            except Exception:
                candidate = ""
        parsed = urllib.parse.urlsplit(candidate)
        if parsed.scheme.lower() in ("http", "https") and parsed.netloc:
            return candidate
        return str(fallback or "")

    def _normalize_playback_proxy_urls(self, values):
        if isinstance(values, str):
            values = values.replace("\r", "\n").split("\n")
        if not isinstance(values, (list, tuple)):
            raise ValueError("反代线路地址列表格式错误")
        normalized = []
        seen = set()
        for value in values:
            if not str(value or "").strip():
                continue
            url = self._normalize_playback_proxy_url(value)
            identity = self._playback_proxy_url_identity(url)
            if identity in seen:
                continue
            seen.add(identity)
            normalized.append(url)
        if len(normalized) > 12:
            raise ValueError("反代线路地址最多 12 条")
        return normalized

    def _probe_playback_proxy_line(self, prefix, timeout=4):
        normalized = self._normalize_playback_proxy_url(prefix)
        # 使用稳定公开目标验证前缀路由或镜像线路的基本转发能力；
        # 业务媒体是否可播仍由站点或直播测活单独判定。
        target = "https://example.com/"
        request_url = self._playback_proxy_media_url(normalized, target)
        request = urllib.request.Request(
            request_url,
            headers={
                "Accept": "application/json, text/plain, */*",
                "User-Agent": "okhttp/4.12.0",
                "Range": "bytes=0-1023",
            },
        )
        opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
        try:
            with opener.open(request, timeout=max(1, int(timeout))) as response:
                status = int(getattr(response, "status", response.getcode()))
                response.read(64 * 1024)
            if 200 <= status < 400:
                return {
                    "state": "available",
                    "status": status,
                    "detail": "线路目标转发可用",
                }
            return {
                "state": "error",
                "status": status,
                "detail": "线路返回 HTTP {}".format(status),
            }
        except urllib.error.HTTPError as exc:
            status = int(exc.code or 0)
            if status in (401, 403):
                return {
                    "state": "credential_error",
                    "status": status,
                    "detail": "线路凭据无效（HTTP {}）".format(status),
                }
            return {
                "state": "error",
                "status": status,
                "detail": "线路返回 HTTP {}".format(status),
            }
        except (urllib.error.URLError, socket.timeout, TimeoutError) as exc:
            return {
                "state": "limited",
                "status": 0,
                "detail": "线路暂时无法验证: {}".format(
                    str(getattr(exc, "reason", exc))[:120]
                ),
            }

    def _validate_playback_proxy_lines(self, urls):
        results = []
        for index, prefix in enumerate(urls):
            result = self._probe_playback_proxy_line(prefix)
            result["line"] = index + 1
            results.append(result)
            if result.get("state") == "credential_error":
                label = (
                    "线路{}".format(self.PLAYBACK_PROXY_LINE_NAMES[index])
                    if index < len(self.PLAYBACK_PROXY_LINE_NAMES)
                    else "线路{}".format(index + 1)
                )
                raise ValueError(
                    "{}凭据无效（HTTP {}），请检查令牌拼写".format(
                        label,
                        result.get("status", 0),
                    )
                )
        return results

    def _migrate_proxy_prefix_states(self, values, old_urls, new_urls):
        if not isinstance(values, dict):
            return {}
        old_urls = list(old_urls or [])
        new_urls = list(new_urls or [])
        new_by_identity = {
            self._playback_proxy_url_identity(value): value
            for value in new_urls
        }
        migrated = copy.deepcopy(values)
        for state in migrated.values():
            if not isinstance(state, dict):
                continue
            prefix = str(state.get("prefix", "") or "").strip()
            if not prefix:
                continue
            identity = self._playback_proxy_url_identity(prefix)
            replacement = new_by_identity.get(identity, "")
            changed = False
            if not replacement and new_urls:
                index = next(
                    (
                        offset for offset, value in enumerate(old_urls)
                        if self._playback_proxy_url_identity(value) == identity
                    ),
                    0,
                )
                replacement = new_urls[min(index, len(new_urls) - 1)]
                changed = replacement != prefix
            if replacement:
                state["prefix"] = replacement
            if changed and (
                str(state.get("mode", "")) == "auto"
                or bool(state.get("qualification"))
            ):
                state["needs_retest"] = True
        return migrated

    def _is_legacy_builtin_playback_proxy_extend(self, data, urls):
        if not isinstance(data, dict):
            return False
        try:
            revision = int(
                data.get("playback_proxy_settings_revision", 0) or 0
            )
        except Exception:
            revision = 0
        if revision >= self.PLAYBACK_PROXY_SETTINGS_REVISION:
            return False
        legacy_urls = self._normalize_playback_proxy_urls(
            self.PLAYBACK_PROXY_LEGACY_DEFAULT_URLS
        )
        if list(urls or []) != legacy_urls:
            return False
        package_url = str(
            data.get("package_service_url", data.get("packageServiceUrl", ""))
            or ""
        ).strip()
        scan_base = str(
            data.get("scan_base_path", data.get("scanBasePath", "")) or ""
        ).strip()
        package_sites = data.get(
            "package_download_sites", data.get("packageDownloadSites", [])
        )
        has_builtin_site = any(
            isinstance(item, dict)
            and str(item.get("id", "")).strip() == "cf-service"
            and str(item.get("url", "")).strip() == package_url
            for item in package_sites
        ) if isinstance(package_sites, list) else False
        return (
            self._as_bool(
                data.get(
                    "playback_proxy_enabled",
                    data.get("playbackProxyEnabled", False),
                ),
                False,
            )
            and bool(package_url)
            and bool(scan_base)
            and has_builtin_site
        )

    def _normalize_playback_proxy_sources(self, values, urls=None):
        if not isinstance(values, dict):
            return {}
        allowed = {
            self._playback_proxy_url_identity(value): str(value)
            for value in (urls if urls is not None else self.playback_proxy_urls)
        }
        normalized = {}
        for identity, state in values.items():
            source_identity = str(identity or "").strip()
            if not source_identity or not isinstance(state, dict):
                continue
            try:
                prefix = self._normalize_playback_proxy_url(state.get("prefix", ""))
            except Exception:
                continue
            prefix = allowed.get(self._playback_proxy_url_identity(prefix), "")
            mode = str(state.get("mode", "auto") or "auto").strip().lower()
            if not prefix or mode not in self.PLAYBACK_PROXY_STATE_MODES:
                continue
            normalized_state = {"prefix": prefix, "mode": mode}
            if mode == "auto":
                qualification = str(
                    state.get("qualification", "") or ""
                ).strip()
                # 旧算法在单次直连失败后就可能自动入列。
                # 升级时只保留具备新证据标记的自动项，手动项不受影响。
                if qualification != self.PLAYBACK_PROXY_AUTO_QUALIFICATION:
                    continue
                normalized_state["qualification"] = qualification
                if self._as_bool(state.get("needs_retest"), False):
                    normalized_state["needs_retest"] = True
            normalized[source_identity] = normalized_state
        return normalized

    def _normalize_playback_proxy_policies(self, values, urls=None):
        if not isinstance(values, dict):
            return {}
        configured = list(
            urls if urls is not None else self.playback_proxy_urls
        )
        allowed = {
            self._playback_proxy_url_identity(value): str(value)
            for value in configured
        }
        normalized = {}
        for identity, state in values.items():
            source_identity = str(identity or "").strip()
            if not source_identity or not isinstance(state, dict):
                continue
            policy = str(state.get("policy", "") or "").strip().lower()
            if policy not in self.PLAYBACK_PROXY_POLICY_MODES:
                continue
            if policy == "force_direct":
                normalized[source_identity] = {"policy": policy}
                continue
            prefix = ""
            try:
                prefix = self._normalize_playback_proxy_url(
                    state.get("prefix", "")
                )
            except Exception:
                pass
            prefix = allowed.get(self._playback_proxy_url_identity(prefix), "")
            if not prefix and configured:
                prefix = configured[0]
            normalized[source_identity] = {
                "policy": policy,
                "prefix": prefix,
            }
        return normalized

    def _reconcile_playback_proxy_policies(self):
        self.playback_proxy_sources = self._normalize_playback_proxy_sources(
            self.playback_proxy_sources, self.playback_proxy_urls
        )
        self.playback_proxy_policies = self._normalize_playback_proxy_policies(
            self.playback_proxy_policies, self.playback_proxy_urls
        )
        for identity, state in list(self.playback_proxy_sources.items()):
            if (
                isinstance(state, dict)
                and state.get("mode") == "manual"
                and identity not in self.playback_proxy_policies
            ):
                self.playback_proxy_policies[identity] = {
                    "policy": "force_proxy",
                    "prefix": str(state.get("prefix", "") or ""),
                }
        if not self.playback_proxy_enabled:
            self.playback_proxy_sources = {}
            return
        for identity, policy_state in self.playback_proxy_policies.items():
            policy = str(policy_state.get("policy", "") or "")
            if policy == "force_direct":
                self.playback_proxy_sources.pop(identity, None)
                continue
            prefix = str(policy_state.get("prefix", "") or "")
            if self.playback_proxy_enabled and self.playback_proxy_urls:
                if prefix not in self.playback_proxy_urls:
                    prefix = self.playback_proxy_urls[0]
                    policy_state["prefix"] = prefix
                self.playback_proxy_sources[identity] = {
                    "prefix": prefix,
                    "mode": "manual",
                }
            else:
                self.playback_proxy_sources.pop(identity, None)

    @staticmethod
    def _live_proxy_relay_base(prefix):
        try:
            parsed = urllib.parse.urlsplit(str(prefix or "").strip())
            if parsed.scheme.lower() not in ("http", "https") or not parsed.netloc:
                return ""
            segments = [item for item in parsed.path.split("/") if item]
            if not segments or segments[0].lower() != "proxy" or len(segments) > 2:
                return ""
            token = segments[1] if len(segments) == 2 else ""
            suffix = urllib.parse.quote(
                urllib.parse.unquote(token), safe=""
            ) + "/" if token else ""
            return "{}://{}/relay/{}".format(
                parsed.scheme.lower(), parsed.netloc, suffix
            )
        except Exception:
            return ""

    def _live_proxy_relay_url(self, prefix, target):
        return self._playback_proxy_media_url(prefix, target)

    def _playback_proxy_media_url(self, prefix, target):
        try:
            normalized = self._normalize_playback_proxy_url(prefix)
        except Exception:
            return ""
        value = str(target or "").strip()
        parsed = urllib.parse.urlsplit(value)
        if parsed.scheme.lower() not in ("http", "https") or not parsed.netloc:
            return ""
        if value.startswith(normalized):
            return value
        base = self._live_proxy_relay_base(normalized)
        if base:
            if value.startswith(base):
                return value
            return "{}{}/{}{}{}".format(
                base,
                parsed.scheme.lower(),
                urllib.parse.quote(parsed.netloc, safe=""),
                parsed.path or "/",
                ("?" + parsed.query) if parsed.query else "",
            )
        return self._playback_proxy_request_url(normalized, value)

    def _live_proxy_capability_available(self, prefix):
        return bool(
            self._playback_proxy_media_url(prefix, "https://example.com/")
        )

    def _normalize_live_proxy_policies(self, values, urls=None):
        if not isinstance(values, dict):
            return {}
        configured = list(
            urls if urls is not None else self.playback_proxy_urls
        )
        allowed = {
            self._playback_proxy_url_identity(value): str(value)
            for value in configured
            if self._live_proxy_capability_available(value)
        }
        normalized = {}
        for identity, state in values.items():
            live_identity = str(identity or "").strip()
            if not live_identity or not isinstance(state, dict):
                continue
            policy = str(state.get("policy", "") or "").strip().lower()
            if policy not in self.LIVE_PROXY_POLICY_MODES:
                continue
            if policy == "force_direct":
                normalized[live_identity] = {"policy": policy}
                continue
            try:
                prefix = self._normalize_playback_proxy_url(
                    state.get("prefix", "")
                )
            except Exception:
                prefix = ""
            prefix = allowed.get(self._playback_proxy_url_identity(prefix), "")
            if not prefix and allowed:
                prefix = next(iter(allowed.values()))
            if prefix:
                normalized[live_identity] = {
                    "policy": "force_proxy",
                    "prefix": prefix,
                }
        return normalized

    def _normalize_live_proxy_auto_sources(self, values, urls=None):
        if not isinstance(values, dict):
            return {}
        configured = list(
            urls if urls is not None else self.playback_proxy_urls
        )
        allowed = {
            self._playback_proxy_url_identity(value): str(value)
            for value in configured
            if self._live_proxy_capability_available(value)
        }
        normalized = {}
        for identity, state in values.items():
            live_identity = str(identity or "").strip()
            if not live_identity or not isinstance(state, dict):
                continue
            if str(state.get("qualification", "") or "") != (
                self.LIVE_PROXY_AUTO_QUALIFICATION
            ):
                continue
            try:
                prefix = self._normalize_playback_proxy_url(
                    state.get("prefix", "")
                )
            except Exception:
                continue
            prefix = allowed.get(self._playback_proxy_url_identity(prefix), "")
            if not prefix:
                continue
            normalized[live_identity] = {
                "prefix": prefix,
                "qualification": self.LIVE_PROXY_AUTO_QUALIFICATION,
                "checked_at": str(state.get("checked_at", "") or ""),
                "detail": str(state.get("detail", "") or "")[:240],
            }
            if self._as_bool(state.get("needs_retest"), False):
                normalized[live_identity]["needs_retest"] = True
        return normalized

    def _live_proxy_identity(self, item):
        identity = str(item.get("id", "") or "").strip()
        if identity:
            return identity
        return "live_" + self._digest(
            self._live_fingerprint(self._registry_item_live(item)), 20
        )

    def _live_proxy_policy(self, item):
        return self.live_proxy_policies.get(
            self._live_proxy_identity(item), {}
        )

    def _live_proxy_policy_label(self, item):
        state = self._live_proxy_policy(item)
        policy = str(state.get("policy", "") or "")
        if policy == "force_direct":
            return "强制直连"
        if policy == "force_proxy":
            return "强制反代 · {}".format(
                self._playback_proxy_line_label(state.get("prefix", ""))
            )
        automatic = self.live_proxy_auto_sources.get(
            self._live_proxy_identity(item), {}
        )
        if isinstance(automatic, dict) and automatic.get("prefix"):
            return "自动反代{} · {}".format(
                "（待复检）" if automatic.get("needs_retest") else "",
                self._playback_proxy_line_label(automatic.get("prefix", ""))
            )
        return "自动判断 · 当前直连"

    @staticmethod
    def _live_proxy_supported(live):
        if not isinstance(live, dict):
            return False
        try:
            live_type = int(live.get("type", 0) or 0)
        except Exception:
            live_type = 0
        if live_type != 0:
            return False
        value = str(live.get("url", "") or "").strip()
        if value:
            return urllib.parse.urlsplit(value).scheme.lower() in (
                "http", "https", "file"
            )
        return isinstance(live.get("groups"), list)

    def _rewrite_live_group_urls(self, value, prefix, key=""):
        if isinstance(value, dict):
            return {
                child: self._rewrite_live_group_urls(
                    item, prefix, str(child).lower()
                )
                for child, item in value.items()
            }
        if isinstance(value, list):
            return [
                self._rewrite_live_group_urls(item, prefix, key)
                for item in value
            ]
        if isinstance(value, str) and key in ("url", "urls"):
            return self._live_proxy_relay_url(prefix, value) or value
        return value

    def _rewrite_local_live_playlist(self, reference, prefix, identity):
        path = self._reference_path(reference)
        if not path or not os.path.isfile(path):
            return ""
        if os.path.getsize(path) > self.MAX_PACKAGE_CONFIG_SIZE:
            raise ValueError("本地直播列表超过反代改写上限")
        with open(path, "r", encoding="utf-8-sig", errors="replace") as fp:
            text = fp.read()
        trailing = text.endswith(("\n", "\r"))
        output = []
        for line in text.splitlines():
            stripped = line.strip()
            if stripped.startswith("#"):
                def replace_uri(match):
                    target = match.group(2) or match.group(3) or ""
                    rewritten = self._live_proxy_relay_url(prefix, target)
                    quote = "'" if match.group(1).startswith("'") else '"'
                    return "URI={}{}{}".format(
                        quote, rewritten or target, quote
                    )
                line = re.sub(
                    r"URI=(\"([^\"]*)\"|'([^']*)')",
                    replace_uri,
                    line,
                    flags=re.IGNORECASE,
                )
            elif stripped:
                rewritten = self._live_proxy_relay_url(prefix, stripped)
                if rewritten:
                    line = line[: len(line) - len(line.lstrip())] + rewritten
            output.append(line)
        content = "\n".join(output) + ("\n" if trailing else "")
        target_dir = os.path.join(
            self.local_base_dir,
            self.PLAYBACK_PROXY_MANAGED_DIR,
            self.LIVE_PROXY_MANAGED_DIR,
        )
        os.makedirs(target_dir, exist_ok=True)
        target = os.path.join(
            target_dir,
            "{}.m3u".format(
                self._digest("{}|{}|{}".format(identity, prefix, path), 32)
            ),
        )
        temp = target + ".tmp"
        try:
            with open(temp, "w", encoding="utf-8", newline="\n") as fp:
                fp.write(content)
            os.replace(temp, target)
        finally:
            try:
                if os.path.exists(temp):
                    os.remove(temp)
            except Exception:
                pass
        return self._file_url(target)

    def _live_with_proxy_policy(self, item, live):
        result = copy.deepcopy(live)
        state = self._live_proxy_policy(item)
        policy = str(state.get("policy", "") or "")
        automatic = not bool(policy)
        if not policy:
            state = self.live_proxy_auto_sources.get(
                self._live_proxy_identity(item), {}
            )
            policy = "force_proxy" if state.get("prefix") else ""
        if (
            not self.playback_proxy_enabled
            or policy != "force_proxy"
            or not self._live_proxy_supported(result)
        ):
            return result
        prefix = str(state.get("prefix", "") or "")
        if not self._live_proxy_capability_available(prefix):
            return result
        value = str(result.get("url", "") or "").strip()
        if value:
            scheme = urllib.parse.urlsplit(value).scheme.lower()
            if scheme in ("http", "https"):
                result["url"] = self._live_proxy_relay_url(prefix, value)
            elif scheme == "file":
                result["url"] = self._rewrite_local_live_playlist(
                    value, prefix, self._live_proxy_identity(item)
                ) or value
        elif isinstance(result.get("groups"), list):
            result["groups"] = self._rewrite_live_group_urls(
                result["groups"], prefix
            )
        marker = result.get(self.OK_CONFIG_MARKER, {})
        if not isinstance(marker, dict):
            marker = {}
        marker["liveProxy"] = {
            "mode": "auto" if automatic else "force_proxy",
            "line": self._playback_proxy_line_label(prefix),
        }
        result[self.OK_CONFIG_MARKER] = marker
        return result

    def _bundle_remote_python_url(self, value):
        url = str(value or "").strip()
        if not url or len(url) > 4096:
            return ""
        try:
            parsed = urllib.parse.urlsplit(url)
            if (
                parsed.scheme.lower() not in ("http", "https")
                or not parsed.netloc
                or parsed.username is not None
                or parsed.password is not None
            ):
                return ""
            path = urllib.parse.unquote(parsed.path).lower()
            if not path.endswith(".py"):
                return ""
            port = parsed.port
            expected_port = 443 if parsed.scheme.lower() == "https" else 80
            if port is not None and port != expected_port:
                return ""
            host = str(parsed.hostname or "").strip().rstrip(".").lower()
            if (
                not host
                or host == "localhost"
                or host.endswith((".localhost", ".local", ".internal"))
            ):
                return ""
            try:
                address = ipaddress.ip_address(host)
                if not address.is_global:
                    return ""
            except ValueError:
                pass
            return self._encoded_download_url(url)
        except Exception:
            return ""

    def _bundle_remote_python_dir(self):
        return os.path.join(
            self.local_base_dir,
            self.PLAYBACK_PROXY_MANAGED_DIR,
            self.BUNDLE_PY_CACHE_DIR,
        )

    def _bundle_remote_python_cache_path(self, value):
        url = self._bundle_remote_python_url(value)
        if not url:
            return ""
        return os.path.join(
            self._bundle_remote_python_dir(),
            "{}.py".format(self._digest(url, 32)),
        )

    def _valid_cached_bundle_python(self, value):
        path = self._bundle_remote_python_cache_path(value)
        if not path or not os.path.isfile(path) or not os.access(path, os.R_OK):
            return ""
        valid, _detail = self._validate_source("PY", path)
        return path if valid else ""

    def _set_playback_proxy_settings(self, enabled, values, verify_remote=False):
        urls = self._normalize_playback_proxy_urls(values)
        line_checks = (
            self._validate_playback_proxy_lines(urls)
            if verify_remote and urls
            else []
        )
        old_urls = list(self.playback_proxy_urls)
        previous = (
            self.playback_proxy_enabled,
            list(self.playback_proxy_urls),
            copy.deepcopy(self.playback_proxy_sources),
            copy.deepcopy(self.playback_proxy_policies),
            copy.deepcopy(self.live_proxy_policies),
            copy.deepcopy(self.live_proxy_auto_sources),
        )
        self.playback_proxy_enabled = bool(enabled and urls)
        self.playback_proxy_urls = urls
        migrated_sources = self._migrate_proxy_prefix_states(
            self.playback_proxy_sources, old_urls, urls
        )
        migrated_policies = self._migrate_proxy_prefix_states(
            self.playback_proxy_policies, old_urls, urls
        )
        migrated_live_policies = self._migrate_proxy_prefix_states(
            self.live_proxy_policies, old_urls, urls
        )
        migrated_live_auto = self._migrate_proxy_prefix_states(
            self.live_proxy_auto_sources, old_urls, urls
        )
        self.playback_proxy_sources = self._normalize_playback_proxy_sources(
            migrated_sources, urls
        )
        self.playback_proxy_policies = migrated_policies
        self._reconcile_playback_proxy_policies()
        self.live_proxy_policies = self._normalize_live_proxy_policies(
            migrated_live_policies, urls
        )
        self.live_proxy_auto_sources = self._normalize_live_proxy_auto_sources(
            migrated_live_auto, urls
        )
        try:
            self._write_playback_proxy_runtime_config()
            self._save_settings()
            self._log(
                "INFO",
                "反代线路设置已保存: 总开关={} 地址数={} 已分配={}".format(
                    "开" if self.playback_proxy_enabled else "关",
                    len(self.playback_proxy_urls),
                    len(self.playback_proxy_sources),
                ),
            )
            if not self._refresh_locked(allow_empty=True):
                raise ValueError(self.status.get("error") or "配置刷新失败")
            _, reload_detail = self._reload_app_vod_config(
                expected_keys=self._generated_registry_keys(),
                refresh_types=("category",),
            )
        except Exception:
            (
                self.playback_proxy_enabled,
                self.playback_proxy_urls,
                self.playback_proxy_sources,
                self.playback_proxy_policies,
                self.live_proxy_policies,
                self.live_proxy_auto_sources,
            ) = previous
            try:
                self._write_playback_proxy_runtime_config()
                self._save_settings()
            except Exception:
                pass
            raise
        return {
            "enabled": self.playback_proxy_enabled,
            "urls": list(self.playback_proxy_urls),
            "sources": len(self.playback_proxy_sources),
            "reload": reload_detail,
            "line_checks": line_checks,
        }

    def _set_live_proxy_policy(self, live_entry, policy, prefix=""):
        if not isinstance(live_entry, dict):
            raise ValueError("直播源不存在，请重新扫描")
        live = live_entry.get("live", {})
        if not self._live_proxy_supported(live):
            raise ValueError("该直播类型暂不支持 HTTP(S) relay")
        identity = self._live_proxy_identity(live_entry)
        policy = str(policy or "auto").strip().lower()
        if policy not in ("auto",) + self.LIVE_PROXY_POLICY_MODES:
            raise ValueError("直播反代策略无效")
        previous = copy.deepcopy(self.live_proxy_policies)
        if policy == "auto":
            self.live_proxy_policies.pop(identity, None)
        elif policy == "force_direct":
            self.live_proxy_policies[identity] = {"policy": "force_direct"}
        else:
            if not self.playback_proxy_enabled:
                raise ValueError("请先开启反代线路总开关")
            normalized = self._normalize_playback_proxy_url(prefix)
            if normalized not in self.playback_proxy_urls:
                raise ValueError("所选反代线路已不存在")
            if not self._live_proxy_capability_available(normalized):
                raise ValueError("所选线路不支持 HTTP(S) 媒体转发")
            self.live_proxy_policies[identity] = {
                "policy": "force_proxy",
                "prefix": normalized,
            }
        try:
            self._save_settings()
            if not self._refresh_locked(allow_empty=True):
                raise ValueError(self.status.get("error") or "配置刷新失败")
            _, reload_detail = self._reload_app_vod_config(
                expected_keys=self._generated_registry_keys(),
                refresh_types=("category",),
            )
        except Exception:
            self.live_proxy_policies = previous
            try:
                self._save_settings()
                self._refresh_locked(allow_empty=True)
            except Exception:
                pass
            raise
        label = {
            "auto": "自动判断（当前保持直连）",
            "force_direct": "强制直连",
            "force_proxy": "强制反代 · {}".format(
                self._playback_proxy_line_label(prefix)
            ),
        }[policy]
        return "{} 已设为{}；{}".format(
            str(live.get("name", "直播源")), label, reload_detail
        )

    def _batch_set_live_proxy_policies(
        self, live_entries, policy, prefix=""
    ):
        entries = [
            item for item in live_entries
            if isinstance(item, dict)
            and self._live_proxy_supported(item.get("live", {}))
        ]
        if not entries:
            raise ValueError("没有选中可设置的直播源")
        policy = str(policy or "auto").strip().lower()
        if policy not in ("auto",) + self.LIVE_PROXY_POLICY_MODES:
            raise ValueError("直播反代策略无效")
        if policy == "force_proxy":
            if not self.playback_proxy_enabled:
                raise ValueError("请先开启反代线路总开关")
            prefix = self._normalize_playback_proxy_url(prefix)
            if (
                prefix not in self.playback_proxy_urls
                or not self._live_proxy_capability_available(prefix)
            ):
                raise ValueError("所选线路不支持 HTTP(S) 媒体转发")
        previous = copy.deepcopy(self.live_proxy_policies)
        try:
            for item in entries:
                identity = self._live_proxy_identity(item)
                if policy == "auto":
                    self.live_proxy_policies.pop(identity, None)
                elif policy == "force_direct":
                    self.live_proxy_policies[identity] = {
                        "policy": "force_direct"
                    }
                else:
                    self.live_proxy_policies[identity] = {
                        "policy": "force_proxy",
                        "prefix": prefix,
                    }
            self._save_settings()
            if not self._refresh_locked(allow_empty=True):
                raise ValueError(self.status.get("error") or "配置刷新失败")
            _, reload_detail = self._reload_app_vod_config(
                expected_keys=self._generated_registry_keys(),
                refresh_types=("category",),
            )
        except Exception:
            self.live_proxy_policies = previous
            try:
                self._save_settings()
                self._refresh_locked(allow_empty=True)
            except Exception:
                pass
            raise
        label = {
            "auto": "自动判断",
            "force_direct": "强制直连",
            "force_proxy": "强制反代 · {}".format(
                self._playback_proxy_line_label(prefix)
            ),
        }[policy]
        return "已将 {} 个直播源设为{}；{}".format(
            len(entries), label, reload_detail
        )

    def _start_batch_live_proxy_update(
        self, live_entries, policy, prefix=""
    ):
        reserved, active = self._reserve_background_operation(
            "playback_proxy"
        )
        if not reserved:
            return False, self._background_operation_conflict(active)
        owner = self
        entries = copy.deepcopy(live_entries)

        def worker():
            try:
                with owner.lock:
                    message = owner._batch_set_live_proxy_policies(
                        entries, policy, prefix
                    )
                owner._notify_app(message)
            except Exception as exc:
                message = "批量直播反代策略更新失败：{}".format(exc)
                owner._log("ERROR", message)
                owner._notify_app(message)
            finally:
                owner._release_background_operation("playback_proxy")

        thread = threading.Thread(
            target=worker,
            name="local-live-proxy-batch",
        )
        thread.daemon = True
        thread.start()
        return True, "正在批量更新直播反代策略，完成后会提示结果"

    def _start_live_proxy_policy_update(
        self, live_entry, policy, prefix=""
    ):
        reserved, active = self._reserve_background_operation(
            "playback_proxy"
        )
        if not reserved:
            return False, self._background_operation_conflict(active)
        owner = self
        snapshot = copy.deepcopy(live_entry)

        def worker():
            try:
                with owner.lock:
                    message = owner._set_live_proxy_policy(
                        snapshot, policy, prefix
                    )
                owner._notify_app(message)
            except Exception as exc:
                message = "直播反代策略更新失败：{}".format(exc)
                owner._log("ERROR", message)
                owner._notify_app(message)
            finally:
                owner._release_background_operation("playback_proxy")

        thread = threading.Thread(
            target=worker,
            name="local-live-proxy-policy",
        )
        thread.daemon = True
        thread.start()
        return True, "正在更新直播反代策略，完成后会提示结果"

    def _live_probe_targets(self, live):
        if not isinstance(live, dict):
            return []
        value = str(live.get("url", "") or "").strip()
        parsed = urllib.parse.urlsplit(value)
        candidates = []
        if parsed.scheme.lower() in ("http", "https"):
            try:
                sample = self._open_media_sample(
                    value,
                    {"User-Agent": "okhttp/4.12.0"},
                    time.monotonic() + self.MEDIA_PROBE_TIMEOUT * 2,
                    self.MEDIA_PLAYLIST_BYTES,
                )
                body = bytes(sample.get("body", b"") or b"")
                if self._is_hls_playlist_body(body):
                    base = str(sample.get("url", value) or value)
                    text = body.decode("utf-8-sig", errors="replace")
                    for line in text.splitlines():
                        target = line.strip()
                        if target and not target.startswith("#"):
                            absolute = urllib.parse.urljoin(base, target)
                            if urllib.parse.urlsplit(absolute).scheme.lower() in (
                                "http", "https"
                            ):
                                candidates.append(absolute)
            except Exception:
                pass
            if not candidates:
                candidates.append(value)
        elif parsed.scheme.lower() == "file":
            path = self._reference_path(value)
            try:
                with open(path, "r", encoding="utf-8-sig", errors="replace") as fp:
                    for line in fp:
                        target = line.strip()
                        if urllib.parse.urlsplit(target).scheme.lower() in (
                            "http", "https"
                        ):
                            candidates.append(target)
            except Exception:
                pass
        elif isinstance(live.get("groups"), list):
            def collect(item, key=""):
                if isinstance(item, dict):
                    for child, child_value in item.items():
                        collect(child_value, str(child).lower())
                elif isinstance(item, list):
                    for child_value in item:
                        collect(child_value, key)
                elif isinstance(item, str) and key in ("url", "urls"):
                    if urllib.parse.urlsplit(item).scheme.lower() in (
                        "http", "https"
                    ):
                        candidates.append(item)
            collect(live["groups"])
        candidates = list(dict.fromkeys(candidates))
        if len(candidates) <= 3:
            return candidates
        indexes = (0, len(candidates) // 2, len(candidates) - 1)
        return [candidates[index] for index in indexes]

    @staticmethod
    def _live_probe_available(result):
        return isinstance(result, dict) and str(
            result.get("state", "") or ""
        ) == "available"

    def _test_live_proxy_entry(self, live_entry):
        identity = self._live_proxy_identity(live_entry)
        policy = self._live_proxy_policy(live_entry)
        if policy.get("policy") in self.LIVE_PROXY_POLICY_MODES:
            return "manual", self._live_proxy_policy_label(live_entry)
        targets = self._live_probe_targets(live_entry.get("live", {}))
        if not targets:
            return "limited", "未提取到可检测的 HTTP(S) 频道"

        failed = []
        for _attempt in range(2):
            failed = [
                target
                for target in targets
                if not self._live_probe_available(
                    self._probe_media_url(target, {"User-Agent": "okhttp/4.12.0"})
                )
            ]
            if not failed:
                self.live_proxy_auto_sources.pop(identity, None)
                return "direct", "代表频道连续直连成功，保持直连"
        live_prefixes = [
            value for value in self.playback_proxy_urls
            if self._live_proxy_capability_available(value)
        ]
        for prefix in live_prefixes:
            relay_results = [
                self._probe_media_url(
                    self._live_proxy_relay_url(prefix, target),
                    {"User-Agent": "okhttp/4.12.0"},
                )
                for target in failed
            ]
            if relay_results and all(
                self._live_probe_available(result) for result in relay_results
            ):
                self.live_proxy_auto_sources[identity] = {
                    "prefix": prefix,
                    "qualification": self.LIVE_PROXY_AUTO_QUALIFICATION,
                    "checked_at": time.strftime("%Y-%m-%d %H:%M:%S"),
                    "detail": "连续两次直连失败，反代代表频道取样通过",
                }
                return "proxy", "自动启用{}".format(
                    self._playback_proxy_line_label(prefix)
                )
        return "limited", "直连连续失败，反代线路未取得完整代表频道样本；保留原状态"

    def _run_live_proxy_test(self):
        candidates = [
            item for item in self.cache.get("lives", [])
            if self._live_proxy_supported(item.get("live", {}))
        ]
        if not candidates:
            raise ValueError("当前没有可检测的直播源")
        counts = {"direct": 0, "proxy": 0, "limited": 0, "manual": 0}
        details = []
        previous = copy.deepcopy(self.live_proxy_auto_sources)
        try:
            for item in candidates:
                state, detail = self._test_live_proxy_entry(item)
                counts[state] = counts.get(state, 0) + 1
                details.append(
                    "{}：{}".format(
                        item.get("live", {}).get("name", "直播源"), detail
                    )
                )
            self._save_settings()
            if not self._refresh_locked(allow_empty=True):
                raise ValueError(self.status.get("error") or "配置刷新失败")
            _, reload_detail = self._reload_app_vod_config(
                expected_keys=self._generated_registry_keys(),
                refresh_types=("category",),
            )
        except Exception:
            self.live_proxy_auto_sources = previous
            try:
                self._save_settings()
            except Exception:
                pass
            raise
        return (
            "直播反代检测完成：直连 {} · 自动反代 {} · 受限 {} · 人工策略 {}；{}；{}"
        ).format(
            counts["direct"],
            counts["proxy"],
            counts["limited"],
            counts["manual"],
            "；".join(details[:6]),
            reload_detail,
        )

    def _start_live_proxy_test(self):
        if not self.playback_proxy_enabled:
            return False, "请先开启反代线路总开关"
        if not any(
            self._live_proxy_capability_available(value)
            for value in self.playback_proxy_urls
        ):
            return False, "当前线路不支持 HTTP(S) 媒体转发"
        reserved, active = self._reserve_background_operation(
            "playback_proxy"
        )
        if not reserved:
            return False, self._background_operation_conflict(active)
        owner = self

        def worker():
            try:
                with owner.lock:
                    message = owner._run_live_proxy_test()
                owner._notify_app(message)
            except Exception as exc:
                message = "直播反代检测失败：{}".format(exc)
                owner._log("ERROR", message)
                owner._notify_app(message)
            finally:
                owner._release_background_operation("playback_proxy")

        thread = threading.Thread(
            target=worker,
            name="local-live-proxy-test",
        )
        thread.daemon = True
        thread.start()
        return True, "正在检测直播直连与反代线路，完成后会提示结果"

    def _playback_proxy_state(self, source):
        if not self.playback_proxy_enabled or not isinstance(source, dict):
            return {}
        state = self.playback_proxy_sources.get(str(source.get("identity", "")), {})
        if not isinstance(state, dict):
            return {}
        prefix = str(state.get("prefix", "") or "")
        if prefix and not self._playback_proxy_line_compatible(source, prefix):
            return {}
        return dict(state)

    def _playback_proxy_policy(self, source):
        identity = (
            str(source.get("identity", "") or "").strip()
            if isinstance(source, dict)
            else str(source or "").strip()
        )
        state = self.playback_proxy_policies.get(identity, {})
        if not isinstance(state, dict):
            return {"policy": "auto"}
        policy = str(state.get("policy", "") or "")
        if policy not in self.PLAYBACK_PROXY_POLICY_MODES:
            return {"policy": "auto"}
        return dict(state)

    def _playback_proxy_policy_label(self, source):
        capability = self._playback_proxy_capability(source)
        capability_label = str(capability.get("label", "") or "")
        state = self._playback_proxy_policy(source)
        policy = str(state.get("policy", "auto") or "auto")
        if policy == "force_direct":
            return "{} · 强制直连".format(capability_label)
        if policy == "force_proxy":
            prefix = str(state.get("prefix", "") or "")
            if prefix and not self._playback_proxy_line_compatible(
                source, prefix
            ):
                return "{} · 强制反代 · 等待可用线路".format(
                    capability_label
                )
            return "{} · 强制反代 · {}".format(
                capability_label,
                self._playback_proxy_line_label(prefix)
                if prefix else "等待配置线路"
            )
        effective = self._playback_proxy_state(source)
        if effective:
            return "{} · 自动判断{} · {}".format(
                capability_label,
                "（待复检）" if effective.get("needs_retest") else "",
                self._playback_proxy_line_label(effective.get("prefix", ""))
            )
        return "{} · 自动判断 · 当前直连".format(capability_label)

    def _apply_source_playback_proxy_policy(self, source, policy, prefix=""):
        if not self._playback_proxy_eligible(source):
            raise ValueError(
                self._playback_proxy_capability(source).get(
                    "reason", "当前来源不支持统一反代"
                )
            )
        identity = str(source.get("identity", "") or "").strip()
        if identity in self.manual_ignored_sources:
            raise ValueError("该站点已被用户手工屏蔽，请先在屏蔽分类中恢复")
        if identity in self.adult_blocked_sources:
            raise ValueError("该站点已被成人内容过滤，请先在屏蔽分类中恢复")
        policy = str(policy or "auto").strip().lower()
        current = self.playback_proxy_sources.get(identity, {})
        if policy == "auto":
            self.playback_proxy_policies.pop(identity, None)
            if isinstance(current, dict) and current.get("mode") == "manual":
                self.playback_proxy_sources.pop(identity, None)
            return "已恢复自动判断"
        self.auto_blocked_sources.discard(identity)
        self.pruned_sources.discard(identity)
        self._retest_auto_blocked.discard(identity)
        self._sync_ignored_sources()
        if policy == "force_direct":
            self.playback_proxy_policies[identity] = {
                "policy": "force_direct"
            }
            self.playback_proxy_sources.pop(identity, None)
            return "已强制使用直连"
        if policy != "force_proxy":
            raise ValueError("反代策略无效")
        if not self.playback_proxy_urls:
            raise ValueError("请先在设置中添加反代线路地址")
        prefix = str(prefix or "").strip()
        compatible = self._playback_proxy_compatible_urls(source)
        if not compatible:
            raise ValueError("该来源需要支持 HTTP(S) 目标转发的反代线路")
        if prefix not in compatible:
            prefix = compatible[0]
        self.playback_proxy_enabled = True
        self.playback_proxy_policies[identity] = {
            "policy": "force_proxy",
            "prefix": prefix,
        }
        self.playback_proxy_sources[identity] = {
            "prefix": prefix,
            "mode": "manual",
        }
        return "已强制使用反代线路：{}".format(
            self._playback_proxy_line_label(prefix)
        )

    def _set_source_playback_proxy_policy(self, source, policy, prefix=""):
        previous = (
            self.playback_proxy_enabled,
            copy.deepcopy(self.playback_proxy_sources),
            copy.deepcopy(self.playback_proxy_policies),
            set(self.auto_blocked_sources),
            set(self.pruned_sources),
            set(self._retest_auto_blocked),
            set(self.ignored_sources),
        )
        try:
            message = self._apply_source_playback_proxy_policy(
                source, policy, prefix
            )
            self._save_settings()
            if not self._refresh_locked(allow_empty=True):
                raise ValueError(self.status.get("error") or "配置刷新失败")
            _, reload_detail = self._reload_app_vod_config(
                expected_keys=self._generated_registry_keys(),
                refresh_types=("category",),
            )
            return message, reload_detail
        except Exception:
            (
                self.playback_proxy_enabled,
                self.playback_proxy_sources,
                self.playback_proxy_policies,
                self.auto_blocked_sources,
                self.pruned_sources,
                self._retest_auto_blocked,
                self.ignored_sources,
            ) = previous
            try:
                self._save_settings()
            except Exception:
                pass
            raise

    def _apply_automatic_playback_proxy_result(self, source, result):
        identity = str(source.get("identity", "") or "").strip()
        if not identity or not self._playback_proxy_eligible(source):
            return False
        if self._playback_proxy_policy(source).get("policy") != "auto":
            return False
        current = self.playback_proxy_sources.get(identity, {})
        if isinstance(current, dict) and current.get("mode") == "manual":
            return False
        prefix = str(result.get("playback_proxy_prefix", "") or "").strip()
        qualified = bool(result.get("playback_proxy_auto_qualified", False))
        media_proven = self._test_result_has_media_proof(result)
        if prefix and qualified and media_proven:
            next_state = {
                "prefix": prefix,
                "mode": "auto",
                "qualification": self.PLAYBACK_PROXY_AUTO_QUALIFICATION,
            }
            if current != next_state:
                self.playback_proxy_sources[identity] = next_state
                return True
            return False
        if (
            isinstance(current, dict)
            and current.get("mode") == "auto"
            and not prefix
            and media_proven
        ):
            self.playback_proxy_sources.pop(identity, None)
            return True
        return False

    @staticmethod
    def _test_result_has_media_proof(result):
        if not isinstance(result, dict):
            return False
        media = result.get("media", {})
        if not isinstance(media, dict):
            return False
        try:
            sample_bytes = int(media.get("sample_bytes", 0) or 0)
        except Exception:
            sample_bytes = 0
        return (
            str(result.get("state", "")) == "available"
            and str(result.get("test_stage", "")) == "media"
            and str(result.get("playback_state", ""))
            in ("media_sample", "proxy_media_sample")
            and sample_bytes > 0
        )

    def _playback_proxy_capability(self, source):
        unsupported = {
            "kind": "unsupported",
            "label": self.PLAYBACK_PROXY_CAPABILITY_LABELS["unsupported"],
            "reason": "未识别到可安全包装的播放出口",
            "adapter": "",
            "path": "",
        }
        if not isinstance(source, dict):
            return unsupported
        source_type = str(source.get("type", "") or "").strip().upper()
        path = str(source.get("playback_proxy_path", "") or "").strip()
        if path and os.path.isfile(path):
            return {
                "kind": "py_media",
                "label": self.PLAYBACK_PROXY_CAPABILITY_LABELS["py_media"],
                "reason": "整包远程 PY 已固化为本地可包装文件",
                "adapter": "python",
                "path": path,
            }
        if source_type == "PY":
            if not path and not source.get("csp_site"):
                path = str(source.get("path", "") or "").strip()
            if path and os.path.isfile(path):
                return {
                    "kind": "py_media",
                    "label": self.PLAYBACK_PROXY_CAPABILITY_LABELS["py_media"],
                    "reason": "独立 PY 播放出口可完整包装",
                    "adapter": "python",
                    "path": path,
                }
            unsupported["reason"] = "PY 源不是可独立包装的本地文件"
            return unsupported
        if source_type == "JS":
            if source.get("csp_site"):
                site = source.get("csp_site", {})
                if not isinstance(site, dict) or site.get("ext"):
                    unsupported["reason"] = "DRPY 规则或带 ext 的 JS 包不改写运行时"
                    return unsupported
                path = self._site_reference_path(site.get("api", ""))
            else:
                path = str(source.get("path", "") or "").strip()
            if not path or not os.path.isfile(path):
                unsupported["reason"] = "JS 播放脚本不存在或不可读"
                return unsupported
            try:
                text = self._read_text(path, min(self.max_source_size, 512 * 1024))
            except Exception as exc:
                unsupported["reason"] = "JS 播放脚本读取失败: {}".format(exc)
                return unsupported
            has_player = bool(
                re.search(
                    r"\b(?:playerContent|play)\s*(?:[:=]|\()",
                    text,
                )
            )
            if not self._has_direct_js_spider(text) or not has_player:
                unsupported["reason"] = "JS 未识别到稳定的 playerContent/play 出口"
                return unsupported
            if re.search(r"\bmodule\.exports\s*=", text):
                adapter = "commonjs"
            elif self._has_quickjs_export(text):
                adapter = "esm"
            else:
                unsupported["reason"] = "JS 全局入口无法在不改原文件时安全包装"
                return unsupported
            return {
                "kind": "js_player",
                "label": self.PLAYBACK_PROXY_CAPABILITY_LABELS["js_player"],
                "reason": "已识别 {} 播放出口".format(adapter.upper()),
                "adapter": adapter,
                "path": path,
            }
        if source_type == "HTML":
            path = str(source.get("path", "") or "").strip()
            if not path or not os.path.isfile(path):
                unsupported["reason"] = "HTML 页面不存在或不可读"
                return unsupported
            try:
                text = self._read_text(path, min(self.max_source_size, 512 * 1024))
            except Exception as exc:
                unsupported["reason"] = "HTML 页面读取失败: {}".format(exc)
                return unsupported
            signatures = []
            lower = text.casefold()
            csp = re.search(
                r"<meta\b[^>]*http-equiv\s*=\s*(['\"]?)content-security-policy\1[^>]*>",
                lower,
                re.IGNORECASE,
            )
            if csp and "'unsafe-inline'" not in csp.group(0):
                unsupported["reason"] = "HTML CSP 不允许注入受管播放脚本"
                return unsupported
            if re.search(r"\bfm\s*\.\s*play\s*\(", lower):
                signatures.append("fm.play")
            if re.search(r"<(?:video|source)\b", lower):
                signatures.append("video/source")
            if re.search(r"\b(?:hls\s*\.\s*loadsource|new\s+hls\s*\()", lower):
                signatures.append("Hls.js")
            if not signatures:
                unsupported["reason"] = "HTML 未识别到 fm.play、video/source 或 Hls.js 出口"
                return unsupported
            return {
                "kind": "html_native_play",
                "label": self.PLAYBACK_PROXY_CAPABILITY_LABELS[
                    "html_native_play"
                ],
                "reason": "已识别 {}".format("、".join(signatures)),
                "adapter": "html",
                "path": path,
            }
        unsupported["reason"] = "{} 类型不支持站点播放包装".format(
            source_type or "未知"
        )
        return unsupported

    def _playback_proxy_eligible(self, source):
        capability = self._playback_proxy_capability(source)
        if isinstance(source, dict):
            source["proxy_capability"] = capability.get("kind", "unsupported")
            source["proxy_capability_reason"] = capability.get("reason", "")
            source["proxy_capability_label"] = capability.get("label", "")
        return capability.get("kind") != "unsupported"

    def _playback_proxy_line_compatible(self, source, prefix):
        capability = self._playback_proxy_capability(source)
        if capability.get("kind") == "unsupported":
            return False
        if capability.get("kind") == "py_media":
            return True
        return self._live_proxy_capability_available(prefix)

    def _playback_proxy_compatible_urls(self, source):
        return [
            prefix
            for prefix in self.playback_proxy_urls
            if self._playback_proxy_line_compatible(source, prefix)
        ]

    @staticmethod
    def _playback_proxy_request_url(prefix, target):
        return str(prefix or "") + str(target or "")

    def _playback_proxy_assigned(self, source):
        return bool(
            self._playback_proxy_eligible(source)
            and self._playback_proxy_state(source)
        )

    def _playback_proxy_summary(self):
        auto_count = sum(
            1
            for state in self.playback_proxy_sources.values()
            if isinstance(state, dict) and state.get("mode") == "auto"
        )
        force_proxy_count = sum(
            1
            for state in self.playback_proxy_policies.values()
            if isinstance(state, dict) and state.get("policy") == "force_proxy"
        )
        force_direct_count = sum(
            1
            for state in self.playback_proxy_policies.values()
            if isinstance(state, dict) and state.get("policy") == "force_direct"
        )
        media_count = sum(
            1 for value in self.playback_proxy_urls
            if self._live_proxy_capability_available(value)
        )
        live_force_count = sum(
            1 for state in self.live_proxy_policies.values()
            if isinstance(state, dict) and state.get("policy") == "force_proxy"
        )
        return "{} · {} 条地址（媒体转发 {}） · 站点自动 {} / 反代 {} / 直连 {} · 直播自动 {} / 强制 {}".format(
            "已开启" if self.playback_proxy_enabled else "已关闭",
            len(self.playback_proxy_urls),
            media_count,
            auto_count,
            force_proxy_count,
            force_direct_count,
            len(self.live_proxy_auto_sources),
            live_force_count,
        )

    def _playback_proxy_line_label(self, prefix):
        value = str(prefix or "").strip()
        try:
            index = self.playback_proxy_urls.index(value)
        except ValueError:
            return "当前线路"
        if index < len(self.PLAYBACK_PROXY_LINE_NAMES):
            return "线路{}".format(self.PLAYBACK_PROXY_LINE_NAMES[index])
        return "线路{}".format(index + 1)

    def _hide_playback_proxy_urls(self, value):
        text = str(value or "")
        indexed = list(enumerate(self.playback_proxy_urls))
        for index, prefix in sorted(indexed, key=lambda item: len(item[1]), reverse=True):
            if not prefix:
                continue
            label = (
                "线路{}".format(self.PLAYBACK_PROXY_LINE_NAMES[index])
                if index < len(self.PLAYBACK_PROXY_LINE_NAMES)
                else "线路{}".format(index + 1)
            )
            text = text.replace(prefix, label)
        return text

    def _cycle_source_playback_proxy(self, source):
        if not self._playback_proxy_eligible(source):
            raise ValueError(
                self._playback_proxy_capability(source).get(
                    "reason", "当前来源不支持统一反代"
                )
            )
        if not self.playback_proxy_urls:
            raise ValueError("请先在设置中添加反代线路地址")
        compatible_urls = self._playback_proxy_compatible_urls(source)
        if not compatible_urls:
            raise ValueError("该来源需要支持 HTTP(S) 目标转发的反代线路")
        identity = str(source.get("identity", "") or "").strip()
        previous_enabled = self.playback_proxy_enabled
        previous = copy.deepcopy(self.playback_proxy_sources)
        previous_policies = copy.deepcopy(self.playback_proxy_policies)
        current = self.playback_proxy_sources.get(identity, {})
        prefix = str(current.get("prefix", "") or "") if isinstance(current, dict) else ""
        if not prefix:
            next_prefix = compatible_urls[0]
            self.playback_proxy_sources[identity] = {
                "prefix": next_prefix,
                "mode": "manual",
            }
            self.playback_proxy_policies[identity] = {
                "policy": "force_proxy",
                "prefix": next_prefix,
            }
            self.playback_proxy_enabled = True
            message = "已手动启用反代线路：{}".format(
                self._playback_proxy_line_label(next_prefix)
            )
        else:
            compatible_index = (
                compatible_urls.index(prefix) if prefix in compatible_urls else -1
            )
            if compatible_index + 1 < len(compatible_urls):
                next_prefix = compatible_urls[compatible_index + 1]
                self.playback_proxy_sources[identity] = {
                    "prefix": next_prefix,
                    "mode": "manual",
                }
                self.playback_proxy_policies[identity] = {
                    "policy": "force_proxy",
                    "prefix": next_prefix,
                }
                message = "已切换反代线路：{}".format(
                    self._playback_proxy_line_label(next_prefix)
                )
            else:
                self.playback_proxy_sources.pop(identity, None)
                self.playback_proxy_policies[identity] = {
                    "policy": "force_direct"
                }
                message = "已强制使用直连"
        try:
            self._save_settings()
            if not self._refresh_locked(allow_empty=True):
                raise ValueError(self.status.get("error") or "配置刷新失败")
        except Exception:
            self.playback_proxy_enabled = previous_enabled
            self.playback_proxy_sources = previous
            self.playback_proxy_policies = previous_policies
            try:
                self._save_settings()
            except Exception:
                pass
            raise
        return message

    def _playback_proxy_cycle_worker(self, source):
        source_name = str(source.get("name", "站点") or "站点")
        try:
            self._ensure_background_task_active(
                self._playback_proxy_cancel, "反代线路更新"
            )
            with self.lock:
                self._ensure_background_task_active(
                    self._playback_proxy_cancel, "反代线路更新"
                )
                message = self._cycle_source_playback_proxy(source)
                _, reload_detail = self._reload_app_vod_config(
                    expected_keys=self._generated_registry_keys(),
                    refresh_types=("category",),
                )
            self._schedule_manager_page_refresh(delay=0.35)
            result = "{}：{}；{}".format(source_name, message, reload_detail)
            self._log("INFO", result)
        except BackgroundTaskCancelled:
            result = "{}反代线路更新已取消".format(source_name)
            self._log("INFO", result)
        except Exception as exc:
            result = "{}反代线路切换失败：{}".format(
                source_name, self._hide_playback_proxy_urls(exc)
            )
            self._log("ERROR", result)
        finally:
            self._playback_proxy_thread = None
            self._release_background_operation("playback_proxy")
        self._notify_app(result)

    def _start_source_playback_proxy_cycle(self, source):
        if not self._playback_proxy_eligible(source):
            return False, self._playback_proxy_capability(source).get(
                "reason", "当前来源不支持统一反代"
            )
        if not self.playback_proxy_urls:
            return False, "请先在设置中添加反代线路地址"
        if not self._playback_proxy_compatible_urls(source):
            return False, "该来源需要支持 HTTP(S) 目标转发的反代线路"
        reserved, active = self._reserve_background_operation("playback_proxy")
        if not reserved:
            return False, self._background_operation_conflict(active)
        self._playback_proxy_cancel.clear()
        self._destroyed = False
        worker = threading.Thread(
            target=self._playback_proxy_cycle_worker,
            args=(copy.deepcopy(source),),
            name="local-playback-proxy-update",
        )
        worker.daemon = True
        self._playback_proxy_thread = worker
        try:
            worker.start()
        except Exception:
            self._playback_proxy_thread = None
            self._release_background_operation("playback_proxy")
            raise
        return True, "正在为 {} 更新反代线路，完成后会提示结果".format(
            source.get("name", "站点")
        )

    def _playback_proxy_policy_worker(self, source, policy, prefix):
        source_name = str(source.get("name", "站点") or "站点")
        try:
            self._ensure_background_task_active(
                self._playback_proxy_cancel, "反代策略更新"
            )
            with self.lock:
                message, reload_detail = self._set_source_playback_proxy_policy(
                    source, policy, prefix
                )
            self._schedule_manager_page_refresh(delay=0.35)
            result = "{}：{}；{}".format(source_name, message, reload_detail)
            self._log("INFO", result)
        except BackgroundTaskCancelled:
            result = "{}反代策略更新已取消".format(source_name)
            self._log("INFO", result)
        except Exception as exc:
            result = "{}反代策略更新失败：{}".format(
                source_name, self._hide_playback_proxy_urls(exc)
            )
            self._log("ERROR", result)
        finally:
            self._playback_proxy_thread = None
            self._release_background_operation("playback_proxy")
        self._notify_app(result)

    def _start_source_playback_proxy_policy(self, source, policy, prefix=""):
        if not self._playback_proxy_eligible(source):
            return False, self._playback_proxy_capability(source).get(
                "reason", "当前来源不支持统一反代"
            )
        if policy == "force_proxy" and not self.playback_proxy_urls:
            return False, "请先在设置中添加反代线路地址"
        if policy == "force_proxy" and not self._playback_proxy_compatible_urls(
            source
        ):
            return False, "该来源需要支持 HTTP(S) 目标转发的反代线路"
        reserved, active = self._reserve_background_operation("playback_proxy")
        if not reserved:
            return False, self._background_operation_conflict(active)
        self._playback_proxy_cancel.clear()
        self._destroyed = False
        worker = threading.Thread(
            target=self._playback_proxy_policy_worker,
            args=(copy.deepcopy(source), policy, prefix),
            name="local-playback-proxy-policy",
        )
        worker.daemon = True
        self._playback_proxy_thread = worker
        try:
            worker.start()
        except Exception:
            self._playback_proxy_thread = None
            self._release_background_operation("playback_proxy")
            raise
        return True, "正在为 {} 应用{}".format(
            source.get("name", "站点"),
            {
                "auto": "自动判断",
                "force_direct": "强制直连",
                "force_proxy": "强制反代",
            }.get(policy, "反代策略"),
        )

    def _playback_proxy_visible_sources(self):
        candidates = []
        seen = set()
        for source in list(self.cache.get("sources", [])) + list(
            self.cache.get("ignored", [])
        ):
            identity = str(source.get("identity", "") or "").strip()
            if (
                not identity
                or identity in seen
                or identity in self.manual_ignored_sources
                or identity in self.adult_blocked_sources
            ):
                continue
            if source.get("ignored") and identity not in (
                self.auto_blocked_sources | self.pruned_sources
            ):
                continue
            seen.add(identity)
            candidates.append(source)
        return self._sorted_sources_for_display(candidates)

    def _playback_proxy_management_candidates(self):
        return [
            source
            for source in self._playback_proxy_visible_sources()
            if self._playback_proxy_eligible(source)
        ]

    def _playback_proxy_loaded_summary(self, management=None):
        visible = self._playback_proxy_visible_sources()
        manageable = list(
            management
            if management is not None
            else self._playback_proxy_management_candidates()
        )
        manageable_ids = {
            str(source.get("identity", "") or "") for source in manageable
        }
        bundle_unsupported = sum(
            1
            for source in visible
            if str(source.get("identity", "") or "") not in manageable_ids
            and (
                bool(str(source.get("package_id", "") or "").strip())
                or "#bundle-site-" in str(source.get("identity", "") or "")
            )
        )
        parts = [
            "已加载 {} 个站点".format(len(visible)),
            "其中 {} 个有可包装播放出口".format(len(manageable)),
        ]
        if bundle_unsupported:
            parts.append(
                "{} 个整包 XBPQ/JAR 站点没有独立播放出口".format(
                    bundle_unsupported
                )
            )
        unsupported = len(visible) - len(manageable) - bundle_unsupported
        if unsupported > 0:
            parts.append("{} 个来源未识别到稳定播放出口".format(unsupported))
        return "；".join(parts)

    @staticmethod
    def _playback_proxy_candidate_summary(candidates, limit=3):
        candidates = list(candidates or [])
        if not candidates:
            return "当前可选 0 个"
        labels = []
        for source in candidates[: max(1, int(limit or 1))]:
            label = str(source.get("name", "") or "").strip()
            if not label:
                label = str(source.get("relative_in_root", "") or "未命名").strip()
            labels.append(label)
        if len(candidates) > len(labels):
            labels.append("等")
        return "当前可选 {} 个：{}".format(len(candidates), "、".join(labels))

    def _batch_playback_proxy_candidates(self):
        assigned = set(self.playback_proxy_sources)
        return [
            source
            for source in self._playback_proxy_management_candidates()
            if str(source.get("identity", "")) not in assigned
            and bool(self._playback_proxy_compatible_urls(source))
        ]

    def _batch_playback_proxy_empty_message(self, disable=False):
        management = self._playback_proxy_management_candidates()
        assigned = set(self.playback_proxy_sources)
        summary = self._playback_proxy_loaded_summary(management)
        if disable:
            return "当前没有已启用的反代线路站点；{}".format(summary)
        if not management:
            return "当前没有可设置反代策略的本地站点；{}".format(summary)
        unassigned = [
            source
            for source in management
            if str(source.get("identity", "")) not in assigned
        ]
        if unassigned and not any(
            self._playback_proxy_compatible_urls(source)
            for source in unassigned
        ):
            return "当前没有可用于 {} 个可包装站点的反代线路；{}".format(
                len(unassigned), summary
            )
        if not unassigned:
            return "当前所有独立本地站点均已启用反代线路"
        return "当前没有可批量启用反代线路的站点"

    def _batch_disable_playback_proxy_candidates(self):
        assigned = set(self.playback_proxy_sources)
        return [
            source
            for source in self._playback_proxy_management_candidates()
            if str(source.get("identity", "")) in assigned
        ]

    def _batch_enable_playback_proxy(self, source_ids):
        if not self.playback_proxy_urls:
            raise ValueError("请先在设置中添加反代线路地址")
        selected = {
            str(source_id or "").strip()
            for source_id in source_ids
            if str(source_id or "").strip()
        }
        candidates = {
            str(source.get("id", "")): source
            for source in self._batch_playback_proxy_candidates()
        }
        sources = [
            source
            for source_id, source in candidates.items()
            if source_id in selected
        ]
        if not sources:
            raise ValueError("没有可批量启用的站点")
        previous_enabled = self.playback_proxy_enabled
        previous_sources = copy.deepcopy(self.playback_proxy_sources)
        previous_policies = copy.deepcopy(self.playback_proxy_policies)
        previous_blocked = (
            set(self.auto_blocked_sources),
            set(self.pruned_sources),
            set(self._retest_auto_blocked),
            set(self.ignored_sources),
        )
        prefix = ""
        for source in sources:
            source_prefix = self._playback_proxy_compatible_urls(source)[0]
            if not prefix:
                prefix = source_prefix
            self._apply_source_playback_proxy_policy(
                source, "force_proxy", source_prefix
            )
        self.playback_proxy_enabled = True
        try:
            self._save_settings()
            if not self._refresh_locked(allow_empty=True):
                raise ValueError(self.status.get("error") or "配置刷新失败")
            _, reload_detail = self._reload_app_vod_config(
                expected_keys=self._generated_registry_keys(),
                refresh_types=("category",),
            )
        except Exception:
            self.playback_proxy_enabled = previous_enabled
            self.playback_proxy_sources = previous_sources
            self.playback_proxy_policies = previous_policies
            (
                self.auto_blocked_sources,
                self.pruned_sources,
                self._retest_auto_blocked,
                self.ignored_sources,
            ) = previous_blocked
            try:
                self._save_settings()
            except Exception:
                pass
            raise
        return sources, prefix, reload_detail

    def _batch_disable_playback_proxy(self, source_ids):
        selected = {
            str(source_id or "").strip()
            for source_id in source_ids
            if str(source_id or "").strip()
        }
        candidates = {
            str(source.get("id", "")): source
            for source in self._batch_disable_playback_proxy_candidates()
        }
        sources = [
            source
            for source_id, source in candidates.items()
            if source_id in selected
        ]
        if not sources:
            raise ValueError("没有可批量取消的站点")
        previous_sources = copy.deepcopy(self.playback_proxy_sources)
        previous_policies = copy.deepcopy(self.playback_proxy_policies)
        previous_blocked = (
            set(self.auto_blocked_sources),
            set(self.pruned_sources),
            set(self._retest_auto_blocked),
            set(self.ignored_sources),
        )
        for source in sources:
            self._apply_source_playback_proxy_policy(
                source, "force_direct"
            )
        try:
            self._save_settings()
            if not self._refresh_locked(allow_empty=True):
                raise ValueError(self.status.get("error") or "配置刷新失败")
            _, reload_detail = self._reload_app_vod_config(
                expected_keys=self._generated_registry_keys(),
                refresh_types=("category",),
            )
        except Exception:
            self.playback_proxy_sources = previous_sources
            self.playback_proxy_policies = previous_policies
            (
                self.auto_blocked_sources,
                self.pruned_sources,
                self._retest_auto_blocked,
                self.ignored_sources,
            ) = previous_blocked
            try:
                self._save_settings()
            except Exception:
                pass
            raise
        return sources, reload_detail

    @staticmethod
    def _load_health_setting_int(data, keys, fallback, minimum, maximum):
        for key in keys:
            if key not in data:
                continue
            try:
                value = int(data[key])
                return value if minimum <= value <= maximum else fallback
            except Exception:
                return fallback
        return fallback

    def _save_settings(self):
        path = os.path.abspath(os.path.expanduser(self.settings_path))
        self._sync_legacy_package_download_fields()
        data = {
            "type_enabled": {
                source_type: bool(self.type_enabled.get(source_type, True))
                for source_type in self.TYPE_ORDER
            },
            "pending_type_enabled": {
                source_type: bool(
                    self.pending_type_enabled.get(
                        source_type, self.type_enabled.get(source_type, True)
                    )
                )
                for source_type in self.TYPE_ORDER
            },
            "block_adult_sites": bool(self.block_adult_sites),
            "pending_block_adult_sites": bool(
                self.pending_block_adult_sites
            ),
            "adult_filter_revision": int(self.adult_filter_revision or 0),
            "strict_recognition": bool(self.strict_recognition),
            "auto_scan_on_empty": bool(self.auto_scan_on_empty),
            "auto_prune_blocked": bool(self.auto_prune_blocked),
            "pruned_sources": sorted(self.pruned_sources),
            "auto_scan_suspended": bool(self.auto_scan_suspended),
            "scan_base_path": self.scan_base_path,
            "site_test_concurrency": int(self.site_test_concurrency),
            "site_test_batch_limit": int(self.site_test_batch_limit),
            "playback_proxy_enabled": bool(self.playback_proxy_enabled),
            "playback_proxy_urls": list(self.playback_proxy_urls),
            "playback_proxy_settings_revision": int(
                self.playback_proxy_settings_revision
            ),
            "playback_proxy_sources": copy.deepcopy(
                self.playback_proxy_sources
            ),
            "playback_proxy_policies": copy.deepcopy(
                self.playback_proxy_policies
            ),
            "live_proxy_policies": copy.deepcopy(
                self.live_proxy_policies
            ),
            "live_proxy_auto_sources": copy.deepcopy(
                self.live_proxy_auto_sources
            ),
            "ignored_sources": sorted(self.ignored_sources),
            "manual_ignored_sources": sorted(self.manual_ignored_sources),
            "auto_blocked_sources": sorted(self.auto_blocked_sources),
            "adult_blocked_sources": sorted(self.adult_blocked_sources),
            "adult_allowed_sources": sorted(self.adult_allowed_sources),
            "site_test_results": self.site_test_results,
            "site_test_history": self.site_test_history,
            "source_metadata": self.source_metadata,
            "retest_pending": list(self._retest_pending),
            "retest_auto_blocked": sorted(self._retest_auto_blocked),
            "last_app_port": int(self.last_app_port or 0),
            "ok_base_config_url": self.ok_base_config_url,
            "ok_last_target": self.ok_last_target,
            "package_download_sites": [
                {
                    "id": str(item.get("id", "")),
                    "name": str(item.get("name", "")),
                    "url": str(item.get("url", "")),
                    "enabled": self._package_download_site_active(item),
                    "site_enabled": self._package_download_site_enabled(item),
                    "live_enabled": self._package_download_live_enabled(item),
                    "health_enabled": self._package_download_health_enabled(item),
                    "source_type": str(item.get("source_type", "zip")),
                    "site_count": max(0, int(item.get("site_count", 0) or 0)),
                    "dependency_count": max(
                        0, int(item.get("dependency_count", 0) or 0)
                    ),
                    "live_count": max(0, int(item.get("live_count", 0) or 0)),
                }
                for item in self.package_download_sites
            ],
            "package_service_url": self.package_service_url,
            "package_download_url": self.package_download_url,
            "package_download_enabled": bool(self.package_download_enabled),
            "online_repositories": [
                {
                    "id": str(item.get("id", "")),
                    "name": str(item.get("name", "")),
                    "url": str(item.get("url", "")),
                    "enabled": self._as_bool(item.get("enabled", True), True),
                    "version": str(item.get("version", "")),
                    "manifest_digest": str(item.get("manifest_digest", "")),
                    "file_count": max(0, int(item.get("file_count", 0) or 0)),
                    "last_sync": str(item.get("last_sync", "")),
                    "last_status": str(item.get("last_status", "")),
                    "last_message": str(item.get("last_message", ""))[:240],
                }
                for item in self.online_repositories
            ],
            "package_device_id": self.package_device_id,
            "author_scan_surprise_shown": bool(
                self._author_scan_surprise_shown
            ),
        }
        self._atomic_write_plain_json(path, data)

    def _normalize_scan_base_path(self, value):
        path = str(value or "").strip().strip('"').strip("'")
        if path.lower().startswith("file://"):
            path = path[7:]
        if not path:
            return ""
        path = os.path.expanduser(path)
        if not os.path.isabs(path):
            path = os.path.join(self.STORAGE_ROOT, path.lstrip("/"))
        return os.path.realpath(os.path.abspath(path))

    def _scan_roots_for_base(self, base_path):
        return [
            {
                "path": _detect_child_dir(base_path, "py", "python"),
                "type": "PY",
                "extensions": [".py"],
            },
            {
                "path": _detect_child_dir(base_path, "js", "javascript"),
                "type": "JS",
                "extensions": [".js", ".json"],
            },
            {
                "path": _detect_child_dir(base_path, "csp"),
                "type": "CSP",
                "extensions": [".json"],
            },
            {
                "path": _detect_child_dir(base_path, "XBPQ"),
                "type": "XBPQ",
                "extensions": [".json"],
            },
            {
                "path": _detect_child_dir(base_path, "html"),
                "type": "HTML",
                "extensions": [".html"],
            },
        ]

    def _auto_classify_extension_map(self):
        """根据当前扫描根构建 扩展名 -> 目标子目录 的映射。
        只有映射到唯一类型的扩展名才可安全归类；.json 同时属于 JS/CSP/XBPQ，视为歧义不移动。"""
        ext_to_dirs = {}
        for root in self.scan_roots:
            directory = root.get("path")
            if not directory:
                continue
            for ext in root.get("extensions", []):
                ext_to_dirs.setdefault(ext.lower(), []).append(directory)
        unique = {}
        ambiguous = set()
        for ext, dirs in ext_to_dirs.items():
            distinct = list(dict.fromkeys(dirs))
            if len(distinct) == 1:
                unique[ext] = distinct[0]
            else:
                ambiguous.add(ext)
        return unique, ambiguous

    def _auto_classify_files(self):
        """把扫描根目录顶层散落的源文件移动到对应类型子目录。
        - 仅处理根目录顶层的普通文件，不递归、不动子目录内已归类文件。
        - 跳过符号链接、隐藏文件、管理脚本自身。
        - 目标已存在同名文件时跳过，绝不覆盖。
        - .json 等歧义扩展名不移动，计入“需手动归类”。
        返回 (moved, skipped_conflict, skipped_ambiguous, skipped_other, created_dirs, errors, detail_lines)。
        """
        base = self.local_base_dir
        if not base or not os.path.isdir(base):
            raise ValueError("扫描根目录不存在: {}".format(base or "(未设置)"))

        unique_map, ambiguous_exts = self._auto_classify_extension_map()

        moved = 0
        skipped_conflict = 0
        skipped_ambiguous = 0
        skipped_other = 0
        created_dirs = []
        errors = []
        detail = []

        try:
            entries = sorted(os.listdir(base), key=lambda v: v.lower())
        except Exception as exc:
            raise ValueError("读取扫描根目录失败: {} ({})".format(base, exc))

        self_real = None
        try:
            self_real = os.path.realpath(__file__)
        except Exception:
            self_real = None

        for name in entries:
            if name.startswith("."):
                continue
            src = os.path.join(base, name)
            try:
                if os.path.islink(src) or not os.path.isfile(src):
                    continue
            except Exception:
                continue
            src_real = os.path.realpath(src)
            if self_real and src_real == self_real:
                continue
            lower_name = name.lower()
            matched_exts = [
                ext
                for ext in set(unique_map) | ambiguous_exts
                if lower_name.endswith(ext)
            ]
            if not matched_exts:
                skipped_other += 1
                continue
            # 先匹配 .csp.json 这类多后缀，避免被末尾 .json
            # 误判为歧义文件。
            ext = max(matched_exts, key=len)
            if ext in ambiguous_exts:
                skipped_ambiguous += 1
                detail.append("歧义跳过 {}（{} 可属多个类型，请手动归类）".format(name, ext))
                continue
            target_dir = unique_map.get(ext)
            if not target_dir:
                skipped_other += 1
                continue
            destination = os.path.join(target_dir, name)
            if os.path.exists(destination):
                skipped_conflict += 1
                detail.append("冲突跳过 {}（目标已存在同名文件）".format(name))
                continue
            try:
                if not os.path.isdir(target_dir):
                    os.makedirs(target_dir, exist_ok=True)
                    created_dirs.append(target_dir)
                shutil.move(src, destination)
                moved += 1
                detail.append("已归类 {} -> {}/".format(name, os.path.basename(target_dir)))
            except Exception as exc:
                errors.append("{}: {}".format(name, exc))
                self._log("ERROR", "自动分类移动失败 {} -> {}: {}".format(src, destination, exc))

        return (
            moved,
            skipped_conflict,
            skipped_ambiguous,
            skipped_other,
            created_dirs,
            errors,
            detail,
        )

    def _run_auto_classify(self):
        """执行自动分类并生成用户可见的摘要与详情文本。调用方需持有 self.lock。
        仅移动文件，不触发扫描或重载；完成后提示用户点击“一键扫描并加载”。"""
        try:
            (
                moved,
                skipped_conflict,
                skipped_ambiguous,
                skipped_other,
                created_dirs,
                errors,
                detail,
            ) = self._auto_classify_files()
        except Exception as exc:
            self._log("ERROR", "自动分类失败: {}".format(exc))
            message = "自动分类失败：{}".format(exc)
            return message, message

        parts = ["已归类 {} 个".format(moved)]
        if skipped_conflict:
            parts.append("同名冲突跳过 {}".format(skipped_conflict))
        if skipped_ambiguous:
            parts.append("歧义(.json 等)跳过 {}".format(skipped_ambiguous))
        if skipped_other:
            parts.append("其它跳过 {}".format(skipped_other))
        if errors:
            parts.append("失败 {}".format(len(errors)))
        summary = "；".join(parts)
        if moved:
            summary += "；请点击“扫描并加载”生效"

        content_lines = [
            "扫描根目录：{}".format(self.local_base_dir),
            "本次归类：{} 个文件".format(moved),
            "同名冲突跳过：{}".format(skipped_conflict),
            "歧义扩展名跳过：{}（.json 同属 JS/CSP/XBPQ，需手动放入对应子目录）".format(
                skipped_ambiguous
            ),
            "其它跳过：{}（无扩展名或未配置类型）".format(skipped_other),
        ]
        if created_dirs:
            content_lines.append(
                "新建子目录：{}".format(
                    ", ".join(os.path.basename(d) for d in created_dirs)
                )
            )
        if detail:
            content_lines.append("")
            content_lines.append("明细:")
            content_lines.extend("- " + line for line in detail[:100])
            if len(detail) > 100:
                content_lines.append("- ...（其余 {} 条省略）".format(len(detail) - 100))
        if errors:
            content_lines.append("")
            content_lines.append("失败明细:")
            content_lines.extend("- " + line for line in errors[:50])
        content_lines.append("")
        content_lines.append(
            "仅移动文件，未触发扫描；确认无误后点击“扫描并加载”重新收录。"
        )
        self._log(
            "INFO",
            "自动分类完成: 移动 {}, 冲突 {}, 歧义 {}, 其它 {}, 失败 {}".format(
                moved, skipped_conflict, skipped_ambiguous, skipped_other, len(errors)
            ),
        )
        return summary, "\n".join(content_lines)

    def _apply_scan_base_path(self, value):
        path = self._normalize_scan_base_path(value)
        if path:
            self.scan_base_path = path
            self.local_base_dir = path
            self.scan_roots = self._scan_roots_for_base(path)
        else:
            self.scan_base_path = ""
            self.local_base_dir = self.LOCAL_BASE_DIR
            self.scan_roots = [dict(item) for item in self.configured_scan_roots]

    def _create_scan_base_tree(self, path):
        created = []
        try:
            if os.path.exists(path) and not os.path.isdir(path):
                raise ValueError("输入路径不是目录: {}".format(path))
            if not os.path.isdir(path):
                missing = []
                current = path
                while current and not os.path.exists(current):
                    missing.append(current)
                    parent = os.path.dirname(current)
                    if parent == current:
                        break
                    current = parent
                for directory in reversed(missing):
                    if os.path.isdir(directory):
                        continue
                    try:
                        os.mkdir(directory)
                    except FileExistsError:
                        if not os.path.isdir(directory):
                            raise
                    else:
                        created.append(directory)
            for root in self._scan_roots_for_base(path):
                directory = root["path"]
                if os.path.isdir(directory):
                    continue
                try:
                    os.mkdir(directory)
                except FileExistsError:
                    if not os.path.isdir(directory):
                        raise
                else:
                    created.append(directory)
            return created
        except Exception:
            self._remove_created_scan_dirs(created)
            raise

    def _remove_created_scan_dirs(self, directories):
        for directory in reversed(directories):
            try:
                os.rmdir(directory)
            except Exception:
                pass

    @staticmethod
    def _parse_health_setting_input(value, fallback, minimum, maximum, label):
        text = str(value or "").strip()
        if not text:
            return int(fallback)
        try:
            parsed = int(text, 10)
        except Exception:
            raise ValueError("{}必须是整数".format(label))
        if parsed < minimum or parsed > maximum:
            raise ValueError(
                "{}必须在 {}-{} 之间".format(label, minimum, maximum)
            )
        return parsed

    def _set_scan_and_health_settings(
        self, scan_value, concurrency_value, batch_limit_value
    ):
        path = self._normalize_scan_base_path(scan_value)
        concurrency = self._parse_health_setting_input(
            concurrency_value,
            self.SITE_TEST_CONCURRENCY,
            self.SITE_TEST_CONCURRENCY_MIN,
            self.SITE_TEST_CONCURRENCY_MAX,
            "测活线程数",
        )
        batch_limit = self._parse_health_setting_input(
            batch_limit_value,
            self.SITE_TEST_BATCH_LIMIT,
            self.SITE_TEST_BATCH_LIMIT_MIN,
            self.SITE_TEST_BATCH_LIMIT_MAX,
            "单批测活上限",
        )
        previous_path = self.scan_base_path
        previous_base = self.local_base_dir
        previous_roots = [dict(item) for item in self.scan_roots]
        previous_concurrency = self.site_test_concurrency
        previous_batch_limit = self.site_test_batch_limit
        created_dirs = []
        try:
            if path:
                created_dirs = self._create_scan_base_tree(path)
                if not os.access(path, os.R_OK):
                    raise ValueError("目录不可读: {}".format(path))
            self._apply_scan_base_path(path)
            self.site_test_concurrency = concurrency
            self.site_test_batch_limit = batch_limit
            self._save_settings()
            self._set_manual_idle_status(
                "扫描与测活参数已更新，请点击“扫描并加载”"
            )
            self._clear_scan_cache_file()
            if created_dirs:
                self._log(
                    "INFO",
                    "扫描分类目录已自动创建: {}".format(
                        ", ".join(created_dirs)
                    ),
                )
        except Exception:
            self.scan_base_path = previous_path
            self.local_base_dir = previous_base
            self.scan_roots = previous_roots
            self.site_test_concurrency = previous_concurrency
            self.site_test_batch_limit = previous_batch_limit
            self._remove_created_scan_dirs(created_dirs)
            raise
        return {
            "scan_base_path": self.scan_base_path,
            "site_test_concurrency": self.site_test_concurrency,
            "site_test_batch_limit": self.site_test_batch_limit,
        }

    def _set_scan_base_path(self, value):
        saved = self._set_scan_and_health_settings(
            value,
            str(self.site_test_concurrency),
            str(self.site_test_batch_limit),
        )
        return saved["scan_base_path"]

    def _set_pending_type_settings(self, values, block_adult_sites=None):
        previous = dict(self.pending_type_enabled)
        previous_block_adult = self.pending_block_adult_sites
        previous_dirty = self.config_dirty
        for source_type in self.TYPE_ORDER:
            if source_type in values:
                self.pending_type_enabled[source_type] = bool(values[source_type])
        if block_adult_sites is not None:
            self.pending_block_adult_sites = bool(block_adult_sites)
        self.config_dirty = any(
            self.pending_type_enabled[item] != self.type_enabled[item]
            for item in self.TYPE_ORDER
        ) or self.pending_block_adult_sites != self.block_adult_sites
        try:
            self._save_settings()
        except Exception:
            self.pending_type_enabled = previous
            self.pending_block_adult_sites = previous_block_adult
            self.config_dirty = previous_dirty
            raise
        return self.config_dirty

    def _current_android_activity(self, jclass):
        app_class = jclass("com.fongmi.android.tv.App")
        activity_class = jclass("android.app.Activity")
        modifier_class = jclass("java.lang.reflect.Modifier")
        app_info = app_class.getClass()
        activity_info = activity_class.getClass()

        for method in app_info.getDeclaredMethods():
            try:
                if not modifier_class.isStatic(method.getModifiers()):
                    continue
                if len(method.getParameterTypes()) != 0:
                    continue
                if not activity_info.isAssignableFrom(method.getReturnType()):
                    continue
                method.setAccessible(True)
                try:
                    activity = method.invoke(None, [])
                except Exception:
                    activity = method.invoke(None)
                if activity is not None:
                    return activity
            except Exception:
                continue

        app = None
        for field in app_info.getDeclaredFields():
            try:
                if not modifier_class.isStatic(field.getModifiers()):
                    continue
                if not app_info.isAssignableFrom(field.getType()):
                    continue
                field.setAccessible(True)
                app = field.get(None)
                if app is not None:
                    break
            except Exception:
                continue
        if app is not None:
            for field in app.getClass().getDeclaredFields():
                try:
                    if modifier_class.isStatic(field.getModifiers()):
                        continue
                    if not activity_info.isAssignableFrom(field.getType()):
                        continue
                    field.setAccessible(True)
                    activity = field.get(app)
                    if activity is not None:
                        return activity
                except Exception:
                    continue
        raise ValueError("未找到当前 Android 页面")

    def _android_ui_context(self, jclass):
        try:
            activity = self._current_android_activity(jclass)
            if activity is not None:
                return activity, activity
        except Exception:
            pass

        context_candidates = []
        try:
            app_class = jclass("com.fongmi.android.tv.App")
            for method_name in ("get", "getInstance", "instance"):
                try:
                    method = getattr(app_class, method_name)
                    context = method() if callable(method) else method
                    if context is not None:
                        context_candidates.append(context)
                except Exception:
                    continue
        except Exception:
            pass
        try:
            activity_thread = jclass("android.app.ActivityThread")
            context = activity_thread.currentApplication()
            if context is not None:
                context_candidates.append(context)
        except Exception:
            pass
        try:
            platform = jclass("com.chaquo.python.Python").getPlatform()
            for method_name in (
                "getApplication", "getApplicationContext", "getContext",
            ):
                try:
                    method = getattr(platform, method_name)
                    context = method() if callable(method) else method
                    if context is not None:
                        context_candidates.append(context)
                except Exception:
                    continue
        except Exception:
            pass
        return None, next(
            (context for context in context_candidates if context is not None),
            None,
        )

    def _open_scan_base_dialog(self):
        try:
            from java import dynamic_proxy, jclass

            toast_class = jclass("android.widget.Toast")
            edit_text_class = jclass("android.widget.EditText")
            linear_layout_class = jclass("android.widget.LinearLayout")
            text_view_class = jclass("android.widget.TextView")
            input_type = jclass("android.text.InputType")
            click_listener = jclass(
                "android.content.DialogInterface$OnClickListener"
            )
            view_click_listener = jclass("android.view.View$OnClickListener")
            runnable_class = jclass("java.lang.Runnable")
            try:
                builder_class = jclass(
                    "com.google.android.material.dialog.MaterialAlertDialogBuilder"
                )
            except Exception:
                builder_class = jclass("android.app.AlertDialog$Builder")
            activity = self._current_android_activity(jclass)
            owner = self

            class NoopListener(dynamic_proxy(click_listener)):
                def onClick(self, dialog, which):
                    return None

            class SaveButtonListener(dynamic_proxy(view_click_listener)):
                def __init__(self, path_edit, concurrency_edit, batch_edit, dialog):
                    super().__init__()
                    self.path_edit = path_edit
                    self.concurrency_edit = concurrency_edit
                    self.batch_edit = batch_edit
                    self.dialog = dialog

                def onClick(self, view):
                    try:
                        with owner.lock:
                            saved = owner._set_scan_and_health_settings(
                                str(self.path_edit.getText().toString()),
                                str(self.concurrency_edit.getText().toString()),
                                str(self.batch_edit.getText().toString()),
                            )
                        toast_class.makeText(
                            activity,
                            "扫描与测活参数已保存：{} 线程，单批 {}".format(
                                saved["site_test_concurrency"],
                                saved["site_test_batch_limit"],
                            ),
                            toast_class.LENGTH_LONG,
                        ).show()
                        self.dialog.dismiss()
                    except Exception as exc:
                        toast_class.makeText(
                            activity,
                            "参数保存失败: {}".format(exc),
                            toast_class.LENGTH_LONG,
                        ).show()

            class ShowDialog(dynamic_proxy(runnable_class)):
                def run(self):
                    try:
                        self._run_dialog()
                    except Exception as exc:
                        message = "扫描与测活参数输入框打开失败: {}".format(exc)
                        owner._log("ERROR", message)
                        try:
                            toast_class.makeText(
                                activity, message, toast_class.LENGTH_LONG
                            ).show()
                        except Exception:
                            owner._notify_app(message)

                def _run_dialog(self):
                    density = float(
                        activity.getResources().getDisplayMetrics().density
                    )
                    padding = int(16 * density + 0.5)
                    spacing = int(8 * density + 0.5)
                    container = linear_layout_class(activity)
                    container.setOrientation(linear_layout_class.VERTICAL)
                    container.setPadding(padding, spacing, padding, 0)

                    path_label = text_view_class(activity)
                    path_label.setText("扫描地址")
                    path_edit = edit_text_class(activity)
                    current = owner.scan_base_path
                    path_edit.setSingleLine(True)
                    path_edit.setInputType(
                        input_type.TYPE_CLASS_TEXT
                        | input_type.TYPE_TEXT_VARIATION_URI
                    )
                    path_edit.setHint(
                        "留空自动探测：{}".format(owner.local_base_dir)
                    )
                    path_edit.setText(current)
                    path_edit.setSelection(len(current))

                    concurrency_label = text_view_class(activity)
                    concurrency_label.setText("测活线程数（1-32）")
                    concurrency_label.setPadding(0, spacing, 0, 0)
                    concurrency_edit = edit_text_class(activity)
                    concurrency_edit.setSingleLine(True)
                    concurrency_edit.setInputType(
                        input_type.TYPE_CLASS_NUMBER
                        | input_type.TYPE_NUMBER_FLAG_SIGNED
                    )
                    concurrency_edit.setHint("留空使用默认 10")
                    concurrency_edit.setText(str(owner.site_test_concurrency))

                    batch_label = text_view_class(activity)
                    batch_label.setText("单批测活上限（1-3000）")
                    batch_label.setPadding(0, spacing, 0, 0)
                    batch_edit = edit_text_class(activity)
                    batch_edit.setSingleLine(True)
                    batch_edit.setInputType(
                        input_type.TYPE_CLASS_NUMBER
                        | input_type.TYPE_NUMBER_FLAG_SIGNED
                    )
                    batch_edit.setHint("留空使用默认 300")
                    batch_edit.setText(str(owner.site_test_batch_limit))

                    container.addView(path_label)
                    container.addView(path_edit)
                    container.addView(concurrency_label)
                    container.addView(concurrency_edit)
                    container.addView(batch_label)
                    container.addView(batch_edit)

                    noop_listener = NoopListener()
                    builder = builder_class(activity)
                    builder.setTitle("扫描与测活参数")
                    builder.setView(container)
                    builder.setPositiveButton("保存", noop_listener)
                    builder.setNegativeButton("取消", noop_listener)
                    dialog = builder.show()
                    save_listener = SaveButtonListener(
                        path_edit, concurrency_edit, batch_edit, dialog
                    )
                    dialog.getButton(-1).setOnClickListener(save_listener)
                    path_edit.requestFocus()
                    owner._dialog_refs.append(
                        [
                            container,
                            path_edit,
                            concurrency_edit,
                            batch_edit,
                            noop_listener,
                            save_listener,
                            dialog,
                        ]
                    )
                    owner._dialog_refs = owner._dialog_refs[-12:]

            runner = ShowDialog()
            self._dialog_refs.append(runner)
            self._dialog_refs = self._dialog_refs[-12:]
            activity.runOnUiThread(runner)
            return True, ""
        except Exception as exc:
            return False, "扫描与测活参数输入框打开失败: {}".format(exc)

    def _open_playback_proxy_dialog(self):
        try:
            from java import dynamic_proxy, jclass

            toast_class = jclass("android.widget.Toast")
            edit_text_class = jclass("android.widget.EditText")
            linear_layout_class = jclass("android.widget.LinearLayout")
            text_view_class = jclass("android.widget.TextView")
            switch_class = jclass("android.widget.Switch")
            input_type = jclass("android.text.InputType")
            click_listener = jclass(
                "android.content.DialogInterface$OnClickListener"
            )
            view_click_listener = jclass("android.view.View$OnClickListener")
            runnable_class = jclass("java.lang.Runnable")
            try:
                builder_class = jclass(
                    "com.google.android.material.dialog.MaterialAlertDialogBuilder"
                )
            except Exception:
                builder_class = jclass("android.app.AlertDialog$Builder")
            activity = self._current_android_activity(jclass)
            owner = self

            class NoopListener(dynamic_proxy(click_listener)):
                def onClick(self, dialog, which):
                    return None

            class SaveButtonListener(dynamic_proxy(view_click_listener)):
                def __init__(self, enabled_switch, urls_edit, dialog):
                    super().__init__()
                    self.enabled_switch = enabled_switch
                    self.urls_edit = urls_edit
                    self.dialog = dialog

                def onClick(self, view):
                    try:
                        raw_urls = str(self.urls_edit.getText().toString())
                        enabled = bool(self.enabled_switch.isChecked())
                        if raw_urls.strip() and not owner.playback_proxy_urls:
                            enabled = True
                        with owner.lock:
                            saved = owner._set_playback_proxy_settings(
                                enabled,
                                raw_urls,
                                verify_remote=True,
                            )
                        toast_class.makeText(
                            activity,
                            "反代线路设置已保存：{}，{} 条地址".format(
                                "开启" if saved["enabled"] else "关闭",
                                len(saved["urls"]),
                            ),
                            toast_class.LENGTH_LONG,
                        ).show()
                        self.dialog.dismiss()
                    except Exception as exc:
                        toast_class.makeText(
                            activity,
                            "反代线路设置保存失败: {}".format(exc),
                            toast_class.LENGTH_LONG,
                        ).show()

            class ShowDialog(dynamic_proxy(runnable_class)):
                def run(self):
                    density = float(
                        activity.getResources().getDisplayMetrics().density
                    )
                    padding = int(16 * density + 0.5)
                    spacing = int(8 * density + 0.5)
                    container = linear_layout_class(activity)
                    container.setOrientation(linear_layout_class.VERTICAL)
                    container.setPadding(padding, spacing, padding, 0)
                    description = text_view_class(activity)
                    description.setText(
                        "每行一个线路地址；保存时验证目标转发，支持路径前缀和带凭据线路"
                    )
                    description.setTextSize(13.0)
                    enabled_switch = switch_class(activity)
                    enabled_switch.setText("自动启用反代线路")
                    enabled_switch.setChecked(bool(owner.playback_proxy_enabled))
                    enabled_switch.setPadding(0, spacing, 0, spacing)
                    urls_edit = edit_text_class(activity)
                    urls_edit.setSingleLine(False)
                    urls_edit.setMinLines(3)
                    urls_edit.setMaxLines(8)
                    urls_edit.setInputType(
                        input_type.TYPE_CLASS_TEXT
                        | input_type.TYPE_TEXT_VARIATION_URI
                        | input_type.TYPE_TEXT_FLAG_MULTI_LINE
                    )
                    urls_edit.setText("\n".join(owner.playback_proxy_urls))
                    container.addView(description)
                    container.addView(enabled_switch)
                    container.addView(urls_edit)
                    noop_listener = NoopListener()
                    builder = builder_class(activity)
                    builder.setTitle("反代线路")
                    builder.setView(container)
                    builder.setPositiveButton("保存", noop_listener)
                    builder.setNegativeButton("取消", noop_listener)
                    dialog = builder.show()
                    save_listener = SaveButtonListener(
                        enabled_switch, urls_edit, dialog
                    )
                    dialog.getButton(-1).setOnClickListener(save_listener)
                    owner._dialog_refs.append(
                        [
                            container,
                            description,
                            enabled_switch,
                            urls_edit,
                            noop_listener,
                            save_listener,
                            dialog,
                        ]
                    )
                    owner._dialog_refs = owner._dialog_refs[-12:]

            runner = ShowDialog()
            self._dialog_refs.append(runner)
            self._dialog_refs = self._dialog_refs[-12:]
            activity.runOnUiThread(runner)
            return True, ""
        except Exception as exc:
            return False, "反代线路设置打开失败: {}".format(exc)

    def _normalize_package_download_url(self, value):
        url = str(value or "").strip().strip('"').strip("'")
        if not url:
            raise ValueError("下载地址不能为空")
        if len(url) > 2048:
            raise ValueError("下载地址过长")
        parsed = urllib.parse.urlsplit(url)
        if parsed.scheme.lower() not in ("http", "https") or not parsed.netloc:
            raise ValueError("下载地址必须是 http 或 https URL")
        return url

    def _package_download_url_identity(self, value):
        return self._encoded_download_url(value).casefold()

    def _package_download_url_looks_zip(self, value):
        try:
            path = urllib.parse.unquote(
                urllib.parse.urlsplit(
                    self._normalize_package_download_url(value)
                ).path
            )
            return path.rstrip("/").lower().endswith(".zip")
        except Exception:
            return False

    def _package_request_headers(self, accept="*/*"):
        headers = {
            "User-Agent": "okhttp/4.12.0",
            "Accept": accept,
            "X-Device-ID": self.package_device_id,
            "X-Client-Version": self.VERSION,
            "Connection": "close",
        }
        if self._package_authorization_ticket:
            headers["X-Release-Ticket"] = self._package_authorization_ticket
        return headers

    def _package_service_error(self, error):
        status = int(getattr(error, "code", 0) or 0)
        headers = getattr(error, "headers", None)
        retry_after = str(headers.get("Retry-After", "") if headers else "").strip()
        code = str(headers.get("X-Package-Code", "") if headers else "").strip()
        message = ""
        try:
            payload = error.read(8192)
            data = json.loads(payload.decode("utf-8-sig"))
            if isinstance(data, dict):
                code = str(data.get("code", code) or code).strip()
                message = str(data.get("message", data.get("error", "")) or "").strip()
                retry_after = str(data.get("retryAfter", retry_after) or retry_after).strip()
        except Exception:
            pass
        try:
            retry_seconds = max(0, int(float(retry_after)))
        except Exception:
            retry_seconds = 0
        if code == "SERVICE_DISABLED":
            text = "接口已关闭：{}".format(message or "服务维护中，请稍后再试")
            return PackageServiceRequestError(text, status, code, retry_seconds)
        if code == "RATE_LIMITED":
            seconds = max(1, retry_seconds)
            wait = "{} 秒".format(seconds) if seconds < 60 else "{} 分钟".format((seconds + 59) // 60)
            text = "请求次数已达上限：{}（{}后重试）".format(message or "访问过于频繁", wait)
            return PackageServiceRequestError(text, status, code, seconds)
        if code == "CLIENT_UPGRADE_REQUIRED":
            text = message or "接口要求使用本地影仓 v4.0 或更高版本"
            return PackageServiceRequestError(text, status, code, retry_seconds)
        text = message or "CF 接口返回 HTTP {}".format(status)
        return PackageServiceRequestError(text, status, code, retry_seconds)

    def _package_cf_url(
        self, value, include_sites=None, include_lives=None, source_url=None
    ):
        url = self._normalize_package_download_url(value)
        if (
            include_sites is None
            and include_lives is None
            and source_url is None
        ):
            return url
        parsed = urllib.parse.urlsplit(url)
        query = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True)
        query = [
            (key, item)
            for key, item in query
            if key not in ("sites", "lives", "url")
        ]
        if source_url is not None and str(source_url).strip():
            query.append(
                ("url", self._normalize_package_download_url(source_url))
            )
        if include_sites is not None:
            query.append(("sites", "1" if include_sites else "0"))
        if include_lives is not None:
            query.append(("lives", "1" if include_lives else "0"))
        return urllib.parse.urlunsplit(
            (parsed.scheme, parsed.netloc, parsed.path, urllib.parse.urlencode(query), parsed.fragment)
        )

    def _package_open_url(self, value, timeout=15, accept="*/*", method="GET"):
        url = self._normalize_package_download_url(value)
        request = urllib.request.Request(
            self._encoded_download_url(url),
            headers=self._package_request_headers(accept),
            method=str(method or "GET").upper(),
        )
        opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
        try:
            return opener.open(request, timeout=max(2, int(timeout)))
        except urllib.error.HTTPError as exc:
            raise self._package_service_error(exc)

    def _package_open_direct_url(
        self, value, timeout=15, accept="*/*", method="GET", range_header=""
    ):
        url = self._normalize_package_download_url(value)
        headers = {
            "User-Agent": "okhttp/4.12.0",
            "Accept": accept,
            "Connection": "close",
        }
        if range_header:
            headers["Range"] = str(range_header)
        request = urllib.request.Request(
            self._encoded_download_url(url),
            headers=headers,
            method=str(method or "GET").upper(),
        )
        opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
        return opener.open(request, timeout=max(2, int(timeout)))

    def _package_read_limited(self, response, limit, initial=b""):
        payload = bytearray(initial or b"")
        while True:
            chunk = response.read(min(128 * 1024, int(limit) + 1 - len(payload)))
            if not chunk:
                break
            payload.extend(chunk)
            if len(payload) > int(limit):
                raise ValueError("下载内容超过大小上限")
        return bytes(payload)

    def _package_parse_json_text(self, text):
        value = str(text or "").lstrip("\ufeff")
        try:
            return json.loads(value)
        except json.JSONDecodeError:
            cleaned = self._strip_json_trailing_commas(
                self._strip_json_comments(value)
            )
            return json.loads(cleaned)

    def _package_header_int(self, headers, name):
        try:
            return max(0, int(str(headers.get(name, "0") or "0").strip()))
        except (TypeError, ValueError, OverflowError):
            return 0

    def _probe_cf_package_download_url(self, value, timeout=8):
        source_url = self._normalize_package_download_url(value)
        if not self.package_service_url:
            raise ValueError("未配置 CF 解析服务地址")
        url = self._package_cf_url(
            self.package_service_url,
            include_sites=True,
            include_lives=True,
            source_url=source_url,
        )
        try:
            with self._package_open_url(
                url,
                timeout=timeout,
                accept="application/zip, application/json, text/plain, application/octet-stream, */*",
                method="HEAD",
            ) as response:
                status = int(getattr(response, "status", response.getcode()))
                if status < 200 or status >= 300:
                    raise ValueError("网址返回 HTTP {}".format(status))
                final_url = str(getattr(response, "geturl", lambda: url)() or url)
                content_length = str(response.headers.get("Content-Length", "")).strip()
                total_size = int(content_length) if content_length.isdigit() else 0
                if total_size > self.MAX_PACKAGE_DOWNLOAD_SIZE:
                    raise ValueError("下载内容超过大小上限")
                content_type = str(response.headers.get("Content-Type", "")).split(
                    ";", 1
                )[0].strip()
                if content_type in ("application/zip", "application/octet-stream"):
                    return {
                        "url": source_url,
                        "status": status,
                        "content_type": content_type,
                        "size": total_size,
                        "zip": True,
                        "source_type": "config",
                        "site_count": self._package_header_int(
                            response.headers, "X-Package-Sites"
                        ),
                        "dependency_count": self._package_header_int(
                            response.headers, "X-Package-Dependencies"
                        ),
                        "live_count": self._package_header_int(
                            response.headers, "X-Package-Lives"
                        ),
                    }
                raise ValueError(
                    "CF 没有返回 ZIP 本地包（Content-Type: {}）；"
                    "请检查解析服务是否允许动态接口转换".format(
                        content_type or "未知"
                    )
                )
        except urllib.error.HTTPError as exc:
            raise ValueError("网址访问失败：HTTP {}".format(exc.code))
        except urllib.error.URLError as exc:
            raise ValueError("网址访问失败：{}".format(exc.reason))
        except (socket.timeout, TimeoutError):
            raise ValueError("网址检测超时")

    def _probe_direct_zip_url(self, value, timeout=8):
        url = self._normalize_package_download_url(value)
        try:
            with self._package_open_direct_url(
                url,
                timeout=timeout,
                accept="application/zip, application/octet-stream, */*",
                range_header="bytes=0-3",
            ) as response:
                status = int(getattr(response, "status", response.getcode()))
                if status < 200 or status >= 300:
                    raise ValueError("网址返回 HTTP {}".format(status))
                length = str(response.headers.get("Content-Length", "")).strip()
                total_size = int(length) if length.isdigit() else 0
                content_range = str(response.headers.get("Content-Range", ""))
                match = re.search(r"/(\d+)\s*$", content_range)
                if match:
                    total_size = int(match.group(1))
                if total_size > self.MAX_PACKAGE_DOWNLOAD_SIZE:
                    raise ValueError("下载内容超过大小上限")
                signature = response.read(4)
                if signature not in (b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08"):
                    raise ValueError("网址不是 ZIP 本地包")
                content_type = str(response.headers.get("Content-Type", "")).split(
                    ";", 1
                )[0].strip()
                return {
                    "url": str(
                        getattr(response, "geturl", lambda: url)() or url
                    ),
                    "status": status,
                    "content_type": content_type,
                    "size": total_size,
                    "zip": True,
                    "source_type": "zip",
                    "site_count": 0,
                    "dependency_count": 0,
                    "live_count": 0,
                }
        except urllib.error.HTTPError as exc:
            raise ValueError("ZIP 网址访问失败：HTTP {}".format(exc.code))
        except urllib.error.URLError as exc:
            raise ValueError("ZIP 网址访问失败：{}".format(exc.reason))
        except (socket.timeout, TimeoutError):
            raise ValueError("ZIP 网址检测超时")

    def _probe_package_download_url(self, value, timeout=8):
        try:
            return self._probe_cf_package_download_url(value, timeout=timeout)
        except ValueError as cf_error:
            try:
                return self._probe_direct_zip_url(value, timeout=timeout)
            except ValueError:
                raise cf_error

    def _enabled_package_download_sites(self):
        return [
            dict(item)
            for item in self.package_download_sites
            if self._package_download_site_active(item)
        ]

    def _find_package_download_site(self, site_id):
        value = str(site_id or "").strip()
        for item in self.package_download_sites:
            if str(item.get("id", "")) == value:
                return item
        return None

    def _package_download_site_type_label(self, item):
        sites = max(0, int(item.get("site_count", 0) or 0))
        dependencies = max(0, int(item.get("dependency_count", 0) or 0))
        lives = max(0, int(item.get("live_count", 0) or 0))
        source_type = str(item.get("source_type", "config")).lower()
        prefix = (
            "内置"
            if source_type == "builtin"
            else "ZIP"
            if source_type == "zip"
            else "接口"
        )
        return "{}{}/{}·直播{}".format(prefix, sites, dependencies, lives)

    def _package_download_sites_summary(self):
        return ", ".join(
            "{}[{}]:站{}·播{}·测{}".format(
                item.get("name", "未命名"),
                self._package_download_site_type_label(item),
                "开" if self._package_download_site_enabled(item) else "关",
                "开" if self._package_download_live_enabled(item) else "关",
                "开" if self._package_download_health_enabled(item) else "关",
            )
            for item in self.package_download_sites
        ) or "无"

    def _add_or_update_package_download_site(
        self, name, url, verify=False, probe=None
    ):
        if self._package_download_running():
            raise ValueError("本地包下载安装中，请完成后再更新网址")
        clean_name = self._normalize_package_download_name(name)
        clean_url = self._normalize_package_download_url(url)
        if (
            not self.package_external_configured
            and str(
                (probe if isinstance(probe, dict) else {}).get(
                    "source_type", ""
                )
            ).lower() != "zip"
        ):
            probe = self._probe_direct_zip_url(clean_url)
        elif verify:
            probe = self._probe_package_download_url(clean_url)
        probe = probe if isinstance(probe, dict) else {}
        source_type = (
            "zip"
            if str(probe.get("source_type", "")).lower() == "zip"
            or self._package_download_url_looks_zip(clean_url)
            else "config"
        )
        previous = copy.deepcopy(self.package_download_sites)
        name_match = None
        url_match = None
        for item in self.package_download_sites:
            if str(item.get("name", "")).casefold() == clean_name.casefold():
                name_match = item
            if self._package_download_url_identity(
                item.get("url", "")
            ) == self._package_download_url_identity(clean_url):
                url_match = item
        if name_match is not None and url_match is not None and name_match is not url_match:
            raise ValueError("备注名和网址分别属于两个已有站点")
        target = name_match or url_match
        created = target is None
        moved = None
        if created:
            if len(self.package_download_sites) >= 50:
                raise ValueError("下载站点最多保存 50 个")
            installed_marker = self._read_package_install_marker(
                self._package_install_target(site_name=clean_name)
            )
            installed_id = re.sub(
                r"[^A-Za-z0-9_-]+",
                "",
                str(installed_marker.get("id", "")).strip(),
            )[:40]
            target = {
                "id": installed_id
                or self._package_download_site_id(clean_name, clean_url),
                "name": clean_name,
                "url": clean_url,
                "enabled": True,
                "site_enabled": True,
                "live_enabled": True,
                "health_enabled": True,
                "source_type": source_type,
                "site_count": max(0, int(probe.get("site_count", 0) or 0)),
                "dependency_count": max(
                    0, int(probe.get("dependency_count", 0) or 0)
                ),
                "live_count": max(0, int(probe.get("live_count", 0) or 0)),
            }
            self.package_download_sites.append(target)
        else:
            old_name = str(target.get("name", "")).strip()
            if old_name and old_name != clean_name:
                moved = self._rename_package_install_directory(
                    target.get("id", ""), old_name, clean_name
                )
            target["name"] = clean_name
            target["url"] = clean_url
            target["source_type"] = source_type
            target["site_count"] = max(
                0, int(probe.get("site_count", 0) or 0)
            )
            target["dependency_count"] = max(
                0, int(probe.get("dependency_count", 0) or 0)
            )
            target["live_count"] = max(
                0, int(probe.get("live_count", 0) or 0)
            )
        try:
            self._sync_legacy_package_download_fields()
            self._save_settings()
        except Exception:
            self.package_download_sites = previous
            self._sync_legacy_package_download_fields()
            try:
                self._rollback_package_install_rename(moved)
            except Exception as rollback_exc:
                self._log(
                    "ERROR",
                    "下载站点备注目录回滚失败: {}".format(rollback_exc),
                )
            raise
        return dict(target), created

    def _set_package_download_site_states(self, values):
        if not isinstance(values, dict):
            raise ValueError("下载站点开关数据无效")
        previous = copy.deepcopy(self.package_download_sites)
        changed = False
        for item in self.package_download_sites:
            site_id = str(item.get("id", ""))
            if site_id not in values:
                continue
            state = values[site_id]
            if isinstance(state, dict):
                site_enabled = bool(state.get("site", state.get("site_enabled", True)))
                live_enabled = bool(state.get("live", state.get("live_enabled", True)))
                health_enabled = bool(
                    state.get("health", state.get("health_enabled", True))
                )
            else:
                site_enabled = bool(state)
                live_enabled = bool(state)
                health_enabled = True
            if (
                self._package_download_site_enabled(item) != site_enabled
                or self._package_download_live_enabled(item) != live_enabled
                or self._package_download_health_enabled(item) != health_enabled
            ):
                item["site_enabled"] = site_enabled
                item["live_enabled"] = live_enabled
                item["health_enabled"] = health_enabled
                item["enabled"] = bool(site_enabled or live_enabled)
                changed = True
        try:
            self._sync_legacy_package_download_fields()
            self._save_settings()
        except Exception:
            self.package_download_sites = previous
            self._sync_legacy_package_download_fields()
            raise
        return changed

    def _delete_package_download_sites(self, site_ids):
        if self._package_download_running():
            raise ValueError("本地包下载安装中，请完成后再删除")
        selected = {
            str(item).strip() for item in site_ids if str(item).strip()
        } if isinstance(site_ids, (list, tuple, set)) else set()
        if not selected:
            raise ValueError("请选择要删除的下载站点")
        existing_ids = {
            str(item.get("id", "")).strip()
            for item in self.package_download_sites
        }
        matched = selected & existing_ids
        if not matched:
            raise ValueError("选择的下载站点已不存在")
        previous = copy.deepcopy(self.package_download_sites)
        removed = [
            dict(item)
            for item in self.package_download_sites
            if str(item.get("id", "")).strip() in matched
        ]
        deletion = self._stage_package_install_deletions(removed)
        self.package_download_sites = [
            item
            for item in self.package_download_sites
            if str(item.get("id", "")).strip() not in matched
        ]
        try:
            self._sync_legacy_package_download_fields()
            self._save_settings()
        except Exception:
            self.package_download_sites = previous
            self._sync_legacy_package_download_fields()
            try:
                self._rollback_package_install_deletions(deletion)
            except Exception as rollback_exc:
                self._log(
                    "ERROR",
                    "下载站点删除回滚失败: {}".format(rollback_exc),
                )
            raise
        removed_counts = {}
        for entry in deletion.get("entries", []):
            site_id = str(entry.get("site_id", "")).strip()
            removed_counts[site_id] = removed_counts.get(site_id, 0) + 1
        for item in removed:
            item["removed_package_dirs"] = removed_counts.get(
                str(item.get("id", "")).strip(), 0
            )
        quarantine = str(deletion.get("quarantine", "")).strip()
        cleanup_error = ""
        if quarantine:
            try:
                shutil.rmtree(quarantine)
            except Exception as exc:
                cleanup_error = str(exc)
                self._log(
                    "WARN",
                    "本地包已移出扫描目录，但临时回收目录清理失败: {} ({})".format(
                        quarantine, exc
                    ),
                )
        for item in removed:
            item["package_cleanup_error"] = cleanup_error
        return removed

    def _open_package_download_url_dialog(self):
        try:
            from java import dynamic_proxy, jclass

            toast_class = jclass("android.widget.Toast")
            edit_text_class = jclass("android.widget.EditText")
            linear_layout_class = jclass("android.widget.LinearLayout")
            text_view_class = jclass("android.widget.TextView")
            input_type = jclass("android.text.InputType")
            click_listener = jclass(
                "android.content.DialogInterface$OnClickListener"
            )
            runnable_class = jclass("java.lang.Runnable")
            try:
                builder_class = jclass(
                    "com.google.android.material.dialog.MaterialAlertDialogBuilder"
                )
            except Exception:
                builder_class = jclass("android.app.AlertDialog$Builder")
            activity = self._current_android_activity(jclass)
            owner = self

            class SaveListener(dynamic_proxy(click_listener)):
                def __init__(self, name_edit, url_edit):
                    super().__init__()
                    self.name_edit = name_edit
                    self.url_edit = url_edit

                def onClick(self, dialog, which):
                    try:
                        name = str(self.name_edit.getText().toString())
                        url = str(self.url_edit.getText().toString())
                        clean_name = owner._normalize_package_download_name(name)
                        clean_url = owner._normalize_package_download_url(url)
                        def save_site():
                            try:
                                probe = None
                                if not owner.package_external_configured:
                                    probe = owner._probe_direct_zip_url(clean_url)
                                with owner.lock:
                                    saved, created = owner._add_or_update_package_download_site(
                                        clean_name,
                                        clean_url,
                                        verify=False,
                                        probe=probe,
                                    )
                                owner._notify_app(
                                    "已{}{}：{}；请在推荐页点击“下载本地包”".format(
                                        "添加" if created else "更新",
                                        "接口"
                                        if owner.package_external_configured
                                        else " ZIP 直链",
                                        saved["name"],
                                    )
                                )
                            except Exception as exc:
                                owner._notify_app(
                                    "接口保存失败：{}".format(exc)
                                )

                        toast_class.makeText(
                            activity,
                            "保存接口中"
                            if owner.package_external_configured
                            else "校验 ZIP 直链中",
                            toast_class.LENGTH_SHORT,
                        ).show()
                        if owner.package_external_configured:
                            save_site()
                        else:
                            worker = threading.Thread(
                                target=save_site,
                                name="local-package-zip-check",
                            )
                            worker.daemon = True
                            worker.start()
                    except Exception as exc:
                        toast_class.makeText(
                            activity,
                            "下载地址保存失败: {}".format(exc),
                            toast_class.LENGTH_LONG,
                        ).show()

            class CancelListener(dynamic_proxy(click_listener)):
                def onClick(self, dialog, which):
                    return None

            class ShowDialog(dynamic_proxy(runnable_class)):
                def run(self):
                    density = float(
                        activity.getResources().getDisplayMetrics().density
                    )
                    padding = int(16 * density + 0.5)
                    row_padding = int(8 * density + 0.5)
                    container = linear_layout_class(activity)
                    container.setOrientation(linear_layout_class.VERTICAL)
                    container.setPadding(padding, row_padding, padding, 0)
                    description = text_view_class(activity)
                    description.setText(
                        "未配置解析服务，仅支持 ZIP 本地包直链，保存时校验格式"
                        if not owner.package_external_configured
                        else "已配置解析服务，支持 JSON 接口或 ZIP 直链"
                    )
                    description.setTextSize(13.0)
                    description.setPadding(0, 0, 0, row_padding)
                    container.addView(description)
                    name_edit = edit_text_class(activity)
                    name_edit.setSingleLine(True)
                    name_edit.setHint("备注名，例如：第二线路")
                    name_edit.setInputType(input_type.TYPE_CLASS_TEXT)
                    container.addView(name_edit)
                    url_edit = edit_text_class(activity)
                    url_edit.setSingleLine(True)
                    url_edit.setInputType(
                        input_type.TYPE_CLASS_TEXT
                        | input_type.TYPE_TEXT_VARIATION_URI
                    )
                    url_edit.setHint(
                        "http://摸鱼儿.cc 或 https://example.com/package.zip"
                        if owner.package_external_configured
                        else "https://example.com/package.zip"
                    )
                    container.addView(url_edit)
                    save_listener = SaveListener(name_edit, url_edit)
                    cancel_listener = CancelListener()
                    builder = builder_class(activity)
                    builder.setTitle("添加本地包接口")
                    builder.setView(container)
                    builder.setPositiveButton("保存", save_listener)
                    builder.setNegativeButton("取消", cancel_listener)
                    dialog = builder.show()
                    name_edit.requestFocus()
                    owner._dialog_refs.extend(
                        [
                            container,
                            description,
                            name_edit,
                            url_edit,
                            save_listener,
                            cancel_listener,
                            dialog,
                        ]
                    )
                    owner._dialog_refs = owner._dialog_refs[-12:]

            runner = ShowDialog()
            self._dialog_refs.append(runner)
            self._dialog_refs = self._dialog_refs[-12:]
            activity.runOnUiThread(runner)
            return True, ""
        except Exception as exc:
            return False, "下载地址输入框打开失败: {}".format(exc)

    def _open_package_download_switches_dialog(self):
        try:
            from java import dynamic_proxy, jclass

            toast_class = jclass("android.widget.Toast")
            linear_layout_class = jclass("android.widget.LinearLayout")
            text_view_class = jclass("android.widget.TextView")
            switch_class = jclass("android.widget.Switch")
            layout_params_class = jclass(
                "android.widget.LinearLayout$LayoutParams"
            )
            click_listener = jclass(
                "android.content.DialogInterface$OnClickListener"
            )
            view_click_listener = jclass("android.view.View$OnClickListener")
            runnable_class = jclass("java.lang.Runnable")
            try:
                builder_class = jclass(
                    "com.google.android.material.dialog.MaterialAlertDialogBuilder"
                )
            except Exception:
                builder_class = jclass("android.app.AlertDialog$Builder")
            activity = self._current_android_activity(jclass)
            owner = self

            class NoopListener(dynamic_proxy(click_listener)):
                def onClick(self, dialog, which):
                    return None

            class SaveButtonListener(dynamic_proxy(view_click_listener)):
                def __init__(self, switches, dialog):
                    super().__init__()
                    self.switches = switches
                    self.dialog = dialog

                def onClick(self, view):
                    values = {
                        site_id: {
                            "site": bool(controls["site"].isChecked()),
                            "live": bool(controls["live"].isChecked()),
                            "health": bool(controls["health"].isChecked()),
                        }
                        for site_id, controls in self.switches.items()
                    }
                    try:
                        with owner.lock:
                            changed = owner._set_package_download_site_states(values)
                        reload_detail = "无需刷新"
                        if changed:
                            _, reload_detail = (
                                owner._schedule_package_integration_refresh(
                                    "下载站点开关变更"
                                )
                            )
                        toast_class.makeText(
                            activity,
                            "下载站点开关已保存；{}".format(reload_detail)
                            if changed
                            else "下载站点开关未变更",
                            toast_class.LENGTH_LONG,
                        ).show()
                        self.dialog.dismiss()
                    except Exception as exc:
                        toast_class.makeText(
                            activity,
                            "下载站点开关保存失败: {}".format(exc),
                            toast_class.LENGTH_LONG,
                        ).show()

            class ShowDialog(dynamic_proxy(runnable_class)):
                def run(self):
                    density = float(
                        activity.getResources().getDisplayMetrics().density
                    )
                    padding = int(16 * density + 0.5)
                    row_padding = int(8 * density + 0.5)
                    container = linear_layout_class(activity)
                    container.setOrientation(linear_layout_class.VERTICAL)
                    container.setPadding(padding, row_padding, padding, 0)
                    description = text_view_class(activity)
                    description.setText(
                        "站点与直播决定下载和加载内容；测活决定该包下载完成后是否统一检测"
                    )
                    description.setTextSize(13.0)
                    description.setPadding(0, 0, 0, row_padding)
                    container.addView(description)
                    switches = {}
                    for site in owner.package_download_sites:
                        row = linear_layout_class(activity)
                        row.setOrientation(linear_layout_class.HORIZONTAL)
                        row.setPadding(0, row_padding, 0, row_padding)
                        name_view = text_view_class(activity)
                        name_view.setText(str(site.get("name", "未命名")))
                        name_view.setTextSize(16.0)
                        name_view.setLayoutParams(
                            layout_params_class(0, -2, 1.0)
                        )
                        site_control = switch_class(activity)
                        site_control.setText("站点")
                        site_control.setTextSize(14.0)
                        site_control.setChecked(
                            owner._package_download_site_enabled(site)
                        )
                        site_control.setFocusable(True)
                        live_control = switch_class(activity)
                        live_control.setText("直播")
                        live_control.setTextSize(14.0)
                        live_control.setChecked(
                            owner._package_download_live_enabled(site)
                        )
                        live_control.setFocusable(True)
                        health_control = switch_class(activity)
                        health_control.setText("测活")
                        health_control.setTextSize(14.0)
                        health_control.setChecked(
                            owner._package_download_health_enabled(site)
                        )
                        health_control.setFocusable(True)
                        row.addView(name_view)
                        row.addView(site_control)
                        row.addView(live_control)
                        row.addView(health_control)
                        container.addView(row)
                        switches[str(site.get("id", ""))] = {
                            "site": site_control,
                            "live": live_control,
                            "health": health_control,
                        }
                    noop_listener = NoopListener()
                    builder = builder_class(activity)
                    builder.setTitle("接口开关")
                    builder.setView(container)
                    builder.setPositiveButton("保存", noop_listener)
                    builder.setNegativeButton("取消", noop_listener)
                    dialog = builder.show()
                    save_listener = SaveButtonListener(switches, dialog)
                    dialog.getButton(-1).setOnClickListener(save_listener)
                    owner._dialog_refs.append(
                        [
                            container,
                            description,
                            switches,
                            noop_listener,
                            save_listener,
                            dialog,
                        ]
                    )
                    owner._dialog_refs = owner._dialog_refs[-12:]

            runner = ShowDialog()
            self._dialog_refs.append(runner)
            self._dialog_refs = self._dialog_refs[-12:]
            activity.runOnUiThread(runner)
            return True, ""
        except Exception as exc:
            return False, "下载站点开关打开失败: {}".format(exc)

    def _open_package_download_delete_dialog(self):
        try:
            from java import dynamic_proxy, jclass

            toast_class = jclass("android.widget.Toast")
            linear_layout_class = jclass("android.widget.LinearLayout")
            text_view_class = jclass("android.widget.TextView")
            switch_class = jclass("android.widget.Switch")
            click_listener = jclass(
                "android.content.DialogInterface$OnClickListener"
            )
            view_click_listener = jclass("android.view.View$OnClickListener")
            runnable_class = jclass("java.lang.Runnable")
            try:
                builder_class = jclass(
                    "com.google.android.material.dialog.MaterialAlertDialogBuilder"
                )
            except Exception:
                builder_class = jclass("android.app.AlertDialog$Builder")
            activity = self._current_android_activity(jclass)
            owner = self

            class NoopListener(dynamic_proxy(click_listener)):
                def onClick(self, dialog, which):
                    return None

            class DeleteButtonListener(dynamic_proxy(view_click_listener)):
                def __init__(self, switches, dialog):
                    super().__init__()
                    self.switches = switches
                    self.dialog = dialog

                def onClick(self, view):
                    selected = [
                        site_id
                        for site_id, control in self.switches.items()
                        if bool(control.isChecked())
                    ]
                    try:
                        with owner.lock:
                            removed = owner._delete_package_download_sites(selected)
                        _, refresh_detail = owner._schedule_package_integration_refresh(
                            "下载站点删除", allow_empty=True
                        )
                        names = "、".join(
                            str(item.get("name", "未命名")) for item in removed
                        )
                        package_count = sum(
                            int(item.get("removed_package_dirs", 0) or 0)
                            for item in removed
                        )
                        cleanup_error = next(
                            (
                                str(item.get("package_cleanup_error", "")).strip()
                                for item in removed
                                if str(item.get("package_cleanup_error", "")).strip()
                            ),
                            "",
                        )
                        cleanup_detail = (
                            "本地包已移出加载目录，磁盘清理失败，请查看日志"
                            if cleanup_error
                            else "清理本地包 {} 个".format(package_count)
                        )
                        toast_class.makeText(
                            activity,
                            "已删除：{}；{}；{}".format(
                                names, cleanup_detail, refresh_detail
                            ),
                            toast_class.LENGTH_LONG,
                        ).show()
                        self.dialog.dismiss()
                    except Exception as exc:
                        toast_class.makeText(
                            activity,
                            "下载站点删除失败: {}".format(exc),
                            toast_class.LENGTH_LONG,
                        ).show()

            class ShowDialog(dynamic_proxy(runnable_class)):
                def run(self):
                    density = float(
                        activity.getResources().getDisplayMetrics().density
                    )
                    padding = int(16 * density + 0.5)
                    row_padding = int(8 * density + 0.5)
                    container = linear_layout_class(activity)
                    container.setOrientation(linear_layout_class.VERTICAL)
                    container.setPadding(padding, row_padding, padding, 0)
                    description = text_view_class(activity)
                    description.setText(
                        "删除后同时清理该接口的本地包、站点与直播集成"
                    )
                    description.setTextSize(13.0)
                    description.setPadding(0, 0, 0, row_padding)
                    container.addView(description)
                    switches = {}
                    for site in owner.package_download_sites:
                        control = switch_class(activity)
                        control.setText(
                            "{} · {}".format(
                                site.get("name", "未命名"), site.get("url", "")
                            )
                        )
                        control.setTextSize(15.0)
                        control.setChecked(False)
                        control.setPadding(0, row_padding, 0, row_padding)
                        control.setFocusable(True)
                        container.addView(control)
                        switches[str(site.get("id", ""))] = control
                    noop_listener = NoopListener()
                    builder = builder_class(activity)
                    builder.setTitle("删除接口")
                    builder.setView(container)
                    builder.setPositiveButton("删除", noop_listener)
                    builder.setNegativeButton("取消", noop_listener)
                    dialog = builder.show()
                    delete_listener = DeleteButtonListener(switches, dialog)
                    dialog.getButton(-1).setOnClickListener(delete_listener)
                    owner._dialog_refs.append(
                        [
                            container,
                            description,
                            switches,
                            noop_listener,
                            delete_listener,
                            dialog,
                        ]
                    )
                    owner._dialog_refs = owner._dialog_refs[-12:]

            runner = ShowDialog()
            self._dialog_refs.append(runner)
            self._dialog_refs = self._dialog_refs[-12:]
            activity.runOnUiThread(runner)
            return True, ""
        except Exception as exc:
            return False, "下载站点删除界面打开失败: {}".format(exc)

    def _open_blocked_sources_dialog(self, delete_files=False):
        try:
            from java import dynamic_proxy, jclass

            toast_class = jclass("android.widget.Toast")
            linear_layout_class = jclass("android.widget.LinearLayout")
            scroll_view_class = jclass("android.widget.ScrollView")
            text_view_class = jclass("android.widget.TextView")
            switch_class = jclass("android.widget.Switch")
            click_listener = jclass(
                "android.content.DialogInterface$OnClickListener"
            )
            view_click_listener = jclass("android.view.View$OnClickListener")
            runnable_class = jclass("java.lang.Runnable")
            try:
                builder_class = jclass(
                    "com.google.android.material.dialog.MaterialAlertDialogBuilder"
                )
            except Exception:
                builder_class = jclass("android.app.AlertDialog$Builder")
            activity = self._current_android_activity(jclass)
            owner = self
            blocked = (
                self._deletable_blocked_sources()
                if delete_files
                else sorted(
                    self.cache.get("ignored", []),
                    key=lambda item: (
                        str(item.get("name", "")).casefold(),
                        str(item.get("identity", "")),
                    ),
                )
            )
            if not blocked:
                return False, (
                    "当前没有可独立删除的屏蔽文件"
                    if delete_files
                    else "当前没有屏蔽站点"
                )

            class NoopListener(dynamic_proxy(click_listener)):
                def onClick(self, dialog, which):
                    return None

            class SelectAllListener(dynamic_proxy(view_click_listener)):
                def __init__(self, switches):
                    super().__init__()
                    self.switches = switches

                def onClick(self, view):
                    for control in self.switches.values():
                        control.setChecked(True)

            class ApplyListener(dynamic_proxy(view_click_listener)):
                def __init__(self, switches, dialog):
                    super().__init__()
                    self.switches = switches
                    self.dialog = dialog

                def onClick(self, view):
                    selected = [
                        source_id
                        for source_id, control in self.switches.items()
                        if bool(control.isChecked())
                    ]
                    if not selected:
                        toast_class.makeText(
                            activity, "请先选择站点", toast_class.LENGTH_SHORT
                        ).show()
                        return
                    try:
                        with owner.lock:
                            if delete_files:
                                changed, reload_detail, warnings = (
                                    owner._delete_blocked_source_files(selected)
                                )
                            else:
                                changed, reload_detail = owner._restore_blocked_sources(
                                    selected
                                )
                                warnings = []
                        toast_class.makeText(
                            activity,
                            "{}；{}{}".format(
                                "已删除 {} 个屏蔽文件".format(len(changed))
                                if delete_files
                                else "已解除 {} 个站点".format(len(changed)),
                                reload_detail,
                                "；{} 个隔离文件清理失败".format(len(warnings))
                                if warnings
                                else "",
                            ),
                            toast_class.LENGTH_LONG,
                        ).show()
                        self.dialog.dismiss()
                    except Exception as exc:
                        toast_class.makeText(
                            activity,
                            "{}失败: {}".format(
                                "删除屏蔽文件"
                                if delete_files
                                else "批量解除屏蔽",
                                exc,
                            ),
                            toast_class.LENGTH_LONG,
                        ).show()

            class ShowDialog(dynamic_proxy(runnable_class)):
                def run(self):
                    density = float(
                        activity.getResources().getDisplayMetrics().density
                    )
                    padding = int(16 * density + 0.5)
                    row_padding = int(8 * density + 0.5)
                    container = linear_layout_class(activity)
                    container.setOrientation(linear_layout_class.VERTICAL)
                    container.setPadding(padding, row_padding, padding, row_padding)
                    description = text_view_class(activity)
                    description.setText(
                        "仅列出可独立删除的本地文件，整包单站点不显示"
                        if delete_files
                        else "选择多个站点后一次解除、一次加载"
                    )
                    description.setTextSize(13.0)
                    description.setPadding(0, 0, 0, row_padding)
                    container.addView(description)
                    switches = {}
                    for source in blocked:
                        control = switch_class(activity)
                        control.setText(
                            "{} · {} · {}".format(
                                source.get("name", "未命名"),
                                source.get("type", ""),
                                source.get("relative_in_root", ""),
                            )
                        )
                        control.setTextSize(15.0)
                        control.setChecked(False)
                        control.setPadding(0, row_padding, 0, row_padding)
                        control.setFocusable(True)
                        container.addView(control)
                        switches[str(source.get("id", ""))] = control
                    scroll = scroll_view_class(activity)
                    scroll.addView(container)
                    noop_listener = NoopListener()
                    builder = builder_class(activity)
                    builder.setTitle(
                        "删除屏蔽文件" if delete_files else "批量解除屏蔽"
                    )
                    builder.setView(scroll)
                    builder.setPositiveButton(
                        "删除选中" if delete_files else "解除选中",
                        noop_listener,
                    )
                    builder.setNeutralButton("全选", noop_listener)
                    builder.setNegativeButton("取消", noop_listener)
                    dialog = builder.show()
                    apply_listener = ApplyListener(switches, dialog)
                    select_all_listener = SelectAllListener(switches)
                    dialog.getButton(-1).setOnClickListener(apply_listener)
                    dialog.getButton(-3).setOnClickListener(select_all_listener)
                    owner._dialog_refs.append(
                        [
                            scroll,
                            container,
                            description,
                            switches,
                            noop_listener,
                            apply_listener,
                            select_all_listener,
                            dialog,
                        ]
                    )
                    owner._dialog_refs = owner._dialog_refs[-12:]

            runner = ShowDialog()
            self._dialog_refs.append(runner)
            self._dialog_refs = self._dialog_refs[-12:]
            activity.runOnUiThread(runner)
            return True, ""
        except Exception as exc:
            return False, "{}界面打开失败: {}".format(
                "删除屏蔽文件" if delete_files else "批量解除屏蔽",
                exc,
            )

    def _open_live_proxy_policy_dialog(self, live_entry):
        try:
            from java import dynamic_proxy, jclass

            click_listener = jclass(
                "android.content.DialogInterface$OnClickListener"
            )
            runnable_class = jclass("java.lang.Runnable")
            try:
                builder_class = jclass(
                    "com.google.android.material.dialog.MaterialAlertDialogBuilder"
                )
            except Exception:
                builder_class = jclass("android.app.AlertDialog$Builder")
            activity = self._current_android_activity(jclass)
            owner = self
            choices = [
                ("自动判断（无可靠证据时保持直连）", "auto", ""),
                ("强制直连", "force_direct", ""),
            ]
            choices.extend(
                (
                    "强制反代 · {}".format(
                        self._playback_proxy_line_label(prefix)
                    ),
                    "force_proxy",
                    prefix,
                )
                for prefix in self.playback_proxy_urls
                if self._live_proxy_capability_available(prefix)
            )
            state = self._live_proxy_policy(live_entry)
            current_policy = str(state.get("policy", "auto") or "auto")
            current_prefix = str(state.get("prefix", "") or "")
            selected = 0
            for index, (_label, policy, prefix) in enumerate(choices):
                if policy == current_policy and (
                    policy != "force_proxy" or prefix == current_prefix
                ):
                    selected = index
                    break
            selection = {"index": selected}

            class ChoiceListener(dynamic_proxy(click_listener)):
                def onClick(self, dialog, which):
                    selection["index"] = int(which)

            class ApplyListener(dynamic_proxy(click_listener)):
                def onClick(self, dialog, which):
                    index = max(
                        0, min(selection["index"], len(choices) - 1)
                    )
                    _label, policy, prefix = choices[index]
                    started, message = owner._start_live_proxy_policy_update(
                        live_entry, policy, prefix
                    )
                    owner._notify_app(message)
                    if not started:
                        owner._log("WARN", message)

            class ShowDialog(dynamic_proxy(runnable_class)):
                def run(self):
                    try:
                        choice_listener = ChoiceListener()
                        apply_listener = ApplyListener()
                        builder = builder_class(activity)
                        builder.setTitle(
                            "{} · 直播连接方式".format(
                                live_entry.get("live", {}).get(
                                    "name", "直播源"
                                )
                            )
                        )
                        builder.setSingleChoiceItems(
                            [item[0] for item in choices],
                            selected,
                            choice_listener,
                        )
                        builder.setPositiveButton(
                            "保存并重载", apply_listener
                        )
                        builder.setNegativeButton("取消", None)
                        dialog = builder.show()
                        owner._dialog_refs.append(
                            [
                                dialog,
                                choices,
                                selection,
                                choice_listener,
                                apply_listener,
                            ]
                        )
                        owner._dialog_refs = owner._dialog_refs[-12:]
                    except Exception as exc:
                        message = "直播反代策略界面打开失败: {}".format(exc)
                        owner._log("ERROR", message)
                        owner._notify_app(message)

            runner = ShowDialog()
            self._dialog_refs.append(runner)
            self._dialog_refs = self._dialog_refs[-12:]
            activity.runOnUiThread(runner)
            return True, ""
        except Exception as exc:
            return False, "直播反代策略界面打开失败: {}".format(exc)

    def _open_live_proxy_source_dialog(self):
        try:
            from java import dynamic_proxy, jclass

            click_listener = jclass(
                "android.content.DialogInterface$OnClickListener"
            )
            runnable_class = jclass("java.lang.Runnable")
            try:
                builder_class = jclass(
                    "com.google.android.material.dialog.MaterialAlertDialogBuilder"
                )
            except Exception:
                builder_class = jclass("android.app.AlertDialog$Builder")
            activity = self._current_android_activity(jclass)
            owner = self
            candidates = [
                item for item in self.cache.get("lives", [])
                if self._live_proxy_supported(item.get("live", {}))
            ]
            if not candidates:
                return False, "当前没有可设置 relay 的 M3U/TXT 或内联直播"
            labels = [
                "{} · {}".format(
                    item.get("live", {}).get("name", "未命名直播"),
                    self._live_proxy_policy_label(item),
                )
                for item in candidates
            ]

            class SourceListener(dynamic_proxy(click_listener)):
                def onClick(self, dialog, which):
                    index = int(which)
                    if index < 0 or index >= len(candidates):
                        return
                    opened, message = owner._open_live_proxy_policy_dialog(
                        candidates[index]
                    )
                    if not opened:
                        owner._notify_app(message)

            class ShowDialog(dynamic_proxy(runnable_class)):
                def run(self):
                    try:
                        listener = SourceListener()
                        builder = builder_class(activity)
                        builder.setTitle("单直播源连接策略")
                        builder.setItems(labels, listener)
                        builder.setNegativeButton("取消", None)
                        dialog = builder.show()
                        owner._dialog_refs.append(
                            [dialog, candidates, labels, listener]
                        )
                        owner._dialog_refs = owner._dialog_refs[-12:]
                    except Exception as exc:
                        message = "直播反代管理界面打开失败: {}".format(exc)
                        owner._log("ERROR", message)
                        owner._notify_app(message)

            runner = ShowDialog()
            self._dialog_refs.append(runner)
            self._dialog_refs = self._dialog_refs[-12:]
            activity.runOnUiThread(runner)
            return True, ""
        except Exception as exc:
            return False, "直播反代管理界面打开失败: {}".format(exc)

    def _open_batch_live_proxy_dialog(self, policy):
        try:
            from java import dynamic_proxy, jclass

            multi_listener = jclass(
                "android.content.DialogInterface$OnMultiChoiceClickListener"
            )
            click_listener = jclass(
                "android.content.DialogInterface$OnClickListener"
            )
            runnable_class = jclass("java.lang.Runnable")
            try:
                builder_class = jclass(
                    "com.google.android.material.dialog.MaterialAlertDialogBuilder"
                )
            except Exception:
                builder_class = jclass("android.app.AlertDialog$Builder")
            activity = self._current_android_activity(jclass)
            owner = self
            prefix = ""
            if policy == "force_proxy":
                prefix = next(
                    (
                        value for value in self.playback_proxy_urls
                        if self._live_proxy_capability_available(value)
                    ),
                    "",
                )
                if not prefix:
                    return False, "当前线路不支持 HTTP(S) 媒体转发"
            candidates = []
            for item in self.cache.get("lives", []):
                if not self._live_proxy_supported(item.get("live", {})):
                    continue
                current = self._live_proxy_policy(item)
                current_policy = str(current.get("policy", "auto") or "auto")
                if policy == "auto" and current_policy == "auto":
                    continue
                if policy == "force_direct" and current_policy == policy:
                    continue
                if (
                    policy == "force_proxy"
                    and current_policy == policy
                    and str(current.get("prefix", "") or "") == prefix
                ):
                    continue
                candidates.append(item)
            if not candidates:
                return False, "当前没有需要批量更新的直播源"
            labels = [
                "{} · {}".format(
                    item.get("live", {}).get("name", "未命名直播"),
                    self._live_proxy_policy_label(item),
                )
                for item in candidates
            ]
            selected = [False] * len(candidates)

            class SelectListener(dynamic_proxy(multi_listener)):
                def onClick(self, dialog, which, checked):
                    index = int(which)
                    if 0 <= index < len(selected):
                        selected[index] = bool(checked)

            class ApplyListener(dynamic_proxy(click_listener)):
                def onClick(self, dialog, which):
                    chosen = [
                        item for index, item in enumerate(candidates)
                        if selected[index]
                    ]
                    if not chosen:
                        owner._notify_app("请至少选择一个直播源")
                        return
                    started, message = owner._start_batch_live_proxy_update(
                        chosen, policy, prefix
                    )
                    owner._notify_app(message)
                    if not started:
                        owner._log("WARN", message)

            class ShowDialog(dynamic_proxy(runnable_class)):
                def run(self):
                    try:
                        select_listener = SelectListener()
                        apply_listener = ApplyListener()
                        builder = builder_class(activity)
                        title = {
                            "auto": "批量恢复直播自动判断",
                            "force_direct": "批量强制直播直连",
                            "force_proxy": "批量强制直播反代",
                        }[policy]
                        builder.setTitle(title)
                        builder.setMultiChoiceItems(
                            labels, selected, select_listener
                        )
                        builder.setPositiveButton("保存并重载", apply_listener)
                        builder.setNegativeButton("取消", None)
                        dialog = builder.show()
                        owner._dialog_refs.append(
                            [
                                dialog,
                                candidates,
                                labels,
                                selected,
                                select_listener,
                                apply_listener,
                            ]
                        )
                        owner._dialog_refs = owner._dialog_refs[-12:]
                    except Exception as exc:
                        message = "批量直播反代界面打开失败: {}".format(exc)
                        owner._log("ERROR", message)
                        owner._notify_app(message)

            runner = ShowDialog()
            self._dialog_refs.append(runner)
            self._dialog_refs = self._dialog_refs[-12:]
            activity.runOnUiThread(runner)
            return True, ""
        except Exception as exc:
            return False, "批量直播反代界面打开失败: {}".format(exc)

    def _open_source_playback_proxy_policy_dialog(self, source):
        try:
            from java import dynamic_proxy, jclass

            click_listener = jclass(
                "android.content.DialogInterface$OnClickListener"
            )
            runnable_class = jclass("java.lang.Runnable")
            try:
                builder_class = jclass(
                    "com.google.android.material.dialog.MaterialAlertDialogBuilder"
                )
            except Exception:
                builder_class = jclass("android.app.AlertDialog$Builder")
            activity = self._current_android_activity(jclass)
            owner = self
            choices = [
                ("自动判断", "auto", ""),
                ("强制直连", "force_direct", ""),
            ]
            choices.extend(
                (
                    "强制反代 · {}（故障时自动切换）".format(
                        self._playback_proxy_line_label(prefix)
                    ),
                    "force_proxy",
                    prefix,
                )
                for prefix in self.playback_proxy_urls
                if self._playback_proxy_line_compatible(source, prefix)
            )
            policy_state = self._playback_proxy_policy(source)
            current_policy = str(policy_state.get("policy", "auto") or "auto")
            current_prefix = str(policy_state.get("prefix", "") or "")
            selected = 0
            for index, (_label, policy, prefix) in enumerate(choices):
                if policy == current_policy and (
                    policy != "force_proxy" or prefix == current_prefix
                ):
                    selected = index
                    break
            selection = {"index": selected}

            class ChoiceListener(dynamic_proxy(click_listener)):
                def onClick(self, dialog, which):
                    selection["index"] = int(which)

            class ApplyListener(dynamic_proxy(click_listener)):
                def onClick(self, dialog, which):
                    index = max(0, min(selection["index"], len(choices) - 1))
                    _label, policy, prefix = choices[index]
                    try:
                        started, message = owner._start_source_playback_proxy_policy(
                            source, policy, prefix
                        )
                        owner._notify_app(message)
                        if not started:
                            owner._log("WARN", message)
                    except Exception as exc:
                        owner._notify_app("反代策略更新失败：{}".format(exc))

            class ShowDialog(dynamic_proxy(runnable_class)):
                def run(self):
                    try:
                        choice_listener = ChoiceListener()
                        apply_listener = ApplyListener()
                        builder = builder_class(activity)
                        builder.setTitle(
                            "{} · 连接方式".format(source.get("name", "站点"))
                        )
                        builder.setSingleChoiceItems(
                            [item[0] for item in choices],
                            selected,
                            choice_listener,
                        )
                        builder.setPositiveButton("保存并重载", apply_listener)
                        builder.setNegativeButton("取消", None)
                        dialog = builder.show()
                        owner._dialog_refs.append(
                            [
                                dialog,
                                choices,
                                selection,
                                choice_listener,
                                apply_listener,
                            ]
                        )
                        owner._dialog_refs = owner._dialog_refs[-12:]
                    except Exception as exc:
                        message = "单站点反代策略界面打开失败: {}".format(exc)
                        owner._log("ERROR", message)
                        owner._notify_app(message)

            runner = ShowDialog()
            self._dialog_refs.append(runner)
            self._dialog_refs = self._dialog_refs[-12:]
            activity.runOnUiThread(runner)
            return True, ""
        except Exception as exc:
            return False, "单站点反代策略界面打开失败: {}".format(exc)

    def _open_playback_proxy_source_dialog(self):
        try:
            from java import dynamic_proxy, jclass

            click_listener = jclass(
                "android.content.DialogInterface$OnClickListener"
            )
            runnable_class = jclass("java.lang.Runnable")
            try:
                builder_class = jclass(
                    "com.google.android.material.dialog.MaterialAlertDialogBuilder"
                )
            except Exception:
                builder_class = jclass("android.app.AlertDialog$Builder")
            activity = self._current_android_activity(jclass)
            owner = self
            candidates = self._playback_proxy_management_candidates()
            if not candidates:
                return False, "当前没有可设置反代策略的 PY、JS 或 HTML 来源"
            labels = [
                "{} · {}".format(
                    source.get("name", "未命名"),
                    self._playback_proxy_policy_label(source),
                )
                for source in candidates
            ]

            class SourceListener(dynamic_proxy(click_listener)):
                def onClick(self, dialog, which):
                    index = int(which)
                    if index < 0 or index >= len(candidates):
                        return
                    opened, message = owner._open_source_playback_proxy_policy_dialog(
                        candidates[index]
                    )
                    if not opened:
                        owner._notify_app(message)

            class ShowDialog(dynamic_proxy(runnable_class)):
                def run(self):
                    try:
                        listener = SourceListener()
                        builder = builder_class(activity)
                        builder.setTitle("单站点连接策略")
                        builder.setItems(labels, listener)
                        builder.setNegativeButton("取消", None)
                        dialog = builder.show()
                        owner._dialog_refs.append(
                            [dialog, candidates, labels, listener]
                        )
                        owner._dialog_refs = owner._dialog_refs[-12:]
                    except Exception as exc:
                        message = "单站点线路管理界面打开失败: {}".format(exc)
                        owner._log("ERROR", message)
                        owner._notify_app(message)

            runner = ShowDialog()
            self._dialog_refs.append(runner)
            self._dialog_refs = self._dialog_refs[-12:]
            activity.runOnUiThread(runner)
            return True, ""
        except Exception as exc:
            return False, "单站点线路管理界面打开失败: {}".format(exc)

    def _open_batch_playback_proxy_dialog(self, disable=False):
        try:
            from java import dynamic_proxy, jclass

            toast_class = jclass("android.widget.Toast")
            linear_layout_class = jclass("android.widget.LinearLayout")
            scroll_view_class = jclass("android.widget.ScrollView")
            text_view_class = jclass("android.widget.TextView")
            switch_class = jclass("android.widget.Switch")
            click_listener = jclass(
                "android.content.DialogInterface$OnClickListener"
            )
            view_click_listener = jclass("android.view.View$OnClickListener")
            runnable_class = jclass("java.lang.Runnable")
            try:
                builder_class = jclass(
                    "com.google.android.material.dialog.MaterialAlertDialogBuilder"
                )
            except Exception:
                builder_class = jclass("android.app.AlertDialog$Builder")
            activity = self._current_android_activity(jclass)
            owner = self
            candidates = (
                self._batch_disable_playback_proxy_candidates()
                if disable
                else self._batch_playback_proxy_candidates()
            )
            if not candidates:
                return False, self._batch_playback_proxy_empty_message(disable)
            if not disable and not self.playback_proxy_urls:
                return False, "请先在设置中添加反代线路地址"
            candidate_summary = self._playback_proxy_candidate_summary(
                candidates, limit=8
            )
            self._log(
                "INFO",
                "批量{}反代线路候选: {}".format(
                    "取消" if disable else "启用", candidate_summary
                ),
            )

            class NoopListener(dynamic_proxy(click_listener)):
                def onClick(self, dialog, which):
                    return None

            class SetAllListener(dynamic_proxy(view_click_listener)):
                def __init__(self, switches):
                    super().__init__()
                    self.switches = switches

                def onClick(self, view):
                    for control in self.switches.values():
                        control.setChecked(False if disable else True)

            class ApplyListener(dynamic_proxy(view_click_listener)):
                def __init__(self, switches, dialog):
                    super().__init__()
                    self.switches = switches
                    self.dialog = dialog

                def onClick(self, view):
                    selected = [
                        source_id
                        for source_id, control in self.switches.items()
                        if (
                            not bool(control.isChecked())
                            if disable
                            else bool(control.isChecked())
                        )
                    ]
                    if not selected:
                        owner._log(
                            "WARN",
                            "批量{}反代线路未保存: 未选择站点".format(
                                "取消" if disable else "启用"
                            ),
                        )
                        toast_class.makeText(
                            activity,
                            "请至少关闭一个站点"
                            if disable
                            else "请至少开启一个站点",
                            toast_class.LENGTH_SHORT,
                        ).show()
                        return
                    view.setEnabled(False)
                    toast_class.makeText(
                        activity,
                        "正在{} {} 个站点，请稍候".format(
                            "取消" if disable else "启用", len(selected)
                        ),
                        toast_class.LENGTH_SHORT,
                    ).show()

                    listener = self

                    def worker():
                        success = False
                        try:
                            with owner.lock:
                                if disable:
                                    changed, reload_detail = (
                                        owner._batch_disable_playback_proxy(selected)
                                    )
                                else:
                                    changed, prefix, reload_detail = (
                                        owner._batch_enable_playback_proxy(selected)
                                    )
                            if disable:
                                message = "已为 {} 个站点取消反代线路；{}".format(
                                    len(changed),
                                    owner._hide_playback_proxy_urls(reload_detail),
                                )
                            else:
                                message = "已为 {} 个站点启用反代线路：{}；{}".format(
                                    len(changed),
                                    owner._playback_proxy_line_label(prefix),
                                    owner._hide_playback_proxy_urls(reload_detail),
                                )
                            success = True
                            owner._schedule_manager_page_refresh(delay=0.35)
                        except Exception as exc:
                            message = "批量{}反代线路失败: {}".format(
                                "取消" if disable else "启用",
                                owner._hide_playback_proxy_urls(exc),
                            )

                        class FinishRunnable(dynamic_proxy(runnable_class)):
                            def run(self):
                                toast_class.makeText(
                                    activity, message, toast_class.LENGTH_LONG
                                ).show()
                                if success:
                                    listener.dialog.dismiss()
                                else:
                                    view.setEnabled(True)

                        finished = FinishRunnable()
                        owner._dialog_refs.append(finished)
                        owner._dialog_refs = owner._dialog_refs[-12:]
                        activity.runOnUiThread(finished)

                    task = threading.Thread(
                        target=worker,
                        name="webhtv-proxy-batch-{}".format(
                            "disable" if disable else "enable"
                        ),
                    )
                    task.daemon = True
                    owner._dialog_refs.append(task)
                    owner._dialog_refs = owner._dialog_refs[-12:]
                    task.start()

            class ShowDialog(dynamic_proxy(runnable_class)):
                def run(self):
                    density = float(
                        activity.getResources().getDisplayMetrics().density
                    )
                    padding = int(16 * density + 0.5)
                    row_padding = int(8 * density + 0.5)
                    container = linear_layout_class(activity)
                    container.setOrientation(linear_layout_class.VERTICAL)
                    container.setPadding(padding, row_padding, padding, row_padding)
                    description = text_view_class(activity)
                    if disable:
                        description.setText(
                            "开关表示当前线路状态；关闭后写入强制直连，不会被自动检测重新开启"
                        )
                    else:
                        description.setText(
                            "只列出未启用且可包装的 PY、JS 或 HTML；"
                            "已默认选中全部，保存后统一使用反代线路：{}".format(
                                owner._playback_proxy_line_label(
                                    owner.playback_proxy_urls[0]
                                )
                            )
                        )
                    description.setTextSize(13.0)
                    description.setPadding(0, 0, 0, row_padding)
                    container.addView(description)
                    switches = {}
                    for source in candidates:
                        control = switch_class(activity)
                        control.setText(
                            "{} · {}".format(
                                source.get("name", "未命名"),
                                source.get("relative_in_root", ""),
                            )
                        )
                        control.setTextSize(15.0)
                        control.setChecked(True)
                        control.setPadding(0, row_padding, 0, row_padding)
                        control.setFocusable(True)
                        container.addView(control)
                        switches[str(source.get("id", ""))] = control
                    scroll = scroll_view_class(activity)
                    scroll.addView(container)
                    noop_listener = NoopListener()
                    builder = builder_class(activity)
                    builder.setTitle(
                        "批量{}反代线路".format("取消" if disable else "启用")
                    )
                    builder.setView(scroll)
                    builder.setPositiveButton(
                        "保存更改" if disable else "启用选中", noop_listener
                    )
                    builder.setNeutralButton(
                        "全部关闭" if disable else "全部开启", noop_listener
                    )
                    builder.setNegativeButton("取消", noop_listener)
                    dialog = builder.show()
                    apply_listener = ApplyListener(switches, dialog)
                    set_all_listener = SetAllListener(switches)
                    dialog.getButton(-1).setOnClickListener(apply_listener)
                    dialog.getButton(-3).setOnClickListener(set_all_listener)
                    owner._dialog_refs.append(
                        [
                            scroll,
                            container,
                            description,
                            switches,
                            noop_listener,
                            apply_listener,
                            set_all_listener,
                            dialog,
                        ]
                    )
                    owner._dialog_refs = owner._dialog_refs[-12:]

            runner = ShowDialog()
            self._dialog_refs.append(runner)
            self._dialog_refs = self._dialog_refs[-12:]
            activity.runOnUiThread(runner)
            return True, ""
        except Exception as exc:
            return False, "批量{}反代线路界面打开失败: {}".format(
                "取消" if disable else "启用", exc
            )

    def _open_source_metadata_dialog(self):
        try:
            from java import dynamic_proxy, jclass

            toast_class = jclass("android.widget.Toast")
            linear_layout_class = jclass("android.widget.LinearLayout")
            edit_text_class = jclass("android.widget.EditText")
            switch_class = jclass("android.widget.Switch")
            input_type = jclass("android.text.InputType")
            click_listener = jclass(
                "android.content.DialogInterface$OnClickListener"
            )
            runnable_class = jclass("java.lang.Runnable")
            try:
                builder_class = jclass(
                    "com.google.android.material.dialog.MaterialAlertDialogBuilder"
                )
            except Exception:
                builder_class = jclass("android.app.AlertDialog$Builder")
            activity = self._current_android_activity(jclass)
            owner = self
            sources = sorted(
                self.cache.get("sources", []) + self.cache.get("ignored", []),
                key=lambda item: (
                    str(item.get("name", "")).casefold(),
                    str(item.get("identity", "")),
                ),
            )
            if not sources:
                return False, "暂无站点，请先扫描"

            class CancelListener(dynamic_proxy(click_listener)):
                def onClick(self, dialog, which):
                    return None

            class SaveListener(dynamic_proxy(click_listener)):
                def __init__(self, source, favorite_control, tags_edit):
                    super().__init__()
                    self.source = source
                    self.favorite_control = favorite_control
                    self.tags_edit = tags_edit

                def onClick(self, dialog, which):
                    try:
                        with owner.lock:
                            saved = owner._set_source_metadata(
                                self.source.get("identity", ""),
                                favorite=bool(self.favorite_control.isChecked()),
                                tags=str(self.tags_edit.getText().toString()),
                            )
                        toast_class.makeText(
                            activity,
                            "已保存：{} · 收藏{} · 标签 {}".format(
                                self.source.get("name", "站点"),
                                "开" if saved["favorite"] else "关",
                                "、".join(saved["tags"]) or "无",
                            ),
                            toast_class.LENGTH_LONG,
                        ).show()
                        owner._schedule_manager_page_refresh()
                    except Exception as exc:
                        toast_class.makeText(
                            activity,
                            "收藏与标签保存失败: {}".format(exc),
                            toast_class.LENGTH_LONG,
                        ).show()

            def show_editor(source):
                density = float(activity.getResources().getDisplayMetrics().density)
                padding = int(16 * density + 0.5)
                row_padding = int(8 * density + 0.5)
                metadata = owner._source_metadata_for(source)
                container = linear_layout_class(activity)
                container.setOrientation(linear_layout_class.VERTICAL)
                container.setPadding(padding, row_padding, padding, 0)
                favorite_control = switch_class(activity)
                favorite_control.setText("收藏")
                favorite_control.setChecked(bool(metadata.get("favorite", False)))
                favorite_control.setPadding(0, row_padding, 0, row_padding)
                container.addView(favorite_control)
                tags_edit = edit_text_class(activity)
                tags_edit.setSingleLine(False)
                tags_edit.setInputType(input_type.TYPE_CLASS_TEXT)
                tags_edit.setHint("标签用逗号分隔，最多 8 个")
                tags_edit.setText("，".join(owner._normalize_source_tags(metadata.get("tags", []))))
                container.addView(tags_edit)
                save_listener = SaveListener(source, favorite_control, tags_edit)
                cancel_listener = CancelListener()
                builder = builder_class(activity)
                builder.setTitle(str(source.get("name", "收藏与标签")))
                builder.setView(container)
                builder.setPositiveButton("保存", save_listener)
                builder.setNegativeButton("取消", cancel_listener)
                dialog = builder.show()
                owner._dialog_refs.append(
                    [container, favorite_control, tags_edit, save_listener, cancel_listener, dialog]
                )
                owner._dialog_refs = owner._dialog_refs[-12:]

            class SelectListener(dynamic_proxy(click_listener)):
                def onClick(self, dialog, which):
                    index = int(which)
                    if 0 <= index < len(sources):
                        show_editor(sources[index])

            class ShowDialog(dynamic_proxy(runnable_class)):
                def run(self):
                    labels = []
                    for source in sources:
                        metadata = owner._source_metadata_for(source)
                        labels.append(
                            "{}{} · {}{}".format(
                                "★ " if metadata.get("favorite") else "",
                                source.get("name", "未命名"),
                                source.get("type", ""),
                                " · " + "、".join(metadata.get("tags", []))
                                if metadata.get("tags") else "",
                            )
                        )
                    select_listener = SelectListener()
                    cancel_listener = CancelListener()
                    builder = builder_class(activity)
                    builder.setTitle("选择站点")
                    builder.setItems(labels, select_listener)
                    builder.setNegativeButton("取消", cancel_listener)
                    dialog = builder.show()
                    owner._dialog_refs.append(
                        [labels, select_listener, cancel_listener, dialog]
                    )
                    owner._dialog_refs = owner._dialog_refs[-12:]

            runner = ShowDialog()
            self._dialog_refs.append(runner)
            self._dialog_refs = self._dialog_refs[-12:]
            activity.runOnUiThread(runner)
            return True, ""
        except Exception as exc:
            return False, "收藏与标签界面打开失败: {}".format(exc)

    def _open_repository_url_dialog(self):
        try:
            from java import dynamic_proxy, jclass

            toast_class = jclass("android.widget.Toast")
            linear_layout_class = jclass("android.widget.LinearLayout")
            text_view_class = jclass("android.widget.TextView")
            edit_text_class = jclass("android.widget.EditText")
            input_type = jclass("android.text.InputType")
            click_listener = jclass(
                "android.content.DialogInterface$OnClickListener"
            )
            runnable_class = jclass("java.lang.Runnable")
            try:
                builder_class = jclass(
                    "com.google.android.material.dialog.MaterialAlertDialogBuilder"
                )
            except Exception:
                builder_class = jclass("android.app.AlertDialog$Builder")
            activity = self._current_android_activity(jclass)
            owner = self

            class SaveListener(dynamic_proxy(click_listener)):
                def __init__(self, name_edit, url_edit):
                    super().__init__()
                    self.name_edit = name_edit
                    self.url_edit = url_edit

                def onClick(self, dialog, which):
                    try:
                        name = str(self.name_edit.getText().toString())
                        url = str(self.url_edit.getText().toString())
                        with owner.lock:
                            repository, created = owner._add_or_update_online_repository(
                                name, url
                            )
                        toast_class.makeText(
                            activity,
                            "已{}在线仓库：{}；返回推荐页点击“同步在线仓库”".format(
                                "添加" if created else "更新",
                                repository.get("name", "仓库"),
                            ),
                            toast_class.LENGTH_LONG,
                        ).show()
                    except Exception as exc:
                        toast_class.makeText(
                            activity,
                            "在线仓库保存失败: {}".format(exc),
                            toast_class.LENGTH_LONG,
                        ).show()

            class CancelListener(dynamic_proxy(click_listener)):
                def onClick(self, dialog, which):
                    return None

            class ShowDialog(dynamic_proxy(runnable_class)):
                def run(self):
                    density = float(
                        activity.getResources().getDisplayMetrics().density
                    )
                    padding = int(16 * density + 0.5)
                    row_padding = int(8 * density + 0.5)
                    container = linear_layout_class(activity)
                    container.setOrientation(linear_layout_class.VERTICAL)
                    container.setPadding(padding, row_padding, padding, 0)
                    description = text_view_class(activity)
                    description.setText(
                        "填写备注名和 repository.json 地址；同备注名再次保存会更新地址"
                    )
                    description.setTextSize(13.0)
                    description.setPadding(0, 0, 0, row_padding)
                    container.addView(description)
                    name_edit = edit_text_class(activity)
                    name_edit.setSingleLine(True)
                    name_edit.setHint("备注名，例如：我的仓库")
                    name_edit.setInputType(input_type.TYPE_CLASS_TEXT)
                    container.addView(name_edit)
                    url_edit = edit_text_class(activity)
                    url_edit.setSingleLine(True)
                    url_edit.setHint(
                        "https://HOST/path/repository.json 或 GitHub 仓库地址"
                    )
                    url_edit.setInputType(
                        input_type.TYPE_CLASS_TEXT
                        | input_type.TYPE_TEXT_VARIATION_URI
                    )
                    container.addView(url_edit)
                    save_listener = SaveListener(name_edit, url_edit)
                    cancel_listener = CancelListener()
                    builder = builder_class(activity)
                    builder.setTitle("添加在线仓库")
                    builder.setView(container)
                    builder.setPositiveButton("保存", save_listener)
                    builder.setNegativeButton("取消", cancel_listener)
                    dialog = builder.show()
                    name_edit.requestFocus()
                    owner._dialog_refs.extend(
                        [
                            container,
                            description,
                            name_edit,
                            url_edit,
                            save_listener,
                            cancel_listener,
                            dialog,
                        ]
                    )
                    owner._dialog_refs = owner._dialog_refs[-12:]

            runner = ShowDialog()
            self._dialog_refs.append(runner)
            self._dialog_refs = self._dialog_refs[-12:]
            activity.runOnUiThread(runner)
            return True, ""
        except Exception as exc:
            return False, "在线仓库输入框打开失败: {}".format(exc)

    def _open_repository_switches_dialog(self):
        try:
            from java import dynamic_proxy, jclass

            toast_class = jclass("android.widget.Toast")
            linear_layout_class = jclass("android.widget.LinearLayout")
            switch_class = jclass("android.widget.Switch")
            click_listener = jclass(
                "android.content.DialogInterface$OnClickListener"
            )
            view_click_listener = jclass("android.view.View$OnClickListener")
            runnable_class = jclass("java.lang.Runnable")
            try:
                builder_class = jclass(
                    "com.google.android.material.dialog.MaterialAlertDialogBuilder"
                )
            except Exception:
                builder_class = jclass("android.app.AlertDialog$Builder")
            activity = self._current_android_activity(jclass)
            owner = self

            class NoopListener(dynamic_proxy(click_listener)):
                def onClick(self, dialog, which):
                    return None

            class SaveButtonListener(dynamic_proxy(view_click_listener)):
                def __init__(self, switches, dialog):
                    super().__init__()
                    self.switches = switches
                    self.dialog = dialog

                def onClick(self, view):
                    try:
                        values = {
                            repository_id: bool(control.isChecked())
                            for repository_id, control in self.switches.items()
                        }
                        with owner.lock:
                            changed = owner._set_online_repository_states(values)
                        toast_class.makeText(
                            activity,
                            "在线仓库开关已保存"
                            if changed
                            else "在线仓库开关未变更",
                            toast_class.LENGTH_LONG,
                        ).show()
                        self.dialog.dismiss()
                    except Exception as exc:
                        toast_class.makeText(
                            activity,
                            "在线仓库开关保存失败: {}".format(exc),
                            toast_class.LENGTH_LONG,
                        ).show()

            class ShowDialog(dynamic_proxy(runnable_class)):
                def run(self):
                    padding = int(
                        16
                        * float(activity.getResources().getDisplayMetrics().density)
                        + 0.5
                    )
                    container = linear_layout_class(activity)
                    container.setOrientation(linear_layout_class.VERTICAL)
                    container.setPadding(padding, padding // 2, padding, 0)
                    switches = {}
                    for repository in owner.online_repositories:
                        control = switch_class(activity)
                        control.setText(
                            "{} · {}".format(
                                repository.get("name", "未命名"),
                                repository.get("url", ""),
                            )
                        )
                        control.setTextSize(14.0)
                        control.setChecked(
                            owner._as_bool(repository.get("enabled", True), True)
                        )
                        control.setPadding(0, padding // 2, 0, padding // 2)
                        container.addView(control)
                        switches[str(repository.get("id", ""))] = control
                    noop_listener = NoopListener()
                    builder = builder_class(activity)
                    builder.setTitle("在线仓库开关")
                    builder.setView(container)
                    builder.setPositiveButton("保存", noop_listener)
                    builder.setNegativeButton("取消", noop_listener)
                    dialog = builder.show()
                    save_listener = SaveButtonListener(switches, dialog)
                    dialog.getButton(-1).setOnClickListener(save_listener)
                    owner._dialog_refs.append(
                        [container, switches, noop_listener, save_listener, dialog]
                    )
                    owner._dialog_refs = owner._dialog_refs[-12:]

            runner = ShowDialog()
            self._dialog_refs.append(runner)
            self._dialog_refs = self._dialog_refs[-12:]
            activity.runOnUiThread(runner)
            return True, ""
        except Exception as exc:
            return False, "在线仓库开关打开失败: {}".format(exc)

    def _open_repository_delete_dialog(self):
        try:
            from java import dynamic_proxy, jclass

            toast_class = jclass("android.widget.Toast")
            linear_layout_class = jclass("android.widget.LinearLayout")
            switch_class = jclass("android.widget.Switch")
            click_listener = jclass(
                "android.content.DialogInterface$OnClickListener"
            )
            view_click_listener = jclass("android.view.View$OnClickListener")
            runnable_class = jclass("java.lang.Runnable")
            try:
                builder_class = jclass(
                    "com.google.android.material.dialog.MaterialAlertDialogBuilder"
                )
            except Exception:
                builder_class = jclass("android.app.AlertDialog$Builder")
            activity = self._current_android_activity(jclass)
            owner = self

            class NoopListener(dynamic_proxy(click_listener)):
                def onClick(self, dialog, which):
                    return None

            class DeleteButtonListener(dynamic_proxy(view_click_listener)):
                def __init__(self, switches, dialog):
                    super().__init__()
                    self.switches = switches
                    self.dialog = dialog

                def onClick(self, view):
                    selected = [
                        repository_id
                        for repository_id, control in self.switches.items()
                        if bool(control.isChecked())
                    ]
                    self.dialog.dismiss()

                    def remove_worker():
                        try:
                            removed, detail = owner._delete_online_repositories(
                                selected
                            )
                            message = "已删除在线仓库：{}；{}".format(
                                    "、".join(
                                        item.get("name", "未命名")
                                        for item in removed
                                    ),
                                    detail,
                            )
                            owner._repository_sync_state = "success"
                            owner._repository_sync_message = message
                            owner._notify_app(message)
                        except Exception as exc:
                            message = "在线仓库删除失败：{}".format(exc)
                            owner._repository_sync_state = "error"
                            owner._repository_sync_message = message
                            owner._notify_app(message)
                        finally:
                            with owner._repository_sync_lock:
                                owner._repository_sync_thread = None

                    worker = threading.Thread(
                        target=remove_worker,
                        name="local-online-repository-delete",
                    )
                    worker.daemon = True
                    with owner._repository_sync_lock:
                        running = owner._repository_sync_thread
                        if running is not None and running.is_alive():
                            owner._notify_app("在线仓库任务进行中，请稍候")
                            return
                        owner._repository_sync_state = "deleting"
                        owner._repository_sync_message = "正在删除所选在线仓库"
                        owner._repository_sync_thread = worker
                        worker.start()

            class ShowDialog(dynamic_proxy(runnable_class)):
                def run(self):
                    padding = int(
                        16
                        * float(activity.getResources().getDisplayMetrics().density)
                        + 0.5
                    )
                    container = linear_layout_class(activity)
                    container.setOrientation(linear_layout_class.VERTICAL)
                    container.setPadding(padding, padding // 2, padding, 0)
                    switches = {}
                    for repository in owner.online_repositories:
                        control = switch_class(activity)
                        control.setText(
                            "{} · 删除其托管文件".format(
                                repository.get("name", "未命名")
                            )
                        )
                        control.setTextSize(15.0)
                        control.setChecked(False)
                        control.setPadding(0, padding // 2, 0, padding // 2)
                        container.addView(control)
                        switches[str(repository.get("id", ""))] = control
                    noop_listener = NoopListener()
                    builder = builder_class(activity)
                    builder.setTitle("删除在线仓库")
                    builder.setView(container)
                    builder.setPositiveButton("删除", noop_listener)
                    builder.setNegativeButton("取消", noop_listener)
                    dialog = builder.show()
                    delete_listener = DeleteButtonListener(switches, dialog)
                    dialog.getButton(-1).setOnClickListener(delete_listener)
                    owner._dialog_refs.append(
                        [container, switches, noop_listener, delete_listener, dialog]
                    )
                    owner._dialog_refs = owner._dialog_refs[-12:]

            runner = ShowDialog()
            self._dialog_refs.append(runner)
            self._dialog_refs = self._dialog_refs[-12:]
            activity.runOnUiThread(runner)
            return True, ""
        except Exception as exc:
            return False, "在线仓库删除界面打开失败: {}".format(exc)

    def _open_scan_types_dialog(self):
        try:
            from java import dynamic_proxy, jclass

            toast_class = jclass("android.widget.Toast")
            linear_layout_class = jclass("android.widget.LinearLayout")
            text_view_class = jclass("android.widget.TextView")
            switch_class = jclass("android.widget.Switch")
            click_listener = jclass(
                "android.content.DialogInterface$OnClickListener"
            )
            view_click_listener = jclass("android.view.View$OnClickListener")
            runnable_class = jclass("java.lang.Runnable")
            try:
                builder_class = jclass(
                    "com.google.android.material.dialog.MaterialAlertDialogBuilder"
                )
            except Exception:
                builder_class = jclass("android.app.AlertDialog$Builder")
            activity = self._current_android_activity(jclass)
            owner = self

            class NoopListener(dynamic_proxy(click_listener)):
                def onClick(self, dialog, which):
                    return None

            class SaveButtonListener(dynamic_proxy(view_click_listener)):
                def __init__(self, switches, adult_switch, dialog):
                    super().__init__()
                    self.switches = switches
                    self.adult_switch = adult_switch
                    self.dialog = dialog

                def onClick(self, view):
                    values = {
                        source_type: bool(control.isChecked())
                        for source_type, control in self.switches.items()
                    }
                    try:
                        with owner.lock:
                            dirty = owner._set_pending_type_settings(
                                values,
                                block_adult_sites=bool(
                                    self.adult_switch.isChecked()
                                ),
                            )
                        message = (
                            "扫描类型已保存，请点击应用并加载"
                            if dirty
                            else "扫描类型设置未变更"
                        )
                        toast_class.makeText(
                            activity, message, toast_class.LENGTH_LONG
                        ).show()
                        self.dialog.dismiss()
                    except Exception as exc:
                        toast_class.makeText(
                            activity,
                            "扫描类型保存失败: {}".format(exc),
                            toast_class.LENGTH_LONG,
                        ).show()

            class ShowDialog(dynamic_proxy(runnable_class)):
                def run(self):
                    try:
                        self._run_dialog()
                    except Exception as exc:
                        message = "扫描类型开关打开失败: {}".format(exc)
                        owner._log("ERROR", message)
                        try:
                            toast_class.makeText(
                                activity, message, toast_class.LENGTH_LONG
                            ).show()
                        except Exception:
                            owner._notify_app(message)

                def _run_dialog(self):
                    density = float(
                        activity.getResources().getDisplayMetrics().density
                    )
                    padding = int(16 * density + 0.5)
                    row_padding = int(8 * density + 0.5)
                    container = linear_layout_class(activity)
                    container.setOrientation(linear_layout_class.VERTICAL)
                    container.setPadding(padding, row_padding, padding, 0)
                    description = text_view_class(activity)
                    description.setText(
                        "选择扫描时读取的站点类型"
                    )
                    description.setTextSize(13.0)
                    description.setPadding(0, 0, 0, row_padding)
                    container.addView(description)
                    switches = {}
                    for source_type in owner.TYPE_ORDER:
                        control = switch_class(activity)
                        control.setText(
                            "{} 扫描".format(
                                owner.TYPE_LABEL.get(source_type, source_type)
                            )
                        )
                        control.setTextSize(16.0)
                        control.setChecked(
                            bool(
                                owner.pending_type_enabled.get(
                                    source_type,
                                    owner.type_enabled.get(source_type, True),
                                )
                            )
                        )
                        control.setPadding(0, row_padding, 0, row_padding)
                        control.setFocusable(True)
                        container.addView(control)
                        switches[source_type] = control
                    adult_switch = switch_class(activity)
                    adult_switch.setText("屏蔽 18+ 站点")
                    adult_switch.setTextSize(16.0)
                    adult_switch.setChecked(
                        bool(owner.pending_block_adult_sites)
                    )
                    adult_switch.setPadding(0, row_padding, 0, row_padding)
                    adult_switch.setFocusable(True)
                    container.addView(adult_switch)
                    noop_listener = NoopListener()
                    builder = builder_class(activity)
                    builder.setTitle("扫描类型开关")
                    builder.setView(container)
                    builder.setPositiveButton("保存", noop_listener)
                    builder.setNegativeButton("取消", noop_listener)
                    dialog = builder.show()
                    save_listener = SaveButtonListener(
                        switches, adult_switch, dialog
                    )
                    dialog.getButton(-1).setOnClickListener(save_listener)
                    owner._dialog_refs.append(
                        [
                            container,
                            description,
                            switches,
                            adult_switch,
                            noop_listener,
                            save_listener,
                            dialog,
                        ]
                    )
                    owner._dialog_refs = owner._dialog_refs[-12:]

            runner = ShowDialog()
            self._dialog_refs.append(runner)
            self._dialog_refs = self._dialog_refs[-12:]
            activity.runOnUiThread(runner)
            return True, ""
        except Exception as exc:
            return False, "扫描类型开关打开失败: {}".format(exc)

    def _apply_pending_type_settings(self):
        previous_types = dict(self.type_enabled)
        previous_pending = dict(self.pending_type_enabled)
        previous_block_adult = self.block_adult_sites
        previous_pending_block_adult = self.pending_block_adult_sites
        previous_dirty = self.config_dirty
        try:
            self.type_enabled = {
                source_type: bool(
                    self.pending_type_enabled.get(
                        source_type, self.type_enabled.get(source_type, True)
                    )
                )
                for source_type in self.TYPE_ORDER
            }
            self.pending_type_enabled = dict(self.type_enabled)
            self.block_adult_sites = bool(self.pending_block_adult_sites)
            self.pending_block_adult_sites = self.block_adult_sites
            self.config_dirty = False
            self._sync_ignored_sources()
            self._save_settings()
        except Exception:
            self.type_enabled = previous_types
            self.pending_type_enabled = previous_pending
            self.block_adult_sites = previous_block_adult
            self.pending_block_adult_sites = previous_pending_block_adult
            self.config_dirty = previous_dirty
            self._sync_ignored_sources()
            raise

    def _apply_pending_scan_settings_for_download(self, source_label):
        if not self.config_dirty:
            return False
        self._apply_pending_type_settings()
        self._log(
            "INFO",
            "{}自动扫描前已应用待生效设置: 18+={}".format(
                source_label,
                "屏蔽" if self.block_adult_sites else "加载",
            ),
        )
        return True

    def _adult_filter_rules_need_refresh(self):
        return bool(
            self.block_adult_sites
            and self.adult_filter_revision != self.ADULT_FILTER_REVISION
        )

    def _reset_downloaded_adult_allowances(self, targets, source_label):
        if not self.block_adult_sites or not self.adult_allowed_sources:
            return 0
        normalized_targets = []
        for target in targets:
            value = str(target or "").strip()
            if value:
                normalized_targets.append(
                    os.path.realpath(os.path.abspath(os.path.expanduser(value)))
                )
        if not normalized_targets:
            return 0
        identities = set()
        for source in self.cache.get("sources", []) + self.cache.get("ignored", []):
            identity = str(source.get("identity", "")).strip()
            path = str(source.get("path", "")).strip()
            if identity not in self.adult_allowed_sources or not path:
                continue
            source_path = os.path.realpath(
                os.path.abspath(os.path.expanduser(path))
            )
            if any(
                self._path_is_within(source_path, target)
                for target in normalized_targets
            ):
                identities.add(identity)
        if not identities:
            return 0
        previous = set(self.adult_allowed_sources)
        try:
            self.adult_allowed_sources.difference_update(identities)
            self._sync_ignored_sources()
            self._save_settings()
        except Exception:
            self.adult_allowed_sources = previous
            self._sync_ignored_sources()
            raise
        self._log(
            "INFO",
            "{}已撤销本次来源的18+手动放行: {} 个".format(
                source_label, len(identities)
            ),
        )
        return len(identities)

    def _load_scan_cache_payload(self, warn=True):
        path = os.path.abspath(os.path.expanduser(self.cache_path))
        if not os.path.isfile(path):
            return {}
        try:
            with open(path, "r", encoding="utf-8") as fp:
                data = json.load(fp)
            if (
                not isinstance(data, dict)
                or data.get("version") != self.CACHE_VERSION
                or str(data.get("app_mode", "")) != self.app_mode
            ):
                return {}
            return data
        except Exception as exc:
            if warn:
                self._warn(
                    "增量扫描缓存读取失败，将全量扫描: {}".format(
                        exc
                    )
                )
            return {}

    def _load_scan_cache(self):
        data = self._load_scan_cache_payload()
        files = data.get("files", {}) if isinstance(data, dict) else {}
        return files if isinstance(files, dict) else {}

    def _save_scan_cache(self, files):
        path = os.path.abspath(os.path.expanduser(self.cache_path))
        previous = self._load_scan_cache_payload(warn=False)
        data = {
            "version": self.CACHE_VERSION,
            "app_mode": self.app_mode,
            "files": files,
        }
        if isinstance(previous.get("snapshot"), dict):
            data["snapshot"] = previous["snapshot"]
        self._atomic_write_plain_json(path, data)

    def _scan_snapshot_sources(self):
        fields = (
            "id",
            "identity",
            "key",
            "type",
            "path",
            "scan_root",
            "root_order",
            "relative_in_root",
            "base_name",
            "package_label",
            "package_id",
            "name",
            "validation",
            "ignored",
            "size",
            "mtime_ns",
            "csp_site",
            "dependencies",
            "test_result",
            "site",
            "health_site",
            "proxy_wrapper_path",
            "proxy_payload_path",
            "playback_proxy_path",
            "proxy_capability",
            "proxy_capability_reason",
            "proxy_capability_label",
        )
        result = []
        for source in self.cache["sources"] + self.cache["ignored"]:
            result.append(
                {
                    field: copy.deepcopy(source[field])
                    for field in fields
                    if field in source
                }
            )
        return result

    def _scan_location_signature(self):
        payload = {
            "scan_base_path": self.scan_base_path,
            "local_base_dir": self.local_base_dir,
            "roots": [
                {
                    "path": os.path.realpath(
                        os.path.abspath(os.path.expanduser(str(item.get("path", ""))))
                    ),
                    "type": str(item.get("type", "")).upper(),
                    "extensions": sorted(
                        str(value).lower()
                        for value in item.get("extensions", [])
                        if str(value).strip()
                    ),
                }
                for item in self.scan_roots
                if isinstance(item, dict)
            ],
        }
        return self._digest(
            json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")),
            24,
        )

    def _playback_proxy_settings_signature(self):
        payload = {
            "enabled": bool(self.playback_proxy_enabled),
            "urls": list(self.playback_proxy_urls),
            "sources": self._normalize_playback_proxy_sources(
                self.playback_proxy_sources,
                self.playback_proxy_urls,
            ) if self.playback_proxy_enabled else {},
            "policies": self._normalize_playback_proxy_policies(
                self.playback_proxy_policies,
                self.playback_proxy_urls,
            ),
            "live_policies": self._normalize_live_proxy_policies(
                self.live_proxy_policies,
                self.playback_proxy_urls,
            ),
            "live_auto_sources": self._normalize_live_proxy_auto_sources(
                self.live_proxy_auto_sources,
                self.playback_proxy_urls,
            ),
            "settings_revision": int(self.playback_proxy_settings_revision),
            "wrapper_revision": int(self.PLAYBACK_PROXY_WRAPPER_REVISION),
        }
        return self._digest(
            json.dumps(
                payload,
                ensure_ascii=True,
                sort_keys=True,
                separators=(",", ":"),
            ),
            24,
        )

    def _save_scan_snapshot(self):
        path = os.path.abspath(os.path.expanduser(self.cache_path))
        data = self._load_scan_cache_payload(warn=False)
        files = data.get("files", {}) if isinstance(data, dict) else {}
        if not isinstance(files, dict):
            files = {}
        status_fields = (
            "scan_time",
            "found",
            "included",
            "skipped",
            "duplicates",
            "cache_hits",
            "cache_misses",
            "ignored",
            "adult_filtered",
            "compatibility_blocked",
            "stale_ignored_removed",
            "manual_sites",
            "generated_sites",
            "generated_lives",
            "duplicate_lives",
            "added_sites",
            "updated_sites",
            "removed_sites",
            "unchanged_sites",
        )
        data = {
            "version": self.CACHE_VERSION,
            "app_mode": self.app_mode,
            "files": files,
            "snapshot": {
                "registry_token": self._registry_token(self.output_path),
                "package_state": self._package_download_state_signature(),
                "scan_state": self._scan_location_signature(),
                "playback_proxy_state": self._playback_proxy_settings_signature(),
                "sources": self._scan_snapshot_sources(),
                "lives": copy.deepcopy(self.cache.get("lives", [])),
                "contexts": copy.deepcopy(self.cache.get("contexts", [])),
                "status": {
                    field: copy.deepcopy(self.status.get(field))
                    for field in status_fields
                },
            },
        }
        self._atomic_write_plain_json(path, data)

    def _set_cached_source_ignored(self, source, ignored):
        source_id = str(source.get("id", "")).strip()
        current = self.cache.get("source_index", {}).get(source_id)
        if current is None:
            raise ValueError("站点不存在，请重新扫描")
        self.cache["sources"] = [
            item for item in self.cache.get("sources", [])
            if str(item.get("id", "")) != source_id
        ]
        self.cache["ignored"] = [
            item for item in self.cache.get("ignored", [])
            if str(item.get("id", "")) != source_id
        ]
        current["ignored"] = bool(ignored)
        target = "ignored" if ignored else "sources"
        self.cache[target].append(current)
        type_counts = {}
        ignored_counts = {}
        for item in self.cache["sources"]:
            source_type = str(item.get("type", "")).upper()
            type_counts[source_type] = type_counts.get(source_type, 0) + 1
        for item in self.cache["ignored"]:
            source_type = str(item.get("type", "")).upper()
            ignored_counts[source_type] = ignored_counts.get(source_type, 0) + 1
        self.cache["type_counts"] = type_counts
        self.cache["ignored_counts"] = ignored_counts
        self.status["included"] = len(self.cache["sources"])
        self.status["ignored"] = len(self.cache["ignored"])
        self.status["registry_changed"] = True
        self.status["write_state"] = "屏蔽设置待“扫描并加载”生效"

    def _managed_download_file_paths(self):
        managed = set()
        for repository in self.online_repositories:
            try:
                state = self._load_repository_state(repository)
            except Exception as exc:
                self._warn(
                    "读取在线仓库托管文件状态失败: {}".format(exc)
                )
                continue
            for item in state.get("files", []):
                path = str(item.get("destination", "") or "").strip()
                if path:
                    managed.add(
                        os.path.realpath(
                            os.path.abspath(os.path.expanduser(path))
                        )
                    )
        return managed

    def _is_package_owned_file(self, path, ownership_cache=None):
        ownership_cache = ownership_cache if isinstance(ownership_cache, dict) else {}
        current = os.path.dirname(os.path.realpath(path))
        roots = {
            os.path.realpath(
                os.path.abspath(os.path.expanduser(str(item.get("path", ""))))
            )
            for item in self.scan_roots
            if str(item.get("path", "")).strip()
        }
        visited = []
        result = False
        while current and any(self._path_is_within(current, root) for root in roots):
            if current in ownership_cache:
                result = bool(ownership_cache[current])
                break
            visited.append(current)
            marker = self._read_package_install_marker(current)
            if str(marker.get("id", "")).strip():
                result = True
                break
            parent = os.path.dirname(current)
            if parent == current:
                break
            current = parent
        for directory in visited:
            ownership_cache[directory] = result
        return result

    def _deletable_blocked_sources(self):
        """Return blocked entries backed by one independent local file."""
        all_sources = list(self.cache.get("sources", [])) + list(
            self.cache.get("ignored", [])
        )
        managed_download_paths = self._managed_download_file_paths()
        package_ownership_cache = {}
        path_owners = {}
        dependency_paths = set()
        for source in all_sources:
            path = str(source.get("path", "") or "").strip()
            if path:
                real_path = os.path.realpath(
                    os.path.abspath(os.path.expanduser(path))
                )
                path_owners.setdefault(real_path, set()).add(
                    str(source.get("identity", "") or "")
                )
            for dependency in source.get("dependencies", []) or []:
                value = str(dependency or "").strip()
                if value:
                    dependency_paths.add(
                        os.path.realpath(
                            os.path.abspath(os.path.expanduser(value))
                        )
                    )

        candidates = []
        for source in self.cache.get("ignored", []):
            identity = str(source.get("identity", "") or "")
            if "#bundle-site-" in identity:
                continue
            source_type = str(source.get("type", "") or "").upper()
            if source_type not in self.TYPE_ORDER:
                continue
            raw_path = str(source.get("path", "") or "").strip()
            if not raw_path:
                continue
            path = os.path.abspath(os.path.expanduser(raw_path))
            if os.path.islink(path) or not os.path.isfile(path):
                continue
            real_path = os.path.realpath(path)
            if (
                real_path in managed_download_paths
                or self._is_package_owned_file(real_path, package_ownership_cache)
            ):
                continue
            if len(path_owners.get(real_path, set())) != 1:
                continue
            if real_path in dependency_paths:
                continue

            roots = []
            scan_root = str(source.get("scan_root", "") or "").strip()
            if scan_root:
                roots.append(scan_root)
            roots.extend(
                str(item.get("path", "") or "").strip()
                for item in self.scan_roots
                if str(item.get("type", "") or "").upper() == source_type
            )
            roots = [
                os.path.realpath(os.path.abspath(os.path.expanduser(root)))
                for root in roots
                if root
            ]
            if not roots or not any(
                self._path_is_within(real_path, root) for root in roots
            ):
                continue
            candidates.append(source)

        return sorted(
            candidates,
            key=lambda item: (
                str(item.get("name", "")).casefold(),
                str(item.get("identity", "")),
            ),
        )

    def _stage_blocked_source_file_deletions(self, sources):
        token = self._digest(str(time.time_ns()), 12)
        transaction = []
        try:
            for source in sources:
                original = os.path.abspath(
                    os.path.expanduser(str(source.get("path", "") or ""))
                )
                staged = os.path.join(
                    os.path.dirname(original),
                    ".local-media-delete-{}.tmp".format(
                        self._digest(original + token, 16)
                    ),
                )
                if not os.path.isfile(original) or os.path.islink(original):
                    raise ValueError("待删除源文件已不存在: {}".format(original))
                if os.path.exists(staged):
                    raise ValueError("删除隔离目标已存在: {}".format(staged))
                os.replace(original, staged)
                transaction.append(
                    {
                        "identity": str(source.get("identity", "") or ""),
                        "original": original,
                        "staged": staged,
                    }
                )
            return transaction
        except Exception:
            self._rollback_blocked_source_file_deletions(transaction)
            raise

    @staticmethod
    def _rollback_blocked_source_file_deletions(transaction):
        for item in reversed(transaction or []):
            original = str(item.get("original", "") or "")
            staged = str(item.get("staged", "") or "")
            if not staged or not os.path.exists(staged):
                continue
            if os.path.exists(original):
                raise ValueError("屏蔽文件回滚目标已存在: {}".format(original))
            os.replace(staged, original)

    def _delete_blocked_source_files(self, source_ids):
        if self._site_test_is_running():
            raise ValueError("后台检测进行中，请完成后再删除")
        if self._package_download_running():
            raise ValueError("本地包下载安装中，请完成后再删除")
        if self._repository_sync_running():
            raise ValueError("在线仓库同步中，请完成后再删除")

        ordered_ids = list(
            dict.fromkeys(
                str(value or "").strip()
                for value in source_ids
                if str(value or "").strip()
            )
        )
        candidate_by_id = {
            str(source.get("id", "") or ""): source
            for source in self._deletable_blocked_sources()
        }
        sources = [
            candidate_by_id[source_id]
            for source_id in ordered_ids
            if source_id in candidate_by_id
        ]
        if not sources:
            raise ValueError("没有可删除的屏蔽文件")

        previous_registry = copy.deepcopy(self._load_registry())
        previous = {
            "manual_ignored_sources": set(self.manual_ignored_sources),
            "auto_blocked_sources": set(self.auto_blocked_sources),
            "adult_blocked_sources": set(self.adult_blocked_sources),
            "adult_allowed_sources": set(self.adult_allowed_sources),
            "ignored_sources": set(self.ignored_sources),
            "pruned_sources": set(self.pruned_sources),
            "site_test_results": copy.deepcopy(self.site_test_results),
            "site_test_history": copy.deepcopy(self.site_test_history),
            "source_metadata": copy.deepcopy(self.source_metadata),
            "retest_pending": list(self._retest_pending),
            "retest_auto_blocked": set(self._retest_auto_blocked),
        }
        transaction = []
        try:
            transaction = self._stage_blocked_source_file_deletions(sources)
            if not self._refresh_locked(allow_empty=True):
                raise ValueError(
                    self.status.get("error") or self.status.get("write_state")
                )
            reloaded, reload_detail = self._reload_app_vod_config_sync(
                expected_keys=self._generated_registry_keys()
            )
            if not reloaded:
                raise ValueError("App 重载校验失败：{}".format(reload_detail))
        except Exception as exc:
            rollback_errors = []
            try:
                self._rollback_blocked_source_file_deletions(transaction)
            except Exception as rollback_exc:
                rollback_errors.append("文件回滚失败：{}".format(rollback_exc))
            self.manual_ignored_sources = previous["manual_ignored_sources"]
            self.auto_blocked_sources = previous["auto_blocked_sources"]
            self.adult_blocked_sources = previous["adult_blocked_sources"]
            self.adult_allowed_sources = previous["adult_allowed_sources"]
            self.ignored_sources = previous["ignored_sources"]
            self.pruned_sources = previous["pruned_sources"]
            self.site_test_results = previous["site_test_results"]
            self.site_test_history = previous["site_test_history"]
            self.source_metadata = previous["source_metadata"]
            self._retest_pending = previous["retest_pending"]
            self._retest_auto_blocked = previous["retest_auto_blocked"]
            try:
                self._save_settings()
                if not self._refresh_locked(allow_empty=True):
                    raise ValueError(
                        self.status.get("error") or self.status.get("write_state")
                    )
                self._reload_app_vod_config_sync(
                    expected_keys=self._generated_registry_keys()
                )
            except Exception as rollback_exc:
                rollback_errors.append("配置回滚失败：{}".format(rollback_exc))
                try:
                    current_token = self._registry_token(self.output_path)
                    self._atomic_write_json(
                        previous_registry,
                        create_backup=False,
                        expected_token=current_token,
                    )
                    self._write_standard_configs(previous_registry)
                except Exception as registry_exc:
                    rollback_errors.append(
                        "注册表回滚失败：{}".format(registry_exc)
                    )
            detail = "；".join(rollback_errors)
            raise ValueError(
                "删除屏蔽文件失败：{}{}".format(
                    exc, "；" + detail if detail else ""
                )
            )

        identities = {
            str(source.get("identity", "") or "") for source in sources
        }
        for identity in identities:
            self.site_test_history.pop(identity, None)
            self.source_metadata.pop(identity, None)
            self.pruned_sources.discard(identity)
            self._retest_auto_blocked.discard(identity)
        self._retest_pending = [
            identity
            for identity in self._retest_pending
            if identity not in identities
        ]
        cleanup_warnings = []
        try:
            self._save_settings()
        except Exception as exc:
            cleanup_warnings.append("删除后管理状态保存失败: {}".format(exc))
        for item in transaction:
            staged = str(item.get("staged", "") or "")
            try:
                os.remove(staged)
            except FileNotFoundError:
                pass
            except Exception as exc:
                cleanup_warnings.append("{}: {}".format(staged, exc))
        for warning in cleanup_warnings:
            self._warn("屏蔽文件已隔离但清理失败: {}".format(warning))
        self._log(
            "INFO",
            "已删除屏蔽源文件 {} 个: {}".format(
                len(sources),
                ", ".join(os.path.basename(source["path"]) for source in sources),
            ),
        )
        return sources, reload_detail, cleanup_warnings

    def _restore_blocked_sources(self, source_ids):
        """批量恢复屏蔽站点，只写入和重载一次。"""
        ordered_ids = list(dict.fromkeys(
            str(value or "").strip() for value in source_ids
            if str(value or "").strip()
        ))
        sources = []
        for source_id in ordered_ids:
            source = self.cache.get("source_index", {}).get(source_id)
            if source and source.get("ignored"):
                sources.append(source)
        if not sources:
            raise ValueError("没有可解除屏蔽的站点")

        try:
            previous_registry = copy.deepcopy(self._load_registry())
        except Exception as exc:
            raise ValueError("解除屏蔽前注册表快照失败：{}".format(exc))

        previous = {
            "ignored_sources": set(self.ignored_sources),
            "manual_ignored_sources": set(self.manual_ignored_sources),
            "auto_blocked_sources": set(self.auto_blocked_sources),
            "adult_blocked_sources": set(self.adult_blocked_sources),
            "adult_allowed_sources": set(self.adult_allowed_sources),
            "pruned_sources": set(self.pruned_sources),
            "site_test_results": dict(self.site_test_results),
            "retest_pending": list(self._retest_pending),
            "retest_auto_blocked": set(self._retest_auto_blocked),
            "cache": copy.deepcopy(self.cache),
            "status": copy.deepcopy(self.status),
        }
        expected_registry = None
        identities = {source["identity"] for source in sources}
        for source in sources:
            identity = source["identity"]
            self.manual_ignored_sources.discard(identity)
            self.auto_blocked_sources.discard(identity)
            if source.get("adult_blocked") or (
                self.block_adult_sites and self._is_adult_source(source)
            ):
                self.adult_allowed_sources.add(identity)
            self.adult_blocked_sources.discard(identity)
            self.pruned_sources.discard(identity)
            self._retest_auto_blocked.discard(identity)
            self.site_test_results.pop(identity, None)
        self._retest_pending = [
            value for value in self._retest_pending if value not in identities
        ]
        self._sync_ignored_sources()

        try:
            self._save_settings()
            for source in sources:
                self._set_cached_source_ignored(source, False)
            expected_registry = self._merge_registry(
                copy.deepcopy(previous_registry)
            )[0]
            self._generate_config()
            try:
                self._save_scan_snapshot()
            except Exception as exc:
                self._warn("批量解除后扫描列表快照保存失败: {}".format(exc))
        except Exception as exc:
            self.ignored_sources = previous["ignored_sources"]
            self.manual_ignored_sources = previous["manual_ignored_sources"]
            self.auto_blocked_sources = previous["auto_blocked_sources"]
            self.adult_blocked_sources = previous["adult_blocked_sources"]
            self.adult_allowed_sources = previous["adult_allowed_sources"]
            self.pruned_sources = previous["pruned_sources"]
            self.site_test_results = previous["site_test_results"]
            self._retest_pending = previous["retest_pending"]
            self._retest_auto_blocked = previous["retest_auto_blocked"]
            self.cache = previous["cache"]
            self.status = previous["status"]
            rollback_error = ""
            try:
                self._save_settings()
                current_registry = self._load_registry()
                if expected_registry is not None and current_registry == expected_registry:
                    current_token = self._registry_token(self.output_path)
                    self._atomic_write_json(
                        previous_registry,
                        create_backup=False,
                        expected_token=current_token,
                    )
                    if self.app_mode == self.APP_MODE_OKTV:
                        self._ok_write_generated_configs(previous_registry)
                elif current_registry != previous_registry:
                    rollback_error = "注册表已被其他操作修改，未覆盖"
            except Exception as rollback_exc:
                rollback_error = "注册表回滚失败：{}".format(rollback_exc)
            raise ValueError(
                "解除屏蔽保存失败：{}{}".format(
                    exc, "；" + rollback_error if rollback_error else ""
                )
            )

        _, reload_detail = self._reload_app_vod_config(
            expected_keys=self._generated_registry_keys(),
            refresh_types=("category",),
        )
        return sources, reload_detail

    def _restore_scan_snapshot(self):
        data = self._load_scan_cache_payload(warn=False)
        snapshot = data.get("snapshot", {}) if isinstance(data, dict) else {}
        if not isinstance(snapshot, dict):
            return False
        expected_token = str(snapshot.get("registry_token", ""))
        if not expected_token or expected_token != self._registry_token(
            self.output_path
        ):
            return False
        if str(
            snapshot.get("package_state", "")
        ) != self._package_download_state_signature():
            return False
        if str(snapshot.get("scan_state", "")) != self._scan_location_signature():
            return False
        if str(snapshot.get("playback_proxy_state", "")) != (
            self._playback_proxy_settings_signature()
        ):
            return False
        raw_sources = snapshot.get("sources", [])
        if not isinstance(raw_sources, list):
            return False
        raw_lives = snapshot.get("lives", [])
        if not isinstance(raw_lives, list):
            raw_lives = []
        raw_contexts = snapshot.get("contexts", [])
        if not isinstance(raw_contexts, list):
            raw_contexts = []

        restored = self._empty_cache()
        seen_ids = set()
        for raw in raw_sources:
            if not isinstance(raw, dict):
                continue
            source = copy.deepcopy(raw)
            source_id = str(source.get("id", "")).strip()
            identity = str(source.get("identity", "")).strip()
            source_type = str(source.get("type", "")).upper()
            if (
                not source_id
                or source_id in seen_ids
                or not identity
                or source_type not in self.TYPE_ORDER
                or not isinstance(source.get("site"), dict)
            ):
                continue
            source["type"] = source_type
            source["ignored"] = identity in self.ignored_sources
            source["adult_blocked"] = (
                self.block_adult_sites and identity in self.adult_blocked_sources
            )
            test_result = self.site_test_results.get(identity, {})
            if not isinstance(test_result, dict) or test_result.get(
                "source_signature"
            ) != self._source_signature(source):
                test_result = {}
            source["test_result"] = test_result
            seen_ids.add(source_id)
            restored["source_index"][source_id] = source
            if source["ignored"]:
                restored["ignored"].append(source)
                counts = restored["ignored_counts"]
            else:
                restored["sources"].append(source)
                counts = restored["type_counts"]
            counts[source_type] = counts.get(source_type, 0) + 1
        restored["lives"] = [
            copy.deepcopy(item)
            for item in raw_lives
            if isinstance(item, dict) and isinstance(item.get("live"), dict)
        ]
        restored["contexts"] = [
            copy.deepcopy(item)
            for item in raw_contexts
            if isinstance(item, dict)
            and isinstance(item.get("context"), dict)
            and bool(item.get("context"))
        ]
        if (
            not restored["sources"]
            and not restored["ignored"]
            and not restored["lives"]
            and not restored["contexts"]
        ):
            return False

        current_manual = self.status["manual_sites"]
        current_generated = self.status["generated_sites"]
        saved_status = snapshot.get("status", {})
        self.cache = restored
        if isinstance(saved_status, dict):
            for field in self._empty_status():
                if field in saved_status:
                    self.status[field] = copy.deepcopy(saved_status[field])
        self.status["included"] = len(restored["sources"])
        self.status["ignored"] = len(restored["ignored"])
        self.status["manual_sites"] = current_manual
        self.status["generated_sites"] = current_generated
        self.status["written"] = True
        self.status["registry_changed"] = False
        self.status["write_state"] = "已恢复上次成功扫描结果"
        self.status["error"] = ""
        return True

    def _atomic_write_plain_json(self, path, data):
        directory = os.path.dirname(path)
        if directory and not os.path.isdir(directory):
            os.makedirs(directory, exist_ok=True)
        temp_path = path + ".tmp"
        content = json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
        try:
            with open(temp_path, "w", encoding="utf-8") as fp:
                fp.write(content)
                fp.flush()
                os.fsync(fp.fileno())
            os.replace(temp_path, path)
        except Exception:
            try:
                if os.path.exists(temp_path):
                    os.remove(temp_path)
            except Exception:
                pass
            raise

    # --------------------------------------------------------------------------
    # OK影视兼容：标准配置合并与 A/B 热重载
    # --------------------------------------------------------------------------
    def _ok_config_paths(self):
        return (
            os.path.realpath(os.path.abspath(os.path.expanduser(self.ok_config_a))),
            os.path.realpath(os.path.abspath(os.path.expanduser(self.ok_config_b))),
        )

    def _is_ok_generated_config_url(self, value):
        url = str(value or "").strip()
        if not url:
            return False
        path = self._reference_path(url)
        if path:
            return path in set(self._ok_config_paths())
        clean = url.split("?", 1)[0].rstrip("/")
        return any(clean.endswith("/" + os.path.basename(path)) for path in self._ok_config_paths())

    def _current_vod_config_url(self):
        """读取当前活动点播接口，两端通用。

        OK影视经 Java Config 读取；WebHTV 走本机管理接口的 type=0 活动项。
        """
        if self.app_mode == self.APP_MODE_OKTV:
            return self._ok_current_config_url()
        last_error = "未发现 WebHTV 本机服务"
        for port in self._app_port_candidates():
            try:
                payload = self._request_json(
                    "http://127.0.0.1:{}/manage/configs".format(port),
                    self.APP_REQUEST_TIMEOUT,
                )
                items = payload.get("items", []) if isinstance(payload, dict) else []
                current = next(
                    (
                        item
                        for item in items
                        if isinstance(item, dict)
                        and int(item.get("type", -1)) == 0
                        and bool(item.get("active", False))
                        and str(item.get("url", "")).strip()
                    ),
                    None,
                )
                if current:
                    return str(current["url"]).strip()
                last_error = "WebHTV 未返回当前点播接口"
            except Exception as exc:
                last_error = str(exc)
        raise ValueError("读取 WebHTV 当前点播接口失败: {}".format(last_error))

    def _ok_current_config_url(self):
        try:
            from java import jclass

            return str(
                jclass("com.fongmi.android.tv.bean.Config").vod().getUrl() or ""
            ).strip()
        except Exception as exc:
            raise ValueError("读取 OK影视当前点播接口失败: {}".format(exc))

    def _ok_fetch_config_text(self, value):
        path = self._reference_path(value)
        if path and os.path.isfile(path):
            with open(path, "r", encoding="utf-8-sig") as fp:
                return fp.read()
        lower = value.lower()
        if lower.startswith("assets://"):
            try:
                from java import jclass

                port = int(jclass("com.github.catvod.Proxy").getPort())
                value = "http://127.0.0.1:{}/{}".format(
                    port, value[9:].lstrip("/")
                )
            except Exception as exc:
                raise ValueError("无法读取 assets 配置: {}".format(exc))
        if not value.lower().startswith(("http://", "https://")):
            raise ValueError("不支持的基础配置地址: {}".format(value))
        request = urllib.request.Request(
            value,
            headers={"User-Agent": "okhttp/4.12.0", "Accept": "application/json"},
        )
        opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
        with opener.open(request, timeout=10) as response:
            return response.read().decode("utf-8-sig")

    def _ok_decoder_base64(self, text):
        match = re.search(r"[A-Za-z0-9]{8}\*\*", text)
        if not match:
            return text
        payload = text[match.end() :]
        return base64.b64decode(payload).decode("utf-8")

    def _ok_decoder_cbc(self, text):
        try:
            decoded = bytes.fromhex(text).decode("utf-8", errors="replace").lower()
            key_start = decoded.index("$#") + 2
            key_end = decoded.index("#$", key_start)
            key = (decoded[key_start:key_end] + "0" * 16)[:16].encode("utf-8")
            iv = (decoded[-13:] + "0" * 16)[:16].encode("utf-8")
            start = text.index("2324") + 4
            encrypted = bytes.fromhex(text[start:-26])
            from Crypto.Cipher import AES

            plain = AES.new(key, AES.MODE_CBC, iv).decrypt(encrypted)
            padding = plain[-1]
            if padding < 1 or padding > 16 or plain[-padding:] != bytes([padding]) * padding:
                raise ValueError("AES 填充无效")
            return plain[:-padding].decode("utf-8")
        except Exception as exc:
            raise ValueError("2423 配置解密失败: {}".format(exc))

    def _ok_fix_relative_urls(self, base_url, text):
        protected = {}

        def protect(match):
            token = "__LOCAL_AUTO_JS_{}__".format(len(protected))
            protected[token] = urllib.parse.urljoin(base_url, match.group(1))
            return '"{}"'.format(token)

        pattern = re.compile(r'"((?:\.\.?/)[^"?]+\.js\?[^" ]*)"')
        value = pattern.sub(protect, text)
        parent = urllib.parse.urljoin(base_url, "../")
        current = urllib.parse.urljoin(base_url, "./")
        value = value.replace("../", parent).replace("./", current)
        for token, resolved in protected.items():
            value = value.replace(token, resolved)
        return value

    def _ok_decode_config(self, url, depth=0):
        value = str(url or "").strip()
        if not value:
            raise ValueError("基础配置地址为空")
        if depth > 3:
            raise ValueError("多仓配置嵌套过深")
        text = self._ok_fetch_config_text(value).strip()
        if "**" in text:
            text = self._ok_decoder_base64(text)
        if text.startswith("2423"):
            text = self._ok_decoder_cbc(re.sub(r"\s+", "", text))
        text = self._ok_fix_relative_urls(value, text)
        try:
            data = json.loads(text.lstrip("\ufeff"))
        except Exception as exc:
            raise ValueError("基础配置不是有效 JSON: {}".format(exc))
        if not isinstance(data, dict):
            raise ValueError("基础配置顶层必须是 JSON 对象")
        urls = data.get("urls")
        if isinstance(urls, list) and urls:
            first = urls[0]
            child_url = first.get("url", "") if isinstance(first, dict) else first
            return self._ok_decode_config(child_url, depth + 1)
        return data

    def _ok_clean_base_config(self, config):
        data = copy.deepcopy(config) if isinstance(config, dict) else {}
        data.pop(self.OK_CONFIG_MARKER, None)
        sites = data.get("sites", [])
        if isinstance(sites, dict):
            sites = [sites]
        elif not isinstance(sites, list):
            sites = [sites] if isinstance(sites, str) and sites.strip() else []
        clean_sites = []
        for site in sites:
            if isinstance(site, dict):
                key = str(site.get("key", "")).strip()
                if key.startswith(self.GENERATED_KEY_PREFIX) or site.get(
                    self.OK_CONFIG_MARKER
                ):
                    continue
            clean_sites.append(site)
        data["sites"] = clean_sites
        lives = data.get("lives", [])
        if isinstance(lives, dict):
            lives = [lives]
        elif not isinstance(lives, list):
            lives = []
        data["lives"] = [
            live
            for live in lives
            if not isinstance(live, dict) or not live.get(self.OK_CONFIG_MARKER)
        ]
        return data

    def _ok_read_base_cache(self):
        path = os.path.abspath(os.path.expanduser(self.ok_base_cache_path))
        if not os.path.isfile(path):
            return {}
        try:
            cached = self._read_config_file(path, "OK影视基础配置缓存")
            config = cached.get("config", cached)
            return self._ok_clean_base_config(config)
        except Exception as exc:
            self._warn("OK影视基础配置缓存读取失败: {}".format(exc))
            return {}

    def _ok_save_base_cache(self, config):
        payload = {
            "baseUrl": self.ok_base_config_url,
            "savedAt": time.strftime("%Y-%m-%d %H:%M:%S"),
            "config": self._ok_clean_base_config(config),
        }
        self._atomic_write_plain_json(self.ok_base_cache_path, payload)

    def _ok_base_config(self):
        current_url = self._current_vod_config_url()
        current_generated = self._is_ok_generated_config_url(current_url)

        if current_url and not current_generated:
            config = self._ok_clean_base_config(self._ok_decode_config(current_url))
            self.ok_base_config_url = current_url
            self._ok_save_base_cache(config)
            self._save_settings()
            return config

        if self.ok_base_config_url and not self._is_ok_generated_config_url(
            self.ok_base_config_url
        ):
            try:
                config = self._ok_clean_base_config(
                    self._ok_decode_config(self.ok_base_config_url)
                )
                self._ok_save_base_cache(config)
                return config
            except Exception as exc:
                self._warn(
                    "{} 基础配置刷新失败，使用本地缓存: {}".format(
                        self._app_mode_label(), exc
                    )
                )

        cached = self._ok_read_base_cache()
        if cached:
            return cached
        if current_url:
            return self._ok_clean_base_config(self._ok_decode_config(current_url))
        raise ValueError(
            "未找到 {} 当前配置或基础配置缓存".format(self._app_mode_label())
        )

    def _ok_registry_sites(self, registry):
        sites = []
        used_keys = set()
        skipped_html = 0
        for item in registry.get("items", []):
            if not isinstance(item, dict) or not bool(item.get("enabled", True)):
                continue
            site = self._registry_item_site(item)
            if not isinstance(site, dict):
                continue
            kind = str(item.get("kind", "")).strip().lower()
            if kind == "live":
                continue
            source_type = self._generated_item_type(item)
            if source_type == "HTML" or kind == "webhome" or site.get("homePage"):
                skipped_html += 1
                continue
            if not str(site.get("key", "")).strip():
                continue
            site = copy.deepcopy(site)
            base_key = str(site.get("key", "")).strip()
            unique_key = base_key
            index = 2
            while unique_key in used_keys:
                unique_key = "{}_{}".format(base_key, index)
                index += 1
            used_keys.add(unique_key)
            site["key"] = unique_key
            if self._is_generated_registry_item(item):
                site[self.OK_CONFIG_MARKER] = {
                    "version": self.OK_CONFIG_VERSION,
                    "sourceId": str(item.get("id", "")),
                    "originalKey": base_key,
                }
            sites.append(site)
        if skipped_html:
            if self.app_mode == self.APP_MODE_WEBHTV:
                # WebHTV 的 WebHome 由站点注入通道加载，不进标准配置。
                self._log(
                    "INFO",
                    "已跳过 {} 个 HTML 站点，改由 WebHome 注入加载".format(
                        skipped_html
                    ),
                )
            else:
                self._warn(
                    "{} 不支持 WebHome，已跳过 {} 个 HTML 站点".format(
                        self._app_mode_label(), skipped_html
                    )
                )
        return sites

    def _ok_registry_lives(self, registry):
        lives = []
        seen = set()
        for item in registry.get("items", []):
            if (
                not isinstance(item, dict)
                or not bool(item.get("enabled", True))
                or str(item.get("kind", "")).strip().lower() != "live"
            ):
                continue
            live = copy.deepcopy(self._registry_item_live(item))
            fingerprint = self._live_fingerprint(live)
            if not fingerprint or fingerprint in seen:
                continue
            seen.add(fingerprint)
            live.setdefault(
                self.OK_CONFIG_MARKER,
                {
                    "version": self.OK_CONFIG_VERSION,
                    "sourceId": str(item.get("id", "")),
                },
            )
            lives.append(live)
        return lives

    def _registry_root_contexts(self, registry):
        contexts = []
        for item in registry.get("items", []):
            if (
                not isinstance(item, dict)
                or not bool(item.get("enabled", True))
                or str(item.get("kind", "")).strip().lower() != "other"
            ):
                continue
            other = item.get("other", {})
            if isinstance(other, dict) and other:
                contexts.append(copy.deepcopy(other))
        return contexts

    def _merge_root_context(self, target, source):
        if not isinstance(target, dict) or not isinstance(source, dict):
            return target
        for key, value in source.items():
            if key in ("sites", "lives") or value is None:
                continue
            current = target.get(key)
            if isinstance(value, list):
                if not value:
                    continue
                merged = copy.deepcopy(current) if isinstance(current, list) else []
                fingerprints = {
                    json.dumps(
                        item,
                        ensure_ascii=False,
                        sort_keys=True,
                        separators=(",", ":"),
                    )
                    for item in merged
                }
                for item in value:
                    fingerprint = json.dumps(
                        item,
                        ensure_ascii=False,
                        sort_keys=True,
                        separators=(",", ":"),
                    )
                    if fingerprint not in fingerprints:
                        fingerprints.add(fingerprint)
                        merged.append(copy.deepcopy(item))
                target[key] = merged
            elif isinstance(value, dict):
                if not value:
                    continue
                if not isinstance(current, dict):
                    target[key] = copy.deepcopy(value)
                    continue
                for child, child_value in value.items():
                    if child not in current or current.get(child) in (None, ""):
                        current[child] = copy.deepcopy(child_value)
            elif key not in target or current in (None, ""):
                target[key] = copy.deepcopy(value)
        return target

    def _ok_build_config(self, registry):
        config = self._ok_base_config()
        local_sites = self._ok_registry_sites(registry)
        local_lives = self._ok_registry_lives(registry)
        root_contexts = self._registry_root_contexts(registry)
        for context in root_contexts:
            self._merge_root_context(config, context)
        local_keys = {
            str(site.get("key", "")).strip()
            for site in local_sites
            if isinstance(site, dict)
        }
        base_sites = config.get("sites", [])
        if isinstance(base_sites, dict):
            base_sites = [base_sites]
        elif not isinstance(base_sites, list):
            base_sites = [base_sites] if isinstance(base_sites, str) and base_sites.strip() else []
        base_sites = [
            site
            for site in base_sites
            if not isinstance(site, dict)
            or str(site.get("key", "")).strip() not in local_keys
        ]
        config["sites"] = local_sites + base_sites
        base_lives = config.get("lives", [])
        if isinstance(base_lives, dict):
            base_lives = [base_lives]
        elif not isinstance(base_lives, list):
            base_lives = []
        base_lives = [
            live
            for live in base_lives
            if not isinstance(live, dict) or not live.get(self.OK_CONFIG_MARKER)
        ]
        base_live_fingerprints = {
            self._live_fingerprint(live)
            for live in base_lives
            if isinstance(live, dict)
        }
        local_lives = [
            live
            for live in local_lives
            if self._live_fingerprint(live) not in base_live_fingerprints
        ]
        local_lives = [
            self._live_with_proxy_policy(
                {
                    "id": str(
                        live.get(self.OK_CONFIG_MARKER, {}).get(
                            "sourceId", ""
                        )
                    )
                    if isinstance(live.get(self.OK_CONFIG_MARKER), dict)
                    else "",
                    "live": live,
                },
                live,
            )
            for live in local_lives
        ]
        merged_lives = local_lives + base_lives
        # 空占位会在 App 解析时被移除，同时确保关闭最后一个集成源后清空旧直播。
        config["lives"] = merged_lives or [{}]
        home_key = str(registry.get("homeKey", "")).strip()
        if home_key and home_key in local_keys:
            config["home"] = home_key
        config[self.OK_CONFIG_MARKER] = {
            "version": self.OK_CONFIG_VERSION,
            "loader": self.VERSION,
            "baseUrl": self.ok_base_config_url,
            "generatedSites": len(local_sites),
            "generatedLives": len(local_lives),
            "generatedContexts": len(root_contexts),
        }
        return config

    def _is_webhome_registry_item(self, item):
        """判断注册表项是否属于 WebHome（HTML）能力。

        v6.2 统一加载模型下，WebHome 是唯一仍走站点注入的类型，
        其余站点一律通过标准 TVBox 点播配置加载。
        """
        if not isinstance(item, dict):
            return False
        kind = str(item.get("kind", "")).strip().lower()
        if kind == "live" or kind == "other":
            return False
        if kind == "webhome":
            return True
        if self._generated_item_type(item) == "HTML":
            return True
        site = self._registry_item_site(item)
        return bool(
            isinstance(site, dict) and str(site.get("homePage", "")).strip()
        )

    def _standard_config_paths(self):
        return self._ok_config_paths()

    def _standard_config_reference(self):
        """已生成的标准点播配置引用；尚未生成时返回空串。

        WebHTV 用它把点播接口切到本脚本生成的配置；OK影视沿用自己的
        A/B 切换逻辑，不经过这里。
        """
        for path in self._standard_config_paths():
            if os.path.isfile(path):
                return self._file_url(path)
        return ""

    def _write_standard_configs(self, registry):
        """写入标准 TVBox 点播配置。

        v6.2 起 WebHTV 与 OK影视共用这一份配置，是两端的主加载链路。
        OK影视没有其它加载途径，基础配置读取失败必须向上暴露；WebHTV 仍有
        WebHome 注入通道且注册表已经落盘，此时只记录警告，不让一次本机服务
        不可达把整轮扫描判定为失败。
        """
        try:
            config = self._ok_build_config(registry)
        except Exception as exc:
            if self.app_mode == self.APP_MODE_OKTV:
                raise
            self._warn("标准点播配置生成失败，本轮跳过: {}".format(exc))
            return None
        for path in self._standard_config_paths():
            self._atomic_write_plain_json(path, config)
        self.status["write_state"] = "已生成 {} 本地配置".format(
            self._app_mode_label()
        )
        self.status["written"] = True
        return config

    def _ok_write_generated_configs(self, registry):
        return self._write_standard_configs(registry)

    def _ok_next_config_target(self):
        current_url = self._ok_current_config_url()
        path_a, path_b = self._ok_config_paths()
        current_path = self._reference_path(current_url)
        target_path = path_b if current_path == path_a else path_a
        return self._file_url(target_path)

    def _perform_ok_vod_reload(self):
        try:
            from java import jclass

            port = int(jclass("com.github.catvod.Proxy").getPort())
            if port < 1:
                raise ValueError("OK影视本机服务尚未启动")
            target = self._ok_next_config_target()
            config = json.dumps(
                {"type": 0, "url": target, "name": "本地自动加载"},
                ensure_ascii=False,
                separators=(",", ":"),
            )
            body = urllib.parse.urlencode(
                {"config": config, "targets": "[]", "force": "false"}
            ).encode("utf-8")
            request = urllib.request.Request(
                "http://127.0.0.1:{}/action?do=sync&mode=1&type=history".format(port),
                data=body,
                headers={
                    "Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
                    "Connection": "close",
                },
            )
            opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
            with opener.open(request, timeout=2.5) as response:
                if int(getattr(response, "status", response.getcode())) != 200:
                    raise ValueError("HTTP {}".format(response.getcode()))
            self.ok_last_target = target
            self._save_settings()
            return True, "OK影视本地站点已主动重载"
        except Exception as exc:
            self._warn("OK影视主动重载失败: {}".format(exc))
            return False, "OK影视配置已生成；主动重载失败，重启 App 后生效"

    def _reload_app_vod_config(self, expected_keys=None, refresh_types=None):
        if not self.auto_reload_app:
            return False, "配置已写入；App 自动重载已关闭"
        expected = set(expected_keys) if expected_keys is not None else None
        with self.lock:
            try:
                registry_snapshot = copy.deepcopy(self._load_registry())
            except Exception as exc:
                self._warn("App 主动重载快照创建失败: {}".format(exc))
                return False, "配置已写入；主动重载快照创建失败"
            reload_state = {
                "last_app_port": int(self.last_app_port or 0),
                "generated_live_count": len(self.cache.get("lives", [])),
                "registry": registry_snapshot,
            }
            self._reload_generation += 1
            generation = self._reload_generation
            worker = threading.Thread(
                target=self._delayed_app_vod_reload,
                args=(
                    generation,
                    expected,
                    reload_state,
                    tuple(refresh_types or ("home", "category")),
                ),
                name="local-source-reload",
            )
            worker.daemon = True
            worker.start()
        self._log(
            "INFO",
            "已安排 App 主动重载: delay={}s items={}".format(
                self.APP_RELOAD_DELAY,
                len(expected) if expected is not None else "-",
            ),
        )
        return True, "已安排主动重载 {} 站点列表".format(self._app_mode_label())

    def _reload_app_vod_config_sync(self, expected_keys=None):
        trace_id = self._active_operation_trace_id()
        started = time.monotonic()
        self._trace_log(
            trace_id,
            "app_reload",
            "sync.begin",
            app_mode=self._app_mode_label(),
            auto_reload=bool(self.auto_reload_app),
            expected_site_count=(
                len(expected_keys) if expected_keys is not None else None
            ),
            delay_seconds=max(0.1, float(self.APP_RELOAD_DELAY)),
        )
        if not self.auto_reload_app:
            self._trace_log(
                trace_id,
                "app_reload",
                "sync.skipped",
                level="WARN",
                reason="auto reload disabled",
            )
            return False, "配置已写入；App 自动重载已关闭"
        try:
            reload_state = {
                "last_app_port": int(self.last_app_port or 0),
                "generated_live_count": len(self.cache.get("lives", [])),
                "registry": copy.deepcopy(self._load_registry()),
            }
        except Exception as exc:
            self._trace_log(
                trace_id,
                "app_reload",
                "snapshot.failed",
                level="ERROR",
                error=str(exc),
            )
            return False, "主动重载快照创建失败：{}".format(exc)
        self._trace_log(
            trace_id,
            "app_reload",
            "snapshot.complete",
            last_app_port=reload_state.get("last_app_port", 0),
            generated_live_count=reload_state.get("generated_live_count", 0),
            registry_item_count=len(
                reload_state.get("registry", {}).get("items", [])
            ),
        )
        time.sleep(max(0.1, float(self.APP_RELOAD_DELAY)))
        ok, detail = self._perform_app_vod_reload(
            set(expected_keys) if expected_keys is not None else None,
            reload_state,
        )
        if ok and self.app_mode == self.APP_MODE_WEBHTV:
            time.sleep(max(0.2, float(self.APP_PAGE_REFRESH_DELAY)))
            page_ok, page_detail = self._perform_manager_page_refresh(
                refresh_types=("home", "category")
            )
            detail = "{}；{}".format(detail, page_detail)
            if not page_ok:
                # 站点运行态已校验成功时，页面刷新只是 UI 收尾。
                # 不能因为页面接口暂时不可用而回滚已下载内容。
                self._warn("站点已重载，{}；可手动刷新页面".format(page_detail))
        self._trace_log(
            trace_id,
            "app_reload",
            "sync.complete",
            level="INFO" if ok else "WARN",
            ok=bool(ok),
            detail=detail,
            elapsed_ms=int((time.monotonic() - started) * 1000),
        )
        return ok, detail

    def _local_server_port(self):
        """返回 App 本机服务端口；探测不到时返回 0。

        与 _app_port_candidates 不同，这里只取真正确认可用的端口，不做
        全端口段枚举——测活要的是"当前进程的服务地址"，枚举出来的端口
        可能属于另一个同时运行的 App。
        """
        port = self._current_proxy_port()
        if port:
            return port
        with self.lock:
            return int(self.last_app_port or 0)

    def _runtime_site_reference(self, reference):
        """把 file:// 引用改写成 App 本机 HTTP 地址。

        宿主的 Site.objectFrom 会对 api/ext/homePage 调 UrlUtil.convert，
        把 file:// 换成 http://127.0.0.1:<port>/file/。QuickJS 的
        Module.fetch 只认 http/assets/lib 前缀，拿到原始 file:// 会返回
        null，随后 createObj 对 null 取内容抛异常，站点被误判为失效。
        测活直接调 BaseLoader.getSpider，绕过了那次转换，所以必须在这里
        补上，否则本地 JS 站点永远初始化失败。
        """
        value = str(reference or "").strip()
        if not value.lower().startswith("file://"):
            return value
        port = self._local_server_port()
        if not port:
            return value
        relative = value[7:].lstrip("/")
        return "http://127.0.0.1:{}/file/{}".format(
            port, urllib.parse.quote(relative, safe="/%:@&=+$,~")
        )

    def _current_proxy_port(self):
        try:
            from java import jclass

            port = int(jclass("com.github.catvod.Proxy").getPort())
            return port if port > 0 else 0
        except Exception:
            return 0

    def _app_port_candidates(self):
        ports = []
        current_port = self._current_proxy_port()
        if current_port:
            ports.append(current_port)
        if self.last_app_port and self.last_app_port not in ports:
            ports.append(self.last_app_port)
        ports.extend(port for port in self.app_server_ports if port not in ports)
        return ports

    def _schedule_manager_page_refresh(self, delay=0.2):
        with self.lock:
            self._page_refresh_generation += 1
            generation = self._page_refresh_generation
        worker = threading.Thread(
            target=self._delayed_manager_page_refresh,
            args=(generation, max(0.05, float(delay))),
            name="local-source-page-refresh",
        )
        worker.daemon = True
        worker.start()
        return True, "已安排首页和分类页轻量刷新"

    def _schedule_package_integration_refresh(self, reason, allow_empty=False):
        worker = threading.Thread(
            target=self._package_integration_refresh_worker,
            args=(
                str(reason or "下载站点设置变更"),
                bool(allow_empty),
            ),
            name="local-package-integration-refresh",
        )
        worker.daemon = True
        worker.start()
        return True, "已安排重新扫描并同步本地直播和播放解析"

    def _package_integration_refresh_worker(self, reason, allow_empty=False):
        try:
            with self.lock:
                ok = self._refresh_locked(
                    allow_empty=(
                        bool(allow_empty) or not any(self.type_enabled.values())
                    )
                )
                if not ok:
                    raise ValueError(
                        self.status["error"] or self.status["write_state"]
                    )
                _, reload_detail = self._reload_app_vod_config(
                    expected_keys=self._generated_registry_keys()
                )
            message = "{}已生效：集成直播 {} 项、播放上下文 {} 组；{}".format(
                reason,
                len(self.cache.get("lives", [])),
                len(self.cache.get("contexts", [])),
                reload_detail,
            )
            self._log("INFO", message)
            self._notify_app(message)
        except Exception as exc:
            message = "{}后的本地包上下文同步失败: {}".format(reason, exc)
            self._log("ERROR", message)
            self._notify_app(message)

    def _delayed_manager_page_refresh(self, generation, delay):
        time.sleep(delay)
        try:
            with self.lock:
                if generation != self._page_refresh_generation:
                    return
            ok, detail = self._perform_manager_page_refresh()
            self._log("INFO" if ok else "WARN", detail)
            self._notify_app(detail)
        except Exception as exc:
            detail = "页面轻量刷新失败: {}".format(exc)
            self._log("ERROR", detail)
            self._notify_app(detail)

    def _perform_manager_page_refresh(self, refresh_types=None):
        refresh_types = tuple(refresh_types or ("home", "category"))
        labels = {"home": "首页", "category": "分类页"}
        last_error = "未发现当前 App 本机服务"
        for port in self._app_port_candidates():
            base = "http://127.0.0.1:{}".format(port)
            try:
                for refresh_type in refresh_types:
                    query = urllib.parse.urlencode(
                        {"do": "refresh", "type": refresh_type}
                    )
                    self._request_status(
                        base + "/action?" + query,
                        max(1.2, self.APP_REQUEST_TIMEOUT * 3),
                    )
                    time.sleep(0.08)
                self._remember_app_port(port)
                page_names = "和".join(
                    labels.get(item, item) for item in refresh_types
                )
                return True, "{}已轻量刷新".format(page_names or "页面")
            except Exception as exc:
                last_error = str(exc)
        return False, "页面轻量刷新失败: {}".format(last_error)

    def _delayed_app_vod_reload(
        self, generation, expected_keys, reload_state, refresh_types
    ):
        time.sleep(max(0.1, float(self.APP_RELOAD_DELAY)))
        try:
            with self.lock:
                if generation != self._reload_generation:
                    return
            # 网络请求必须在锁外执行，避免 Android UI 线程保存设置时等待。
            ok, detail = self._perform_app_vod_reload(expected_keys, reload_state)
            if ok and self.app_mode == self.APP_MODE_WEBHTV:
                time.sleep(max(0.2, float(self.APP_PAGE_REFRESH_DELAY)))
                # 主动补刷调用方需要的页面，避免 App 内部 VodConfig.load 只
                # 更新运行态、当前列表仍保留旧内容。
                page_ok, page_detail = self._perform_manager_page_refresh(
                    refresh_types=refresh_types
                )
                detail = "{}；{}".format(detail, page_detail)
                if not page_ok:
                    self._warn(
                        "站点已重载，{}；可手动刷新页面".format(page_detail)
                    )
            self._log("INFO" if ok else "WARN", detail)
            self._notify_app(detail)
        except Exception as exc:
            detail = "App 主动重载失败: {}".format(exc)
            self._log("ERROR", detail)
            self._notify_app(detail)

    def _perform_app_vod_reload(self, expected_keys=None, reload_state=None):
        trace_id = self._active_operation_trace_id()
        if self.app_mode == self.APP_MODE_OKTV:
            self._trace_log(
                trace_id,
                "app_reload",
                "oktv.begin",
                expected_site_count=(
                    len(expected_keys) if expected_keys is not None else None
                ),
            )
            ok, detail = self._perform_ok_vod_reload()
            self._trace_log(
                trace_id,
                "app_reload",
                "oktv.complete",
                level="INFO" if ok else "WARN",
                ok=bool(ok),
                detail=detail,
            )
            return ok, detail
        last_error = "未发现 WebHTV 本机服务"
        state = reload_state if isinstance(reload_state, dict) else {}
        ports = []
        if "last_app_port" in state:
            last_app_port = int(state.get("last_app_port") or 0)
        else:
            with self.lock:
                last_app_port = int(self.last_app_port or 0)
        if last_app_port:
            ports.append(last_app_port)
        ports.extend(port for port in self.app_server_ports if port not in ports)
        self._trace_log(
            trace_id,
            "app_reload",
            "webhtv.begin",
            ports=ports,
            expected_site_count=(
                len(expected_keys) if expected_keys is not None else None
            ),
            generated_config=self._safe_log_url(
                self._standard_config_reference()
            ),
        )
        for port in ports:
            base = "http://127.0.0.1:{}".format(port)
            port_started = time.monotonic()
            self._trace_log(
                trace_id,
                "app_reload",
                "port.begin",
                port=port,
            )
            try:
                payload = self._request_json(
                    base + "/manage/configs", self.APP_REQUEST_TIMEOUT
                )
                items = payload.get("items", []) if isinstance(payload, dict) else []
                current = next(
                    (
                        item
                        for item in items
                        if isinstance(item, dict)
                        and int(item.get("type", -1)) == 0
                        and bool(item.get("active", False))
                    ),
                    None,
                )
                if not current or not str(current.get("url", "")).strip():
                    last_error = "WebHTV 未返回当前点播接口"
                    self._trace_log(
                        trace_id,
                        "app_reload",
                        "port.rejected",
                        level="WARN",
                        port=port,
                        config_count=len(items),
                        reason=last_error,
                    )
                    continue
                # v6.2：普通站点改由标准点播配置承载，因此这里切换到本脚本
                # 生成的配置文件；未能生成时退回重载原接口，保证 WebHome
                # 注入仍能刷新。
                target_url = str(current["url"]).strip()
                generated_target = self._standard_config_reference()
                self._trace_log(
                    trace_id,
                    "app_reload",
                    "config.detected",
                    port=port,
                    config_count=len(items),
                    current_url=self._safe_log_url(target_url),
                    generated_url=self._safe_log_url(generated_target),
                )
                if generated_target:
                    generated_registered = any(
                        isinstance(item, dict)
                        and int(item.get("type", -1)) == 0
                        and self._live_config_reference_matches(
                            item.get("url", ""), generated_target
                        )
                        for item in items
                    )
                    if not generated_registered:
                        self._trace_log(
                            trace_id,
                            "app_reload",
                            "config.register.begin",
                            port=port,
                            target_url=self._safe_log_url(generated_target),
                        )
                        register_query = urllib.parse.urlencode(
                            {
                                "type": 0,
                                "url": generated_target,
                                "name": "本地影仓点播",
                            }
                        )
                        self._request_json(
                            base + "/manage/configs?" + register_query,
                            max(1.5, self.APP_REQUEST_TIMEOUT * 4),
                        )
                        self._trace_log(
                            trace_id,
                            "app_reload",
                            "config.register.complete",
                            port=port,
                        )
                    target_url = generated_target
                # 只重载当前点播配置，不再向 /manage/csp 回写
                # registry。/manage/csp 会用 Java 格式重写同一文件，
                # 使扫描快照 token 变化，新 Spider 会误判为无快照并
                # 再次自动补扫，形成“重载 -> 补扫 -> 重载”循环。
                query = urllib.parse.urlencode(
                    {"type": 0, "url": target_url}
                )
                self._trace_log(
                    trace_id,
                    "app_reload",
                    "config.use.begin",
                    port=port,
                    target_url=self._safe_log_url(target_url),
                )
                self._request_json(
                    base + "/manage/config/use?" + query,
                    max(1.5, self.APP_REQUEST_TIMEOUT * 4),
                )
                self._trace_log(
                    trace_id,
                    "app_reload",
                    "config.use.complete",
                    port=port,
                )
                runtime_ok, runtime_detail = self._verify_webhtv_site_keys(
                    base, expected_keys
                )
                self._trace_log(
                    trace_id,
                    "app_reload",
                    "runtime.verify.complete",
                    level="INFO" if runtime_ok else "WARN",
                    port=port,
                    ok=bool(runtime_ok),
                    detail=runtime_detail,
                )
                if not runtime_ok:
                    last_error = runtime_detail
                    # 已成功读取配置并完成切换，当前端口就是 WebHTV 管理端口。
                    # 继续枚举只会让连接拒绝覆盖真正的运行态错误。
                    self._remember_app_port(port)
                    self._trace_log(
                        trace_id,
                        "app_reload",
                        "webhtv.failed",
                        level="WARN",
                        attempted_ports=[port],
                        error=last_error,
                    )
                    return False, "App 主动重载失败：{}；重启 App 后生效".format(
                        last_error
                    )
                try:
                    refreshed = self._request_json(
                        base + "/manage/configs",
                        max(1.2, self.APP_REQUEST_TIMEOUT * 3),
                    )
                    refreshed_items = (
                        refreshed.get("items", [])
                        if isinstance(refreshed, dict)
                        else []
                    )
                    if isinstance(refreshed_items, list):
                        items = refreshed_items
                except Exception:
                    pass
                live_ok, live_detail = self._reload_webhtv_live_config(
                    base, items, state
                )
                if not live_ok:
                    self._warn(live_detail)
                self._remember_app_port(port)
                self._trace_log(
                    trace_id,
                    "app_reload",
                    "port.success",
                    port=port,
                    live_ok=bool(live_ok),
                    live_detail=live_detail,
                    elapsed_ms=int((time.monotonic() - port_started) * 1000),
                )
                return True, "WebHTV 站点列表已主动重载，已触发页面刷新；{}".format(
                    live_detail
                )
            except Exception as exc:
                last_error = str(exc)
                self._trace_log(
                    trace_id,
                    "app_reload",
                    "port.failed",
                    level="WARN",
                    port=port,
                    elapsed_ms=int((time.monotonic() - port_started) * 1000),
                    error=str(exc),
                )
        if last_error:
            self._warn("WebHTV 本机管理接口未确认: {}".format(last_error))
        self._trace_log(
            trace_id,
            "app_reload",
            "webhtv.failed",
            level="WARN",
            attempted_ports=ports,
            error=last_error,
        )
        return False, "App 主动重载失败：{}；配置已写入，重启 App 后生效".format(
            last_error
        )

    def _verify_webhtv_site_keys(self, base, expected_keys):
        trace_id = self._active_operation_trace_id()
        if expected_keys is None:
            time.sleep(0.25)
            self._trace_log(
                trace_id,
                "app_reload",
                "runtime.verify.skipped",
                reason="expected keys not provided",
            )
            return True, "未要求校验运行态站点"
        expected = {
            str(key).strip() for key in expected_keys if str(key).strip()
        }
        self._trace_log(
            trace_id,
            "app_reload",
            "runtime.verify.begin",
            base=self._safe_log_url(base),
            expected_count=len(expected),
        )
        deadline = time.time() + max(5.0, self.APP_REQUEST_TIMEOUT * 12)
        last_error = "WebHTV 运行态站点尚未刷新"
        attempts = 0
        actual_count = 0
        missing_count = len(expected)
        stale_count = 0
        missing = set(expected)
        stale_generated = set()
        while time.time() < deadline:
            attempts += 1
            try:
                payload = self._request_json(
                    base + "/manage/proxy/suggest/sites",
                    max(0.6, self.APP_REQUEST_TIMEOUT * 2),
                )
                raw_sites = (
                    payload.get("sites", [])
                    if isinstance(payload, dict)
                    else []
                )
                actual = {
                    str(item.get("key", "")).strip()
                    for item in raw_sites
                    if isinstance(item, dict)
                    and str(item.get("key", "")).strip()
                }
                missing = expected - actual
                stale_generated = {
                    key
                    for key in actual
                    if key.startswith(self.GENERATED_KEY_PREFIX)
                    and key not in expected
                }
                actual_count = len(actual)
                missing_count = len(missing)
                stale_count = len(stale_generated)
                if not missing and not stale_generated:
                    self._trace_log(
                        trace_id,
                        "app_reload",
                        "runtime.verify.matched",
                        attempts=attempts,
                        expected_count=len(expected),
                        actual_count=actual_count,
                    )
                    return True, "WebHTV 运行态站点已核对"
                details = []
                if missing:
                    details.append(
                        "缺少 {} 个生成站点".format(len(missing))
                    )
                if stale_generated:
                    details.append(
                        "残留 {} 个旧生成站点".format(
                            len(stale_generated)
                        )
                    )
                last_error = "WebHTV 运行态{}".format("、".join(details))
            except Exception as exc:
                last_error = "WebHTV 运行态回读失败: {}".format(exc)
            time.sleep(0.15)
        self._trace_log(
            trace_id,
            "app_reload",
            "runtime.verify.failed",
            level="WARN",
            attempts=attempts,
            expected_count=len(expected),
            actual_count=actual_count,
            missing_count=missing_count,
            stale_generated_count=stale_count,
            missing_keys=sorted(missing)[:12],
            stale_generated_keys=sorted(stale_generated)[:12],
            error=last_error,
        )
        return False, last_error

    def _live_config_reference_matches(self, left, right):
        left_value = str(left or "").strip()
        right_value = str(right or "").strip()
        if not left_value or not right_value:
            return False
        left_path = self._reference_path(left_value)
        right_path = self._reference_path(right_value)
        if left_path and right_path:
            return left_path == right_path
        return left_value.rstrip("/") == right_value.rstrip("/")

    def _live_config_lives(self, reference, name="原直播"):
        value = str(reference or "").strip()
        if not value:
            return []
        path = self._reference_path(value)
        try:
            if path:
                if not os.path.isfile(path):
                    raise ValueError("本地直播配置不存在: {}".format(path))
                try:
                    data = self._load_json_compatible(path)
                except Exception:
                    return [{"name": str(name or "原直播"), "type": 0, "url": value}]
            else:
                with self._package_open_url(
                    value, timeout=8, accept="application/json,text/plain,*/*"
                ) as response:
                    payload = self._package_read_limited(
                        response, self.MAX_PACKAGE_CONFIG_SIZE
                    )
                try:
                    data = self._package_parse_json_text(
                        payload.decode("utf-8-sig")
                    )
                except Exception:
                    return [{"name": str(name or "原直播"), "type": 0, "url": value}]
        except Exception as exc:
            raise ValueError("原活动直播配置读取失败: {}".format(exc))
        if not isinstance(data, dict):
            raise ValueError("原活动直播配置格式无效")
        lives = data.get("lives", [])
        if isinstance(lives, dict):
            lives = [lives]
        if not isinstance(lives, list):
            raise ValueError("原活动直播配置的 lives 格式无效")
        return [copy.deepcopy(item) for item in lives if isinstance(item, dict)]

    def _existing_webhtv_base_lives(self):
        path = os.path.abspath(os.path.expanduser(self.webhtv_live_config_path))
        if not os.path.isfile(path):
            return []
        try:
            data = self._load_json_compatible(path)
        except Exception as exc:
            self._warn("旧本地直播聚合配置读取失败: {}".format(exc))
            return []
        marker = data.get(self.OK_CONFIG_MARKER, {}) if isinstance(data, dict) else {}
        base_fingerprints = {
            str(item).strip()
            for item in marker.get("baseLiveFingerprints", [])
            if str(item).strip()
        } if isinstance(marker, dict) else set()
        lives = data.get("lives", []) if isinstance(data, dict) else []
        if isinstance(lives, dict):
            lives = [lives]
        return [
            copy.deepcopy(item)
            for item in lives
            if isinstance(item, dict)
            and self._live_fingerprint(item) in base_fingerprints
        ]

    def _write_webhtv_live_config(
        self, base_lives=None, registry=None, generated_live_count=None
    ):
        lives = []
        seen = set()
        for live in base_lives if isinstance(base_lives, list) else []:
            if not isinstance(live, dict):
                continue
            item = copy.deepcopy(live)
            fingerprint = self._live_fingerprint(item)
            if not fingerprint or fingerprint in seen:
                continue
            seen.add(fingerprint)
            lives.append(item)
        base_fingerprints = set(seen)
        if not isinstance(registry, dict):
            try:
                registry = self._load_registry()
            except Exception:
                registry = {"items": []}
        for item in registry.get("items", []):
            if (
                not isinstance(item, dict)
                or not bool(item.get("enabled", True))
                or str(item.get("kind", "")).strip().lower() != "live"
            ):
                continue
            live = copy.deepcopy(self._registry_item_live(item))
            fingerprint = self._live_fingerprint(live)
            if not fingerprint or fingerprint in seen:
                continue
            seen.add(fingerprint)
            lives.append(self._live_with_proxy_policy(item, live))
        payload = {
            self.OK_CONFIG_MARKER: {
                "version": self.OK_CONFIG_VERSION,
                "loader": self.VERSION,
                "generatedLives": (
                    len(self.cache.get("lives", []))
                    if generated_live_count is None
                    else max(0, int(generated_live_count))
                ),
                "baseLives": len(base_fingerprints),
                "baseLiveFingerprints": sorted(base_fingerprints),
            },
            "lives": lives or [{}],
        }
        changed = True
        try:
            changed = self._load_json_compatible(
                self.webhtv_live_config_path
            ) != payload
        except Exception:
            changed = True
        if changed:
            self._atomic_write_plain_json(self.webhtv_live_config_path, payload)
        return self._file_url(self.webhtv_live_config_path), len(lives), changed

    def _reload_webhtv_live_config(self, base, config_items, reload_state=None):
        try:
            state = reload_state if isinstance(reload_state, dict) else {}
            if "generated_live_count" in state:
                generated_live_count = max(
                    0, int(state.get("generated_live_count") or 0)
                )
            else:
                with self.lock:
                    generated_live_count = len(self.cache.get("lives", []))
            registry = state.get("registry")
            generated_target = self._file_url(self.webhtv_live_config_path)
            active = next(
                (
                    item
                    for item in config_items
                    if isinstance(item, dict)
                    and int(item.get("type", -1)) == 1
                    and bool(item.get("active", False))
                    and str(item.get("url", "")).strip()
                ),
                None,
            )
            active_target = str(active.get("url", "")).strip() if active else ""
            active_is_generated = self._live_config_reference_matches(
                active_target, generated_target
            )
            if active_is_generated:
                base_lives = self._existing_webhtv_base_lives()
            elif active_target and generated_live_count:
                base_lives = self._live_config_lives(
                    active_target,
                    str(active.get("name", "原直播")),
                )
            else:
                base_lives = []
            generated_target, total_lives, content_changed = (
                self._write_webhtv_live_config(
                    base_lives=base_lives,
                    registry=registry,
                    generated_live_count=generated_live_count,
                )
            )
            should_activate = bool(generated_live_count) or active_is_generated
            if not should_activate:
                return True, "没有待集成直播"
            if active_is_generated and not content_changed:
                return True, "直播聚合配置未变化，无需重新加载"
            registered = any(
                isinstance(item, dict)
                and int(item.get("type", -1)) == 1
                and self._live_config_reference_matches(
                    item.get("url", ""), generated_target
                )
                for item in config_items
            )
            if not registered:
                register_query = urllib.parse.urlencode(
                    {
                        "type": 1,
                        "url": generated_target,
                        "name": "本地影仓直播",
                    }
                )
                self._request_json(
                    base + "/manage/configs?" + register_query,
                    max(1.5, self.APP_REQUEST_TIMEOUT * 4),
                )
            use_query = urllib.parse.urlencode(
                {"type": 1, "url": generated_target}
            )
            self._request_json(
                base + "/manage/config/use?" + use_query,
                max(1.5, self.APP_REQUEST_TIMEOUT * 4),
            )
            return True, "直播聚合配置已重载（本地包 {} 项，合计 {} 项）".format(
                generated_live_count, total_lives
            )
        except Exception as exc:
            return False, "直播配置重载失败: {}".format(exc)

    def _remember_app_port(self, port):
        with self.lock:
            if self.last_app_port == int(port):
                return
            self.last_app_port = int(port)
            try:
                self._save_settings()
            except Exception as exc:
                self._warn("App 端口缓存保存失败: {}".format(exc))

    def _generated_registry_keys(self, registry=None):
        """本轮应出现在 App 运行态的自动生成站点 key。

        v6.2 统一加载模型下，普通站点走标准点播配置、WebHome 走注入通道，
        两者都应能被 App 回读到，因此以扫描缓存的未屏蔽站点为准；注入表
        只是其中 WebHome 部分的落盘形式，不再是完整集合。
        OK影视不加载 WebHome，回读集合里也不能包含它，否则会把正常重载
        判定为“缺少生成站点”。
        """
        skip_webhome = self.app_mode != self.APP_MODE_WEBHTV
        keys = set()
        try:
            registry_data = (
                registry if isinstance(registry, dict) else self._load_registry()
            )
        except Exception:
            registry_data = None
        if isinstance(registry_data, dict):
            for site in self._ok_registry_sites(registry_data):
                marker = site.get(self.OK_CONFIG_MARKER)
                runtime_key = str(site.get("key", "")).strip()
                if isinstance(marker, dict) and runtime_key:
                    keys.add(runtime_key)
            if not skip_webhome:
                for item in registry_data.get("items", []):
                    if (
                        not isinstance(item, dict)
                        or not self._is_generated_registry_item(item)
                        or item.get("enabled", True) is False
                        or not self._is_webhome_registry_item(item)
                    ):
                        continue
                    site = self._registry_item_site(item)
                    runtime_key = (
                        str(site.get("key", "")).strip()
                        if isinstance(site, dict)
                        else ""
                    )
                    if runtime_key:
                        keys.add(runtime_key)
            if keys:
                return keys

        used_keys = set()
        for source in self.cache.get("sources", []):
            site = source.get("site") or {}
            if not isinstance(site, dict):
                site = {}
            if skip_webhome and (
                str(source.get("type", "")).upper() == "HTML"
                or str(site.get("homePage", "")).strip()
            ):
                continue
            # 整包的 source.key 是本地管理标识，运行时保留原始 site.key。
            runtime_key = str(site.get("key", "")).strip() or str(
                source.get("key", "")
            ).strip()
            if runtime_key:
                unique_key = runtime_key
                index = 2
                while unique_key in used_keys:
                    unique_key = "{}_{}".format(runtime_key, index)
                    index += 1
                used_keys.add(unique_key)
                keys.add(unique_key)
        if keys:
            return keys
        registry_data = registry_data or {"items": []}
        for item in registry_data.get("items", []):
            if (
                not isinstance(item, dict)
                or not self._is_generated_registry_item(item)
                or item.get("enabled", True) is False
            ):
                continue
            site = self._registry_item_site(item)
            if not isinstance(site, dict):
                continue
            if skip_webhome and self._is_webhome_registry_item(item):
                continue
            runtime_key = str(site.get("key", "")).strip()
            if runtime_key:
                keys.add(runtime_key)
        return keys

    def _request_json(self, url, timeout):
        headers = {"Accept": "application/json", "Connection": "close"}
        request = urllib.request.Request(
            url,
            headers=headers,
        )
        opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
        with opener.open(request, timeout=timeout) as response:
            status = getattr(response, "status", response.getcode())
            raw = response.read()
        if int(status) < 200 or int(status) >= 300:
            raise ValueError("HTTP {}".format(status))
        data = json.loads(raw.decode("utf-8"))
        if not isinstance(data, dict):
            raise ValueError("WebHTV 本机接口返回格式无效")
        return data

    def _request_status(self, url, timeout):
        request = urllib.request.Request(
            url,
            headers={"Accept": "*/*", "Connection": "close"},
        )
        opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
        with opener.open(request, timeout=timeout) as response:
            status = int(getattr(response, "status", response.getcode()))
            response.read(1024)
        if status < 200 or status >= 300:
            raise ValueError("HTTP {}".format(status))
        return status

    def _notify_app(self, message, wait=False, replace=False):
        text = " ".join(str(message or "").split()).strip()
        if not text or self._destroyed:
            return False
        if not self._notification_gate.acquire(False):
            return False
        gate_handed_to_runner = False
        try:
            from java import dynamic_proxy, jclass

            toast_class = jclass("android.widget.Toast")
            runnable_class = jclass("java.lang.Runnable")
            handler_class = jclass("android.os.Handler")
            looper_class = jclass("android.os.Looper")
            activity, context = self._android_ui_context(jclass)
            if context is None:
                return False
            handler = handler_class(looper_class.getMainLooper())
            owner = self
            displayed = threading.Event()

            class ShowNotification(dynamic_proxy(runnable_class)):
                def run(self):
                    try:
                        try:
                            toast = (
                                owner._site_test_toast
                                if replace
                                else None
                            )
                            if toast is None:
                                toast = toast_class.makeText(
                                    context,
                                    text[:120],
                                    toast_class.LENGTH_LONG,
                                )
                                if replace:
                                    owner._site_test_toast = toast
                            else:
                                toast.setText(text[:120])
                            toast.show()
                        except Exception as exc:
                            owner._log(
                                "WARN", "站点通知显示失败: {}".format(exc)
                            )
                    finally:
                        try:
                            owner._notification_refs.remove(self)
                        except (ValueError, AttributeError):
                            pass
                        displayed.set()
                        try:
                            owner._notification_gate.release()
                        except RuntimeError:
                            pass

            runner = ShowNotification()
            self._notification_refs.append(runner)
            try:
                if activity is not None:
                    activity.runOnUiThread(runner)
                    queued = True
                else:
                    queued = handler.post(runner)
            except Exception:
                self._notification_refs.remove(runner)
                raise
            # 部分 Chaquopy 版本会把 Java void/boolean 返回值映射为 None。
            # None 表示调用已发出；只有明确的 false 才视为入队失败。
            if queued is not None and not bool(queued):
                self._notification_refs.remove(runner)
                return False
            gate_handed_to_runner = True
            if wait and not displayed.wait(1.5):
                self._log("WARN", "站点通知等待 UI 显示超时: {}".format(text))
                return False
            return True
        except Exception as exc:
            try:
                self._log("WARN", "站点通知调度失败: {}".format(exc))
            except Exception:
                pass
            return False
        finally:
            if not gate_handed_to_runner:
                try:
                    self._notification_gate.release()
                except RuntimeError:
                    pass

    def _show_author_scan_surprise(self):
        if not self.cache["sources"]:
            return False
        first_scan = not self._author_scan_surprise_shown
        added_sites = max(0, int(self.status.get("added_sites", 0) or 0))
        if first_scan:
            message = "笔落惊风雨，霞飞映晚枫。已点亮 {} 个站点。".format(
                len(self.cache["sources"])
            )
        elif added_sites:
            message = "风过江面，晚枫又点亮 {} 个新站点。".format(
                added_sites
            )
        else:
            return False
        if not self._notify_app(message):
            return False
        if first_scan:
            self._author_scan_surprise_shown = True
            try:
                self._save_settings()
            except Exception as exc:
                self._warn("作者彩蛋状态保存失败: {}".format(exc))
        self._log(
            "INFO",
            "{}扫描彩蛋已显示: {}".format(
                "首次手动" if first_scan else "新增站点", message
            ),
        )
        return True

    def _site_test_cancelled(self, source=None):
        if self._site_test_cancel.is_set():
            return True
        if isinstance(source, dict):
            batch_cancel = source.get("_health_batch_cancel")
            if batch_cancel is not None:
                try:
                    return bool(batch_cancel.is_set())
                except Exception:
                    return False
        return False

    def _site_test_executor_drain_worker(self, executor):
        current = threading.current_thread()
        try:
            executor.shutdown(wait=True)
        finally:
            with self._site_test_control_lock:
                if self._site_test_drain_thread is current:
                    self._site_test_drain_thread = None
                worker = self._site_test_thread
            if worker is None or not worker.is_alive():
                self._release_background_operation("site_test")
            self._log("INFO", "站点检测超时任务已全部退出，可开始下一批")

    def _shutdown_site_test_executor(self, executor, futures):
        if executor is None:
            return False
        for future in futures:
            future.cancel()
        draining = any(not future.done() for future in futures)
        if draining:
            remaining = sum(1 for future in futures if not future.done())
            self._log(
                "INFO",
                "站点检测截止后等待 {} 个在途任务协作退出，期间不启动下一批".format(
                    remaining
                ),
            )
            worker = threading.Thread(
                target=self._site_test_executor_drain_worker,
                args=(executor,),
                name="site-functional-test-drain",
            )
            worker.daemon = True
            with self._site_test_control_lock:
                self._site_test_drain_thread = worker
            try:
                worker.start()
                return True
            except Exception:
                with self._site_test_control_lock:
                    if self._site_test_drain_thread is worker:
                        self._site_test_drain_thread = None
        # Chaquopy 的旧版 Python 不支持 shutdown(cancel_futures=...)。
        # 待执行任务已在上方逐个取消，使用各版本均支持的参数即可。
        executor.shutdown(wait=False)
        return False

    @staticmethod
    def _site_test_percentile(values, percentile):
        ordered = sorted(max(0, int(value or 0)) for value in values)
        if not ordered:
            return 0
        index = int(math.ceil((len(ordered) - 1) * float(percentile)))
        return ordered[max(0, min(index, len(ordered) - 1))]

    def _test_sites_locked(
        self, force=False, source_identities=None, refresh_config_on_change=True
    ):
        active_sources = list(self.cache["sources"])
        ignored_sources = list(self.cache["ignored"])
        targeted = source_identities is not None
        if targeted:
            selected = {
                str(identity).strip()
                for identity in source_identities
                if str(identity).strip()
            }
            active_sources = [
                source
                for source in active_sources
                if source.get("identity") in selected
            ]
            ignored_sources = [
                source
                for source in ignored_sources
                if source.get("identity") in selected
            ]
        all_sources = ignored_sources + active_sources
        source_by_identity = {
            source["identity"]: source for source in all_sources
        }
        if not all_sources:
            return {
                "tested": 0,
                "cached": 0,
                "available": 0,
                "unavailable": 0,
                "limited": 0,
                "blocked": 0,
                "restored": 0,
                "retried": 0,
                "remaining": 0,
                "retest": False,
                "concurrency": int(self.site_test_concurrency),
                "batch_limit": int(self.site_test_batch_limit),
                "notification_count": 0,
                "proxy_changed": 0,
            }
        previous_ignored = set(self.ignored_sources)
        previous_manual_ignored = set(self.manual_ignored_sources)
        previous_auto_blocked = set(self.auto_blocked_sources)
        previous_results = dict(self.site_test_results)
        previous_playback_proxy_sources = copy.deepcopy(
            self.playback_proxy_sources
        )
        previous_cache = copy.deepcopy(self.cache)
        previous_status = self.status
        previous_retest_pending = list(self._retest_pending)
        previous_retest_auto_blocked = set(self._retest_auto_blocked)
        retest = bool(self._retest_pending) and not targeted
        if targeted:
            pending_all = list(all_sources) if force else [
                source
                for source in active_sources
                if not self._has_fresh_test_result(source)
            ]
            pending_count = len(pending_all)
            pending = pending_all
            cached_count = max(0, len(all_sources) - pending_count)
        elif force and not retest:
            self._retest_pending = [
                source["identity"] for source in all_sources
            ]
            self._retest_auto_blocked = set(self.auto_blocked_sources)
            for source in all_sources:
                source["test_result"] = {}
            retest = True
        if targeted:
            pass
        elif retest:
            self._retest_pending = [
                identity
                for identity in self._retest_pending
                if identity in source_by_identity
            ]
            pending_identities = list(self._retest_pending)
            pending = [
                source_by_identity[identity] for identity in pending_identities
            ]
            pending_count = len(self._retest_pending)
            cached_count = max(0, len(all_sources) - pending_count)
        else:
            pending_all = [
                source
                for source in active_sources
                if not self._has_fresh_test_result(source)
            ]
            pending = pending_all
            pending_count = len(pending_all)
            cached_count = len(active_sources) - len(pending_all)
        batch_limit = max(1, int(self.site_test_batch_limit or 300))
        pending = list(pending[:batch_limit])
        counts = {"available": 0, "unavailable": 0, "limited": 0}
        blocked = 0
        restored = 0
        retried = 0
        total = len(pending)
        self._log(
            "INFO",
            "开始站点检测: 模式={} 待请求={} 缓存命中={} 总站点={} 线程={} 单批上限={} 反代={} 地址数={}".format(
                "全部复检" if retest else "增量检测",
                total,
                cached_count,
                len(all_sources),
                self.site_test_concurrency,
                batch_limit,
                "开" if self.playback_proxy_enabled else "关",
                len(self.playback_proxy_urls),
            ),
        )
        self._site_test_toast = None
        executor = None
        future_sources = {}
        batch_cancel = None
        batch_started = time.monotonic()
        try:
            completed_count = 0
            notification_count = 0
            last_notification_count = 0
            last_notification_at = batch_started
            measured_results = []
            retry_skipped = {}
            proxy_changed = 0

            def record_result(source, result):
                nonlocal blocked, restored, retried, completed_count
                nonlocal notification_count, last_notification_count
                nonlocal last_notification_at, proxy_changed
                completed_count += 1
                retried += max(0, int(result.get("attempts", 1) or 1) - 1)
                result = self._finalize_test_result(source, result)
                result["source_signature"] = self._source_signature(source)
                state = result["state"]
                counts[state] += 1
                self.site_test_results[source["identity"]] = result
                if self._apply_automatic_playback_proxy_result(source, result):
                    proxy_changed += 1
                self._append_site_test_history(source["identity"], result)
                source["test_result"] = result
                measured_results.append((source, result))
                skipped_kind = str(result.get("retry_skipped_kind", "")).strip()
                if skipped_kind:
                    retry_skipped[skipped_kind] = retry_skipped.get(skipped_kind, 0) + 1
                state_label = self._test_result_label(result)
                source_name = " ".join(
                    str(source.get("name", "未命名站点")).split()
                )[:60]
                self._log(
                    "INFO",
                    "站点检测 [{}/{}] {}: {} | {} | {}".format(
                        completed_count,
                        total,
                        source_name or "未命名站点",
                        state_label,
                        result.get("detail", ""),
                        source.get("path", source.get("identity", "")),
                    ),
                )
                now = time.monotonic()
                notify_due = (
                    completed_count == total
                    or completed_count - last_notification_count
                    >= self.SITE_TEST_NOTIFY_COMPLETIONS
                    or now - last_notification_at >= self.SITE_TEST_NOTIFY_INTERVAL
                )
                if notify_due:
                    self._notify_app(
                        "[{}/{}] {}：{}".format(
                            completed_count,
                            total,
                            source_name or "未命名站点",
                            state_label,
                        ),
                        wait=False,
                        replace=True,
                    )
                    notification_count += 1
                    last_notification_count = completed_count
                    last_notification_at = now
                if (
                    self._should_auto_block_test_result(result)
                    and source["identity"] not in self.auto_blocked_sources
                ):
                    # 只有同一任务内连续两次 unavailable 才会确认并屏蔽。
                    was_ignored = source["identity"] in self.ignored_sources
                    self.auto_blocked_sources.add(source["identity"])
                    self._sync_ignored_sources()
                    if not was_ignored:
                        blocked += 1
                elif (
                    state in ("available", "limited")
                    and source["identity"] in self.auto_blocked_sources
                ):
                    # 受限本身就表示不能确认失效，也必须解除历史自动屏蔽；
                    # 否则结果显示「受限保留」却仍留在屏蔽分类里。
                    self.auto_blocked_sources.discard(source["identity"])
                    self._sync_ignored_sources()
                    self._retest_auto_blocked.discard(source["identity"])
                    restored += 1

            self._site_test_sample_nonce = time.time_ns()
            workers = max(1, min(int(self.site_test_concurrency), total or 1))
            batch_cancel = threading.Event()
            executor = concurrent.futures.ThreadPoolExecutor(
                max_workers=workers,
                thread_name_prefix="site-functional-test",
            )
            future_sources = {}
            for source in pending:
                candidate = dict(source)
                candidate["_health_batch_cancel"] = batch_cancel
                future = executor.submit(
                    self._test_source_availability_with_retry, candidate
                )
                future_sources[future] = source
            waiting = set(future_sources)
            proxy_attempt_budget = (
                min(3, len(self.playback_proxy_urls))
                if self.playback_proxy_enabled
                else 0
            )
            stall_timeout = max(
                self.SITE_FUNCTION_TEST_TIMEOUT,
                self.SITE_FUNCTION_TEST_TIMEOUT
                * (self.SITE_TEST_FAILURE_CONFIRM_COUNT + proxy_attempt_budget),
            )
            deadline = time.monotonic() + stall_timeout
            while waiting and time.monotonic() < deadline:
                if self._site_test_cancel.is_set():
                    raise SiteTestCancelled("站点检测已取消")
                done, waiting = concurrent.futures.wait(
                    waiting,
                    timeout=min(0.5, max(0.0, deadline - time.monotonic())),
                    return_when=concurrent.futures.FIRST_COMPLETED,
                )
                if done:
                    deadline = time.monotonic() + stall_timeout
                for future in done:
                    source = future_sources[future]
                    try:
                        result = future.result()
                    except Exception as exc:
                        result = self._test_result(
                            "limited",
                            "App 真实 Spider 检测任务异常，无法据此判定站点失效: {}".format(
                                str(exc)[:120]
                            ),
                            time.strftime("%Y-%m-%d %H:%M:%S"),
                            failure_kind="detector",
                            test_stage="functional",
                        )
                    record_result(source, result)
            if waiting:
                batch_cancel.set()
            for future in waiting:
                future.cancel()
                timeout_result = self._test_result(
                    "limited",
                    "App 真实 Spider 超过批次等待时间，已停止等待并保留站点",
                    time.strftime("%Y-%m-%d %H:%M:%S"),
                    failure_kind="batch_timeout",
                    test_stage="functional",
                )
                record_result(
                    future_sources[future],
                    timeout_result,
                )
            self._shutdown_site_test_executor(executor, future_sources)
            executor = None
            if targeted:
                remaining = max(0, pending_count - len(pending))
                processed = {source["identity"] for source in pending}
                queued = [
                    identity
                    for identity in self._retest_pending
                    if identity not in processed
                ]
                queued_seen = set(queued)
                for source in pending_all[len(pending) :]:
                    identity = source["identity"]
                    if identity not in queued_seen:
                        queued.append(identity)
                        queued_seen.add(identity)
                self._retest_pending = queued
            elif retest:
                processed = {source["identity"] for source in pending}
                self._retest_pending = [
                    identity
                    for identity in self._retest_pending
                    if identity not in processed
                ]
                remaining = len(self._retest_pending)
                if not remaining:
                    self._retest_auto_blocked.clear()
            else:
                remaining = max(0, pending_count - len(pending))
            pruned = self._prune_blocked_sources_locked()
            self._save_settings()
            if (blocked or restored or pruned or proxy_changed) and refresh_config_on_change:
                if not self._refresh_locked(allow_empty=True):
                    raise ValueError(self.status["error"] or self.status["write_state"])
            summary = {
                "tested": len(pending),
                "cached": cached_count,
                "available": counts["available"],
                "unavailable": counts["unavailable"],
                "limited": counts["limited"],
                "blocked": blocked,
                "restored": restored,
                "retried": retried,
                "pruned": pruned,
                "remaining": remaining,
                "retest": bool(force or retest),
                "concurrency": workers,
                "batch_limit": batch_limit,
                "notification_count": notification_count,
                "proxy_changed": proxy_changed,
            }
            elapsed_values = [
                int(result.get("elapsed_ms", 0) or 0)
                for _source, result in measured_results
            ]
            summary.update(
                {
                    "elapsed_ms": max(
                        0, int((time.monotonic() - batch_started) * 1000)
                    ),
                    "p50_ms": self._site_test_percentile(elapsed_values, 0.50),
                    "p90_ms": self._site_test_percentile(elapsed_values, 0.90),
                    "p95_ms": self._site_test_percentile(elapsed_values, 0.95),
                    "max_ms": max(elapsed_values or [0]),
                    "retry_skipped": dict(retry_skipped),
                }
            )
            self._log(
                "INFO",
                "站点检测完成: 请求={tested} 即时复测={retried} 可用={available} 功能或结构失效={unavailable} "
                "受限={limited} 新增失效屏蔽={blocked} 恢复={restored} 剩余={remaining}".format(
                    **summary
                ),
            )
            slowest = sorted(
                measured_results,
                key=lambda item: int(item[1].get("elapsed_ms", 0) or 0),
                reverse=True,
            )[:10]
            slowest_text = "；".join(
                "{}|{}|{}|{}ms|复测{}".format(
                    " ".join(str(source.get("name", "未命名站点")).split())[:40],
                    str(source.get("type", ""))[:10],
                    str(result.get("test_stage", "-"))[:20],
                    int(result.get("elapsed_ms", 0) or 0),
                    max(0, int(result.get("attempts", 1) or 1) - 1),
                )
                for source, result in slowest
            ) or "-"
            self._log(
                "INFO",
                "站点检测性能: 线程={concurrency} 单批={batch_limit} 总耗时={elapsed_ms}ms "
                "通知={notification_count} P50={p50_ms}ms P90={p90_ms}ms P95={p95_ms}ms "
                "MAX={max_ms}ms 跳过复测={retry_skipped} 最慢10={slowest}".format(
                    slowest=slowest_text, **summary
                ),
            )
            return summary
        except Exception as exc:
            if batch_cancel is not None:
                batch_cancel.set()
            if executor is not None:
                try:
                    self._shutdown_site_test_executor(executor, future_sources)
                except Exception:
                    pass
            self._log(
                "INFO" if isinstance(exc, SiteTestCancelled) else "ERROR",
                "站点检测批次{}: {}".format(
                    "已取消" if isinstance(exc, SiteTestCancelled) else "失败",
                    exc,
                ),
            )
            self.ignored_sources = previous_ignored
            self.manual_ignored_sources = previous_manual_ignored
            self.auto_blocked_sources = previous_auto_blocked
            self.site_test_results = previous_results
            self.playback_proxy_sources = previous_playback_proxy_sources
            self.cache = previous_cache
            self.status = previous_status
            self._retest_pending = previous_retest_pending
            self._retest_auto_blocked = previous_retest_auto_blocked
            try:
                self._save_settings()
            except Exception:
                pass
            raise

    def _site_test_summary_text(self, summary):
        values = dict(summary)
        values.setdefault("retried", 0)
        text = (
            "站点检测完成：请求 {tested}，即时复测 {retried}，可用 {available}，"
            "功能或结构失效 {unavailable}，受限 {limited}，"
            "新增屏蔽 {blocked}，恢复 {restored}，剩余 {remaining}"
        ).format(**values)
        if int(values.get("remaining", 0) or 0) > 0:
            text += "；本批上限 {}，再次点击测活继续下一批".format(
                int(values.get("batch_limit", self.site_test_batch_limit))
            )
        return text

    def _execute_site_test(self, force=False, source_identities=None):
        """Shared completion path for manual and download-triggered health checks."""
        summary = self._test_sites_locked(
            force=force,
            source_identities=source_identities,
        )
        if summary["blocked"] or summary["restored"]:
            _, reload_detail = self._reload_app_vod_config(
                expected_keys=self._generated_registry_keys()
            )
        else:
            reload_detail = "屏蔽状态未变化，无需重载"
        self.inited = True
        summary_text = self._site_test_summary_text(summary)
        self._log("INFO", "{}；{}".format(summary_text, reload_detail))
        if not self._destroyed:
            self._notify_app(summary_text, wait=False, replace=True)
        return summary, summary_text

    def _run_site_test_worker(self, force):
        current = threading.current_thread()
        try:
            self._execute_site_test(force=force)
        except SiteTestCancelled:
            self._log("INFO", "站点检测后台任务已取消")
        except Exception as exc:
            message = "站点检测失败：{}".format(exc)
            self._log("ERROR", "站点检测后台任务失败: {}".format(exc))
            if not self._destroyed:
                self._notify_app(message)
        finally:
            with self._site_test_control_lock:
                if self._site_test_thread is current:
                    self._site_test_thread = None
                drain = self._site_test_drain_thread
            if drain is None or not drain.is_alive():
                self._release_background_operation("site_test")

    def _start_site_test_worker(self, force=False):
        with self.lock:
            with self._site_test_control_lock:
                worker = self._site_test_thread
                if worker is not None and worker.is_alive():
                    return False
                drain = self._site_test_drain_thread
                if drain is not None and drain.is_alive():
                    return False
                reserved, _active = self._reserve_background_operation("site_test")
                if not reserved:
                    return False
                try:
                    worker = threading.Thread(
                        target=self._run_site_test_worker,
                        args=(bool(force),),
                        name="webhtv-site-test",
                    )
                    worker.daemon = True
                    self._destroyed = False
                    self._background_completion_allowed.clear()
                    self._site_test_cancel.clear()
                    self._site_test_thread = worker
                    worker.start()
                    return True
                except Exception:
                    self._site_test_thread = None
                    self._release_background_operation("site_test")
                    raise

    def _site_test_is_running(self):
        with self._site_test_control_lock:
            worker = self._site_test_thread
            drain = self._site_test_drain_thread
            return bool(
                (worker is not None and worker.is_alive())
                or (drain is not None and drain.is_alive())
            )

    def _source_signature(self, source):
        size = source.get("size")
        modified_ns = source.get("mtime_ns")
        if size is None or modified_ns is None:
            try:
                stat = os.stat(source["path"])
                size = stat.st_size
                modified_ns = getattr(
                    stat, "st_mtime_ns", int(stat.st_mtime * 1000000000)
                )
            except Exception:
                return "missing"
        signature_data = {
            "version": self.SITE_TEST_CACHE_VERSION,
            "type": str(source.get("type", "")).upper(),
            "size": int(size),
            "mtime_ns": int(modified_ns),
        }
        if source.get("dependencies"):
            dependency_stats = []
            for path in source.get("dependencies", []):
                try:
                    stat = os.stat(path)
                    dependency_stats.append(
                        {
                            "path": self._file_url(path),
                            "size": int(stat.st_size),
                            "mtime_ns": int(
                                getattr(
                                    stat,
                                    "st_mtime_ns",
                                    int(stat.st_mtime * 1000000000),
                                )
                            ),
                        }
                    )
                except Exception:
                    dependency_stats.append(
                        {"path": self._file_url(path), "missing": True}
                    )
            signature_data["dependencies"] = dependency_stats
        proxy_values = {
            name: str(os.environ.get(name, ""))
            for name in (
                "HTTP_PROXY",
                "HTTPS_PROXY",
                "NO_PROXY",
                "http_proxy",
                "https_proxy",
                "no_proxy",
            )
            if os.environ.get(name)
        }
        signature_data["proxy"] = self._digest(
            json.dumps(proxy_values, sort_keys=True, separators=(",", ":")), 16
        )
        if signature_data["type"] == "XBPQ":
            signature_data["xbpq_api"] = self._runtime_reference(self.xbpq_api)
            signature_data["xbpq_jar"] = self._xbpq_jar_reference()
        raw = json.dumps(
            signature_data, ensure_ascii=True, sort_keys=True, separators=(",", ":")
        )
        return hashlib.sha256(raw.encode("utf-8")).hexdigest()

    def _has_fresh_test_result(self, source):
        result = self.site_test_results.get(source["identity"])
        if (
            not isinstance(result, dict)
            or result.get("state") not in ("available", "unavailable", "limited")
            or result.get("source_signature") != self._source_signature(source)
        ):
            return False
        try:
            checked_ts = float(result.get("checked_ts", 0) or 0)
        except Exception:
            checked_ts = 0
        if checked_ts <= 0:
            return False
        ttl = {
            "available": self.SITE_TEST_AVAILABLE_TTL,
            "unavailable": self.SITE_TEST_FAILURE_TTL,
            "limited": self.SITE_TEST_LIMITED_TTL,
        }[result["state"]]
        return time.time() - checked_ts < max(1, float(ttl))

    def _site_option_text(self, value):
        if value in (None, ""):
            return ""
        if isinstance(value, str):
            return value
        return json.dumps(
            value, ensure_ascii=False, separators=(",", ":")
        )

    def _spider_result_data(self, payload):
        if isinstance(payload, dict):
            data = payload
        else:
            text = str(payload or "").strip()
            if not text:
                return None
            data = json.loads(text)
        return data if isinstance(data, dict) else None

    def _spider_result_items(self, payload):
        data = self._spider_result_data(payload)
        if data is None:
            return None, []
        items = data.get("list", [])
        if isinstance(items, dict):
            items = [items]
        if not isinstance(items, list):
            items = []
        return data, [item for item in items if isinstance(item, dict)]

    def _spider_result_counts(self, payload):
        data, items = self._spider_result_items(payload)
        if data is None:
            return None
        types = data.get("class", [])
        if isinstance(types, dict):
            types = [types]
        return {
            "items": len(items),
            "item_values": items,
            "types": [
                item for item in types if isinstance(item, dict)
            ] if isinstance(types, list) else [],
            "message": str(data.get("msg", "") or "").strip(),
        }

    @staticmethod
    def _site_sample_index(length, sample_key="", sample_attempt=0):
        length = max(0, int(length or 0))
        if length <= 1:
            return 0
        try:
            attempt = max(0, int(sample_attempt or 0))
        except Exception:
            attempt = 0
        key = str(sample_key or "")
        if not key:
            return attempt % length
        base = int(
            hashlib.sha256(key.encode("utf-8")).hexdigest()[:12], 16
        ) % length
        # 轮转而非每次重新取随机数，保证即时复测不会再次命中同一项。
        return (base + attempt) % length

    @staticmethod
    def _is_spider_bridge_failure(detail):
        text = str(detail or "").casefold()
        return (
            "cannot create com.github.catvod.spider." in text
            and "proxy from" in text
            and " instance" in text
        )

    @staticmethod
    def _health_failure_is_limited(*values):
        text = " ".join(str(value or "") for value in values).casefold()
        if not text:
            return False
        markers = (
            "timeout",
            "timed out",
            "time out",
            "时间已用尽",
            "检测时间已用尽",
            "超过批次等待时间",
            "connectionerror",
            "connection error",
            "connection reset",
            "connection aborted",
            "connection refused",
            "remote disconnected",
            "network request failed",
            "网络请求失败",
            "temporary failure in name resolution",
            "name or service not known",
            "no address associated",
            "nodename nor servname",
            "getaddrinfo failed",
            "dns",
            "sslerror",
            "ssl error",
            "ssl: ",
            "tls",
            "handshake operation",
            "certificate verify failed",
            "unexpected eof",
            "eof occurred",
            "too many requests",
            "rate limit",
            "http 401",
            "http 403",
            "http 408",
            "http 425",
            "http 429",
        )
        if any(marker in text for marker in markers):
            return True
        return bool(re.search(r"\bhttp\s+5\d\d\b", text))

    def _source_requires_account_session(self, source, *extra_values):
        source = source if isinstance(source, dict) else {}
        values = [
            source.get("identity", ""),
            source.get("name", ""),
            source.get("base_name", ""),
            source.get("path", ""),
            source.get("relative_in_root", ""),
            source.get("package_label", ""),
        ]
        for field in ("site", "csp_site"):
            item = source.get(field, {})
            if isinstance(item, dict):
                values.extend(
                    item.get(key, "")
                    for key in ("key", "name", "api", "ext", "jar")
                )
        values.extend(extra_values)
        text = "\n".join(str(value or "")[:4000] for value in values)
        return bool(self.CLOUD_ACCOUNT_PATTERN.search(text))

    def _first_vod_candidate(self, items, sample_key="", sample_attempt=0):
        candidates = []
        for item in items or []:
            if not isinstance(item, dict):
                continue
            vod_id = item.get("vod_id", item.get("vodId", item.get("id", "")))
            vod_id = str(vod_id or "").strip()
            if not vod_id:
                continue
            candidates.append({
                "id": vod_id,
                "name": str(
                    item.get("vod_name", item.get("vodName", item.get("name", "")))
                    or ""
                ).strip()[:80],
            })
        if not candidates:
            return {}
        return candidates[
            self._site_sample_index(
                len(candidates), sample_key, sample_attempt
            )
        ]

    def _extract_play_candidates(
        self, payload, sample_key="", sample_attempt=0
    ):
        data, items = self._spider_result_items(payload)
        if data is None or not items:
            return []
        candidates = []
        for item in items:
            play_from = item.get("vod_play_from", item.get("vodPlayFrom", ""))
            play_url = item.get("vod_play_url", item.get("vodPlayUrl", ""))
            if isinstance(play_url, list):
                play_url = "$$$".join(str(value or "") for value in play_url)
            play_url = str(play_url or "").strip()
            if not play_url:
                continue
            flags = str(play_from or "").split("$$$")
            groups = play_url.split("$$$")
            for group_index, group in enumerate(groups):
                flag = flags[group_index].strip() if group_index < len(flags) else ""
                episodes = []
                for raw_episode in str(group or "").split("#"):
                    raw_episode = raw_episode.strip()
                    if not raw_episode:
                        continue
                    if "$" in raw_episode:
                        name, episode_id = raw_episode.split("$", 1)
                    else:
                        name, episode_id = raw_episode, raw_episode
                    episode_id = str(episode_id or "").strip()
                    if not episode_id:
                        continue
                    episodes.append({
                        "flag": flag,
                        "id": episode_id,
                        "name": str(name or "").strip()[:80],
                        "route_index": group_index,
                    })
                if episodes:
                    episode_key = (
                        "{}|route:{}|{}".format(
                            sample_key, group_index, flag
                        )
                        if sample_key
                        else ""
                    )
                    candidates.append(
                        episodes[
                            self._site_sample_index(
                                len(episodes), episode_key, sample_attempt
                            )
                        ]
                    )
            if candidates:
                break
        return candidates

    def _extract_play_candidate(self, payload):
        candidates = self._extract_play_candidates(payload)
        return candidates[0] if candidates else {}

    def _normalize_player_headers(self, value):
        if isinstance(value, str):
            text = value.strip()
            if text:
                try:
                    value = json.loads(text)
                except Exception:
                    value = {}
        if not isinstance(value, dict):
            return {}
        headers = {}
        for key, item in value.items():
            name = str(key or "").strip()
            content = str(item or "").strip()
            if (
                not name
                or not content
                or "\r" in name
                or "\n" in name
                or "\r" in content
                or "\n" in content
            ):
                continue
            headers[name[:120]] = content[:2000]
        return headers

    def _parse_player_payload(self, payload):
        data = self._spider_result_data(payload)
        if data is None:
            return {}
        url_value = data.get("url", "")
        if isinstance(url_value, list):
            url_value = next(
                (
                    item
                    for item in url_value
                    if isinstance(item, str) and item.strip()
                ),
                "",
            )
        url = str(url_value or "").strip()
        parse_value = data.get("parse", data.get("jx", 0))
        try:
            parse_mode = int(parse_value or 0)
        except Exception:
            parse_mode = 1 if self._as_bool(parse_value, False) else 0
        return {
            "url": url,
            "parse": parse_mode,
            "headers": self._normalize_player_headers(
                data.get("header", data.get("headers", {}))
            ),
            "message": str(data.get("msg", "") or "").strip()[:160],
        }

    def _remaining_probe_timeout(self, deadline):
        remaining = float(deadline) - time.monotonic()
        if remaining <= 0:
            raise TimeoutError("媒体检查时间已用尽")
        return max(0.25, min(float(self.MEDIA_PROBE_TIMEOUT), remaining))

    def _open_media_sample(
        self, url, headers, deadline, limit=None, use_range=True
    ):
        request_headers = self._normalize_player_headers(headers)
        request_headers.setdefault("User-Agent", "Mozilla/5.0 LocalMediaHub/5")
        request_headers.setdefault("Accept", "*/*")
        request_headers.setdefault("Accept-Encoding", "identity")
        if use_range:
            request_headers["Range"] = "bytes=0-{}".format(
                max(0, int((limit or self.MEDIA_SAMPLE_BYTES)) - 1)
            )
        else:
            request_headers.pop("Range", None)
        request = urllib.request.Request(url, headers=request_headers, method="GET")
        with urllib.request.urlopen(
            request, timeout=self._remaining_probe_timeout(deadline)
        ) as response:
            status = int(getattr(response, "status", response.getcode()))
            max_bytes = max(1, int(limit or self.MEDIA_SAMPLE_BYTES))
            body = response.read(max_bytes + 1)[:max_bytes]
            return {
                "status": status,
                "url": str(response.geturl() or url),
                "content_type": str(
                    response.headers.get("Content-Type", "") or ""
                ).split(";", 1)[0].strip().lower(),
                "content_length": str(
                    response.headers.get("Content-Length", "") or ""
                ).strip(),
                "content_range": str(
                    response.headers.get("Content-Range", "") or ""
                ).strip(),
                "body": body,
            }

    def _media_sample_format(self, body, content_type=""):
        data = bytes(body or b"")
        lower_type = str(content_type or "").lower()
        if self._is_hls_playlist_body(data):
            return "hls"
        if "dash+xml" in lower_type or (
            b"<MPD" in data[:8192] or b":MPD" in data[:8192]
        ):
            return "dash"
        if len(data) >= 12 and data[4:8] in (
            b"ftyp", b"styp", b"moof", b"sidx"
        ):
            return "mp4"
        if data.startswith(b"FLV"):
            return "flv"
        if data.startswith(b"OggS"):
            return "ogg"
        if data.startswith(b"fLaC"):
            return "flac"
        if data.startswith(b"RIFF") and data[8:12] == b"WAVE":
            return "wav"
        if data.startswith(b"\x1aE\xdf\xa3"):
            return "matroska"
        if data.startswith(b"ID3") or (
            len(data) >= 2 and data[0] == 0xFF and data[1] & 0xE0 == 0xE0
        ):
            return "audio"
        for offset in range(min(188, len(data))):
            if (
                len(data) > offset + 376
                and data[offset] == 0x47
                and data[offset + 188] == 0x47
                and data[offset + 376] == 0x47
            ):
                return "mpegts"
        if lower_type.startswith("video/"):
            return "video"
        if lower_type.startswith("audio/"):
            return "audio"
        return ""

    @staticmethod
    def _is_hls_playlist_body(body):
        data = bytes(body or b"").lstrip()
        if data.startswith(b"\xef\xbb\xbf"):
            data = data[3:].lstrip()
        return data.startswith(b"#EXTM3U")

    def _media_result(self, state, playback_state, detail, sample=None, fmt=""):
        sample = sample if isinstance(sample, dict) else {}
        final_url = str(sample.get("url", "") or "")
        parsed = urllib.parse.urlsplit(final_url)
        media = {
            "host": str(parsed.hostname or ""),
            "status": int(sample.get("status", 0) or 0),
            "content_type": str(sample.get("content_type", "") or ""),
            "sample_bytes": len(sample.get("body", b"") or b""),
            "format": fmt or "unknown",
        }
        length = str(sample.get("content_length", "") or "")
        if length.isdigit():
            media["content_length"] = int(length)
        return {
            "state": state,
            "playback_state": playback_state,
            "detail": str(detail or "")[:240],
            "media": media,
        }

    def _probe_hls_playlist(self, sample, headers, deadline, depth=0):
        body = bytes(sample.get("body", b"") or b"")
        try:
            text = body.decode("utf-8-sig", errors="replace")
        except Exception:
            text = ""
        if "#EXTM3U" not in text[:4096]:
            return self._media_result(
                "limited",
                "media_failed",
                "播放地址返回的 HLS 清单格式异常",
                sample,
                "hls",
            )
        candidates = []
        for line in text.splitlines():
            value = line.strip()
            if not value or value.startswith("#") or len(value) > 4096:
                continue
            candidate = urllib.parse.urljoin(str(sample.get("url", "")), value)
            parsed = urllib.parse.urlsplit(candidate)
            if parsed.scheme.lower() in ("http", "https") and parsed.netloc:
                candidates.append(candidate)
        if not candidates:
            return self._media_result(
                "limited",
                "media_failed",
                "HLS 清单未发现可抽样的变体或媒体分片",
                sample,
                "hls",
            )
        try:
            nested = self._open_media_sample(
                candidates[0], headers, deadline, self.MEDIA_PLAYLIST_BYTES
            )
        except urllib.error.HTTPError as exc:
            state = "limited" if int(exc.code) in (401, 403) else "unavailable"
            playback_state = "auth_required" if state == "limited" else "media_failed"
            return self._media_result(
                state,
                playback_state,
                "HLS 抽样请求返回 HTTP {}".format(exc.code),
                {"url": candidates[0], "status": int(exc.code)},
                "hls",
            )
        nested_body = bytes(nested.get("body", b"") or b"")
        if self._is_hls_playlist_body(nested_body) and depth < 2:
            return self._probe_hls_playlist(nested, headers, deadline, depth + 1)
        if not nested_body:
            return self._media_result(
                "unavailable",
                "media_failed",
                "HLS 媒体分片为空",
                nested,
                "hls",
            )
        nested_format = self._media_sample_format(nested_body, "")
        if not nested_format or nested_format == "hls":
            return self._media_result(
                "limited",
                "media_failed",
                "HLS 首个分片不是可识别的媒体数据",
                nested,
                "hls",
            )
        return self._media_result(
            "available",
            "media_sample",
            "HLS 清单与首个媒体分片抽样通过",
            nested,
            "hls",
        )

    @staticmethod
    def _dash_local_name(element):
        return str(getattr(element, "tag", "") or "").rsplit("}", 1)[-1]

    def _dash_direct_child(self, element, name):
        if element is None:
            return None
        for child in list(element):
            if self._dash_local_name(child) == name:
                return child
        return None

    @staticmethod
    def _expand_dash_template(value, representation_id, bandwidth, number, time_value):
        marker = "\x00DOLLAR\x00"
        template = str(value or "").replace("$$", marker)
        values = {
            "RepresentationID": str(representation_id or ""),
            "Bandwidth": str(bandwidth or "0"),
            "Number": str(number),
            "Time": str(time_value),
        }

        def replace(match):
            raw = values.get(match.group(1), "")
            width = match.group(2)
            if width:
                try:
                    return ("{:0" + width + "d}").format(int(raw))
                except Exception:
                    return raw
            return raw

        return re.sub(
            r"\$(RepresentationID|Bandwidth|Number|Time)(?:%0(\d+)d)?\$",
            replace,
            template,
        ).replace(marker, "$")

    def _dash_latest_timeline_time(self, timeline):
        segments = [
            child for child in (list(timeline) if timeline is not None else [])
            if self._dash_local_name(child) == "S"
        ]
        cursor = 0
        latest = 0
        for index, segment in enumerate(segments):
            try:
                start = int(segment.attrib.get("t", cursor))
                duration = int(segment.attrib.get("d", 0))
                repeat = int(segment.attrib.get("r", 0))
            except Exception:
                continue
            if duration <= 0:
                continue
            if repeat < 0:
                next_start = None
                for following in segments[index + 1:]:
                    try:
                        next_start = int(following.attrib["t"])
                        break
                    except Exception:
                        continue
                repeat = (
                    max(0, ((next_start - start) // duration) - 1)
                    if next_start is not None and next_start > start
                    else 0
                )
            latest = start + (duration * max(0, repeat))
            cursor = latest + duration
        return str(latest)

    def _probe_dash_playlist(self, sample, headers, deadline):
        body = bytes(sample.get("body", b"") or b"")
        try:
            root = ElementTree.fromstring(body)
        except Exception:
            return self._media_result(
                "limited", "media_failed", "DASH MPD 解析失败", sample, "dash"
            )
        if self._dash_local_name(root) != "MPD":
            return self._media_result(
                "limited", "media_failed", "DASH MPD 根节点无效", sample, "dash"
            )

        period = self._dash_direct_child(root, "Period")
        adaptation = self._dash_direct_child(period, "AdaptationSet")
        representation = self._dash_direct_child(adaptation, "Representation")
        if representation is None:
            return self._media_result(
                "limited", "media_failed", "DASH MPD 未发现媒体表示", sample, "dash"
            )
        lineage = [root, period, adaptation, representation]
        base_url = str(sample.get("url", "") or "")
        for element in lineage:
            base_element = self._dash_direct_child(element, "BaseURL")
            text = str(getattr(base_element, "text", "") or "").strip()
            if text:
                base_url = urllib.parse.urljoin(base_url, text)

        template_attributes = {}
        timeline = None
        segment_list = None
        segment_base = None
        for element in lineage:
            template = self._dash_direct_child(element, "SegmentTemplate")
            if template is not None:
                template_attributes.update(template.attrib)
                nested_timeline = self._dash_direct_child(template, "SegmentTimeline")
                if nested_timeline is not None:
                    timeline = nested_timeline
            nested_list = self._dash_direct_child(element, "SegmentList")
            if nested_list is not None:
                segment_list = nested_list
            nested_base = self._dash_direct_child(element, "SegmentBase")
            if nested_base is not None:
                segment_base = nested_base

        representation_id = representation.attrib.get("id", "")
        bandwidth = representation.attrib.get("bandwidth", "0")
        number = template_attributes.get("startNumber", "1")
        time_value = "0"
        if timeline is not None:
            time_value = self._dash_latest_timeline_time(timeline)

        candidates = []
        initialization = template_attributes.get("initialization", "")
        media = template_attributes.get("media", "")
        if initialization:
            candidates.append((
                "initialization",
                urllib.parse.urljoin(
                    base_url,
                    self._expand_dash_template(
                        initialization,
                        representation_id,
                        bandwidth,
                        number,
                        time_value,
                    ),
                ),
            ))
        if media:
            candidates.append((
                "media",
                urllib.parse.urljoin(
                    base_url,
                    self._expand_dash_template(
                        media,
                        representation_id,
                        bandwidth,
                        number,
                        time_value,
                    ),
                ),
            ))
        if segment_list is not None:
            list_initialization = self._dash_direct_child(
                segment_list, "Initialization"
            )
            source = str(
                getattr(list_initialization, "attrib", {}).get("sourceURL", "")
            ).strip()
            if source:
                candidates.append(("initialization", urllib.parse.urljoin(base_url, source)))
            for child in list(segment_list):
                if self._dash_local_name(child) == "SegmentURL":
                    source = str(child.attrib.get("media", "") or "").strip()
                    if source:
                        candidates.append(("media", urllib.parse.urljoin(base_url, source)))
                        break
        if segment_base is not None:
            base_initialization = self._dash_direct_child(
                segment_base, "Initialization"
            )
            source = str(
                getattr(base_initialization, "attrib", {}).get("sourceURL", "")
            ).strip()
            if source:
                candidates.append(("initialization", urllib.parse.urljoin(base_url, source)))
        if not candidates and base_url != str(sample.get("url", "") or ""):
            candidates.append(("media", base_url))
        if not candidates:
            return self._media_result(
                "limited",
                "media_failed",
                "DASH MPD 未提取到可抽样的初始化段或媒体段",
                sample,
                "dash",
            )

        initialization_sample = None
        last_error = ""
        for kind, target in candidates:
            try:
                nested = self._open_media_sample(target, headers, deadline)
            except Exception as exc:
                last_error = str(exc)[:120]
                continue
            nested_body = bytes(nested.get("body", b"") or b"")
            nested_format = self._media_sample_format(
                nested_body, nested.get("content_type", "")
            )
            if nested_format and nested_format not in ("hls", "dash"):
                if kind == "media":
                    return self._media_result(
                        "available",
                        "media_sample",
                        "DASH MPD 与实际媒体段抽样通过",
                        nested,
                        "dash",
                    )
                initialization_sample = nested
        if initialization_sample is not None and not any(
            kind == "media" for kind, _target in candidates
        ):
            return self._media_result(
                "available",
                "media_sample",
                "DASH MPD 与初始化段抽样通过",
                initialization_sample,
                "dash",
            )
        return self._media_result(
            "limited",
            "media_failed",
            "DASH 媒体段未取得可识别数据{}".format(
                "：" + last_error if last_error else ""
            ),
            initialization_sample or sample,
            "dash",
        )

    def _probe_media_url(self, url, headers=None, deadline=None):
        value = str(url or "").strip()
        parsed = urllib.parse.urlsplit(value)
        if parsed.scheme.lower() not in ("http", "https") or not parsed.netloc:
            return self._media_result(
                "limited",
                "native_parse",
                "播放结果需要 App 原生协议或解析器处理",
                {"url": value},
            )
        deadline = float(deadline or (time.monotonic() + self.MEDIA_PROBE_TIMEOUT * 3))
        try:
            sample = self._open_media_sample(value, headers or {}, deadline)
        except urllib.error.HTTPError as exc:
            code = int(exc.code)
            if code in (401, 403):
                return self._media_result(
                    "limited",
                    "auth_required",
                    "媒体地址需要登录或授权，HTTP {}".format(code),
                    {"url": value, "status": code},
                )
            return self._media_result(
                "unavailable",
                "media_failed",
                "媒体地址返回 HTTP {}".format(code),
                {"url": value, "status": code},
            )
        except (urllib.error.URLError, socket.timeout, TimeoutError) as exc:
            return self._media_result(
                "limited",
                "media_failed",
                "媒体抽样请求受限: {}".format(str(exc)[:120]),
                {"url": value},
            )
        except Exception as exc:
            return self._media_result(
                "limited",
                "media_failed",
                "媒体抽样检查异常: {}".format(str(exc)[:120]),
                {"url": value},
            )

        body = bytes(sample.get("body", b"") or b"")
        content_type = str(sample.get("content_type", "") or "").lower()
        lowered = body[:4096].lstrip().lower()
        if self._is_hls_playlist_body(body):
            hls_result = self._probe_hls_playlist(
                sample, headers or {}, deadline
            )
            if hls_result.get("state") == "available":
                return hls_result
            try:
                manifest_sample = self._open_media_sample(
                    value,
                    headers or {},
                    deadline,
                    self.MEDIA_PLAYLIST_BYTES,
                    use_range=False,
                )
                manifest_body = bytes(manifest_sample.get("body", b"") or b"")
                if self._media_sample_format(
                    manifest_body, manifest_sample.get("content_type", "")
                ) == "dash":
                    return self._probe_dash_playlist(
                        manifest_sample, headers or {}, deadline
                    )
            except Exception:
                pass
            return hls_result
        if (
            content_type in ("text/html", "application/xhtml+xml")
            or lowered.startswith(b"<!doctype html")
            or lowered.startswith(b"<html")
        ):
            return self._media_result(
                "limited",
                "auth_required",
                "播放地址返回网页内容，可能需要登录、Cookie 或 WebView",
                sample,
            )
        if not body:
            return self._media_result(
                "unavailable",
                "media_failed",
                "媒体地址抽样内容为空",
                sample,
            )
        fmt = self._media_sample_format(body, content_type)
        if fmt == "dash":
            return self._probe_dash_playlist(sample, headers or {}, deadline)
        if fmt:
            return self._media_result(
                "available",
                "media_sample",
                "直链媒体样本通过，格式 {}".format(fmt),
                sample,
                fmt,
            )
        return self._media_result(
            "limited",
            "direct_url",
            "已解析直链并取得数据，媒体格式仍需 App 播放器确认",
            sample,
        )

    def _open_playback_proxy_sample(
        self, target, prefix, headers, deadline, limit=None
    ):
        sample = self._open_media_sample(
            self._playback_proxy_request_url(prefix, target),
            headers,
            deadline,
            limit,
        )
        sample["proxy_url"] = sample.get("url", "")
        sample["url"] = self._playback_proxy_response_target(
            prefix, sample.get("proxy_url", ""), target
        )
        return sample

    def _probe_hls_via_playback_proxy(
        self, sample, target, prefix, headers, deadline, depth=0
    ):
        body = bytes(sample.get("body", b"") or b"")
        text = body.decode("utf-8-sig", errors="replace")
        if "#EXTM3U" not in text[:4096]:
            return self._media_result(
                "limited",
                "media_failed",
                "反代线路返回的 HLS 清单格式异常",
                sample,
                "hls",
            )
        candidates = []
        for line in text.splitlines():
            value = line.strip()
            if not value or value.startswith("#") or len(value) > 4096:
                continue
            candidate = urllib.parse.urljoin(
                str(sample.get("url", "") or target), value
            )
            parsed = urllib.parse.urlsplit(candidate)
            if parsed.scheme.lower() in ("http", "https") and parsed.netloc:
                candidates.append(candidate)
        if not candidates:
            return self._media_result(
                "limited",
                "media_failed",
                "反代线路 HLS 清单未发现可抽样的子清单或分片",
                sample,
                "hls",
            )
        candidate = candidates[0]
        try:
            nested = self._open_playback_proxy_sample(
                candidate,
                prefix,
                headers,
                deadline,
                self.MEDIA_PLAYLIST_BYTES,
            )
        except urllib.error.HTTPError as exc:
            return self._media_result(
                "limited" if int(exc.code) in (401, 403) else "unavailable",
                "auth_required" if int(exc.code) in (401, 403) else "media_failed",
                "反代线路 HLS 抽样返回 HTTP {}".format(exc.code),
                {"url": candidate, "status": int(exc.code)},
                "hls",
            )
        nested_body = bytes(nested.get("body", b"") or b"")
        if self._is_hls_playlist_body(nested_body) and depth < 2:
            return self._probe_hls_via_playback_proxy(
                nested,
                candidate,
                prefix,
                headers,
                deadline,
                depth + 1,
            )
        if not nested_body:
            return self._media_result(
                "unavailable",
                "media_failed",
                "反代线路 HLS 媒体分片为空",
                nested,
                "hls",
            )
        nested_format = self._media_sample_format(nested_body, "")
        if not nested_format or nested_format == "hls":
            return self._media_result(
                "limited",
                "media_failed",
                "反代线路 HLS 首个分片不是可识别的媒体数据",
                nested,
                "hls",
            )
        return self._media_result(
            "available",
            "proxy_media_sample",
            "反代线路 HLS 主清单、子清单与首个媒体分片抽样通过",
            nested,
            "hls",
        )

    def _probe_media_via_playback_proxy(
        self, url, prefix, headers=None, deadline=None
    ):
        target = str(url or "").strip()
        parsed = urllib.parse.urlsplit(target)
        if parsed.scheme.lower() not in ("http", "https") or not parsed.netloc:
            return self._media_result(
                "limited", "native_parse", "播放结果不是 HTTP(S) 直链", {"url": target}
            )
        deadline = float(
            deadline or (time.monotonic() + self.MEDIA_PROBE_TIMEOUT * 3)
        )
        try:
            sample = self._open_playback_proxy_sample(
                target, prefix, headers or {}, deadline
            )
        except urllib.error.HTTPError as exc:
            return self._media_result(
                "limited" if int(exc.code) in (400, 401, 403) else "unavailable",
                "proxy_rejected" if int(exc.code) == 400 else "media_failed",
                "反代线路请求返回 HTTP {}".format(exc.code),
                {"url": target, "status": int(exc.code)},
            )
        except Exception as exc:
            return self._media_result(
                "limited",
                "media_failed",
                "反代线路媒体抽样受限: {}".format(str(exc)[:120]),
                {"url": target},
            )
        body = bytes(sample.get("body", b"") or b"")
        content_type = str(sample.get("content_type", "") or "").lower()
        if self._is_hls_playlist_body(body):
            result = self._probe_hls_via_playback_proxy(
                sample, target, prefix, headers or {}, deadline
            )
        elif not body:
            result = self._media_result(
                "unavailable", "media_failed", "反代线路媒体抽样内容为空", sample
            )
        else:
            fmt = self._media_sample_format(body, content_type)
            result = self._media_result(
                "available" if fmt else "limited",
                "proxy_media_sample" if fmt else "direct_url",
                "反代线路直链媒体样本通过，格式 {}".format(fmt)
                if fmt
                else "反代线路已取得数据，媒体格式仍需播放器确认",
                sample,
                fmt,
            )
        result["playback_proxy_prefix"] = prefix
        return result

    def _test_source_functionality(self, source, checked_at):
        started = time.monotonic()
        source_name = source.get("name", "")[:40]
        source_identity = source.get("identity", "")[:20]
        try:
            sample_attempt = max(
                0, int(source.get("_health_sample_attempt", 0) or 0)
            )
        except Exception:
            sample_attempt = 0
        sample_key = "{}|{}".format(
            int(self._site_test_sample_nonce or 0),
            source.get("identity", source.get("path", "")),
        )
        account_session_source = self._source_requires_account_session(source)
        def complete(used, result):
            if isinstance(result, dict):
                elapsed = max(0, int((time.monotonic() - started) * 1000))
                result.setdefault("elapsed_ms", elapsed)
                self._log(
                    "DEBUG",
                    "站点 {} ({}) 测活完成 - 阶段: {}, 状态: {}, 耗时: {} ms, 详情: {}".format(
                        source_name,
                        source_identity,
                        result.get("test_stage", "-"),
                        result.get("state", "-"),
                        elapsed,
                        str(result.get("detail", ""))[:80],
                    ),
                )
            return used, result

        if source.get("type") == "HTML":
            probe_url = self._source_probe_url(source)
            if probe_url:
                self._log(
                    "DEBUG",
                    "站点 {} ({}) 开始 HTML 连通性检测 - URL: {}".format(
                        source_name, source_identity, probe_url[:80]
                    ),
                )
                state, detail = self._probe_site_url(probe_url)
                return complete(
                    True,
                    self._test_result(
                        state,
                        "HTML 站点连通性检测: {}".format(detail),
                        checked_at,
                        origin=self._url_origin(probe_url),
                        failure_kind="connectivity" if state != "available" else "",
                    ),
                )
            return False, None
        site = source.get("health_site", source.get("site", {}))
        if not isinstance(site, dict):
            return complete(
                True,
                self._test_result(
                    "unavailable",
                    "生成站点配置缺失",
                    checked_at,
                    failure_kind="structural",
                ),
            )
        api = self._site_option_text(site.get("api", ""))
        if not api:
            return complete(
                True,
                self._test_result(
                    "unavailable",
                    "站点运行入口缺失",
                    checked_at,
                    failure_kind="structural",
                ),
            )
        # 与宿主 Site.objectFrom 的 UrlUtil.convert 对齐：本机运行时只能
        # 通过 App 的 HTTP 服务读取 file:// 资源。
        runtime_api = self._runtime_site_reference(api)
        runtime_ext = self._runtime_site_reference(
            self._site_option_text(site.get("ext", ""))
        )
        if runtime_api.lower().startswith("file://"):
            # 本机服务端口属于 App 当前运行环境，不是源文件失效证据。
            return complete(
                True,
                self._test_result(
                    "limited",
                    "未探测到 App 本机服务端口，当前运行环境无法执行测活，已保留站点",
                    checked_at,
                    failure_kind="detector",
                    test_stage="functional",
                ),
            )
        try:
            from java import jclass

            # release 构建会混淆 api.loader 下的实现类名，而 bean.Site 由
            # ProGuard 规则保留。通过 Site.spider() 进入同一 BaseLoader，
            # 可同时兼容 minify 和 fastRelease 构建。
            host_site_class = None
            loader = None
            loader_errors = []
            try:
                host_site_class = jclass("com.fongmi.android.tv.bean.Site")
            except Exception as site_loader_exc:
                loader_errors.append("Site={}".format(str(site_loader_exc)[:80]))
            if host_site_class is None:
                try:
                    loader = jclass(
                        "com.fongmi.android.tv.api.loader.BaseLoader"
                    ).get()
                except Exception as base_loader_exc:
                    loader_errors.append(
                        "BaseLoader={}".format(str(base_loader_exc)[:80])
                    )
            if host_site_class is None and loader is None:
                raise RuntimeError("；".join(loader_errors) or "未找到宿主加载入口")
        except Exception as loader_exc:
            return complete(
                True,
                self._test_result(
                    "limited",
                    "App 站点加载入口不可用，本次不据此屏蔽 {} 站点: {}".format(
                        str(source.get("type", "")).upper() or "本地",
                        str(loader_exc)[:100],
                    ),
                    checked_at,
                    failure_kind="detector",
                    test_stage="functional",
                ),
            )
        else:
            try:
                # 测活使用源 identity 派生的唯一 key，避免不同本地包
                # 恰好使用同一 TVBox site.key 时复用了第一个 Spider。
                audit_key = "{}audit_{}".format(
                    self.GENERATED_KEY_PREFIX,
                    self._digest(
                            "{}|{}".format(
                                source.get("identity", ""),
                                "{}|{}".format(
                                    self._source_signature(source), sample_attempt
                                ),
                            ),
                        20,
                    ),
                )
                runtime_jar = self._site_option_text(site.get("jar", ""))
                if host_site_class is not None:
                    host_site = host_site_class()
                    host_site.setKey(audit_key)
                    host_site.setName(source_name or audit_key)
                    host_site.setApi(runtime_api)
                    host_site.setExt(runtime_ext)
                    host_site.setJar(runtime_jar)
                    try:
                        spider = host_site.spider()
                    except Exception as direct_spider_exc:
                        if not self._is_spider_bridge_failure(direct_spider_exc):
                            raise
                        # Site.spider 的 Java 返回类型是 Spider，Chaquopy
                        # 有时会误把真实 JAR Spider 当成 DynamicProxy。
                        # 反射的 Method.invoke 返回 Object，可绕开该类型转换。
                        try:
                            spider = host_site.getClass().getMethod(
                                "spider"
                            ).invoke(host_site)
                        except Exception as reflected_spider_exc:
                            raise RuntimeError(
                                "{}；Object 反射兜底失败: {}".format(
                                    str(direct_spider_exc)[:120],
                                    str(reflected_spider_exc)[:100],
                                )
                            )
                else:
                    spider = loader.getSpider(
                        audit_key, runtime_api, runtime_ext, runtime_jar
                    )
            except Exception as exc:
                return complete(
                    True,
                    self._static_entry_fallback(
                        source,
                        checked_at,
                        "App 真实 Spider 初始化异常: {}".format(str(exc)[:120]),
                    ),
                )

        try:
            spider_class = str(spider.getClass().getName())
        except Exception:
            spider_class = ""
        if spider is None or spider_class.endswith("SpiderNull"):
            return complete(
                True,
                self._static_entry_fallback(
                    source, checked_at, "App 真实 Spider 初始化失败"
                ),
            )

        valid_responses = 0
        list_items = 0
        errors = []
        categories = []
        candidate = {}
        search_items = 0
        deadline = started + max(1.0, float(self.SITE_FUNCTION_TEST_TIMEOUT))

        # 检测是否为直播站点
        site_type = site.get("type", 0)
        is_live = int(site_type or 0) == 1 or "/live/" in api.lower()

        def inspect(label, callback, browse=True):
            nonlocal valid_responses, categories, list_items
            try:
                if time.monotonic() >= deadline:
                    raise TimeoutError("检测时间已用尽")
                counts = self._spider_result_counts(callback())
                if counts is None:
                    errors.append("{}返回为空".format(label))
                    return 0, {}
                valid_responses += 1
                if browse:
                    categories.extend(counts["types"])
                    list_items += counts["items"]
                if counts["items"] > 0:
                    return counts["items"], self._first_vod_candidate(
                        counts.get("item_values", []),
                        "{}|{}".format(sample_key, label),
                        sample_attempt,
                    )
                if counts["message"]:
                    errors.append("{}: {}".format(label, counts["message"][:80]))
                return 0, {}
            except Exception as exc:
                errors.append("{}: {}".format(label, str(exc)[:100]))
                return 0, {}

        # 直播站点测活
        if is_live:
            try:
                if time.monotonic() >= deadline:
                    raise TimeoutError("直播检测时间已用尽")
                live_payload = spider.liveContent("")
                live_data = self._spider_result_data(live_payload)
                if live_data is None:
                    return complete(
                        True,
                        self._test_result(
                            "unavailable",
                            "直播站点 liveContent 返回为空",
                            checked_at,
                            failure_kind="functional",
                            test_stage="live",
                        ),
                    )
                live_class = live_data.get("class", [])
                if isinstance(live_class, dict):
                    live_class = [live_class]
                if not isinstance(live_class, list) or not live_class:
                    return complete(
                        True,
                        self._test_result(
                            "unavailable",
                            "直播站点未返回频道分类",
                            checked_at,
                            failure_kind="functional",
                            test_stage="live",
                        ),
                    )
                live_list = live_data.get("list", [])
                if isinstance(live_list, dict):
                    live_list = [live_list]
                channel_count = len([item for item in live_list if isinstance(item, dict)])
                if not channel_count:
                    return complete(
                        True,
                        self._test_result(
                            "unavailable",
                            "直播站点返回 {} 个分类，但没有频道".format(
                                len(live_class)
                            ),
                            checked_at,
                            failure_kind="functional",
                            test_stage="live",
                        ),
                    )
                candidate = self._first_vod_candidate(
                    live_list, sample_key + "|live", sample_attempt
                )
                list_items = channel_count
                valid_responses += 1
                if not candidate:
                    return complete(
                        True,
                        self._test_result(
                            "unavailable",
                            "直播站点返回 {} 个频道，但没有可检测的频道 ID".format(
                                channel_count
                            ),
                            checked_at,
                            failure_kind="functional",
                            test_stage="live",
                        ),
                    )
            except Exception as exc:
                transient_limited = self._health_failure_is_limited(exc)
                return complete(
                    True,
                    self._test_result(
                        "limited" if transient_limited else "unavailable",
                        "直播站点 liveContent 检测异常: {}".format(str(exc)[:120]),
                        checked_at,
                        failure_kind=(
                            "network_limited" if transient_limited else "functional"
                        ),
                        test_stage="live",
                    ),
                )

        if not is_live:
            count, item = inspect("首页", lambda: spider.homeContent(True))
            if not count:
                count, item = inspect("首页推荐", spider.homeVideoContent)
            if item:
                candidate = item

        category_seen = set()
        category_samples = categories[:5] if not candidate else []
        if len(category_samples) > 1:
            category_offset = self._site_sample_index(
                len(category_samples), sample_key + "|categories", sample_attempt
            )
            category_samples = (
                category_samples[category_offset:]
                + category_samples[:category_offset]
            )
        for category in category_samples:
            type_id = str(
                category.get("type_id", category.get("typeId", "")) or ""
            ).strip()
            if not type_id or type_id in category_seen:
                continue
            category_seen.add(type_id)
            try:
                java_ext = jclass("java.util.HashMap")()
            except Exception:
                java_ext = {}
            count, item = inspect(
                "分类 {}".format(type_id),
                lambda tid=type_id, ext=java_ext: spider.categoryContent(
                    tid, "1", True, ext
                ),
            )
            if item:
                candidate = item
                break

        searchable = self._as_bool(site.get("searchable", True), True)
        if searchable and not candidate:
            search_items, _ = inspect(
                "搜索 '爱'",
                lambda: spider.searchContent("爱", False),
                browse=False,
            )

        if not candidate and not list_items:
            detail = "App 真实 Spider 首页及分类没有可浏览内容"
            if search_items:
                detail += (
                    "；搜索返回 {} 条，证明站点仍可取数，但不作为首页测活通过依据"
                ).format(search_items)
            elif errors:
                detail += "；" + "；".join(errors[:2])
            network_limited = self._health_failure_is_limited(*errors)
            search_only = bool(search_items)
            result_state = (
                "limited"
                if account_session_source or network_limited or search_only
                else "unavailable"
            )
            result = self._test_result(
                result_state,
                (
                    "站点依赖网盘账号、Cookie 或 App 登录状态；" + detail
                    if account_session_source
                    else detail
                ),
                checked_at,
                failure_kind=(
                    "account_required"
                    if account_session_source
                    else (
                        "network_limited"
                        if network_limited
                        else ("search_only" if search_only else "functional")
                    )
                ),
                test_stage="browse",
            )
            if account_session_source:
                result["playback_state"] = "account_required"
                result["retryable"] = True
            elif network_limited:
                result["retryable"] = True
            elif search_only:
                result["playback_state"] = "search_only"
                result["retryable"] = True
            return complete(True, result)

        if candidate:
            try:
                ids = jclass("java.util.ArrayList")()
                ids.add(candidate["id"])
            except Exception:
                ids = [candidate["id"]]
            try:
                if time.monotonic() >= deadline:
                    raise TimeoutError("详情检测时间已用尽")
                detail_payload = spider.detailContent(ids)
                detail_data, detail_items = self._spider_result_items(detail_payload)
                if detail_data is None or not detail_items:
                    result = self._test_result(
                        "limited" if account_session_source else "unavailable",
                        (
                            "列表可用，但详情需要网盘账号、Cookie 或 App 登录状态"
                            if account_session_source
                            else "列表可用，但 App 真实 Spider 详情返回为空"
                        ),
                        checked_at,
                        failure_kind=(
                            "account_required"
                            if account_session_source
                            else "playback"
                        ),
                        test_stage="detail",
                    )
                    result["playback_state"] = (
                        "account_required" if account_session_source else "list_only"
                    )
                    if account_session_source:
                        result["retryable"] = True
                    return complete(True, result)
                plays = self._extract_play_candidates(
                    detail_data,
                    "{}|content:{}".format(sample_key, candidate.get("id", "")),
                    sample_attempt,
                )
                if not plays:
                    result = self._test_result(
                        "limited" if account_session_source else "unavailable",
                        (
                            "列表与详情可用，但选集依赖网盘账号、Cookie 或 App 登录状态"
                            if account_session_source
                            else "列表与详情可用，但详情未提供可抽样的选集"
                        ),
                        checked_at,
                        failure_kind=(
                            "account_required"
                            if account_session_source
                            else "playback"
                        ),
                        test_stage="detail",
                    )
                    result["playback_state"] = (
                        "account_required" if account_session_source else "detail_ok"
                    )
                    if account_session_source:
                        result["retryable"] = True
                    return complete(True, result)
            except Exception as exc:
                account_limited = account_session_source or (
                    self._source_requires_account_session(source, exc)
                )
                network_limited = self._health_failure_is_limited(exc)
                result = self._test_result(
                    (
                        "limited"
                        if account_limited or network_limited
                        else "unavailable"
                    ),
                    (
                        "列表可用，但详情需要账号或 Cookie: {}"
                        if account_limited
                        else "列表可用，但详情检测失败: {}"
                    ).format(str(exc)[:120]),
                    checked_at,
                    failure_kind=(
                        "account_required"
                        if account_limited
                        else ("network_limited" if network_limited else "playback")
                    ),
                    test_stage="detail",
                )
                result["playback_state"] = (
                    "account_required" if account_limited else "list_only"
                )
                if account_limited or network_limited:
                    result["retryable"] = True
                return complete(True, result)

            route_failures = []
            limited_route_failures = []
            account_limited = account_session_source
            last_probe = {}
            for play in plays:
                route_name = play.get("flag", "") or "线路{}".format(
                    int(play.get("route_index", 0)) + 1
                )
                try:
                    try:
                        vip_flags = jclass("java.util.ArrayList")()
                    except Exception:
                        vip_flags = []
                    if time.monotonic() >= deadline:
                        raise TimeoutError("播放解析时间已用尽")
                    player_payload = spider.playerContent(
                        play.get("flag", ""), play["id"], vip_flags
                    )
                    player = self._parse_player_payload(player_payload)
                    if not player and urllib.parse.urlsplit(play["id"]).scheme.lower() in (
                        "http",
                        "https",
                    ):
                        player = {"url": play["id"], "parse": 0, "headers": {}}
                    if not player or not player.get("url"):
                        failure = "{}解析为空".format(route_name)
                        if self._source_requires_account_session(
                            source, route_name, play.get("id", "")
                        ):
                            limited_route_failures.append(failure)
                            account_limited = True
                        else:
                            route_failures.append(failure)
                        continue
                    probe = self._probe_media_url(
                        player["url"], player.get("headers", {}), deadline=deadline
                    )
                    last_probe = probe
                    if probe.get("state") == "available":
                        prefix = "；".join(
                            route_failures + limited_route_failures
                        )
                        content_name = candidate.get("name") or candidate.get("id")
                        detail = "抽样《{}》{}抽样通过：{}".format(
                            str(content_name)[:60], route_name, probe["detail"]
                        )
                        if prefix:
                            detail = "{}；{}".format(prefix, detail)
                        result = self._test_result(
                            "available",
                            detail,
                            checked_at,
                            test_stage="media",
                        )
                        result["playback_state"] = probe.get("playback_state", "")
                        result["media"] = probe.get("media", {})
                        result["playback_routes_tested"] = len(route_failures) + 1
                        result["playback_routes_total"] = len(plays)
                        result["sample_attempt"] = sample_attempt + 1
                        result["sample_content"] = str(content_name)[:80]
                        result["sample_episode"] = str(play.get("name", ""))[:80]
                        return complete(True, result)
                    parse_prefix = (
                        "原生解析未取得媒体："
                        if int(player.get("parse", 0) or 0)
                        else ""
                    )
                    failure = "{}{}{}".format(
                        route_name,
                        parse_prefix,
                        probe.get("detail", "抽样失败"),
                    )
                    if str(probe.get("state", "")) == "limited":
                        limited_route_failures.append(failure)
                        if (
                            probe.get("playback_state")
                            in ("auth_required", "native_parse")
                            or self._source_requires_account_session(
                                source,
                                route_name,
                                play.get("id", ""),
                                player.get("url", ""),
                                player.get("message", ""),
                            )
                        ):
                            account_limited = True
                    else:
                        route_failures.append(failure)
                except Exception as exc:
                    failure = "{}解析失败: {}".format(
                        route_name, str(exc)[:80]
                    )
                    if (
                        isinstance(exc, TimeoutError)
                        or self._health_failure_is_limited(exc)
                        or self._source_requires_account_session(
                            source, route_name, play.get("id", ""), exc
                        )
                    ):
                        limited_route_failures.append(failure)
                        account_limited = account_limited or (
                            self._source_requires_account_session(
                                source, route_name, play.get("id", ""), exc
                            )
                        )
                    else:
                        route_failures.append(failure)

            if limited_route_failures:
                result = self._test_result(
                    "limited",
                    "抽样《{}》存在需要{}确认的播放线路；{}".format(
                        str(candidate.get("name") or candidate.get("id"))[:60],
                        (
                            "网盘账号、Cookie 或 App 登录状态"
                            if account_limited
                            else "App 原生解析器或网络环境"
                        ),
                        "；".join((limited_route_failures + route_failures)[:3]),
                    ),
                    checked_at,
                    failure_kind=(
                        "account_required"
                        if account_limited
                        else "playback_limited"
                    ),
                    test_stage="player",
                )
                result["playback_state"] = (
                    "account_required"
                    if account_limited
                    else last_probe.get("playback_state", "native_parse")
                )
                result["media"] = last_probe.get("media", {})
                result["playback_routes_tested"] = len(plays)
                result["playback_routes_total"] = len(plays)
                result["sample_attempt"] = sample_attempt + 1
                result["sample_content"] = str(
                    candidate.get("name") or candidate.get("id")
                )[:80]
                result["retryable"] = True
                return complete(True, result)

            # 严格模式只接受真实媒体抽样。列表、详情或 parse=1
            # 只能证明数据链路存在，不能证明当前 App 能播放。
            result = self._test_result(
                "unavailable",
                "抽样《{}》的所有播放线路均失败；{}".format(
                    str(candidate.get("name") or candidate.get("id"))[:60],
                    "；".join(route_failures[:3]) or "播放线路暂不可抽样"
                ),
                checked_at,
                failure_kind="playback",
                test_stage="player",
            )
            result["playback_state"] = last_probe.get(
                "playback_state", "media_failed"
            )
            result["media"] = last_probe.get("media", {})
            result["playback_routes_tested"] = len(plays)
            result["playback_routes_total"] = len(plays)
            result["sample_attempt"] = sample_attempt + 1
            result["sample_content"] = str(
                candidate.get("name") or candidate.get("id")
            )[:80]
            return complete(True, result)

        if list_items:
            result = self._test_result(
                "limited" if account_session_source else "unavailable",
                (
                    "站点需要网盘账号或 Cookie，当前返回 {} 条列表但没有可检测 ID"
                    if account_session_source
                    else "App 真实 Spider 返回 {} 条列表数据，但未找到可进入详情的 vod_id"
                ).format(list_items),
                checked_at,
                failure_kind=(
                    "account_required" if account_session_source else "functional"
                ),
                test_stage="functional",
            )
            result["playback_state"] = (
                "account_required" if account_session_source else "list_only"
            )
            if account_session_source:
                result["retryable"] = True
            return complete(True, result)

        if valid_responses:
            detail = "App 真实 Spider 首页、分类和搜索均未返回数据"
            if errors:
                detail += "；" + "；".join(errors[:2])
            network_limited = self._health_failure_is_limited(*errors)
            result = self._test_result(
                "limited" if network_limited else "unavailable",
                detail,
                checked_at,
                failure_kind=(
                    "network_limited" if network_limited else "functional"
                ),
                test_stage="functional",
            )
            if network_limited:
                result["retryable"] = True
            return complete(
                True,
                result,
            )
        network_limited = self._health_failure_is_limited(*errors)
        result = self._test_result(
            "limited" if network_limited else "unavailable",
            "App 真实 Spider 调用失败：{}".format(
                "；".join(errors[:2]) or "没有有效响应"
            ),
            checked_at,
            failure_kind=("network_limited" if network_limited else "functional"),
            test_stage="functional",
        )
        if network_limited:
            result["retryable"] = True
        return complete(
            True,
            result,
        )

    def _test_source_availability_with_retry(self, source):
        first_source = dict(source)
        first_source["_health_sample_attempt"] = 0
        first = self._test_source_availability(first_source)
        first["attempts"] = 1
        first_state = str(first.get("state", ""))
        failure_kind = str(first.get("failure_kind", "")).strip()
        retryable_limited = (
            first_state == "limited"
            and failure_kind
            in ("playback_limited", "network_limited", "search_only")
            and bool(first.get("retryable", False))
        )
        first_proxy_eligible = self._automatic_playback_proxy_failure_eligible(
            first
        )
        if (
            first_state == "limited"
            and bool(first.get("retryable", False))
            and not retryable_limited
        ):
            first["retry_skipped_kind"] = failure_kind or "limited"
        if first_state != "unavailable" and not retryable_limited:
            return first

        source_name = " ".join(
            str(source.get("name", "未命名站点")).split()
        )[:60]
        self._log(
            "INFO",
            "站点 {} 首次检测{}，同一任务更换内容立即复测".format(
                source_name or "未命名站点",
                "受限" if retryable_limited else "失效",
            ),
        )
        if self._site_test_cancelled(source):
            return self._test_result(
                "limited",
                "首次检测失效，任务已取消，未执行确认复测",
                time.strftime("%Y-%m-%d %H:%M:%S"),
                test_stage=str(first.get("test_stage", "") or "functional"),
            )

        second_source = dict(source)
        second_source["_health_sample_attempt"] = 1
        second = self._test_source_availability(second_source)
        second["attempts"] = 2
        second_state = str(second.get("state", ""))
        second_proxy_eligible = self._automatic_playback_proxy_failure_eligible(
            second
        )
        detail = str(second.get("detail", "") or "")
        if second_state == "available":
            suffix = "首次{}后更换内容即时复测通过".format(
                "受限" if retryable_limited else "失效"
            )
        elif retryable_limited:
            # 第一个样本已经证明检测依赖账号或 App 环境；第二个样本失败
            # 也不能把这种不确定性升级成站点失效。
            second["state"] = "limited"
            second["failure_count"] = 0
            second["confirmed"] = False
            second.pop("task_failure_count", None)
            if str(second.get("failure_kind", "")) not in (
                "account_required",
                "network_limited",
                "playback_limited",
                "search_only",
            ):
                second["failure_kind"] = first.get(
                    "failure_kind", "playback_limited"
                )
            if not second.get("playback_state"):
                second["playback_state"] = first.get(
                    "playback_state", "account_required"
                )
            suffix = "首个样本受限，更换内容复测仍无法确认，已保留站点"
        elif second_state == "unavailable":
            second["task_failure_count"] = 2
            suffix = "更换内容即时复测仍失效"
        else:
            suffix = "更换内容即时复测受限，未确认失效"
        second["detail"] = "{}；{}".format(detail[:180], suffix)[:240]
        if (
            first_proxy_eligible
            and second_proxy_eligible
        ):
            proxy_result = self._test_source_through_playback_proxy(source)
            if proxy_result is not None:
                return proxy_result
        uncertain_direct = (
            first_state == "limited"
            or second_state == "limited"
            or str(first.get("test_stage", "")) in ("detail", "player", "media")
            or str(second.get("test_stage", "")) in ("detail", "player", "media")
        )
        if uncertain_direct and str(second.get("state", "")) == "unavailable":
            second["state"] = "limited"
            second["failure_count"] = 0
            second["confirmed"] = False
            second.pop("task_failure_count", None)
            if str(second.get("failure_kind", "")) not in (
                "account_required",
                "network_limited",
            ):
                second["failure_kind"] = "playback_limited"
            second["detail"] = (
                "{}；直连抽样未能可靠确认，反代也未取得可播证据，已按受限保留站点".format(
                    str(second.get("detail", ""))[:155]
                )
            )[:240]
        return second

    def _automatic_playback_proxy_failure_eligible(self, result):
        if not isinstance(result, dict):
            return False
        state = str(result.get("state", ""))
        failure_kind = str(result.get("failure_kind", "")).strip()
        allowed_stages = ("browse", "detail", "player", "media")
        if state == "limited":
            if failure_kind not in ("network_limited", "search_only") or not bool(
                result.get("retryable", False)
            ):
                return False
            allowed_stages = allowed_stages + ("functional",)
        elif state != "unavailable":
            return False
        return (
            str(result.get("test_stage", ""))
            in allowed_stages
            and failure_kind
            not in ("structural", "runtime", "detector", "account_required")
        )

    def _test_source_through_playback_proxy(self, source):
        if (
            not self.playback_proxy_enabled
            or not self.playback_proxy_urls
            or not self._playback_proxy_eligible(source)
        ):
            return None
        current_proxy = self._playback_proxy_state(source)
        prefixes = []
        if current_proxy.get("prefix"):
            prefixes.append(current_proxy["prefix"])
        prefixes.extend(self.playback_proxy_urls)
        tested_prefixes = set()
        limited_result = None
        proxy_attempt = 0
        for prefix in prefixes:
            prefix_identity = self._playback_proxy_url_identity(prefix)
            if prefix_identity in tested_prefixes:
                continue
            if not self._playback_proxy_line_compatible(source, prefix):
                continue
            tested_prefixes.add(prefix_identity)
            proxy_attempt += 1
            if proxy_attempt > 3 or self._site_test_cancelled(source):
                break
            try:
                proxy_source = dict(source)
                proxy_source["_health_sample_attempt"] = 100 + proxy_attempt
                proxy_source["_health_proxy_only"] = True
                wrapper_path = self._ensure_playback_proxy_wrapper(
                    proxy_source, prefix
                )
                health_site = copy.deepcopy(
                    source.get("health_site", source.get("site", {}))
                )
                health_site["api"] = self._file_url(wrapper_path)
                health_site["playbackProxy"] = {
                    "prefix": prefix,
                    "mode": "health",
                }
                proxy_source["health_site"] = health_site
                proxy_result = self._test_source_availability(proxy_source)
            except Exception as exc:
                self._log(
                    "WARN",
                    "站点 {} 反代线路包装复测异常: {}".format(
                        str(source.get("name", "未命名站点"))[:60],
                        str(exc)[:120],
                    ),
                )
                continue
            proxy_result["attempts"] = 2 + proxy_attempt
            if self._test_result_has_media_proof(proxy_result):
                proxy_result["playback_proxy_prefix"] = prefix
                proxy_result["playback_proxy_scope"] = "source_and_media"
                proxy_result["playback_proxy_auto_qualified"] = True
                proxy_result["detail"] = (
                    "直连连续两次未能可靠取得媒体，反代线路包装取得真实媒体样本；{}".format(
                        str(proxy_result.get("detail", ""))[:155]
                    )
                )[:240]
                return proxy_result
            proxy_state = str(proxy_result.get("state", ""))
            proxy_reached_content = (
                str(proxy_result.get("test_stage", ""))
                in ("detail", "player", "media")
                or bool(str(proxy_result.get("sample_content", "")).strip())
                or int(proxy_result.get("playback_routes_total", 0) or 0) > 0
            )
            if (
                proxy_state in ("available", "limited")
                or proxy_reached_content
            ) and limited_result is None:
                proxy_result["state"] = "limited"
                proxy_result["failure_count"] = 0
                proxy_result["confirmed"] = False
                proxy_result.pop("playback_proxy_prefix", None)
                proxy_result["detail"] = (
                    "直连连续两次未能可靠取得媒体，反代线路未取得真实媒体证据，"
                    "按受限保留；{}".format(
                        str(proxy_result.get("detail", ""))[:145]
                    )
                )[:240]
                limited_result = proxy_result
        return limited_result

    @staticmethod
    def _source_is_non_browsable_action(source):
        for field in ("health_site", "csp_site", "site"):
            site = source.get(field, {})
            if not isinstance(site, dict):
                continue
            key = str(site.get("key", "") or "").strip().casefold()
            api = str(site.get("api", "") or "").strip().casefold()
            if key == "push_agent" or api == "csp_push":
                return True
        return False

    def _test_source_availability(self, source):
        checked_at = time.strftime("%Y-%m-%d %H:%M:%S")
        source_type = source["type"]
        path = source["path"]
        try:
            if not os.path.isfile(path) or not os.access(path, os.R_OK):
                return self._test_result(
                    "unavailable",
                    "源文件不存在或不可读",
                    checked_at,
                    failure_kind="structural",
                )
            if self._source_is_non_browsable_action(source):
                return self._test_result(
                    "available",
                    "推送功能入口无需执行影视内容测活",
                    checked_at,
                    test_stage="action",
                )
            package_detail = ""
            if source.get("csp_site"):
                if "#bundle-site-" in str(source.get("identity", "")):
                    missing = [
                        item
                        for item in source.get("dependencies", [])
                        if not os.path.isfile(item) or not os.access(item, os.R_OK)
                    ]
                    if missing:
                        return self._test_result(
                            "unavailable",
                            "整包站点本地依赖已缺失: {}".format(missing[0]),
                            checked_at,
                            failure_kind="structural",
                        )
                    package_detail = source.get("validation", "") or "整包站点依赖完整"
                else:
                    valid, detail = self._validate_source(source_type, path)
                    if not valid:
                        return self._test_result(
                            "unavailable",
                            detail,
                            checked_at,
                            failure_kind="structural",
                        )
                    package_detail = (
                        detail
                        or source.get("validation", "")
                        or "目录包结构有效"
                    )
            if source_type == "HTML":
                valid, detail = self._validate_source("HTML", path)
                if valid:
                    return self._test_result(
                        "available", "本地 WebHome 页面结构有效", checked_at
                    )
                return self._test_result(
                    "unavailable",
                    detail,
                    checked_at,
                    failure_kind="structural",
                )
            if source_type == "JS":
                text = self._read_text(
                    self._source_probe_path(source), 512 * 1024
                )
                if not self._has_direct_js_spider(text):
                    return self._test_result(
                        "unavailable",
                        "未发现可执行的 JS Spider 入口",
                        checked_at,
                        failure_kind="structural",
                    )
            if source_type == "PY":
                valid, detail = self._validate_source("PY", path)
                if not valid:
                    return self._test_result(
                        "unavailable",
                        detail,
                        checked_at,
                        failure_kind="structural",
                    )
            if source_type == "XBPQ" and not source.get("csp_site"):
                ready, detail = self._xbpq_runtime_status()
                if not ready:
                    return self._test_result(
                        "unavailable",
                        detail,
                        checked_at,
                        failure_kind="structural",
                    )

            functional_supported, functional_result = (
                self._test_source_functionality(source, checked_at)
            )
            if functional_supported:
                return functional_result

            probe_url = self._source_probe_url(source)
            if not probe_url:
                probe_path = self._source_probe_path(source)
                probe_url = self._extract_probe_url(probe_path)
            if not probe_url:
                return self._test_result(
                    "limited",
                    "{}；未提取到可安全探测的主页地址".format(
                        package_detail
                    ).lstrip("；"),
                    checked_at,
                )
            origin = self._url_origin(probe_url)
            if not origin:
                return self._test_result("limited", "主页地址格式无法确认", checked_at)
            state, detail = self._probe_site_url(probe_url)
            if package_detail:
                detail = package_detail + "；" + detail
            return self._test_result(state, detail, checked_at, origin)
        except Exception as exc:
            return self._test_result(
                "limited", "检测过程受限: {}".format(exc), checked_at
            )

    def _source_probe_path(self, source):
        site = source.get("csp_site", {})
        if isinstance(site, dict):
            fields = ("homePage", "ext", "api") if "#bundle-site-" in str(
                source.get("identity", "")
            ) else (("api",) if source.get("type") == "JS" else ("ext",))
            for field in fields:
                reference = site.get(field, "")
                if isinstance(reference, str) and reference.strip():
                    path = self._site_reference_path(reference)
                    if path and os.path.isfile(path) and os.access(path, os.R_OK):
                        return path
        return source["path"]

    def _source_probe_url(self, source):
        site = source.get("csp_site", {})
        if not isinstance(site, dict):
            return ""
        for field in ("homePage", "ext", "api"):
            value = site.get(field, "")
            if not isinstance(value, str):
                continue
            parsed = urllib.parse.urlsplit(value.strip())
            if parsed.scheme.lower() in ("http", "https") and parsed.netloc:
                return value.strip()
        return ""

    def _test_result(
        self, state, detail, checked_at, origin="", failure_kind="",
        test_stage="",
    ):
        result = {
            "state": state,
            "detail": str(detail or "")[:240],
            "checked_at": checked_at,
            "checked_ts": time.time(),
        }
        if origin:
            result["origin"] = origin
        if failure_kind:
            result["failure_kind"] = str(failure_kind)
        if test_stage:
            result["test_stage"] = str(test_stage)
        return result

    def _static_entry_fallback(self, source, checked_at, reason):
        """区分站点初始化硬失败、Java 桥接受限和账号环境依赖。"""
        if self._is_spider_bridge_failure(reason):
            return self._test_result(
                "limited",
                "Java Spider 可创建，但 Chaquopy 桥接当前不支持直接测活；已保留站点",
                checked_at,
                failure_kind="detector",
                test_stage="functional",
            )
        if self._source_requires_account_session(source, reason):
            result = self._test_result(
                "limited",
                "站点依赖网盘账号、Cookie 或 App 登录状态，初始化结果不作为失效依据",
                checked_at,
                failure_kind="account_required",
                test_stage="functional",
            )
            result["playback_state"] = "account_required"
            return result
        return self._test_result(
            "limited",
            "{}；Spider 初始化结果可能受并发加载或 App 运行态影响，已保留站点".format(
                str(reason or "App 真实 Spider 初始化失败")[:150]
            ),
            checked_at,
            failure_kind="detector",
            test_stage="functional",
        )

    def _append_site_test_history(self, identity, result):
        identity = str(identity or "").strip()
        if not identity or not isinstance(result, dict):
            return
        entry = {}
        for key in (
            "state",
            "checked_at",
            "checked_ts",
            "test_stage",
            "playback_state",
            "elapsed_ms",
            "detail",
            "failure_kind",
            "confirmed",
        ):
            value = result.get(key)
            if value not in (None, ""):
                entry[key] = value
        media = result.get("media")
        if isinstance(media, dict):
            entry["media"] = {
                key: media[key]
                for key in (
                    "host",
                    "status",
                    "content_type",
                    "sample_bytes",
                    "format",
                )
                if key in media
            }
        history = list(self.site_test_history.get(identity, []))
        history.append(entry)
        self.site_test_history[identity] = history[-self.SITE_TEST_HISTORY_LIMIT :]

    def _site_test_history_text(self, identity, limit=5):
        history = self.site_test_history.get(str(identity or "").strip(), [])
        if not isinstance(history, list) or not history:
            return "无"
        lines = []
        for item in history[-max(1, int(limit)) :]:
            if not isinstance(item, dict):
                continue
            elapsed = item.get("elapsed_ms")
            elapsed_text = "{} ms".format(elapsed) if elapsed not in (None, "") else "-"
            lines.append(
                "- {} · {} · {} · {} · {} · {}".format(
                    item.get("checked_at", "-"),
                    self._test_result_label(item),
                    item.get("test_stage", "-") or "-",
                    item.get("playback_state", "-") or "-",
                    elapsed_text,
                    str(item.get("detail", "") or "")[:120],
                )
            )
        return "\n".join(lines) or "无"

    def _finalize_test_result(self, source, result):
        state = str(result.get("state", ""))
        if state != "unavailable":
            result["failure_count"] = 0
            result["confirmed"] = False
            return result

        identity = source.get("identity", "")
        previous = self.site_test_results.get(identity, {})
        # 只按 failure_count 累计：任何一次非失效判定都会把它清零，
        # 因此首次失效被降级为 limited 后，计数仍然保留，下一次失效才确认。
        previous_count = int(previous.get("failure_count", 0) or 0)
        task_count = int(result.get("task_failure_count", 0) or 0)
        historical_count = previous_count + 1 if previous_count > 0 else 1
        result["failure_count"] = max(task_count, historical_count)

        if result["failure_count"] >= self.SITE_TEST_FAILURE_CONFIRM_COUNT:
            result["confirmed"] = True
            suffix = "已连续 {} 次失效，将从 App 站点列表移除".format(result["failure_count"])
        else:
            result["confirmed"] = False
            result["state"] = "limited"
            suffix = "首次检测失效，等待同一任务即时复测"

        result["detail"] = "{}；{}".format(
            str(result.get("detail", ""))[:180], suffix
        )[:240]
        return result

    def _should_auto_block_test_result(self, result):
        return (
            str(result.get("state", "")) == "unavailable"
            and bool(result.get("confirmed", False))
        )

    def _extract_probe_url(self, path):
        text = self._read_text(path, 512 * 1024)
        text = text.replace("\\/", "/").replace("\\u002F", "/").replace("\\u002f", "/")
        matches = re.findall(r"https?://[^\s\"'<>\\]+", text, flags=re.IGNORECASE)
        scored = []
        for index, value in enumerate(matches):
            value = value.rstrip("),;]}，。")
            parsed = urllib.parse.urlsplit(value)
            if parsed.scheme.lower() not in ("http", "https") or not parsed.netloc:
                continue
            host = (parsed.hostname or "").lower()
            if host in ("127.0.0.1", "localhost") or host.endswith(".local"):
                continue
            lower = value.lower()
            score = -index
            if parsed.path in ("", "/"):
                score += 20
            if re.search(r"\.(?:jpg|jpeg|png|gif|webp|svg|m3u8|mp4|css|woff2?)(?:\?|$)", lower):
                score -= 50
            if host in ("example.com", "www.example.com"):
                score -= 100
            scored.append((score, value))
        return max(scored, default=(0, ""), key=lambda item: item[0])[1]

    def _url_origin(self, url):
        try:
            parsed = urllib.parse.urlsplit(url)
            if parsed.scheme.lower() not in ("http", "https") or not parsed.netloc:
                return ""
            return "{}://{}/".format(parsed.scheme.lower(), parsed.netloc)
        except Exception:
            return ""

    def _probe_site_url(self, url):
        request = urllib.request.Request(
            url,
            headers={
                "User-Agent": "Mozilla/5.0 (Android; TVBox Site Check)",
                "Accept": "text/html,application/json;q=0.9,*/*;q=0.5",
                "Range": "bytes=0-1023",
                "Connection": "close",
            },
        )
        opener = urllib.request.build_opener(
            urllib.request.ProxyHandler(), NoRedirectHandler()
        )
        try:
            with opener.open(request, timeout=self.SITE_TEST_TIMEOUT) as response:
                status = int(getattr(response, "status", response.getcode()))
                response.read(1024)
            if 200 <= status < 400:
                return "available", "站点地址可达 (HTTP {})".format(status)
            return "limited", "站点响应受限 (HTTP {})".format(status)
        except urllib.error.HTTPError as exc:
            if 300 <= int(exc.code) < 400:
                return "available", "站点地址可达并返回跳转 (HTTP {})".format(
                    exc.code
                )
            return "limited", "站点响应受限 (HTTP {})".format(exc.code)
        except urllib.error.URLError as exc:
            reason = exc.reason
            text = str(reason).lower()
            if isinstance(reason, socket.timeout) or "timed out" in text or "timeout" in text:
                return "limited", "主页连接超时"
            if isinstance(reason, (socket.gaierror, ConnectionRefusedError)) or any(
                marker in text
                for marker in (
                    "connection refused",
                    "name or service not known",
                    "nodename nor servname",
                    "no address associated",
                )
            ):
                return "limited", "站点网络不可达: {}".format(reason)
            return "limited", "站点连接受限: {}".format(reason)
        except (socket.timeout, TimeoutError):
            return "limited", "主页连接超时"
        except ConnectionRefusedError as exc:
            return "limited", "站点连接被拒绝: {}".format(exc)
        except Exception as exc:
            return "limited", "主页检测受限: {}".format(exc)

    def _test_result_label(self, result):
        if not isinstance(result, dict):
            return "未检测"
        return {
            "available": "可用",
            "unavailable": "失效",
            "limited": "受限",
        }.get(str(result.get("state", "")), "未检测")

    def _normalize_extension(self, value):
        value = str(value or "").strip().lower()
        if not value:
            return ""
        return value if value.startswith(".") else "." + value

    # --------------------------------------------------------------------------
    # 本地包下载与安全安装
    # --------------------------------------------------------------------------
    def _package_bundle_remote_python_urls(self, targets):
        urls = set()
        for target in targets or []:
            root = os.path.realpath(
                os.path.abspath(os.path.expanduser(str(target or "")))
            )
            if not os.path.isdir(root):
                continue
            try:
                for current, dirs, files in os.walk(root, topdown=True):
                    dirs[:] = [
                        name
                        for name in dirs
                        if not name.startswith(".")
                        and name.lower() not in self.SKIP_DIRS
                        and not os.path.islink(os.path.join(current, name))
                    ]
                    for name in files:
                        if not name.lower().endswith((".json", ".jsonc")):
                            continue
                        path = os.path.join(current, name)
                        try:
                            if os.path.getsize(path) > self.max_source_size:
                                continue
                            data = self._load_json_compatible(path)
                        except Exception:
                            continue
                        if not isinstance(data, dict):
                            continue
                        sites = data.get("sites", [])
                        if isinstance(sites, dict):
                            sites = [sites]
                        if not isinstance(sites, list):
                            continue
                        for site in sites:
                            if not isinstance(site, dict):
                                continue
                            try:
                                site_type = int(site.get("type", 3) or 3)
                            except Exception:
                                continue
                            if site_type != 3 or str(
                                site.get("homePage", site.get("home_page", ""))
                                or ""
                            ).strip():
                                continue
                            url = self._bundle_remote_python_url(site.get("api", ""))
                            if url:
                                urls.add(url)
            except Exception as exc:
                self._warn(
                    "本地包远程 PY 识别失败: {} ({})".format(target, exc)
                )
        return sorted(urls)

    def _read_bundle_remote_python(self, value):
        current = self._bundle_remote_python_url(value)
        if not current:
            raise ValueError("远程 PY 地址不受支持")
        opener = urllib.request.build_opener(
            urllib.request.ProxyHandler(), NoRedirectHandler()
        )
        for _redirects in range(6):
            request = urllib.request.Request(
                current,
                headers={
                    "User-Agent": "okhttp/4.12.0",
                    "Accept": "text/x-python, text/plain, */*",
                    "Accept-Encoding": "identity",
                    "Connection": "close",
                },
            )
            try:
                response = opener.open(
                    request, timeout=self.BUNDLE_PY_DOWNLOAD_TIMEOUT
                )
            except urllib.error.HTTPError as exc:
                status = int(exc.code or 0)
                location = str(exc.headers.get("Location", "") or "").strip()
                if 300 <= status < 400 and location:
                    next_url = self._bundle_remote_python_url(
                        urllib.parse.urljoin(current, location)
                    )
                    if not next_url:
                        raise ValueError("远程 PY 跳转地址不受支持")
                    current = next_url
                    continue
                raise ValueError("远程 PY 返回 HTTP {}".format(status))
            with response:
                status = int(getattr(response, "status", response.getcode()))
                if status < 200 or status >= 300:
                    raise ValueError("远程 PY 返回 HTTP {}".format(status))
                length = str(response.headers.get("Content-Length", "") or "")
                if length.isdigit() and int(length) > self.max_source_size:
                    raise ValueError("远程 PY 超过大小上限")
                body = response.read(self.max_source_size + 1)
            if not body:
                raise ValueError("远程 PY 内容为空")
            if len(body) > self.max_source_size:
                raise ValueError("远程 PY 超过大小上限")
            return body
        raise ValueError("远程 PY 跳转次数过多")

    def _refresh_bundle_remote_python(self, value):
        url = self._bundle_remote_python_url(value)
        path = self._bundle_remote_python_cache_path(url)
        if not url or not path:
            return {"state": "failed", "path": "", "error": "地址不受支持"}
        previous = self._valid_cached_bundle_python(url)
        temp_path = path + ".tmp-{}".format(threading.get_ident())
        try:
            body = self._read_bundle_remote_python(url)
            os.makedirs(os.path.dirname(path), exist_ok=True)
            with open(temp_path, "wb") as fp:
                fp.write(body)
                fp.flush()
                os.fsync(fp.fileno())
            valid, detail = self._validate_source("PY", temp_path)
            if not valid:
                raise ValueError(detail or "远程文件不是有效 PY Spider")
            if previous:
                with open(previous, "rb") as fp:
                    if fp.read() == body:
                        return {"state": "unchanged", "path": previous, "error": ""}
            os.replace(temp_path, path)
            return {"state": "updated", "path": path, "error": ""}
        except Exception as exc:
            if previous:
                return {
                    "state": "reused",
                    "path": previous,
                    "error": str(exc)[:160],
                }
            return {"state": "failed", "path": "", "error": str(exc)[:160]}
        finally:
            try:
                if os.path.exists(temp_path):
                    os.remove(temp_path)
            except Exception:
                pass

    def _localize_package_remote_python(self, successes):
        targets = [
            item.get("result", {}).get("target", "")
            for item in successes or []
            if bool(item.get("sites_enabled", True))
        ]
        urls = self._package_bundle_remote_python_urls(targets)
        summary = {
            "total": len(urls),
            "updated": 0,
            "unchanged": 0,
            "reused": 0,
            "failed": 0,
            "changed": False,
        }
        if not urls:
            return summary
        workers = min(
            len(urls), max(1, int(self.BUNDLE_PY_DOWNLOAD_CONCURRENCY))
        )
        with concurrent.futures.ThreadPoolExecutor(
            max_workers=workers,
            thread_name_prefix="package-py",
        ) as executor:
            future_urls = {
                executor.submit(self._refresh_bundle_remote_python, url): url
                for url in urls
            }
            for future in concurrent.futures.as_completed(future_urls):
                self._ensure_background_task_active(
                    self._package_download_cancel, "本地包远程 PY 下载"
                )
                url = future_urls[future]
                try:
                    result = future.result()
                except Exception as exc:
                    result = {"state": "failed", "error": str(exc)}
                state = str(result.get("state", "failed"))
                if state not in ("updated", "unchanged", "reused", "failed"):
                    state = "failed"
                summary[state] += 1
                if state == "updated":
                    summary["changed"] = True
                if state in ("reused", "failed"):
                    self._log(
                        "WARN",
                        "整包远程 PY {}: {} ({})".format(
                            "刷新失败，继续使用本地缓存"
                            if state == "reused"
                            else "下载失败",
                            self._safe_log_url(url),
                            str(result.get("error", ""))[:120],
                        ),
                    )
        return summary

    def _package_download_running(self):
        with self._package_download_lock:
            worker = self._package_download_thread
            return worker is not None and worker.is_alive()

    def _package_xbpq_root(self):
        for item in self.scan_roots:
            if str(item.get("type", "")).upper() != "XBPQ":
                continue
            path = str(item.get("path", "")).strip()
            if path:
                return os.path.realpath(os.path.abspath(os.path.expanduser(path)))
        return os.path.realpath(
            os.path.abspath(_detect_child_dir(self.local_base_dir, "XBPQ"))
        )

    def _encoded_download_url(self, value):
        url = self._normalize_package_download_url(value)
        parsed = urllib.parse.urlsplit(url)
        host = parsed.hostname or ""
        try:
            encoded_host = (
                host if host.isascii() else host.encode("idna").decode("ascii")
            )
        except Exception as exc:
            raise ValueError("下载地址域名转换失败: {}".format(exc))
        if ":" in encoded_host and not encoded_host.startswith("["):
            encoded_host = "[{}]".format(encoded_host)
        try:
            port = parsed.port
        except ValueError as exc:
            raise ValueError("下载地址端口无效: {}".format(exc))
        netloc = encoded_host + (":{}".format(port) if port is not None else "")
        if parsed.username is not None:
            user_info = urllib.parse.quote(parsed.username, safe="")
            if parsed.password is not None:
                user_info += ":" + urllib.parse.quote(parsed.password, safe="")
            netloc = user_info + "@" + netloc
        path = urllib.parse.quote(urllib.parse.unquote(parsed.path), safe="/%:@")
        # 保留 url=SOURCE_URL 的现有百分号编码，避免源接口自身的 & 被拆成 CF 参数。
        query = urllib.parse.quote(parsed.query, safe="=&%:@/?+")
        return urllib.parse.urlunsplit(
            (parsed.scheme, netloc, path, query, parsed.fragment)
        )

    def _package_name_from_url(self, value):
        parsed = urllib.parse.urlsplit(value)
        name = urllib.parse.unquote(os.path.basename(parsed.path)).strip()
        if name.lower().endswith(".zip"):
            name = name[:-4]
        name = re.sub(r"[\\/:*?\"<>|\x00-\x1f]+", "_", name)
        name = re.sub(r"\s+", " ", name).strip(" ._")
        return (name or "本地包")[:80]

    def _package_name_from_label(self, value):
        return self._normalize_package_download_name(value)

    def _package_download_directory(self):
        path = os.path.expanduser(str(self.package_download_dir or "").strip())
        if not os.path.isabs(path):
            path = os.path.join(self.STORAGE_ROOT, path.lstrip("/"))
        return os.path.realpath(os.path.abspath(path))

    def _package_install_target(self, url=None, site_name=None):
        if site_name is not None:
            return os.path.join(
                self._package_xbpq_root(), self._package_name_from_label(site_name)
            )
        package_name = self._package_name_from_url(
            url if url is not None else self.package_download_url
        )
        return os.path.join(
            self._package_xbpq_root(), "自动下载-{}".format(package_name)
        )

    def _package_install_marker(self, target):
        return os.path.join(target, self.PACKAGE_INSTALL_MARKER)

    def _read_package_install_marker(self, target):
        path = self._package_install_marker(target)
        if not os.path.isfile(path):
            return {}
        try:
            with open(path, "r", encoding="utf-8") as fp:
                data = json.load(fp)
            return data if isinstance(data, dict) else {}
        except Exception:
            return {}

    def _write_package_install_marker(self, target, data):
        path = self._package_install_marker(target)
        temp_path = path + ".tmp"
        try:
            with open(temp_path, "w", encoding="utf-8") as fp:
                json.dump(data, fp, ensure_ascii=False, indent=2)
                fp.write("\n")
                fp.flush()
                os.fsync(fp.fileno())
            os.replace(temp_path, path)
        finally:
            try:
                if os.path.exists(temp_path):
                    os.remove(temp_path)
            except Exception:
                pass

    def _stage_package_install_deletions(self, sites):
        selected_ids = {
            str(item.get("id", "")).strip()
            for item in sites
            if isinstance(item, dict) and str(item.get("id", "")).strip()
        }
        transaction = {"quarantine": "", "entries": []}
        if not selected_ids:
            return transaction
        root = self._package_xbpq_root()
        if not os.path.isdir(root):
            return transaction
        candidates = []
        with os.scandir(root) as entries:
            for entry in entries:
                if entry.is_symlink() or not entry.is_dir(follow_symlinks=False):
                    continue
                marker = self._read_package_install_marker(entry.path)
                site_id = str(marker.get("id", "")).strip()
                if site_id in selected_ids:
                    candidates.append((entry.path, site_id))
        if not candidates:
            return transaction
        parent = os.path.dirname(root)
        quarantine = os.path.join(
            parent,
            ".{}-delete-{}".format(
                os.path.basename(root),
                self._digest(
                    "{}:{}".format(
                        "\n".join(sorted(selected_ids)), time.time_ns()
                    ),
                    16,
                ),
            ),
        )
        os.makedirs(quarantine)
        transaction["quarantine"] = quarantine
        try:
            for source, site_id in sorted(candidates):
                staged = os.path.join(quarantine, os.path.basename(source))
                os.replace(source, staged)
                transaction["entries"].append(
                    {
                        "site_id": site_id,
                        "original": source,
                        "staged": staged,
                    }
                )
            return transaction
        except Exception:
            try:
                self._rollback_package_install_deletions(transaction)
            except Exception as rollback_exc:
                self._log(
                    "ERROR",
                    "本地包删除预处理回滚失败: {}".format(rollback_exc),
                )
            raise

    def _rollback_package_install_deletions(self, transaction):
        if not isinstance(transaction, dict):
            return
        for entry in reversed(transaction.get("entries", [])):
            original = str(entry.get("original", "")).strip()
            staged = str(entry.get("staged", "")).strip()
            if not staged or not os.path.exists(staged):
                continue
            if os.path.exists(original):
                raise ValueError("本地包回滚目标已存在: {}".format(original))
            os.replace(staged, original)
        quarantine = str(transaction.get("quarantine", "")).strip()
        if quarantine and os.path.isdir(quarantine):
            os.rmdir(quarantine)

    def _rename_package_install_directory(self, site_id, old_name, new_name):
        old_target = self._package_install_target(site_name=old_name)
        new_target = self._package_install_target(site_name=new_name)
        if os.path.realpath(old_target) == os.path.realpath(new_target):
            return None
        if not os.path.isdir(old_target):
            return None
        marker = self._read_package_install_marker(old_target)
        if str(marker.get("id", "")).strip() != str(site_id or "").strip():
            return None
        if os.path.exists(new_target):
            raise ValueError(
                "新备注名“{}”对应文件夹已存在，请更换备注名".format(new_name)
            )
        os.replace(old_target, new_target)
        marker["name"] = new_name
        try:
            self._write_package_install_marker(new_target, marker)
        except Exception:
            os.replace(new_target, old_target)
            raise
        return {
            "old": old_target,
            "new": new_target,
            "marker": marker,
            "old_name": old_name,
        }

    def _migrate_builtin_package_install_directory(self):
        for item in self.package_download_sites:
            if str(item.get("source_type", "")).lower() != "builtin":
                continue
            try:
                old_target = self._package_install_target(site_name="CF本地包")
                new_target = self._package_install_target(
                    site_name=self.PACKAGE_DOWNLOAD_NAME
                )
                site_id = str(item.get("id", "cf-service")).strip()
                old_marker = self._read_package_install_marker(old_target)
                new_marker = self._read_package_install_marker(new_target)
                if (
                    os.path.isdir(old_target)
                    and os.path.isdir(new_target)
                    and str(old_marker.get("id", "")).strip() == site_id
                    and str(new_marker.get("id", "")).strip() == site_id
                ):
                    shutil.rmtree(old_target)
                    return
                self._rename_package_install_directory(
                    site_id,
                    "CF本地包",
                    self.PACKAGE_DOWNLOAD_NAME,
                )
            except Exception as exc:
                self._warn("默认包目录迁移失败: {}".format(exc))
            return

    def _rollback_package_install_rename(self, moved):
        if not isinstance(moved, dict):
            return
        old_target = str(moved.get("old", ""))
        new_target = str(moved.get("new", ""))
        if not old_target or not new_target or not os.path.isdir(new_target):
            return
        if os.path.exists(old_target):
            return
        os.replace(new_target, old_target)
        marker = dict(moved.get("marker", {}))
        marker["name"] = str(moved.get("old_name", marker.get("name", "")))
        self._write_package_install_marker(old_target, marker)

    def _enable_package_xbpq_scan(self):
        previous_types = dict(self.type_enabled)
        previous_pending = dict(self.pending_type_enabled)
        previous_dirty = self.config_dirty
        changed = not self.type_enabled.get("XBPQ", True) or not (
            self.pending_type_enabled.get("XBPQ", True)
        )
        if not changed:
            return False
        try:
            self.type_enabled["XBPQ"] = True
            self.pending_type_enabled["XBPQ"] = True
            self.config_dirty = any(
                self.pending_type_enabled[item] != self.type_enabled[item]
                for item in self.TYPE_ORDER
            ) or self.pending_block_adult_sites != self.block_adult_sites
            self._save_settings()
            return True
        except Exception:
            self.type_enabled = previous_types
            self.pending_type_enabled = previous_pending
            self.config_dirty = previous_dirty
            raise

    def _download_package_site(self, site):
        source_type = str(site.get("source_type", "config")).strip().lower()
        source_url_value = str(site.get("url", "")).strip()
        if source_type == "config" and self._package_download_url_looks_zip(
            source_url_value
        ):
            source_type = "zip"
        if source_type in ("builtin", "config"):
            if not self.package_service_url:
                raise ValueError("未配置 CF 解析服务地址")
            source_url = (
                None
                if source_type == "builtin"
                else str(site.get("url", "")).strip()
            )
            url = self._package_cf_url(
                self.package_service_url,
                include_sites=self._package_download_site_enabled(site),
                include_lives=self._package_download_live_enabled(site),
                source_url=source_url,
            )
            try:
                archive_path, size, metadata = self._download_cf_package_archive(
                    url, str(site.get("name", "本地包"))
                )
            except Exception as cf_error:
                if source_type != "config" or not source_url:
                    raise
                try:
                    self._probe_direct_zip_url(source_url, timeout=12)
                    archive_path, size, metadata = self._download_package_archive(
                        source_url, cf_service=False
                    )
                    source_type = "zip"
                except Exception:
                    raise cf_error
        else:
            url = self._normalize_package_download_url(
                source_url_value
            )
            archive_path, size, metadata = self._download_package_archive(
                url, cf_service=False
            )
        metadata["source_type"] = source_type
        return archive_path, size, metadata

    def _download_cf_package_archive(self, url, site_name):
        for attempt in range(2):
            try:
                return self._download_package_archive(url, cf_service=True)
            except PackageServiceRequestError as exc:
                retryable = (
                    exc.code in ("RATE_LIMITED", "PACKAGE_BUILD_FAILED")
                    or exc.status >= 500
                )
                delay = exc.retry_after if exc.code == "RATE_LIMITED" else 3
                if not retryable or attempt or delay > 65:
                    raise
            except (urllib.error.URLError, socket.timeout, TimeoutError):
                delay = 3
                if attempt:
                    raise
            self._package_download_message = "{} 暂未完成，{} 秒后自动重试".format(
                site_name, max(1, delay)
            )
            self._notify_app(self._package_download_message)
            time.sleep(max(1, delay))
        raise ValueError("{} 下载重试未完成".format(site_name))

    def _download_package_archive(self, url, cf_service=True):
        directory = self._package_download_directory()
        os.makedirs(directory, exist_ok=True)
        token = self._digest(url + str(time.time_ns()), 16)
        temp_path = os.path.join(directory, ".package-{}.zip.part".format(token))
        total = 0
        metadata = {
            "source_type": "zip",
            "site_count": 0,
            "dependency_count": 0,
            "live_count": 0,
        }
        try:
            open_url = (
                self._package_open_url
                if cf_service
                else self._package_open_direct_url
            )
            with open_url(
                url,
                timeout=120 if cf_service else 45,
                accept="application/zip, application/octet-stream, */*",
            ) as response:
                status = int(getattr(response, "status", response.getcode()))
                if status < 200 or status >= 300:
                    raise ValueError("下载返回 HTTP {}".format(status))
                length = str(response.headers.get("Content-Length", "")).strip()
                if length.isdigit() and int(length) > self.MAX_PACKAGE_DOWNLOAD_SIZE:
                    raise ValueError("压缩包超过下载上限")
                metadata.update(
                    {
                        "site_count": self._package_header_int(
                            response.headers, "X-Package-Sites"
                        ),
                        "dependency_count": self._package_header_int(
                            response.headers, "X-Package-Dependencies"
                        ),
                        "live_count": self._package_header_int(
                            response.headers, "X-Package-Lives"
                        ),
                    }
                )
                with open(temp_path, "wb") as fp:
                    while True:
                        chunk = response.read(128 * 1024)
                        if not chunk:
                            break
                        total += len(chunk)
                        if total > self.MAX_PACKAGE_DOWNLOAD_SIZE:
                            raise ValueError("压缩包超过下载上限")
                        fp.write(chunk)
                    fp.flush()
                    os.fsync(fp.fileno())
            if total <= 0:
                raise ValueError("下载内容为空")
            if not zipfile.is_zipfile(temp_path):
                raise ValueError("下载内容不是有效 ZIP")
            return temp_path, total, metadata
        except Exception:
            try:
                if os.path.exists(temp_path):
                    os.remove(temp_path)
            except Exception:
                pass
            raise

    def _safe_archive_member_path(self, root, name):
        value = str(name or "").replace("\\", "/").lstrip("/")
        while value.startswith("./"):
            value = value[2:]
        parts = [part for part in value.split("/") if part not in ("", ".")]
        if not parts or any(part == ".." for part in parts):
            raise ValueError("ZIP 包含非法路径: {}".format(name))
        if re.match(r"^[A-Za-z]:", parts[0]):
            raise ValueError("ZIP 包含盘符路径: {}".format(name))
        target = os.path.realpath(os.path.join(root, *parts))
        root_real = os.path.realpath(root)
        if os.path.commonpath((target, root_real)) != root_real:
            raise ValueError("ZIP 路径越界: {}".format(name))
        return target

    def _package_file_digest(self, path, algorithm="sha256"):
        digest = hashlib.new(algorithm)
        with open(path, "rb") as fp:
            while True:
                chunk = fp.read(256 * 1024)
                if not chunk:
                    break
                digest.update(chunk)
        return digest.hexdigest()

    def _package_tree_manifest(self, root):
        files = []
        root_real = os.path.realpath(root)
        for current, dirs, names in os.walk(root_real):
            dirs[:] = sorted(
                name for name in dirs
                if not os.path.islink(os.path.join(current, name))
            )
            for name in sorted(names):
                path = os.path.join(current, name)
                relative = os.path.relpath(path, root_real).replace(os.sep, "/")
                if relative in (
                    ".dual-package-manifest.json",
                    self.PACKAGE_INSTALL_MARKER,
                ):
                    continue
                if os.path.islink(path) or not os.path.isfile(path):
                    raise ValueError("本地包包含不受支持的文件: {}".format(relative))
                files.append(
                    {
                        "path": relative,
                        "size": os.path.getsize(path),
                        "sha256": self._package_file_digest(path),
                    }
                )
        return files

    def _validate_package_manifest(self, root):
        manifest_path = os.path.join(root, ".dual-package-manifest.json")
        if not os.path.isfile(manifest_path):
            files = self._package_tree_manifest(root)
            content = json.dumps(
                files, ensure_ascii=False, sort_keys=True, separators=(",", ":")
            ).encode("utf-8")
            return {
                "verified": False,
                "content_digest": hashlib.sha256(content).hexdigest(),
                "config_sha256": next(
                    (item["sha256"] for item in files if item["path"] == "api.json"),
                    "",
                ),
                "files": files,
            }
        if os.path.getsize(manifest_path) > self.MAX_PACKAGE_CONFIG_SIZE:
            raise ValueError("本地包清单超过大小上限")
        with open(manifest_path, "r", encoding="utf-8") as fp:
            manifest = json.load(fp)
        if not isinstance(manifest, dict) or manifest.get("format") != "tvbox-local-package/v1":
            raise ValueError("本地包清单格式不受支持")
        declared = manifest.get("files")
        if not isinstance(declared, list) or not declared:
            raise ValueError("本地包清单没有文件记录")
        if len(declared) > self.MAX_PACKAGE_FILES:
            raise ValueError("本地包清单文件数量超过上限")
        verified = []
        seen = set()
        for item in declared:
            if not isinstance(item, dict):
                raise ValueError("本地包清单文件记录格式错误")
            relative = str(item.get("path", "")).replace("\\", "/").strip()
            destination = self._safe_archive_member_path(root, relative)
            normalized = os.path.relpath(destination, root).replace(os.sep, "/")
            if normalized != relative or relative in seen:
                raise ValueError("本地包清单路径重复或未规范化: {}".format(relative))
            seen.add(relative)
            if os.path.islink(destination) or not os.path.isfile(destination):
                raise ValueError("本地包清单文件缺失: {}".format(relative))
            try:
                expected_size = int(item.get("size"))
            except Exception:
                raise ValueError("本地包清单文件大小无效: {}".format(relative))
            if expected_size != os.path.getsize(destination):
                raise ValueError("本地包文件大小校验失败: {}".format(relative))
            expected_sha = str(item.get("sha256", "")).lower()
            if not re.fullmatch(r"[0-9a-f]{64}", expected_sha):
                raise ValueError("本地包 SHA-256 格式错误: {}".format(relative))
            actual_sha = self._package_file_digest(destination)
            if actual_sha != expected_sha:
                raise ValueError("本地包 SHA-256 校验失败: {}".format(relative))
            expected_md5 = str(item.get("md5", "")).lower().strip()
            if expected_md5:
                if not re.fullmatch(r"[0-9a-f]{32}", expected_md5):
                    raise ValueError("本地包 MD5 格式错误: {}".format(relative))
                if self._package_file_digest(destination, "md5") != expected_md5:
                    raise ValueError("本地包 MD5 校验失败: {}".format(relative))
            verified.append(
                {"path": relative, "size": expected_size, "sha256": actual_sha}
            )
        actual_paths = {item["path"] for item in self._package_tree_manifest(root)}
        if actual_paths != seen:
            missing = sorted(actual_paths - seen)
            raise ValueError(
                "本地包包含清单外文件: {}".format("、".join(missing[:5]))
            )
        config_sha = str(manifest.get("configSha256", "")).lower()
        api_sha = next(
            (item["sha256"] for item in verified if item["path"] == "api.json"),
            "",
        )
        if not api_sha or config_sha != api_sha:
            raise ValueError("本地包 api.json 摘要与清单不一致")
        stable = {
            "format": manifest.get("format"),
            "includeSites": bool(manifest.get("includeSites", True)),
            "includeLives": bool(manifest.get("includeLives", True)),
            "configSha256": config_sha,
            "files": sorted(verified, key=lambda item: item["path"]),
        }
        content = json.dumps(
            stable, ensure_ascii=False, sort_keys=True, separators=(",", ":")
        ).encode("utf-8")
        return {
            "verified": True,
            "content_digest": hashlib.sha256(content).hexdigest(),
            "config_sha256": config_sha,
            "files": verified,
        }

    def _installed_package_matches(self, target, manifest_info, current_marker):
        expected_digest = str(manifest_info.get("content_digest", ""))
        if (
            not os.path.isdir(target)
            or not expected_digest
            or str(current_marker.get("contentDigest", "")) != expected_digest
        ):
            return False
        try:
            installed = self._validate_package_manifest(target)
        except Exception as exc:
            self._log(
                "WARN",
                "本地包安装目录校验失败，将用下载内容修复: {}".format(exc),
            )
            return False
        if str(installed.get("content_digest", "")) != expected_digest:
            self._log("WARN", "本地包安装目录内容已变化，将用下载内容修复")
            return False
        return True

    def _commit_package_install(self, result):
        if not isinstance(result, dict) or result.get("unchanged"):
            return
        rollback = str(result.get("rollback", "")).strip()
        legacy = str(result.get("legacy_target", "")).strip()
        try:
            if rollback:
                shutil.rmtree(rollback, ignore_errors=True)
            if legacy and os.path.isdir(legacy):
                shutil.rmtree(legacy, ignore_errors=True)
        except Exception as exc:
            self._warn("新包已加载，旧包目录清理失败: {}".format(exc))

    def _rollback_package_install(self, result):
        if not isinstance(result, dict) or result.get("unchanged"):
            return
        target = str(result.get("target", "")).strip()
        rollback = str(result.get("rollback", "")).strip()
        failed = target + ".failed-" + self._digest(str(time.time_ns()), 8)
        if target and os.path.isdir(target):
            os.replace(target, failed)
        try:
            if rollback and os.path.isdir(rollback):
                os.replace(rollback, target)
        finally:
            shutil.rmtree(failed, ignore_errors=True)

    def _extract_package_archive(self, archive_path, package_name, package_site=None):
        xbpq_root = self._package_xbpq_root()
        os.makedirs(xbpq_root, exist_ok=True)
        if isinstance(package_site, dict):
            safe_name = self._package_name_from_label(package_name)
        else:
            safe_name = "自动下载-{}".format(package_name)
        target = os.path.join(xbpq_root, safe_name)
        legacy_target = ""
        if isinstance(package_site, dict):
            legacy_url = str(package_site.get("url", "")).strip()
            if legacy_url:
                legacy_target = self._package_install_target(url=legacy_url)
                if os.path.realpath(legacy_target) == os.path.realpath(target):
                    legacy_target = ""
        if os.path.isdir(target) and isinstance(package_site, dict):
            marker = self._read_package_install_marker(target)
            expected_id = str(package_site.get("id", "")).strip()
            marker_id = str(marker.get("id", "")).strip()
            if not expected_id or marker_id != expected_id:
                raise ValueError(
                    "备注名“{}”对应文件夹已存在且不是本站点安装目录，请更换备注名".format(
                        safe_name
                    )
                )
        token = self._digest(target, 12)
        staging = os.path.join(xbpq_root, ".package-{}.tmp".format(token))
        rollback = os.path.join(xbpq_root, ".package-{}.rollback".format(token))
        shutil.rmtree(staging, ignore_errors=True)
        shutil.rmtree(rollback, ignore_errors=True)
        os.makedirs(staging)
        file_count = 0
        total_size = 0
        supported_count = 0
        installed = False
        had_previous = os.path.isdir(target)
        try:
            with zipfile.ZipFile(archive_path, "r") as archive:
                infos = archive.infolist()
                if len(infos) > self.MAX_PACKAGE_FILES:
                    raise ValueError("ZIP 文件数量超过上限")
                for info in infos:
                    mode = (int(info.external_attr) >> 16) & 0o170000
                    if mode == 0o120000:
                        raise ValueError("ZIP 不允许符号链接: {}".format(info.filename))
                    destination = self._safe_archive_member_path(
                        staging, info.filename
                    )
                    if info.is_dir() or info.filename.endswith("/"):
                        os.makedirs(destination, exist_ok=True)
                        continue
                    file_count += 1
                    size = int(info.file_size)
                    total_size += size
                    if size > self.MAX_PACKAGE_FILE_SIZE:
                        raise ValueError("ZIP 单文件超过上限: {}".format(info.filename))
                    if total_size > self.MAX_PACKAGE_EXTRACT_SIZE:
                        raise ValueError("ZIP 解压总大小超过上限")
                    lower = info.filename.lower()
                    if lower.endswith((".json", ".jsonc", ".js", ".py", ".jar", ".html")):
                        supported_count += 1
                    os.makedirs(os.path.dirname(destination), exist_ok=True)
                    with archive.open(info, "r") as source, open(destination, "wb") as output:
                        shutil.copyfileobj(source, output, 128 * 1024)
                if not file_count or not supported_count:
                    raise ValueError("ZIP 中未发现可扫描的本地源文件")
            manifest_info = self._validate_package_manifest(staging)
            current_marker = self._read_package_install_marker(target)
            if self._installed_package_matches(
                target, manifest_info, current_marker
            ):
                return {
                    "target": target,
                    "files": file_count,
                    "size": total_size,
                    "unchanged": True,
                    "manifest_verified": bool(manifest_info.get("verified")),
                    "content_digest": manifest_info.get("content_digest", ""),
                    "config_sha256": manifest_info.get("config_sha256", ""),
                }
            if isinstance(package_site, dict):
                marker = {
                    "id": str(package_site.get("id", "")),
                    "name": str(package_site.get("name", safe_name)),
                    "url": str(package_site.get("url", "")),
                    "installedAt": time.strftime("%Y-%m-%d %H:%M:%S"),
                    "contentDigest": manifest_info.get("content_digest", ""),
                    "configSha256": manifest_info.get("config_sha256", ""),
                    "manifestVerified": bool(manifest_info.get("verified")),
                    "fileCount": len(manifest_info.get("files", [])),
                }
                self._write_package_install_marker(staging, marker)
            if os.path.isdir(target):
                os.replace(target, rollback)
            os.replace(staging, target)
            installed = True
            return {
                "target": target,
                "files": file_count,
                "size": total_size,
                "rollback": rollback,
                "legacy_target": legacy_target,
                "had_previous": had_previous,
                "unchanged": False,
                "manifest_verified": bool(manifest_info.get("verified")),
                "content_digest": manifest_info.get("content_digest", ""),
                "config_sha256": manifest_info.get("config_sha256", ""),
            }
        except Exception:
            if not installed and not os.path.exists(target) and os.path.isdir(rollback):
                os.replace(rollback, target)
            raise
        finally:
            try:
                shutil.rmtree(staging, ignore_errors=True)
            except Exception as exc:
                self._warn("本地包临时目录清理失败: {}".format(exc))

    def _package_source_count(self, target):
        target_real = os.path.realpath(os.path.abspath(target))
        count = 0
        entries = (
            self.cache["sources"]
            + self.cache["ignored"]
            + self.cache.get("lives", [])
            + self.cache.get("contexts", [])
        )
        for source in entries:
            path = str(source.get("path", "")).strip()
            if not path:
                continue
            try:
                source_real = os.path.realpath(os.path.abspath(path))
                if os.path.commonpath((source_real, target_real)) == target_real:
                    count += 1
            except Exception:
                continue
        return count

    def _package_health_source_identities(self, successes):
        targets = []
        for success in successes:
            if not bool(success.get("health_enabled", True)):
                continue
            target = str(success.get("result", {}).get("target", "")).strip()
            if target:
                targets.append(
                    os.path.realpath(os.path.abspath(os.path.expanduser(target)))
                )
        if not targets:
            return set()
        identities = set()
        candidates = list(self.cache.get("sources", []))
        candidates.extend(
            source
            for source in self.cache.get("ignored", [])
            if source.get("identity") in self.auto_blocked_sources
        )
        for source in candidates:
            path = str(source.get("path", "")).strip()
            identity = str(source.get("identity", "")).strip()
            if not path or not identity:
                continue
            source_path = os.path.realpath(
                os.path.abspath(os.path.expanduser(path))
            )
            if any(self._path_is_within(source_path, target) for target in targets):
                identities.add(identity)
        return identities

    def _run_package_health_check(
        self, successes, health_identities, return_config_changed=False
    ):
        """Run the same functional health engine as the manual test action."""
        def result(detail, changed=False):
            return (detail, bool(changed)) if return_config_changed else detail

        health_requested = any(
            bool(item.get("health_enabled", True)) for item in successes
        )
        if not health_requested:
            return result("未开启下载后测活")
        if not health_identities:
            detail = "已开启下载后测活，但包内没有可检测站点"
            self._log("INFO", detail)
            self._notify_app(detail)
            return result(detail)

        count = len(health_identities)
        self._package_download_state = "testing"
        self._package_download_active_site_name = "批量测活"
        self._package_download_message = "本地包全部处理完成，正在统一测活 {} 个站点".format(
            count
        )
        self._notify_app(
            "本地包全部下载、扫描并加载完成，开始自动测活 {} 个站点".format(
                count
            )
        )
        try:
            self._authorize_release("site_health")
        except ReleaseAuthorizationError as exc:
            self._authorization_last_error = str(exc)
            detail = "下载后自动测活已拒绝：当前版本未获得测活授权：{}".format(
                exc
            )
            self._package_download_message = detail
            self._log("WARN", detail)
            self._notify_app(detail)
            return result(detail)

        config_changed = False
        try:
            if not self._destroyed:
                self._site_test_cancel.clear()
            pending_identities = set(health_identities)
            self._package_download_message = "下载后自动测活全部 {} 个站点".format(
                len(pending_identities)
            )
            self._log("INFO", self._package_download_message)
            summary = self._test_sites_locked(
                force=True,
                source_identities=pending_identities,
                refresh_config_on_change=False,
            )
            config_changed = bool(
                summary.get("blocked", 0)
                or summary.get("restored", 0)
                or summary.get("pruned", 0)
            )
            if config_changed:
                with self.lock:
                    if not self._refresh_locked(allow_empty=True):
                        raise ValueError(
                            self.status["error"] or self.status["write_state"]
                        )
            detail = self._site_test_summary_text(summary)
            self.inited = True
            self._log("INFO", detail)
        except Exception as exc:
            detail = "下载后自动测活失败：{}".format(exc)
            config_changed = False
            self._log("WARN", detail)
        self._package_download_message = detail
        self._notify_app("下载后自动测活完成：{}".format(detail))
        return result(detail, config_changed)

    def _rollback_package_batch(self, successes, refresh=True):
        errors = []
        for success in reversed(successes):
            try:
                self._rollback_package_install(success.get("result", {}))
            except Exception as exc:
                errors.append("{}: {}".format(success.get("name", "本地包"), exc))
        if not refresh:
            return (
                "回滚存在异常：{}".format("；".join(errors))
                if errors
                else "已恢复上一次可用本地包"
            )
        refresh_detail = ""
        try:
            with self.lock:
                restored = self._refresh_locked(
                    allow_empty=not any(self.type_enabled.values())
                )
                self.inited = True
                if restored:
                    reloaded, refresh_detail = self._reload_app_vod_config_sync(
                        expected_keys=self._generated_registry_keys()
                    )
                    if not reloaded:
                        errors.append(refresh_detail)
                else:
                    errors.append(self.status.get("error") or "旧包恢复后扫描失败")
        except Exception as exc:
            errors.append("旧包恢复后加载失败: {}".format(exc))
        if errors:
            return "回滚存在异常：{}".format("；".join(errors))
        return "已恢复上一次可用本地包{}".format(
            "；" + refresh_detail if refresh_detail else ""
        )

    def _package_download_worker(self, sites):
        successes = []
        failures = []
        used_package_names = set()
        reload_failed = False
        trace_id = self._package_download_trace_id or self._new_operation_trace_id(
            "PKG"
        )
        self._package_download_trace_id = trace_id
        task_started = time.monotonic()
        self._trace_log(
            trace_id,
            "package",
            "worker.begin",
            site_count=len(sites),
            xbpq_root=self._package_xbpq_root(),
        )
        try:
            self._ensure_background_task_active(
                self._package_download_cancel, "本地包下载"
            )
            total = len(sites)
            for index, site in enumerate(sites, 1):
                self._ensure_background_task_active(
                    self._package_download_cancel, "本地包下载"
                )
                archive_path = ""
                site_id = str(site.get("id", ""))
                site_name = str(site.get("name", "本地包"))
                url = str(site.get("url", ""))
                site_started = time.monotonic()
                phase = "download"
                self._package_download_active_site_id = site_id
                self._package_download_active_site_name = site_name
                try:
                    self._trace_log(
                        trace_id,
                        "package",
                        "site.begin",
                        index=index,
                        total=total,
                        site_id=site_id,
                        site_name=site_name,
                        source_type=str(site.get("source_type", "config")),
                        source_url=self._safe_log_url(url),
                        include_sites=self._package_download_site_enabled(site),
                        include_lives=self._package_download_live_enabled(site),
                        health_enabled=self._package_download_health_enabled(site),
                    )
                    self._package_download_state = "downloading"
                    self._package_download_message = "正在识别并下载 {}/{}：{}".format(
                        index, total, site_name
                    )
                    self._notify_app(
                        "正在识别并下载本地包 {}/{}：{}".format(
                            index, total, site_name
                        )
                    )
                    archive_path, download_size, source_meta = (
                        self._download_package_site(site)
                    )
                    self._ensure_background_task_active(
                        self._package_download_cancel, "本地包下载"
                    )
                    self._trace_log(
                        trace_id,
                        "package",
                        "download.complete",
                        site_name=site_name,
                        bytes=download_size,
                        elapsed_ms=int((time.monotonic() - site_started) * 1000),
                        metadata=source_meta,
                    )
                    self._package_download_state = "extracting"
                    phase = "extract"
                    package_name = self._package_name_from_label(site_name)
                    package_key = package_name.casefold()
                    if package_key in used_package_names:
                        raise ValueError("下载站点备注名重复: {}".format(site_name))
                    used_package_names.add(package_key)
                    result = self._extract_package_archive(
                        archive_path, package_name, package_site=site
                    )
                    self._trace_log(
                        trace_id,
                        "package",
                        "install.staged",
                        site_name=site_name,
                        target=result.get("target", ""),
                        files=result.get("files", 0),
                        extracted_bytes=result.get("size", 0),
                        unchanged=bool(result.get("unchanged")),
                        manifest_verified=bool(result.get("manifest_verified")),
                        content_digest=str(result.get("content_digest", ""))[:16],
                    )
                    successes.append(
                        {
                            "id": site_id,
                            "name": site_name,
                            "url": url,
                            "download_size": download_size,
                            "source_meta": source_meta,
                            "health_enabled": self._package_download_health_enabled(
                                site
                            ),
                            "sites_enabled": self._package_download_site_enabled(
                                site
                            ),
                            "result": result,
                        }
                    )
                except BackgroundTaskCancelled:
                    raise
                except Exception as exc:
                    failures.append({"name": site_name, "error": str(exc)})
                    self._trace_log(
                        trace_id,
                        "package",
                        "site.failed",
                        level="ERROR",
                        site_name=site_name,
                        phase=phase,
                        elapsed_ms=int((time.monotonic() - site_started) * 1000),
                        error=str(exc),
                    )
                    self._log(
                        "WARN",
                        "{} 本地包下载安装失败: {}".format(site_name, exc),
                    )
                finally:
                    if archive_path:
                        try:
                            os.remove(archive_path)
                        except FileNotFoundError:
                            pass
                        except Exception as exc:
                            self._log(
                                "WARN", "下载临时文件清理失败: {}".format(exc)
                            )
            if not successes:
                raise ValueError(
                    "；".join(
                        "{}: {}".format(item["name"], item["error"])
                        for item in failures
                    )
                    or "没有站点下载成功"
                )
            self._package_download_state = "localizing"
            self._package_download_active_site_name = "远程 PY 本地化"
            self._package_download_message = "本地包下载完成，正在下载包内远程 PY"
            localization = self._localize_package_remote_python(successes)
            if localization["total"]:
                localization_detail = (
                    "远程 PY 本地化 {}/{}，更新 {}、沿用缓存 {}、失败 {}"
                ).format(
                    localization["updated"]
                    + localization["unchanged"]
                    + localization["reused"],
                    localization["total"],
                    localization["updated"],
                    localization["reused"],
                    localization["failed"],
                )
                self._log("INFO", localization_detail)
                self._notify_app(localization_detail)
            else:
                localization_detail = "包内没有需要下载的远程 PY"
            content_changed = any(
                not bool(item.get("result", {}).get("unchanged"))
                for item in successes
            ) or bool(localization["changed"])
            self._package_download_state = (
                "scanning" if content_changed else "checking"
            )
            self._package_download_active_site_id = ""
            self._package_download_active_site_name = "批量扫描"
            self._package_download_message = (
                "下载完成，正在统一扫描并加载"
                if content_changed
                else "下载内容未变化，正在核对状态"
            )
            self._ensure_background_task_active(
                self._package_download_cancel, "本地包下载"
            )
            with self.lock:
                metadata_changed = False
                for success in successes:
                    stored = self._find_package_download_site(success.get("id", ""))
                    meta = success.get("source_meta", {})
                    if stored is None or not isinstance(meta, dict):
                        continue
                    for key in (
                        "source_type",
                        "site_count",
                        "dependency_count",
                        "live_count",
                    ):
                        value = meta.get(key, stored.get(key))
                        if stored.get(key) != value:
                            stored[key] = value
                            metadata_changed = True
                if metadata_changed:
                    self._save_settings()
                xbpq_auto_enabled = self._enable_package_xbpq_scan()
                scan_settings_changed = (
                    self._apply_pending_scan_settings_for_download("本地包下载")
                )
                adult_filter_rules_changed = (
                    self._adult_filter_rules_need_refresh()
                )
                adult_allowances_reset = self._reset_downloaded_adult_allowances(
                    [
                        item.get("result", {}).get("target", "")
                        for item in successes
                    ],
                    "本地包下载",
                )
                requires_scan = bool(
                    content_changed
                    or metadata_changed
                    or xbpq_auto_enabled
                    or scan_settings_changed
                    or adult_filter_rules_changed
                    or adult_allowances_reset
                )
                if requires_scan:
                    self._trace_log(
                        trace_id,
                        "package",
                        "scan.begin",
                        installed_count=len(successes),
                        scan_settings_changed=scan_settings_changed,
                        adult_filter_rules_changed=adult_filter_rules_changed,
                        adult_allowances_reset=adult_allowances_reset,
                        targets=[
                            str(item.get("result", {}).get("target", ""))
                            for item in successes
                        ],
                    )
                    ok = self._refresh_locked(
                        allow_empty=not any(self.type_enabled.values())
                    )
                    self.inited = True
                    if not ok:
                        raise ValueError(
                            self.status["error"] or self.status["write_state"]
                        )
                    self._trace_log(
                        trace_id,
                        "package",
                        "scan.complete",
                        found=self.status.get("found", 0),
                        included=self.status.get("included", 0),
                        skipped=self.status.get("skipped", 0),
                        compatibility_blocked=self.status.get(
                            "compatibility_blocked", 0
                        ),
                        write_state=self.status.get("write_state", ""),
                    )
                    package_source_count = sum(
                        self._package_source_count(item["result"]["target"])
                        for item in successes
                    )
                    if (
                        not package_source_count
                        and self.status["compatibility_blocked"]
                    ):
                        raise PackageCompatibilityError(
                            "本地包已下载解压，但其 JAR 与当前 {} 不兼容，"
                            "已阻止加载".format(self._app_mode_label())
                        )
                else:
                    self._trace_log(
                        trace_id,
                        "package",
                        "scan.skipped",
                        reason="content and metadata unchanged",
                    )
                health_identities = self._package_health_source_identities(
                    successes
                )
            health_result = self._run_package_health_check(
                successes,
                health_identities,
                True,
            )
            self._ensure_background_task_active(
                self._package_download_cancel, "本地包下载"
            )
            if isinstance(health_result, tuple):
                health_detail, health_config_changed = health_result
            else:
                health_detail = str(health_result)
                health_config_changed = False
            self._trace_log(
                trace_id,
                "package",
                "health.complete",
                detail=health_detail,
                identity_count=len(health_identities),
            )
            # 切换点播配置会销毁当前 Spider。自动测活必须在重载前完成，
            # 否则 destroy() 设置的取消标志会立即中止检测。
            expected_keys = self._generated_registry_keys()
            needs_reload = bool(requires_scan or health_config_changed)
            if needs_reload:
                self._trace_log(
                    trace_id,
                    "package",
                    "reload.begin",
                    expected_site_count=len(expected_keys),
                    app_mode=self._app_mode_label(),
                )
                self._background_reload_in_progress.set()
                try:
                    reloaded, reload_detail = self._reload_app_vod_config_sync(
                        expected_keys=expected_keys
                    )
                finally:
                    self._background_reload_in_progress.clear()
                self._trace_log(
                    trace_id,
                    "package",
                    "reload.complete",
                    level="INFO" if reloaded else "WARN",
                    ok=bool(reloaded),
                    detail=reload_detail,
                )
            else:
                reloaded = True
                reload_detail = "内容与屏蔽状态未变化，无需重新扫描和加载"
                self._trace_log(
                    trace_id,
                    "package",
                    "reload.skipped",
                    reason="content and health state unchanged",
                )
            if not reloaded:
                reload_failed = True
                self._warn(
                    "本地包已下载、校验并扫描，{}；文件已保留，重启 App 后生效".format(
                        reload_detail
                    )
                )
            self._ensure_background_task_active(
                self._package_download_cancel, "本地包下载"
            )
            for success in successes:
                self._commit_package_install(success.get("result", {}))
                self._trace_log(
                    trace_id,
                    "package",
                    "install.committed",
                    site_name=success.get("name", "本地包"),
                    target=success.get("result", {}).get("target", ""),
                )
            total_files = sum(item["result"]["files"] for item in successes)
            total_size = sum(item["download_size"] for item in successes)
            failure_detail = "；".join(
                "{}: {}".format(item["name"], item["error"])
                for item in failures
            )
            unchanged_count = sum(
                1 for item in successes if item["result"].get("unchanged")
            )
            message = "批量安装完成：成功 {}/{}，共 {} 个文件、{:.2f} MB{}；{}{}{}；{}；{}".format(
                len(successes),
                len(sites),
                total_files,
                float(total_size) / 1024 / 1024,
                "，内容未变化 {} 个".format(unchanged_count)
                if unchanged_count else "",
                "失败 {} 个（{}）；".format(len(failures), failure_detail)
                if failures
                else "",
                "已自动开启 XBPQ 扫描；" if xbpq_auto_enabled else "",
                reload_detail,
                localization_detail,
                health_detail,
            )
            self._package_download_state = (
                "partial" if failures or reload_failed else "success"
            )
            self._package_download_message = message
            self._log("WARN" if failures or reload_failed else "INFO", message)
            self._trace_log(
                trace_id,
                "package",
                "worker.complete",
                level="WARN" if failures or reload_failed else "INFO",
                state=self._package_download_state,
                success_count=len(successes),
                failure_count=len(failures),
                reload_failed=reload_failed,
                elapsed_ms=int((time.monotonic() - task_started) * 1000),
            )
            self._notify_app(message)
        except BackgroundTaskCancelled as exc:
            rollback_detail = (
                self._rollback_package_batch(successes, refresh=False)
                if successes
                else ""
            )
            message = "{}{}".format(
                exc, "；" + rollback_detail if rollback_detail else ""
            )
            self._package_download_state = "cancelled"
            self._package_download_message = message
            self._log("INFO", message)
        except PackageCompatibilityError as exc:
            self._trace_log(
                trace_id,
                "package",
                "rollback.begin",
                level="WARN",
                reason="compatibility",
                installed_count=len(successes),
                error=str(exc),
            )
            rollback_detail = self._rollback_package_batch(successes) if successes else ""
            message = "{}{}".format(
                str(exc), "；" + rollback_detail if rollback_detail else ""
            )
            self._package_download_state = "incompatible"
            self._package_download_message = message
            self._log("WARN", message)
            self._trace_log(
                trace_id,
                "package",
                "rollback.complete",
                level="WARN",
                state=self._package_download_state,
                detail=rollback_detail,
            )
            self._notify_app(message)
        except Exception as exc:
            self._trace_log(
                trace_id,
                "package",
                "rollback.begin",
                level="ERROR",
                reason="worker_error",
                installed_count=len(successes),
                error=str(exc),
            )
            rollback_detail = self._rollback_package_batch(successes) if successes else ""
            message = "本地包下载安装失败：{}{}".format(
                exc, "；" + rollback_detail if rollback_detail else ""
            )
            self._package_download_state = "error"
            self._package_download_message = message
            self._log("ERROR", message)
            self._trace_log(
                trace_id,
                "package",
                "rollback.complete",
                level="ERROR",
                state=self._package_download_state,
                detail=rollback_detail,
            )
            self._notify_app(message)
        finally:
            self._trace_log(
                trace_id,
                "package",
                "worker.end",
                state=self._package_download_state,
                elapsed_ms=int((time.monotonic() - task_started) * 1000),
            )
            self._package_authorization_ticket = ""
            self._package_download_trace_id = ""
            with self._package_download_lock:
                self._package_download_thread = None
            self._background_completion_allowed.clear()
            self._release_background_operation("package")

    def _start_package_download(self, site_id=""):
        if self._site_test_is_running():
            return False, "后台检测进行中，请待本批完成后再下载"
        if self._repository_sync_running():
            return False, "在线仓库同步中，请完成后再下载本地包"
        enabled_sites = self._enabled_package_download_sites()
        selected_id = str(site_id or "").strip()
        if selected_id:
            selected = next(
                (
                    item
                    for item in enabled_sites
                    if str(item.get("id", "")).strip() == selected_id
                ),
                None,
            )
            if selected is None:
                configured = self._find_package_download_site(selected_id)
                if configured is None:
                    return False, "接口不存在，请刷新后重试"
                return False, "该接口的站点与直播开关均已关闭，请先在设置中开启"
            enabled_sites = [selected]
        if not enabled_sites:
            if not self.package_download_sites:
                return False, (
                    "未配置解析服务，请在 location.json 的 ext 中传入 Worker 地址，"
                    "或在设置中添加接口网址"
                )
            return False, "无已启用的站点或直播，请先在设置中开启"
        with self._package_download_lock:
            worker = self._package_download_thread
            if worker is not None and worker.is_alive():
                return False, "本地包下载安装中，请稍候"
            reserved, active = self._reserve_background_operation("package")
            if not reserved:
                return False, self._background_operation_conflict(active)
            self._package_download_cancel.clear()
            self._background_completion_allowed.clear()
            self._destroyed = False
            trace_id = self._new_operation_trace_id("PKG")
            self._package_download_trace_id = trace_id
            self._trace_log(
                trace_id,
                "package",
                "start.request",
                version=self.VERSION,
                app_mode=self._app_mode_label(),
                site_count=len(enabled_sites),
                service_url=self._safe_log_url(self.package_service_url),
                scan_roots=[
                    "{}:{}".format(item.get("type", ""), item.get("path", ""))
                    for item in self.scan_roots
                ],
            )
            try:
                self._package_authorization_ticket = self._authorize_release(
                    "package_download"
                )
            except ReleaseAuthorizationError as exc:
                self._authorization_last_error = str(exc)
                self._package_authorization_ticket = ""
                message = "当前版本未获得下载授权，已拒绝下载：{}".format(exc)
                self._package_download_state = "denied"
                self._package_download_message = message
                self._trace_log(
                    trace_id,
                    "package",
                    "authorization.denied",
                    level="WARN",
                    error=str(exc),
                )
                self._log("WARN", "本地包下载授权失败: {}".format(exc))
                self._notify_app(message)
                self._release_background_operation("package")
                return False, message
            except Exception:
                self._release_background_operation("package")
                raise
            self._trace_log(
                trace_id,
                "package",
                "authorization.allowed",
                ticket_present=bool(self._package_authorization_ticket),
            )
            names = "、".join(
                "{}({})".format(
                    str(item.get("name", "本地包")),
                    "+".join(
                        label
                        for label, enabled in (
                            ("站点", self._package_download_site_enabled(item)),
                            ("直播", self._package_download_live_enabled(item)),
                            ("测活", self._package_download_health_enabled(item)),
                        )
                        if enabled
                    ),
                )
                for item in enabled_sites
            )
            self._package_download_state = "queued"
            self._package_download_message = "已加入批量任务：{}".format(names)
            self._package_download_active_site_id = ""
            self._package_download_active_site_name = "批量下载"
            worker = threading.Thread(
                target=self._package_download_worker,
                args=(enabled_sites,),
                name="local-package-download",
            )
            worker.daemon = True
            self._package_download_thread = worker
            try:
                worker.start()
            except Exception:
                self._package_download_thread = None
                self._release_background_operation("package")
                raise
            self._trace_log(
                trace_id,
                "package",
                "worker.started",
                thread=worker.name,
                sites=[str(item.get("name", "本地包")) for item in enabled_sites],
            )
        return True, "已开始下载 {} 个接口：{}；完成后统一扫描、加载并按开关测活".format(
            len(enabled_sites), names
        )

    def _is_package_download_action(self, action):
        value = str(action or "")
        return value == self.ACTION_DOWNLOAD_PACKAGE or value.startswith(
            self.ACTION_DOWNLOAD_PACKAGE_PREFIX
        )

    # --------------------------------------------------------------------------
    # 在线仓库
    # --------------------------------------------------------------------------
    def _repository_sync_running(self):
        with self._repository_sync_lock:
            worker = self._repository_sync_thread
            return worker is not None and worker.is_alive()

    def _is_repository_sync_action(self, action):
        return str(action or "") == self.ACTION_SYNC_REPOSITORIES

    def _repository_state_directory(self):
        path = os.path.realpath(
            os.path.abspath(os.path.expanduser(self.online_repository_state_dir))
        )
        os.makedirs(path, exist_ok=True)
        return path

    def _repository_state_path(self, repository):
        repository_id = re.sub(
            r"[^A-Za-z0-9_-]+", "", str(repository.get("id", ""))
        )[:40]
        if not repository_id:
            repository_id = self._repository_id(repository.get("name", "仓库"))
        return os.path.join(
            self._repository_state_directory(), repository_id + ".json"
        )

    def _load_repository_state(self, repository):
        path = self._repository_state_path(repository)
        if not os.path.isfile(path):
            return {}
        try:
            with open(path, "r", encoding="utf-8") as fp:
                data = json.load(fp)
            if (
                not isinstance(data, dict)
                or data.get("format") != self.ONLINE_REPOSITORY_STATE_FORMAT
                or str(data.get("id", "")) != str(repository.get("id", ""))
            ):
                raise ValueError("仓库状态格式不受支持")
            files = data.get("files", [])
            if not isinstance(files, list):
                raise ValueError("仓库状态文件列表错误")
            return data
        except Exception as exc:
            raise ValueError("读取仓库安装状态失败：{}".format(exc))

    def _write_repository_state(self, repository, data):
        payload = dict(data or {})
        payload["format"] = self.ONLINE_REPOSITORY_STATE_FORMAT
        payload["id"] = str(repository.get("id", ""))
        payload["name"] = str(repository.get("name", ""))
        payload["url"] = str(repository.get("url", ""))
        self._atomic_write_plain_json(
            self._repository_state_path(repository), payload
        )

    def _repository_type_root(self, source_type):
        value = str(source_type or "").upper()
        for root in self.scan_roots:
            if str(root.get("type", "")).upper() != value:
                continue
            path = str(root.get("path", "")).strip()
            if path:
                return os.path.realpath(
                    os.path.abspath(os.path.expanduser(path))
                )
        raise ValueError("未配置 {} 扫描目录".format(value))

    def _repository_target_directory(self, repository, source_type):
        root = self._repository_type_root(source_type)
        name = self._normalize_repository_name(repository.get("name", ""))
        target = os.path.realpath(os.path.join(root, name))
        if os.path.commonpath((target, root)) != root:
            raise ValueError("仓库安装目录越界")
        return target

    def _repository_marker_path(self, target):
        return os.path.join(target, self.ONLINE_REPOSITORY_MARKER)

    def _read_repository_marker(self, target):
        path = self._repository_marker_path(target)
        if not os.path.isfile(path):
            return {}
        try:
            with open(path, "r", encoding="utf-8") as fp:
                value = json.load(fp)
            return value if isinstance(value, dict) else {}
        except Exception:
            return {}

    def _write_repository_marker(self, target, repository, manifest):
        self._atomic_write_plain_json(
            self._repository_marker_path(target),
            {
                "format": self.ONLINE_REPOSITORY_STATE_FORMAT,
                "id": str(repository.get("id", "")),
                "name": str(repository.get("name", "")),
                "url": str(repository.get("url", "")),
                "version": str(manifest.get("version", "")),
                "manifestDigest": str(manifest.get("digest", "")),
            },
        )

    def _repository_path_entry(self, value):
        path = str(value or "").replace("\\", "/").strip()
        if not path or path.startswith("/"):
            raise ValueError("仓库文件路径不能为空或使用绝对路径")
        while path.startswith("./"):
            path = path[2:]
        parts = [item for item in path.split("/") if item not in ("", ".")]
        if not parts or any(item == ".." for item in parts):
            raise ValueError("仓库文件路径包含非法层级: {}".format(value))
        if any(
            item.casefold() == self.ONLINE_REPOSITORY_MARKER.casefold()
            for item in parts
        ):
            raise ValueError("仓库文件使用了保留名称: {}".format(value))
        if any(
            re.search(r"[\x00-\x1f<>:\"|?*]", item)
            or re.match(r"^[A-Za-z]:", item)
            for item in parts
        ):
            raise ValueError("仓库文件路径包含非法字符: {}".format(value))
        prefix = parts[0].casefold()
        source_type = self.REPOSITORY_TYPE_DIRS.get(prefix)
        relative_parts = parts[1:] if source_type else parts
        if source_type is None:
            lower = path.casefold()
            if lower.endswith(".py"):
                source_type = "PY"
            elif lower.endswith(".js"):
                source_type = "JS"
            elif lower.endswith(".html"):
                source_type = "HTML"
            else:
                raise ValueError(
                    "仓库文件需以 py/js/csp/xbpq/html 开头；.json 必须明确类型: {}".format(
                        value
                    )
                )
        if not relative_parts:
            raise ValueError("仓库文件缺少文件名: {}".format(value))
        relative = "/".join(relative_parts)
        lower_relative = relative.casefold()
        extensions = tuple(
            str(item).casefold()
            for item in self.TYPE_EXTENSIONS.get(source_type, [])
        )
        if not extensions or not lower_relative.endswith(extensions):
            raise ValueError(
                "{} 文件扩展名与类型不匹配: {}".format(source_type, value)
            )
        return {
            "path": "/".join(parts),
            "type": source_type,
            "relative": relative,
        }

    def _normalize_repository_manifest_files(self, values, base_url):
        if not isinstance(values, list):
            raise ValueError("repository.json 的 files 必须是数组")
        if len(values) > self.MAX_REPOSITORY_FILES:
            raise ValueError("仓库文件数量超过上限")
        result = []
        seen = set()
        for raw in values:
            if isinstance(raw, str):
                item = {"path": raw}
            elif isinstance(raw, dict):
                item = raw
            else:
                raise ValueError("仓库文件记录必须是字符串或对象")
            parsed = self._repository_path_entry(item.get("path", ""))
            identity = (parsed["type"], parsed["relative"].casefold())
            if identity in seen:
                raise ValueError("仓库目标路径重复: {}".format(parsed["path"]))
            seen.add(identity)
            source_value = str(item.get("url", parsed["path"]) or parsed["path"])
            source_url = urllib.parse.urljoin(base_url, source_value)
            source_url = self._normalize_package_download_url(source_url)
            expected_sha = str(item.get("sha256", "") or "").strip().lower()
            if expected_sha and not re.fullmatch(r"[0-9a-f]{64}", expected_sha):
                raise ValueError("SHA-256 格式错误: {}".format(parsed["path"]))
            expected_size = item.get("size")
            if expected_size in (None, ""):
                expected_size = 0
            else:
                try:
                    expected_size = int(expected_size)
                except Exception:
                    raise ValueError("size 格式错误: {}".format(parsed["path"]))
                if expected_size < 0 or expected_size > self.MAX_REPOSITORY_FILE_SIZE:
                    raise ValueError("size 超过上限: {}".format(parsed["path"]))
            parsed.update(
                {
                    "url": source_url,
                    "sha256": expected_sha,
                    "size": expected_size,
                }
            )
            result.append(parsed)
        return result

    def _read_repository_manifest(self, repository):
        url = self._normalize_repository_url(repository.get("url", ""))
        try:
            with self._package_open_direct_url(
                url,
                timeout=20,
                accept="application/json, text/json, text/plain, */*",
            ) as response:
                status = int(getattr(response, "status", response.getcode()))
                if status < 200 or status >= 300:
                    raise ValueError("仓库返回 HTTP {}".format(status))
                length = str(response.headers.get("Content-Length", "") or "").strip()
                if length.isdigit() and int(length) > self.MAX_REPOSITORY_MANIFEST_SIZE:
                    raise ValueError("repository.json 超过大小上限")
                payload = self._package_read_limited(
                    response, self.MAX_REPOSITORY_MANIFEST_SIZE
                )
                final_url = str(
                    getattr(response, "geturl", lambda: url)() or url
                )
                etag = str(response.headers.get("ETag", "") or "").strip()[:160]
                modified = str(
                    response.headers.get("Last-Modified", "") or ""
                ).strip()[:160]
        except urllib.error.HTTPError as exc:
            raise ValueError("仓库访问失败：HTTP {}".format(exc.code))
        except urllib.error.URLError as exc:
            raise ValueError("仓库访问失败：{}".format(exc.reason))
        except (socket.timeout, TimeoutError):
            raise ValueError("仓库访问超时")
        try:
            data = self._package_parse_json_text(
                payload.decode("utf-8-sig")
            )
        except Exception as exc:
            raise ValueError("repository.json 解析失败：{}".format(exc))
        if isinstance(data, list):
            files_value = data
            version = ""
            declared_format = ""
        elif isinstance(data, dict):
            files_value = data.get("files")
            version = str(data.get("version", "") or "").strip()[:80]
            declared_format = str(data.get("format", "") or "").strip()
        else:
            raise ValueError("repository.json 顶层必须是对象或数组")
        if declared_format and declared_format != self.ONLINE_REPOSITORY_FORMAT:
            raise ValueError("repository.json 格式不受支持")
        base_url = final_url.rsplit("/", 1)[0] + "/"
        files = self._normalize_repository_manifest_files(files_value, base_url)
        stable = [
            {
                "path": item["path"],
                "type": item["type"],
                "relative": item["relative"],
                "url": item["url"],
                "sha256": item["sha256"],
                "size": item["size"],
            }
            for item in files
        ]
        digest = hashlib.sha256(
            json.dumps(
                {"version": version, "files": stable},
                ensure_ascii=False,
                sort_keys=True,
                separators=(",", ":"),
            ).encode("utf-8")
        ).hexdigest()
        return {
            "url": final_url,
            "version": version,
            "files": files,
            "fully_hashed": all(bool(item.get("sha256")) for item in files),
            "digest": digest,
            "etag": etag,
            "last_modified": modified,
        }

    def _repository_actual_file_digest(self, path):
        return self._package_file_digest(path, "sha256")

    def _repository_state_is_current(self, repository, state, manifest):
        # 没有在清单中提供 SHA-256 时，repository.json 本身不变也不能证明
        # 远程文件未变化，因此每次同步仍下载文件并比较实际摘要。
        if not manifest.get("fully_hashed"):
            return False
        if not state or state.get("manifestDigest") != manifest.get("digest"):
            return False
        files = state.get("files", [])
        if len(files) != len(manifest.get("files", [])):
            return False
        checked_targets = set()
        for item in files:
            destination = str(item.get("destination", ""))
            expected_sha = str(item.get("sha256", ""))
            source_type = str(item.get("type", ""))
            if not destination or not expected_sha or not os.path.isfile(destination):
                return False
            target = self._repository_target_directory(repository, source_type)
            if os.path.commonpath((os.path.realpath(destination), target)) != target:
                return False
            if target not in checked_targets:
                marker = self._read_repository_marker(target)
                if str(marker.get("id", "")) != str(repository.get("id", "")):
                    return False
                checked_targets.add(target)
            if self._repository_actual_file_digest(destination) != expected_sha:
                return False
        return True

    def _repository_download_matches_state(self, state, state_files):
        if not state or not isinstance(state.get("files"), list):
            return False
        previous = {
            (str(item.get("type", "")), str(item.get("relative", ""))): str(
                item.get("sha256", "")
            )
            for item in state.get("files", [])
        }
        current = {
            (str(item.get("type", "")), str(item.get("relative", ""))): str(
                item.get("sha256", "")
            )
            for item in state_files
        }
        return previous == current

    def _validate_repository_downloaded_file(self, path, item):
        with open(path, "rb") as fp:
            head = fp.read(1024).lstrip()
        if item["type"] != "HTML" and re.match(
            br"(?is)^(?:<!doctype\s+html|<html\b)", head
        ):
            raise ValueError("下载结果是 HTML 页面: {}".format(item["path"]))
        if item["type"] == "PY":
            try:
                with open(path, "rb") as fp:
                    compile(fp.read(), item["path"], "exec")
            except Exception as exc:
                raise ValueError("Python 语法检查失败 {}: {}".format(item["path"], exc))
        if item["type"] in ("JS", "CSP", "XBPQ") and item[
            "relative"
        ].casefold().endswith(".json"):
            try:
                with open(path, "r", encoding="utf-8-sig") as fp:
                    self._package_parse_json_text(fp.read())
            except Exception as exc:
                raise ValueError("JSON 检查失败 {}: {}".format(item["path"], exc))

    def _download_repository_file(self, item, destination):
        os.makedirs(os.path.dirname(destination), exist_ok=True)
        digest = hashlib.sha256()
        total = 0
        try:
            with self._package_open_direct_url(
                item["url"],
                timeout=45,
                accept="application/octet-stream, text/plain, application/json, */*",
            ) as response:
                status = int(getattr(response, "status", response.getcode()))
                if status < 200 or status >= 300:
                    raise ValueError("下载返回 HTTP {}".format(status))
                length = str(response.headers.get("Content-Length", "") or "").strip()
                if length.isdigit() and int(length) > self.MAX_REPOSITORY_FILE_SIZE:
                    raise ValueError("文件超过大小上限")
                with open(destination, "wb") as fp:
                    while True:
                        chunk = response.read(128 * 1024)
                        if not chunk:
                            break
                        total += len(chunk)
                        if total > self.MAX_REPOSITORY_FILE_SIZE:
                            raise ValueError("文件超过大小上限")
                        digest.update(chunk)
                        fp.write(chunk)
                    fp.flush()
                    os.fsync(fp.fileno())
        except urllib.error.HTTPError as exc:
            raise ValueError("下载失败 HTTP {}".format(exc.code))
        except urllib.error.URLError as exc:
            raise ValueError("下载失败：{}".format(exc.reason))
        if total <= 0:
            raise ValueError("下载内容为空")
        if item.get("size") and int(item["size"]) != total:
            raise ValueError("size 校验失败")
        actual_sha = digest.hexdigest()
        if item.get("sha256") and item["sha256"] != actual_sha:
            raise ValueError("SHA-256 校验失败")
        self._validate_repository_downloaded_file(destination, item)
        return total, actual_sha

    def _repository_assert_existing_clean(
        self, repository, state, source_types, allow_managed_repair=False
    ):
        state_files = state.get("files", []) if isinstance(state, dict) else []
        by_type = {}
        for item in state_files:
            by_type.setdefault(str(item.get("type", "")), []).append(item)
        local_files = {}
        dirty_managed = set()
        for source_type in source_types:
            target = self._repository_target_directory(repository, source_type)
            if not os.path.exists(target):
                continue
            if not os.path.isdir(target) or os.path.islink(target):
                raise ValueError("仓库目标不是普通目录: {}".format(target))
            marker = self._read_repository_marker(target)
            if str(marker.get("id", "")) != str(repository.get("id", "")):
                raise ValueError(
                    "目录已存在且不属于该仓库，请更换备注名: {}".format(target)
                )
            expected_paths = {
                os.path.realpath(str(item.get("destination", ""))): item
                for item in by_type.get(source_type, [])
                if str(item.get("destination", "")).strip()
            }
            actual_paths = set()
            for current, dirs, names in os.walk(target):
                linked_dirs = [
                    name
                    for name in dirs
                    if os.path.islink(os.path.join(current, name))
                ]
                if linked_dirs:
                    raise ValueError(
                        "仓库目录包含符号链接，已停止覆盖: {}".format(
                            os.path.join(current, linked_dirs[0])
                        )
                    )
                dirs[:] = [
                    name
                    for name in dirs
                    if not os.path.islink(os.path.join(current, name))
                ]
                for name in names:
                    source_path = os.path.join(current, name)
                    if os.path.islink(source_path):
                        raise ValueError(
                            "仓库目录包含符号链接，已停止覆盖: {}".format(
                                source_path
                            )
                        )
                    path = os.path.realpath(source_path)
                    if path == os.path.realpath(self._repository_marker_path(target)):
                        continue
                    actual_paths.add(path)
            extra = actual_paths - set(expected_paths)
            local_files[source_type] = [
                {
                    "source": path,
                    "relative": os.path.relpath(path, target).replace(os.sep, "/"),
                }
                for path in sorted(extra)
            ]
            for path, item in expected_paths.items():
                if not os.path.isfile(path):
                    if allow_managed_repair:
                        dirty_managed.add(path)
                        continue
                    raise ValueError(
                        "仓库托管文件已被本地删除，已停止覆盖: {}".format(
                            os.path.relpath(path, target).replace(os.sep, "/")
                        )
                    )
                if self._repository_actual_file_digest(path) != str(
                    item.get("sha256", "")
                ):
                    if allow_managed_repair:
                        dirty_managed.add(path)
                        continue
                    raise ValueError(
                        "仓库托管文件已被本地修改，已停止覆盖: {}".format(
                            os.path.relpath(path, target).replace(os.sep, "/")
                        )
                    )
        if allow_managed_repair:
            return local_files, dirty_managed
        return local_files

    def _repository_copy_local_files(self, local_files, stage_root):
        copied = 0
        for source_type, items in local_files.items():
            stage_type = os.path.join(stage_root, source_type)
            staged_names = set()
            if os.path.isdir(stage_type):
                for current, _dirs, names in os.walk(stage_type):
                    for name in names:
                        staged_names.add(
                            os.path.relpath(
                                os.path.join(current, name), stage_type
                            ).replace(os.sep, "/").casefold()
                        )
            for item in items:
                relative = str(item.get("relative", ""))
                if relative.casefold() in staged_names:
                    raise ValueError(
                        "仓库新增文件与本地文件同名，已停止覆盖: {}".format(
                            relative
                        )
                    )
                destination = self._safe_archive_member_path(
                    stage_type, relative
                )
                os.makedirs(os.path.dirname(destination), exist_ok=True)
                shutil.copy2(str(item.get("source", "")), destination)
                staged_names.add(relative.casefold())
                copied += 1
        return copied

    def _prepare_repository_sync(self, repository):
        manifest = self._read_repository_manifest(repository)
        state = self._load_repository_state(repository)
        if self._repository_state_is_current(repository, state, manifest):
            return {
                "repository": repository,
                "manifest": manifest,
                "state": state,
                "unchanged": True,
                "download_size": 0,
                "file_count": len(manifest["files"]),
            }
        token = self._digest(
            str(repository.get("id", "")) + str(time.time_ns()), 12
        )
        work_root = os.path.join(
            self._repository_state_directory(), ".sync-{}".format(token)
        )
        stage_root = os.path.join(work_root, "stage")
        backup_root = os.path.join(work_root, "backup")
        shutil.rmtree(work_root, ignore_errors=True)
        os.makedirs(stage_root)
        total_size = 0
        state_files = []
        downloaded_file_count = 0
        reused_file_count = 0
        previous_files = {
            (str(item.get("type", "")), str(item.get("relative", ""))): item
            for item in state.get("files", [])
            if str(item.get("type", "")) and str(item.get("relative", ""))
        }
        manifest_files = {
            (str(item["type"]), str(item["relative"]))
            for item in manifest["files"]
        }
        removed_file_count = len(set(previous_files) - manifest_files)
        source_types = {
            str(item.get("type", ""))
            for item in state.get("files", [])
            if str(item.get("type", ""))
        }
        source_types.update(item["type"] for item in manifest["files"])
        # 清单和状态摘要相同只能说明内容没有变化，不能说明安装目录还在。
        # 用户手动删除整个仓库目录时，仍须把本次下载的暂存文件安装回扫描目录。
        install_required = any(
            not os.path.isdir(
                self._repository_target_directory(repository, source_type)
            )
            for source_type in source_types
        )
        try:
            target_paths = [
                self._repository_target_directory(repository, source_type)
                for source_type in source_types
            ]
            if len(set(target_paths)) != len(target_paths):
                raise ValueError("多个仓库类型映射到同一安装目录，请调整扫描根目录")
            local_files, dirty_managed = self._repository_assert_existing_clean(
                repository,
                state,
                source_types,
                allow_managed_repair=True,
            )
            install_required = install_required or bool(dirty_managed)
            if dirty_managed:
                self._log(
                    "WARN",
                    "在线仓库 {} 检测到 {} 个托管文件被改动或删除，将从仓库自动修复".format(
                        repository.get("name", "在线仓库"), len(dirty_managed)
                    ),
                )
            for index, item in enumerate(manifest["files"], 1):
                self._repository_sync_message = "正在同步 {} {}/{}：{}".format(
                    repository.get("name", "仓库"),
                    index,
                    len(manifest["files"]),
                    item["path"],
                )
                stage_type = os.path.join(stage_root, item["type"])
                destination = self._safe_archive_member_path(
                    stage_type, item["relative"]
                )
                previous = previous_files.get(
                    (str(item["type"]), str(item["relative"]))
                )
                previous_path = str(previous.get("destination", "")) if previous else ""
                can_reuse = bool(
                    item.get("sha256")
                    and previous
                    and str(previous.get("sha256", "")) == str(item["sha256"])
                    and previous_path
                    and os.path.isfile(previous_path)
                    and os.path.realpath(previous_path) not in dirty_managed
                )
                if can_reuse:
                    os.makedirs(os.path.dirname(destination), exist_ok=True)
                    shutil.copy2(previous_path, destination)
                    size = os.path.getsize(destination)
                    actual_sha = str(previous.get("sha256", ""))
                    reused_file_count += 1
                else:
                    size, actual_sha = self._download_repository_file(
                        item, destination
                    )
                    total_size += size
                    downloaded_file_count += 1
                    if total_size > self.MAX_REPOSITORY_TOTAL_SIZE:
                        raise ValueError("仓库下载总大小超过上限")
                final_destination = os.path.join(
                    self._repository_target_directory(
                        repository, item["type"]
                    ),
                    *item["relative"].split("/"),
                )
                state_files.append(
                    {
                        "path": item["path"],
                        "type": item["type"],
                        "relative": item["relative"],
                        "destination": os.path.realpath(final_destination),
                        "size": size,
                        "sha256": actual_sha,
                    }
                )
            local_file_count = self._repository_copy_local_files(
                local_files, stage_root
            )
            for source_type in {item["type"] for item in manifest["files"]}:
                stage_type = os.path.join(stage_root, source_type)
                os.makedirs(stage_type, exist_ok=True)
                self._write_repository_marker(
                    stage_type, repository, manifest
                )
            content_changed = bool(dirty_managed) or not (
                self._repository_download_matches_state(state, state_files)
            )
            if (
                not content_changed
                and str(state.get("manifestDigest", ""))
                == str(manifest.get("digest", ""))
                and not install_required
            ):
                shutil.rmtree(work_root, ignore_errors=True)
                return {
                    "repository": repository,
                    "manifest": manifest,
                    "state": state,
                    "unchanged": True,
                    "download_size": total_size,
                    "file_count": len(state_files),
                    "downloaded_file_count": downloaded_file_count,
                    "reused_file_count": reused_file_count,
                    "removed_file_count": removed_file_count,
                    "local_file_count": local_file_count,
                }
            return {
                "repository": repository,
                "manifest": manifest,
                "old_state": state,
                "new_state": {
                    "version": manifest.get("version", ""),
                    "manifestDigest": manifest.get("digest", ""),
                    "etag": manifest.get("etag", ""),
                    "lastModified": manifest.get("last_modified", ""),
                    "installedAt": time.strftime("%Y-%m-%d %H:%M:%S"),
                    "files": state_files,
                },
                "unchanged": False,
                "download_size": total_size,
                "file_count": len(state_files),
                "downloaded_file_count": downloaded_file_count,
                "reused_file_count": reused_file_count,
                "removed_file_count": removed_file_count,
                "local_file_count": local_file_count,
                "repaired_file_count": len(dirty_managed),
                "content_changed": content_changed,
                "work_root": work_root,
                "stage_root": stage_root,
                "backup_root": backup_root,
                "source_types": sorted(source_types),
            }
        except Exception:
            shutil.rmtree(work_root, ignore_errors=True)
            raise

    def _install_repository_transaction(self, prepared):
        if prepared.get("unchanged"):
            return prepared
        repository = prepared["repository"]
        moves = []
        try:
            os.makedirs(prepared["backup_root"], exist_ok=True)
            for source_type in prepared["source_types"]:
                target = self._repository_target_directory(
                    repository, source_type
                )
                stage = os.path.join(prepared["stage_root"], source_type)
                backup = os.path.join(prepared["backup_root"], source_type)
                os.makedirs(os.path.dirname(target), exist_ok=True)
                had_target = os.path.isdir(target)
                move = {
                    "type": source_type,
                    "target": target,
                    "backup": backup,
                    "had_target": had_target,
                }
                if had_target:
                    os.replace(target, backup)
                moves.append(move)
                if os.path.isdir(stage):
                    os.replace(stage, target)
            prepared["moves"] = moves
            prepared["installed"] = True
            return prepared
        except Exception:
            prepared["moves"] = moves
            self._rollback_repository_transaction(prepared)
            raise

    def _restore_repository_state_file(self, transaction):
        repository = transaction["repository"]
        old_state = transaction.get("old_state", {})
        path = self._repository_state_path(repository)
        if old_state:
            self._write_repository_state(repository, old_state)
        else:
            try:
                os.remove(path)
            except FileNotFoundError:
                pass

    def _rollback_repository_transaction(self, transaction):
        if not isinstance(transaction, dict) or transaction.get("unchanged"):
            return
        for move in reversed(transaction.get("moves", [])):
            target = move["target"]
            backup = move["backup"]
            if os.path.isdir(target):
                shutil.rmtree(target, ignore_errors=True)
            if os.path.isdir(backup):
                os.replace(backup, target)
        self._restore_repository_state_file(transaction)
        shutil.rmtree(transaction.get("work_root", ""), ignore_errors=True)

    def _persist_repository_transaction(self, transaction):
        if transaction.get("unchanged"):
            return
        self._write_repository_state(
            transaction["repository"], transaction["new_state"]
        )

    def _finalize_repository_transaction(self, transaction):
        if transaction.get("unchanged"):
            return
        shutil.rmtree(transaction.get("work_root", ""), ignore_errors=True)

    def _update_repository_sync_metadata(self, repository_id, prepared, status, message):
        stored = self._find_online_repository(repository_id)
        if stored is None:
            return
        manifest = prepared.get("manifest", {}) if isinstance(prepared, dict) else {}
        if manifest:
            stored["version"] = str(manifest.get("version", ""))
            stored["manifest_digest"] = str(manifest.get("digest", ""))
            stored["file_count"] = len(manifest.get("files", []))
        stored["last_sync"] = time.strftime("%Y-%m-%d %H:%M:%S")
        stored["last_status"] = str(status or "")[:24]
        stored["last_message"] = str(message or "")[:240]

    def _rollback_repository_batch(self, transactions, refresh=True):
        errors = []
        for transaction in reversed(transactions):
            try:
                self._rollback_repository_transaction(transaction)
            except Exception as exc:
                errors.append(
                    "{}: {}".format(
                        transaction.get("repository", {}).get("name", "仓库"),
                        exc,
                    )
                )
        if not refresh:
            return "；".join(errors)
        try:
            with self.lock:
                ok = self._refresh_locked(
                    allow_empty=not any(self.type_enabled.values())
                )
                self.inited = True
                if not ok:
                    errors.append(self.status.get("error") or "回滚后扫描失败")
                else:
                    reloaded, detail = self._reload_app_vod_config_sync(
                        expected_keys=self._generated_registry_keys()
                    )
                    if not reloaded:
                        errors.append(detail)
        except Exception as exc:
            errors.append("回滚后加载失败: {}".format(exc))
        return "；".join(errors)

    def _repository_sync_worker(self, repositories):
        prepared_successes = []
        changed_transactions = []
        failures = []
        reload_failed = False
        trace_id = self._repository_sync_trace_id or self._new_operation_trace_id(
            "REPO"
        )
        self._repository_sync_trace_id = trace_id
        task_started = time.monotonic()
        self._trace_log(
            trace_id,
            "repository",
            "worker.begin",
            repository_count=len(repositories),
        )
        try:
            self._ensure_background_task_active(
                self._repository_sync_cancel, "在线仓库同步"
            )
            total = len(repositories)
            for index, repository in enumerate(repositories, 1):
                self._ensure_background_task_active(
                    self._repository_sync_cancel, "在线仓库同步"
                )
                repository_id = str(repository.get("id", ""))
                repository_name = str(repository.get("name", "在线仓库"))
                repository_started = time.monotonic()
                phase = "manifest"
                prepared = None
                self._repository_sync_active_id = repository_id
                self._repository_sync_active_name = repository_name
                try:
                    self._trace_log(
                        trace_id,
                        "repository",
                        "repository.begin",
                        index=index,
                        total=total,
                        repository_id=repository_id,
                        repository_name=repository_name,
                        manifest_url=self._safe_log_url(repository.get("url", "")),
                    )
                    self._repository_sync_state = "downloading"
                    self._repository_sync_message = "正在同步 {}/{}：{}".format(
                        index, total, repository_name
                    )
                    self._notify_app(self._repository_sync_message)
                    prepared = self._prepare_repository_sync(repository)
                    self._ensure_background_task_active(
                        self._repository_sync_cancel, "在线仓库同步"
                    )
                    manifest = prepared.get("manifest", {})
                    self._trace_log(
                        trace_id,
                        "repository",
                        "manifest.prepared",
                        repository_name=repository_name,
                        version=manifest.get("version", ""),
                        manifest_digest=str(manifest.get("digest", ""))[:16],
                        fully_hashed=bool(manifest.get("fully_hashed")),
                        unchanged=bool(prepared.get("unchanged")),
                        content_changed=bool(prepared.get("content_changed")),
                        file_count=prepared.get("file_count", 0),
                        downloaded_files=prepared.get("downloaded_file_count", 0),
                        reused_files=prepared.get("reused_file_count", 0),
                        removed_files=prepared.get("removed_file_count", 0),
                        preserved_local_files=prepared.get(
                            "local_file_count", 0
                        ),
                        downloaded_bytes=prepared.get("download_size", 0),
                    )
                    if not prepared.get("unchanged"):
                        phase = "install"
                        prepared = self._install_repository_transaction(prepared)
                        changed_transactions.append(prepared)
                        self._trace_log(
                            trace_id,
                            "repository",
                            "install.staged",
                            repository_name=repository_name,
                            source_types=prepared.get("source_types", []),
                            targets=[
                                move.get("target", "")
                                for move in prepared.get("moves", [])
                            ],
                            elapsed_ms=int(
                                (time.monotonic() - repository_started) * 1000
                            ),
                        )
                    else:
                        self._trace_log(
                            trace_id,
                            "repository",
                            "install.skipped",
                            repository_name=repository_name,
                            reason="manifest and installed files unchanged",
                            elapsed_ms=int(
                                (time.monotonic() - repository_started) * 1000
                            ),
                        )
                    prepared_successes.append(prepared)
                except BackgroundTaskCancelled:
                    if isinstance(prepared, dict) and not prepared.get("installed"):
                        shutil.rmtree(prepared.get("work_root", ""), ignore_errors=True)
                    raise
                except Exception as exc:
                    failures.append({"name": repository_name, "error": str(exc)})
                    self._trace_log(
                        trace_id,
                        "repository",
                        "repository.failed",
                        level="ERROR",
                        repository_name=repository_name,
                        phase=phase,
                        elapsed_ms=int(
                            (time.monotonic() - repository_started) * 1000
                        ),
                        error=str(exc),
                    )
                    self._update_repository_sync_metadata(
                        repository_id, {}, "error", str(exc)
                    )
                    self._log(
                        "WARN",
                        "在线仓库 {} 同步失败: {}".format(repository_name, exc),
                    )
            if not prepared_successes:
                raise ValueError(
                    "；".join(
                        "{}: {}".format(item["name"], item["error"])
                        for item in failures
                    )
                    or "没有仓库同步成功"
                )
            content_changed_transactions = [
                item for item in changed_transactions
                if item.get("content_changed", True)
            ]
            reload_detail = "文件未变化，已更新仓库清单"
            self._ensure_background_task_active(
                self._repository_sync_cancel, "在线仓库同步"
            )
            with self.lock:
                scan_settings_changed = (
                    self._apply_pending_scan_settings_for_download("在线仓库下载")
                )
                adult_filter_rules_changed = (
                    self._adult_filter_rules_need_refresh()
                )
                repository_targets = []
                for prepared in prepared_successes:
                    repository = prepared.get("repository", {})
                    source_types = {
                        str(item.get("type", "")).strip()
                        for item in prepared.get("manifest", {}).get("files", [])
                        if str(item.get("type", "")).strip()
                    }
                    repository_targets.extend(
                        self._repository_target_directory(repository, source_type)
                        for source_type in source_types
                    )
                adult_allowances_reset = self._reset_downloaded_adult_allowances(
                    repository_targets,
                    "在线仓库下载",
                )
                requires_scan = bool(
                    content_changed_transactions
                    or scan_settings_changed
                    or adult_filter_rules_changed
                    or adult_allowances_reset
                )
                if requires_scan:
                    self._repository_sync_state = "scanning"
                    self._repository_sync_message = (
                        "仓库文件已下载，正在统一扫描并加载"
                        if content_changed_transactions
                        else "扫描设置已更新，正在重新扫描并加载"
                    )
                    self._trace_log(
                        trace_id,
                        "repository",
                        "scan.begin",
                        changed_count=len(content_changed_transactions),
                        scan_settings_changed=scan_settings_changed,
                        adult_filter_rules_changed=adult_filter_rules_changed,
                        adult_allowances_reset=adult_allowances_reset,
                        targets=[
                            move.get("target", "")
                            for item in content_changed_transactions
                            for move in item.get("moves", [])
                        ],
                    )
                    ok = self._refresh_locked(
                        allow_empty=not any(self.type_enabled.values())
                    )
                    self.inited = True
                    if not ok:
                        raise ValueError(
                            self.status.get("error") or self.status.get("write_state")
                        )
                    self._trace_log(
                        trace_id,
                        "repository",
                        "scan.complete",
                        found=self.status.get("found", 0),
                        included=self.status.get("included", 0),
                        skipped=self.status.get("skipped", 0),
                        compatibility_blocked=self.status.get(
                            "compatibility_blocked", 0
                        ),
                        write_state=self.status.get("write_state", ""),
                    )
                    self._trace_log(
                        trace_id,
                        "repository",
                        "reload.begin",
                        expected_site_count=len(self._generated_registry_keys()),
                        app_mode=self._app_mode_label(),
                    )
                    self._background_reload_in_progress.set()
                    try:
                        reloaded, reload_detail = self._reload_app_vod_config_sync(
                            expected_keys=self._generated_registry_keys()
                        )
                    finally:
                        self._background_reload_in_progress.clear()
                    self._trace_log(
                        trace_id,
                        "repository",
                        "reload.complete",
                        level="INFO" if reloaded else "WARN",
                        ok=bool(reloaded),
                        detail=reload_detail,
                    )
                    if not reloaded:
                        reload_failed = True
                        self._warn(
                            "在线仓库已下载、校验并扫描，{}；文件已保留，重启 App 后生效".format(
                                reload_detail
                            )
                        )
            self._ensure_background_task_active(
                self._repository_sync_cancel, "在线仓库同步"
            )
            for prepared in prepared_successes:
                self._persist_repository_transaction(prepared)
                status = "unchanged" if prepared.get("unchanged") else "success"
                detail = (
                    "内容未变化"
                    if prepared.get("unchanged")
                    else "已安装 {} 个文件（下载 {}，复用 {}，删除 {}）".format(
                        prepared.get("file_count", 0),
                        prepared.get("downloaded_file_count", 0),
                        prepared.get("reused_file_count", 0),
                        prepared.get("removed_file_count", 0),
                    )
                )
                self._update_repository_sync_metadata(
                    prepared["repository"].get("id", ""),
                    prepared,
                    status,
                    detail,
                )
                self._trace_log(
                    trace_id,
                    "repository",
                    "state.persisted",
                    repository_name=prepared["repository"].get("name", "在线仓库"),
                    status=status,
                    state_path=self._repository_state_path(prepared["repository"]),
                    detail=detail,
                )
            self._save_settings()
            for prepared in prepared_successes:
                self._finalize_repository_transaction(prepared)
            self._trace_log(
                trace_id,
                "repository",
                "install.committed",
                transaction_count=len(changed_transactions),
            )
            failure_text = "；".join(
                "{}: {}".format(item["name"], item["error"])
                for item in failures
            )
            changed_count = sum(
                1 for item in prepared_successes if not item.get("unchanged")
            )
            unchanged_count = len(prepared_successes) - changed_count
            message = "在线仓库同步完成：成功 {}/{}，更新 {} 个、未变化 {} 个{}；{}".format(
                len(prepared_successes),
                len(repositories),
                changed_count,
                unchanged_count,
                "，失败 {} 个（{}）".format(len(failures), failure_text)
                if failures
                else "",
                reload_detail,
            )
            self._repository_sync_state = (
                "partial" if failures or reload_failed else "success"
            )
            self._repository_sync_message = message
            self._trace_log(
                trace_id,
                "repository",
                "worker.complete",
                level="WARN" if failures or reload_failed else "INFO",
                state=self._repository_sync_state,
                success_count=len(prepared_successes),
                changed_count=changed_count,
                unchanged_count=unchanged_count,
                failure_count=len(failures),
                reload_failed=reload_failed,
                elapsed_ms=int((time.monotonic() - task_started) * 1000),
            )
            self._log("WARN" if failures or reload_failed else "INFO", message)
            self._notify_app(message)
        except BackgroundTaskCancelled as exc:
            rollback_error = (
                self._rollback_repository_batch(
                    changed_transactions, refresh=False
                )
                if changed_transactions
                else ""
            )
            message = "{}{}".format(
                exc, "；回滚异常：" + rollback_error if rollback_error else ""
            )
            self._repository_sync_state = "cancelled"
            self._repository_sync_message = message
            self._log("INFO", message)
        except Exception as exc:
            self._trace_log(
                trace_id,
                "repository",
                "rollback.begin",
                level="ERROR",
                transaction_count=len(changed_transactions),
                error=str(exc),
            )
            rollback_error = (
                self._rollback_repository_batch(changed_transactions)
                if changed_transactions
                else ""
            )
            self._trace_log(
                trace_id,
                "repository",
                "rollback.complete",
                level="WARN" if rollback_error else "INFO",
                transaction_count=len(changed_transactions),
                rollback_error=rollback_error,
            )
            for transaction in changed_transactions:
                self._update_repository_sync_metadata(
                    transaction.get("repository", {}).get("id", ""),
                    {},
                    "error",
                    "同步失败，已恢复上一次可用文件",
                )
            message = "在线仓库同步失败：{}{}".format(
                exc,
                "；回滚异常：{}".format(rollback_error)
                if rollback_error
                else "；已恢复同步前文件"
                if changed_transactions
                else "",
            )
            self._repository_sync_state = "error"
            self._repository_sync_message = message
            try:
                self._save_settings()
            except Exception:
                pass
            self._log("ERROR", message)
            self._notify_app(message)
        finally:
            self._trace_log(
                trace_id,
                "repository",
                "worker.end",
                level=(
                    "INFO"
                    if self._repository_sync_state == "success"
                    else "WARN"
                ),
                state=self._repository_sync_state,
                elapsed_ms=int((time.monotonic() - task_started) * 1000),
            )
            self._repository_sync_active_id = ""
            self._repository_sync_active_name = ""
            self._repository_sync_trace_id = ""
            with self._repository_sync_lock:
                self._repository_sync_thread = None
            self._background_completion_allowed.clear()
            self._release_background_operation("repository")

    def _start_repository_sync(self):
        if self._site_test_is_running():
            return False, "后台检测进行中，请待本批完成后再同步仓库"
        if self._package_download_running():
            return False, "本地包下载安装中，请完成后再同步仓库"
        repositories = self._enabled_online_repositories()
        if not repositories:
            return False, (
                "未配置在线仓库，请先在设置中填写备注名和仓库地址"
                if not self.online_repositories
                else "没有已启用的在线仓库，请先在设置中开启"
            )
        with self._repository_sync_lock:
            worker = self._repository_sync_thread
            if worker is not None and worker.is_alive():
                return False, "在线仓库同步中，请稍候"
            reserved, active = self._reserve_background_operation("repository")
            if not reserved:
                return False, self._background_operation_conflict(active)
            self._repository_sync_cancel.clear()
            self._background_completion_allowed.clear()
            self._destroyed = False
            trace_id = self._new_operation_trace_id("REPO")
            self._repository_sync_trace_id = trace_id
            self._trace_log(
                trace_id,
                "repository",
                "start.request",
                version=self.VERSION,
                app_mode=self._app_mode_label(),
                repository_count=len(repositories),
                scan_roots=[
                    "{}:{}".format(item.get("type", ""), item.get("path", ""))
                    for item in self.scan_roots
                ],
            )
            try:
                self._authorize_release("package_download")
            except ReleaseAuthorizationError as exc:
                self._authorization_last_error = str(exc)
                self._repository_sync_state = "denied"
                self._repository_sync_message = (
                    "当前版本未获得在线仓库下载授权，已拒绝同步：{}".format(exc)
                )
                self._trace_log(
                    trace_id,
                    "repository",
                    "authorization.denied",
                    level="WARN",
                    error=str(exc),
                )
                self._log("WARN", self._repository_sync_message)
                self._notify_app(self._repository_sync_message)
                self._release_background_operation("repository")
                return False, self._repository_sync_message
            except Exception:
                self._release_background_operation("repository")
                raise
            self._trace_log(
                trace_id,
                "repository",
                "authorization.allowed",
            )
            names = "、".join(item.get("name", "仓库") for item in repositories)
            self._repository_sync_state = "queued"
            self._repository_sync_message = "已加入同步任务：{}".format(names)
            worker = threading.Thread(
                target=self._repository_sync_worker,
                args=(repositories,),
                name="local-online-repository-sync",
            )
            worker.daemon = True
            self._repository_sync_thread = worker
            try:
                worker.start()
            except Exception:
                self._repository_sync_thread = None
                self._release_background_operation("repository")
                raise
            self._trace_log(
                trace_id,
                "repository",
                "worker.started",
                thread=worker.name,
                repositories=[
                    str(item.get("name", "在线仓库")) for item in repositories
                ],
            )
        return True, "已开始同步 {} 个在线仓库：{}".format(
            len(repositories), names
        )

    def _add_or_update_online_repository(self, name, url):
        if self._repository_sync_running():
            raise ValueError("在线仓库同步中，暂不能修改仓库")
        clean_name = self._normalize_repository_name(name)
        clean_url = self._normalize_repository_url(url)
        name_key = clean_name.casefold()
        previous = copy.deepcopy(self.online_repositories)
        target = next(
            (
                item
                for item in self.online_repositories
                if str(item.get("name", "")).casefold() == name_key
            ),
            None,
        )
        for item in self.online_repositories:
            if item is target:
                continue
            if self._normalize_repository_url(item.get("url", "")) == clean_url:
                raise ValueError("该仓库地址已由备注“{}”使用".format(item.get("name", "")))
        created = target is None
        if target is None:
            target = {
                "id": self._repository_id(clean_name),
                "name": clean_name,
                "url": clean_url,
                "enabled": True,
                "version": "",
                "manifest_digest": "",
                "file_count": 0,
                "last_sync": "",
                "last_status": "",
                "last_message": "",
            }
            self.online_repositories.append(target)
        else:
            target["url"] = clean_url
            target["enabled"] = True
        try:
            self._save_settings()
        except Exception:
            self.online_repositories = previous
            raise
        return dict(target), created

    def _set_online_repository_states(self, values):
        if self._repository_sync_running():
            raise ValueError("在线仓库同步中，暂不能修改开关")
        previous = copy.deepcopy(self.online_repositories)
        changed = False
        for item in self.online_repositories:
            repository_id = str(item.get("id", ""))
            if repository_id not in values:
                continue
            enabled = bool(values[repository_id])
            if self._as_bool(item.get("enabled", True), True) != enabled:
                item["enabled"] = enabled
                changed = True
        if not changed:
            return False
        try:
            self._save_settings()
        except Exception:
            self.online_repositories = previous
            raise
        return True

    def _repository_owned_directories(self, repository):
        state = self._load_repository_state(repository)
        source_types = {
            str(item.get("type", ""))
            for item in state.get("files", [])
            if str(item.get("type", ""))
        }
        result = []
        for source_type in source_types:
            target = self._repository_target_directory(repository, source_type)
            marker = self._read_repository_marker(target)
            if os.path.isdir(target) and str(marker.get("id", "")) == str(
                repository.get("id", "")
            ):
                result.append(target)
        return result

    def _delete_online_repositories(self, repository_ids):
        selected = {str(item) for item in repository_ids if str(item)}
        if not selected:
            raise ValueError("请至少选择一个在线仓库")
        with self._repository_sync_lock:
            running_worker = self._repository_sync_thread
        if (
            running_worker is not None
            and running_worker.is_alive()
            and running_worker is not threading.current_thread()
        ):
            raise ValueError("在线仓库同步中，暂不能删除")
        repositories = [
            item
            for item in self.online_repositories
            if str(item.get("id", "")) in selected
        ]
        if not repositories:
            raise ValueError("所选在线仓库不存在")
        token = self._digest(str(time.time_ns()), 12)
        trash_root = os.path.join(
            self._repository_state_directory(), ".delete-{}".format(token)
        )
        moved = []
        previous = copy.deepcopy(self.online_repositories)
        try:
            os.makedirs(trash_root)
            for repository in repositories:
                state = self._load_repository_state(repository)
                source_types = {
                    str(item.get("type", ""))
                    for item in state.get("files", [])
                    if str(item.get("type", ""))
                }
                local_files = self._repository_assert_existing_clean(
                    repository,
                    state,
                    source_types,
                )
                for source_type in source_types:
                    target = self._repository_target_directory(
                        repository, source_type
                    )
                    marker = self._read_repository_marker(target)
                    if not os.path.isdir(target) or str(
                        marker.get("id", "")
                    ) != str(repository.get("id", "")):
                        continue
                    backup = os.path.join(
                        trash_root,
                        repository["id"],
                        os.path.basename(os.path.dirname(target)),
                    )
                    os.makedirs(os.path.dirname(backup), exist_ok=True)
                    os.replace(target, backup)
                    moved.append((target, backup))
                    for item in local_files.get(source_type, []):
                        relative = str(item.get("relative", ""))
                        source = self._safe_archive_member_path(
                            backup, relative
                        )
                        destination = self._safe_archive_member_path(
                            target, relative
                        )
                        os.makedirs(os.path.dirname(destination), exist_ok=True)
                        shutil.copy2(source, destination)
            self.online_repositories = [
                item
                for item in self.online_repositories
                if str(item.get("id", "")) not in selected
            ]
            self._save_settings()
            with self.lock:
                ok = self._refresh_locked(
                    allow_empty=not any(self.type_enabled.values())
                )
                self.inited = True
                if not ok:
                    raise ValueError(
                        self.status.get("error") or self.status.get("write_state")
                    )
                reloaded, detail = self._reload_app_vod_config_sync(
                    expected_keys=self._generated_registry_keys()
                )
                if not reloaded:
                    raise ValueError("App 重载校验失败：{}".format(detail))
            for repository in repositories:
                try:
                    os.remove(self._repository_state_path(repository))
                except FileNotFoundError:
                    pass
                except Exception as exc:
                    self._warn(
                        "在线仓库已删除，旧状态文件清理失败: {}".format(exc)
                    )
            shutil.rmtree(trash_root, ignore_errors=True)
            return repositories, detail
        except Exception:
            self.online_repositories = previous
            for target, backup in reversed(moved):
                if os.path.isdir(backup):
                    if os.path.isdir(target):
                        shutil.rmtree(target, ignore_errors=True)
                    os.makedirs(os.path.dirname(target), exist_ok=True)
                    os.replace(backup, target)
            shutil.rmtree(trash_root, ignore_errors=True)
            try:
                self._save_settings()
            except Exception:
                pass
            try:
                with self.lock:
                    if self._refresh_locked(
                        allow_empty=not any(self.type_enabled.values())
                    ):
                        self.inited = True
                        self._reload_app_vod_config_sync(
                            expected_keys=self._generated_registry_keys()
                        )
            except Exception as rollback_exc:
                self._warn(
                    "在线仓库删除回滚后的配置恢复失败: {}".format(
                        rollback_exc
                    )
                )
            raise

    # --------------------------------------------------------------------------
    # 扫描与配置生成
    # --------------------------------------------------------------------------
    def _ensure_initialized(self):
        if self.inited:
            return
        self.init("")

    def _manual_scan_state_key(self):
        return os.path.realpath(
            os.path.abspath(os.path.expanduser(self.output_path))
        )

    def _begin_manual_scan_request(self):
        key = self._manual_scan_state_key()
        now = time.monotonic()
        with _MANUAL_SCAN_LOCK:
            state = _MANUAL_SCAN_STATE.setdefault(
                key, {"running": False, "last": 0.0}
            )
            if state["running"]:
                return False, "扫描请求已合并：当前扫描正在进行"
            elapsed = now - float(state.get("last", 0.0) or 0.0)
            if elapsed < max(0.5, float(self.MANUAL_SCAN_DEDUP_WINDOW)):
                return False, "已忽略重复触发：本次扫描刚刚完成"
            state["running"] = True
            state["started"] = now
        return True, ""

    def _finish_manual_scan_request(self):
        key = self._manual_scan_state_key()
        with _MANUAL_SCAN_LOCK:
            state = _MANUAL_SCAN_STATE.setdefault(
                key, {"running": False, "last": 0.0}
            )
            state["running"] = False
            state["last"] = time.monotonic()

    def _refresh_locked(self, allow_empty=False, accept_complete_empty=False):
        self.status = self._empty_status()
        self.status["scan_time"] = time.strftime("%Y-%m-%d %H:%M:%S")
        enabled_roots = [
            "{}={}".format(item.get("type", "?"), item.get("path", ""))
            for item in self.scan_roots
            if self.type_enabled.get(str(item.get("type", "")).upper(), True)
        ]
        self._log(
            "INFO",
            "开始扫描: allow_empty={} accept_complete_empty={} roots={}".format(
                bool(self.allow_empty_write or allow_empty),
                bool(accept_complete_empty),
                "; ".join(enabled_roots) or "无已开启目录",
            ),
        )
        try:
            self._write_playback_proxy_runtime_config()
            self._scan_all_roots()
            if self.status["limit_reached"]:
                self.status["write_state"] = "扫描达到保护上限，已保护旧注册表"
                self.status["error"] = "请缩小扫描目录或调整 max_files"
                self._log("WARN", self.status["error"])
                return False
            complete_empty_scan = bool(
                accept_complete_empty
                and enabled_roots
                and not self.status["found"]
                and not self.incomplete_scan_roots
                and not self.incomplete_scan_types
            )
            if (
                not self.cache["sources"]
                and not self.cache["ignored"]
                and not self.cache.get("lives")
                and not (
                    self.allow_empty_write or allow_empty or complete_empty_scan
                )
                and not self._package_filter_excluded
                and not self.status["compatibility_blocked"]
            ):
                self.status["write_state"] = "未找到有效源，已保护旧配置"
                self.status["error"] = "扫描结果为空，未改写站点注入注册表"
                self._log("WARN", self.status["error"])
                return False
            if (
                not self.cache["sources"]
                and not self.cache.get("lives")
                and self.status["compatibility_blocked"]
            ):
                self._warn(
                    "检测到会导致当前 App 退出或接口不兼容的 JAR，"
                    "已清除对应旧注入站点"
                )
            self._generate_config()
            completed = self.status["written"] or self.status["write_state"] == "配置内容未变化"
            if completed:
                try:
                    self._save_scan_snapshot()
                except Exception as exc:
                    self._warn("扫描列表快照保存失败: {}".format(exc))
                if self.auto_scan_suspended:
                    self.auto_scan_suspended = False
                    try:
                        self._save_settings()
                    except Exception as exc:
                        self._warn("自动补扫状态保存失败: {}".format(exc))
                if self._adult_filter_rules_need_refresh():
                    self.adult_filter_revision = self.ADULT_FILTER_REVISION
                    try:
                        self._save_settings()
                    except Exception as exc:
                        self._warn("18+识别规则版本保存失败: {}".format(exc))
            self._log(
                "INFO",
                "扫描完成: 发现={} 有效={} 直播={} 播放上下文={} 忽略={} 跳过={} 重复={} 状态={}".format(
                    self.status["found"],
                    self.status["included"],
                    self.status["generated_lives"],
                    len(self.cache.get("contexts", [])),
                    self.status["ignored"],
                    self.status["skipped"],
                    self.status["duplicates"],
                    self.status["write_state"],
                ),
            )
            return completed
        except Exception as exc:
            self.status["error"] = str(exc)
            self.status["write_state"] = "合并失败"
            self._log("ERROR", "扫描合并失败: {}".format(exc))
            return False

    def _auto_scan_on_enter_locked(self):
        """无有效快照时进入管理页自动补扫一次。

        仅扫描和写入注册表，不做站点网络检测；一键清除或恢复备份后暂停，
        直到下次手动扫描。进程内冷却防止“补扫 -> 重载 -> 再补扫”循环。
        """
        if not self.auto_scan_on_empty or self.auto_scan_suspended:
            return False
        if self.config_dirty or not any(self.type_enabled.values()):
            return False
        now = time.monotonic()
        if now - _AUTO_SCAN_STATE["last"] < self.AUTO_SCAN_COOLDOWN:
            return False
        _AUTO_SCAN_STATE["last"] = now
        ok = self._refresh_locked()
        if not ok:
            if (
                not self.cache["sources"]
                and not self.status["limit_reached"]
                and self.status["write_state"] == "未找到有效源，已保护旧配置"
            ):
                if self.cache["ignored"]:
                    # 保留 cache，屏蔽列表仍可在界面恢复
                    self.status["write_state"] = "全部本地站点已屏蔽，可在屏蔽分类中恢复"
                    self.status["error"] = ""
                else:
                    self._set_manual_idle_status("未发现本地站点，请点击“扫描并加载”")
            return False
        self.status["write_state"] += " · 进入自动补扫"
        if self.status["registry_changed"] and self._snapshot_matches_registry():
            self._reload_app_vod_config(
                expected_keys=self._generated_registry_keys()
            )
        return True

    def _suspend_auto_scan(self):
        if self.auto_scan_suspended:
            return
        self.auto_scan_suspended = True
        try:
            self._save_settings()
        except Exception as exc:
            self._warn("自动补扫暂停状态保存失败: {}".format(exc))

    def _snapshot_matches_registry(self):
        data = self._load_scan_cache_payload(warn=False)
        snapshot = data.get("snapshot", {}) if isinstance(data, dict) else {}
        if not isinstance(snapshot, dict):
            return False
        token = str(snapshot.get("registry_token", ""))
        return bool(token) and token == self._registry_token(self.output_path)

    def _set_manual_idle_status(self, state="请点击“扫描并加载”"):
        self.cache = self._empty_cache()
        self.status = self._empty_status()
        self.status["write_state"] = state
        try:
            registry = self._load_registry()
            items = registry.get("items", [])
            if isinstance(items, list):
                self.status["generated_sites"] = sum(
                    1 for item in items if self._is_generated_registry_item(item)
                )
                self.status["manual_sites"] = len(items) - self.status["generated_sites"]
        except Exception as exc:
            self._warn("注册表状态读取失败: {}".format(exc))

    def _clear_scan_cache_file(self):
        removed = 0
        path = os.path.abspath(os.path.expanduser(self.cache_path))
        protected = {
            os.path.abspath(os.path.expanduser(item))
            for item in (
                self.registry_path,
                self.output_path,
                self.settings_path,
                self.roots_config_path,
            )
        }
        if path in protected:
            self._warn("扫描缓存路径与配置文件冲突，已跳过删除: {}".format(path))
            return 0
        for candidate in (path, path + ".tmp"):
            try:
                os.remove(candidate)
                removed += 1
            except FileNotFoundError:
                pass
            except Exception as exc:
                self._warn("扫描缓存删除失败: {} ({})".format(candidate, exc))
        return removed

    def _scan_all_roots(self):
        self.cache = self._empty_cache()
        self._package_filter_excluded = False
        self._jar_inspection_cache = {}
        self.incomplete_scan_roots = []
        self.incomplete_scan_types = set()
        sources = []
        ignored_sources = []
        live_sources = []
        root_contexts = []
        site_enabled_package_ids = (
            self._site_enabled_package_download_site_ids()
        )
        live_enabled_package_ids = (
            self._live_enabled_package_download_site_ids()
        )
        seen_paths = set()
        self_path = os.path.realpath(__file__)
        old_file_cache = self._load_scan_cache()
        new_file_cache = {}
        available_types = set()
        limit_reached = False

        for root_order, spec in enumerate(self.scan_roots):
            if limit_reached:
                break
            source_type = str(spec.get("type", "")).upper()
            if source_type not in self.TYPE_ORDER:
                self._warn("忽略未知类型目录: {}".format(spec))
                continue
            if not self.type_enabled.get(source_type, True):
                continue
            root = os.path.abspath(os.path.expanduser(str(spec.get("path", ""))))
            extensions = {
                self._normalize_extension(ext)
                for ext in spec.get("extensions", self.TYPE_EXTENSIONS[source_type])
            }
            extensions.discard("")
            if not os.path.isdir(root):
                self._warn("目录不存在: {}".format(root))
                self._mark_scan_incomplete(source_type, root)
                continue
            available_types.add(source_type)
            manifest_owned_paths = self._manifest_owned_source_paths(
                root, source_type
            )
            manifest_owned_paths.update(
                self._bundle_owned_source_paths(root, source_type)
            )
            js_runtime_paths = (
                self._js_runtime_dependency_paths(root)
                if source_type == "JS"
                else set()
            )
            if source_type in ("XBPQ", "CSP"):
                local_jar_pairs, local_jar_ambiguous = self._discover_json_jar_pairs(
                    root, manifest_owned_paths
                )
            else:
                local_jar_pairs, local_jar_ambiguous = {}, set()

            def walk_error(exc, current_type=source_type, current_root=root):
                failed_path = getattr(exc, "filename", "") or current_root
                self._mark_scan_incomplete(current_type, failed_path)
                self._warn("扫描目录读取失败: {} ({})".format(failed_path, exc))

            for current, dirs, files in os.walk(
                root, topdown=True, onerror=walk_error, followlinks=False
            ):
                relative_dir = os.path.relpath(current, root)
                depth = 0 if relative_dir == "." else relative_dir.count(os.sep) + 1
                dirs[:] = sorted(
                    [
                        name
                        for name in dirs
                        if not name.startswith(".")
                        and name.lower() not in self.SKIP_DIRS
                        and not os.path.islink(os.path.join(current, name))
                    ],
                    key=lambda value: value.lower(),
                )
                if depth >= self.max_scan_depth:
                    dirs[:] = []
                for file_name in sorted(files, key=lambda value: value.lower()):
                    full_path = os.path.join(current, file_name)
                    lower_name = file_name.lower()
                    extension = os.path.splitext(lower_name)[1]
                    if extension not in extensions:
                        continue
                    is_manifest = self._is_site_manifest_name(lower_name)
                    if source_type == "JS" and extension == ".json" and not is_manifest:
                        continue
                    candidate_path = os.path.realpath(full_path)
                    if not is_manifest and candidate_path in manifest_owned_paths:
                        continue
                    if source_type == "JS" and candidate_path in js_runtime_paths:
                        continue
                    if self.status["found"] >= self.max_scan_files:
                        limit_reached = True
                        self.status["limit_reached"] = True
                        self._warn(
                            "已达扫描文件上限 {}，后续文件未扫描".format(
                                self.max_scan_files
                            )
                        )
                        break
                    self.status["found"] += 1
                    if os.path.islink(full_path) or not os.path.isfile(full_path):
                        self.status["skipped"] += 1
                        continue
                    real_path = os.path.realpath(full_path)
                    if real_path == self_path and not self._is_auto_loader_python(
                        source_type, lower_name
                    ):
                        continue
                    if real_path in seen_paths:
                        self.status["duplicates"] += 1
                        continue
                    try:
                        readable = os.access(real_path, os.R_OK)
                        stat = os.stat(real_path)
                        file_size = stat.st_size
                        modified_ns = getattr(stat, "st_mtime_ns", int(stat.st_mtime * 1000000000))
                    except Exception as exc:
                        self.status["skipped"] += 1
                        self._mark_scan_incomplete(source_type, full_path)
                        self._warn("读取文件状态失败: {} ({})".format(real_path, exc))
                        continue
                    if not readable or file_size <= 0:
                        self.status["skipped"] += 1
                        self._warn("跳过不可读或空文件: {}".format(real_path))
                        continue
                    if file_size > self.max_source_size:
                        self.status["skipped"] += 1
                        self._warn(
                            "跳过超过大小上限的文件: {} ({} bytes)".format(
                                real_path, file_size
                            )
                        )
                        continue

                    relative_in_root = os.path.relpath(real_path, root).replace(os.sep, "/")
                    if self._is_excluded(source_type, lower_name, relative_in_root):
                        self.status["skipped"] += 1
                        continue
                    identity = self._source_identity(source_type, real_path)
                    if identity in self.pruned_sources:
                        # 已被「自动清理屏蔽站点」移出配置。源文件仍在磁盘上，
                        # 从清除入口复位后即可重新参与扫描。
                        self.status["skipped"] += 1
                        continue

                    bundle = None
                    marker = {}
                    package_id = ""
                    package_label = ""
                    package_sites_enabled = True
                    package_lives_enabled = False
                    if (
                        source_type in ("XBPQ", "CSP")
                        and extension == ".json"
                        and not is_manifest
                    ):
                        marker = self._installed_package_marker(root, real_path)
                        package_id = str(marker.get("id", "")).strip()
                        package_label = str(marker.get("name", "")).strip()
                        package_sites_enabled = (
                            not package_id
                            or package_id in site_enabled_package_ids
                        )
                        package_lives_enabled = bool(
                            package_id
                            and package_id in live_enabled_package_ids
                        )
                        if package_id and (
                            not package_sites_enabled
                            or not package_lives_enabled
                        ):
                            self._package_filter_excluded = True
                        bundle = self._parse_site_bundle(
                            real_path,
                            include_sites=package_sites_enabled,
                            include_lives=package_lives_enabled,
                            include_context=bool(
                                package_id
                                and package_id in site_enabled_package_ids
                            ),
                        )
                    if bundle is not None:
                        role = self._detect_file_role(source_type, lower_name, real_path)
                        if role not in ("source", "forced_source"):
                            self.status["skipped"] += 1
                            self._warn("已按 {} 标识排除: {}".format(role, real_path))
                            continue
                        self.status["cache_misses"] += 1
                        seen_paths.add(real_path)
                        new_file_cache[identity] = {
                            "size": file_size,
                            "mtime_ns": modified_ns,
                            "strict": bool(self.strict_recognition),
                            "role": "bundle",
                            "valid": bool(bundle["sites"]),
                            "validation": "TVBox 整包配置",
                        }
                        root_context = bundle.get("root_context", {})
                        if (
                            isinstance(root_context, dict)
                            and root_context
                            and package_id in site_enabled_package_ids
                        ):
                            root_contexts.append(
                                {
                                    "id": self.GENERATED_CONTEXT_PREFIX
                                    + self._digest(
                                        "{}|{}".format(
                                            package_id, self._file_url(real_path)
                                        ),
                                        16,
                                    ),
                                    "package_id": package_id,
                                    "package_name": package_label,
                                    "path": real_path,
                                    "context": copy.deepcopy(root_context),
                                    "dependencies": copy.deepcopy(
                                        bundle.get(
                                            "root_context_dependencies", []
                                        )
                                    ),
                                }
                            )
                            self._log(
                                "INFO",
                                "整包播放上下文待集成: {} 字段={} 下载站点={}".format(
                                    real_path,
                                    ",".join(root_context.keys()),
                                    package_label or package_id,
                                ),
                            )
                        elif (
                            isinstance(root_context, dict)
                            and root_context
                            and package_id
                        ):
                            self._log(
                                "INFO",
                                "整包播放上下文未集成: {} 字段={} 原因=下载站点已关闭或删除".format(
                                    real_path, ",".join(root_context.keys())
                                ),
                            )
                        if package_id in site_enabled_package_ids:
                            for item in bundle.get("root_context_rejected", []):
                                self._log(
                                    "WARN",
                                    "整包播放上下文已跳过: {}.{} ({})".format(
                                        os.path.basename(real_path),
                                        item.get("field", "未知字段"),
                                        item.get("reason", "无法识别"),
                                    ),
                                )
                        if (
                            bundle.get("lives")
                            and package_id in live_enabled_package_ids
                        ):
                            for entry in bundle["lives"]:
                                live = copy.deepcopy(entry["live"])
                                live_name = str(live.get("name", "直播源")).strip()
                                if package_label and not live_name.startswith(
                                    "【{}】".format(package_label)
                                ):
                                    live["name"] = "【{}】{}".format(
                                        package_label, live_name
                                    )
                                identity_live = self._bundle_live_identity(
                                    real_path, live
                                )
                                live_sources.append(
                                    {
                                        "id": self.GENERATED_LIVE_PREFIX
                                        + self._digest(identity_live, 16),
                                        "identity": identity_live,
                                        "package_id": package_id,
                                        "package_name": package_label,
                                        "path": real_path,
                                        "live": live,
                                        "dependencies": entry["dependencies"],
                                    }
                                )
                            self._log(
                                "INFO",
                                "整包直播待集成: {} 直播={} 下载站点={}".format(
                                    real_path,
                                    len(bundle["lives"]),
                                    package_label or package_id,
                                ),
                            )
                        elif bundle.get("lives") and package_id:
                            self._log(
                                "INFO",
                                "整包直播未集成: {} 直播={} 原因=下载站点已关闭或删除".format(
                                    real_path, len(bundle["lives"])
                                ),
                            )
                        for entry in bundle["sites"]:
                            site = entry["site"]
                            site_identity = self._bundle_source_identity(
                                source_type, real_path, site
                            )
                            if site_identity in self.pruned_sources:
                                # 整包内的单个站点被清理：跳过该站点，
                                # 同包其它站点不受影响，api.json 不动。
                                self.status["skipped"] += 1
                                continue
                            if site_identity in seen_paths:
                                self.status["duplicates"] += 1
                                continue
                            seen_paths.add(site_identity)
                            new_file_cache[site_identity] = {
                                "size": file_size,
                                "mtime_ns": modified_ns,
                                "strict": bool(self.strict_recognition),
                                "role": "bundle_site",
                                "valid": True,
                                "validation": entry["validation"],
                            }
                            source_id = "src_" + self._digest(site_identity, 20)
                            key = (
                                self.GENERATED_KEY_PREFIX
                                + source_type.lower()
                                + "_"
                                + self._digest(site_identity, 14)
                            )
                            source = {
                                "id": source_id,
                                "identity": site_identity,
                                "key": key,
                                "type": source_type,
                                "path": real_path,
                                "scan_root": root,
                                "root_order": root_order,
                                "relative_in_root": "{}::site:{:04d}".format(
                                    relative_in_root, entry["index"]
                                ),
                                "base_name": str(site.get("name", "")).strip(),
                                "package_label": self._bundle_package_label(
                                    root, real_path
                                ),
                                "package_id": package_id,
                                "validation": entry["validation"],
                                "ignored": site_identity in self.ignored_sources,
                                "size": file_size,
                                "mtime_ns": modified_ns,
                                "csp_site": site,
                                "dependencies": entry["dependencies"],
                            }
                            api_path = self._site_reference_path(
                                site.get("api", "")
                            )
                            if (
                                api_path
                                and api_path.lower().endswith(".py")
                                and os.path.isfile(api_path)
                            ):
                                source["playback_proxy_path"] = api_path
                            test_result = self.site_test_results.get(site_identity, {})
                            if not isinstance(test_result, dict) or test_result.get(
                                "source_signature"
                            ) != self._source_signature(source):
                                test_result = {}
                            source["test_result"] = test_result
                            if source["ignored"]:
                                ignored_sources.append(source)
                            else:
                                sources.append(source)
                        rejected = bundle["rejected"]
                        self.status["skipped"] += len(rejected)
                        self._log(
                            "INFO",
                            "整包配置识别: {} 总站点={} 集成={} 跳过={} 站点开关={}".format(
                                real_path,
                                bundle["total"],
                                len(bundle["sites"]),
                                len(rejected),
                                "开" if package_sites_enabled else "关",
                            ),
                        )
                        if rejected:
                            self._warn(
                                "整包配置已跳过 {} 个依赖不完整或不兼容站点: {}".format(
                                    len(rejected), os.path.basename(real_path)
                                )
                            )
                            for item in rejected:
                                self._log(
                                    "WARN",
                                    "整包站点已跳过: {} ({})".format(
                                        item["name"], item["reason"]
                                    ),
                                )
                        if package_id in live_enabled_package_ids:
                            for item in bundle.get("live_rejected", []):
                                self._log(
                                    "WARN",
                                    "整包直播已跳过: {} ({})".format(
                                        item["name"], item["reason"]
                                    ),
                                )
                        continue

                    # CSP 整包（例如 type: 4 CMS 采集站）已在上方优先解析，
                    # 不需要本地 JAR。只有未识别为整包的普通 CSP JSON 才要求配对 JAR。
                    if (
                        source_type == "CSP"
                        and not is_manifest
                        and real_path not in local_jar_pairs
                        and real_path not in local_jar_ambiguous
                    ):
                        self.status["skipped"] += 1
                        continue

                    if package_id and not package_sites_enabled:
                        self._log(
                            "INFO",
                            "本地包站点文件未集成: {} 原因=站点开关已关闭".format(
                                real_path
                            ),
                        )
                        continue

                    cached = old_file_cache.get(identity)
                    cache_hit = (
                        isinstance(cached, dict)
                        and cached.get("size") == file_size
                        and cached.get("mtime_ns") == modified_ns
                        and cached.get("strict") == bool(self.strict_recognition)
                        and not is_manifest
                    )
                    if cache_hit:
                        role = str(cached.get("role", "source"))
                        valid = bool(cached.get("valid", True))
                        validation = str(cached.get("validation", ""))
                        self.status["cache_hits"] += 1
                    else:
                        role = self._detect_file_role(source_type, lower_name, real_path)
                        forced = role == "forced_source"
                        valid, validation = (
                            (True, "已通过 @tvbox-source 强制收录")
                            if forced
                            else self._validate_source(source_type, real_path)
                        )
                        self.status["cache_misses"] += 1
                    new_file_cache[identity] = {
                        "size": file_size,
                        "mtime_ns": modified_ns,
                        "strict": bool(self.strict_recognition),
                        "role": role,
                        "valid": bool(valid),
                        "validation": validation,
                    }
                    if role not in ("source", "forced_source"):
                        self.status["skipped"] += 1
                        self._warn("已按 {} 标识排除: {}".format(role, real_path))
                        continue
                    if not valid and self.strict_recognition:
                        self.status["skipped"] += 1
                        self._warn(validation)
                        continue
                    if validation and not valid:
                        self._warn(validation)
                    csp_site = None
                    dependencies = []
                    if source_type == "JS" and not is_manifest:
                        try:
                            js_text = self._read_text(real_path, 512 * 1024)
                            if (
                                self._has_drpy_rule(js_text)
                                and not self._has_direct_js_spider(js_text)
                            ):
                                runtime_path = self._find_js_runtime(
                                    real_path, root
                                )
                                if not runtime_path:
                                    self.status["skipped"] += 1
                                    self._warn(
                                        "DRPY 规则缺少 drpy2 运行时，已跳过: {}".format(
                                            real_path
                                        )
                                    )
                                    continue
                                csp_site = {
                                    "name": self._drpy_rule_title(js_text)
                                    or os.path.splitext(file_name)[0],
                                    "api": self._file_url(runtime_path),
                                    "ext": self._file_url(real_path),
                                    "type": 3,
                                    "searchable": self.DEFAULT_SEARCHABLE,
                                    "quickSearch": self.DEFAULT_QUICK_SEARCH,
                                }
                                dependencies = [runtime_path]
                                validation = "DRPY 规则；运行时 {}".format(
                                    os.path.basename(runtime_path)
                                )
                        except Exception as exc:
                            self.status["skipped"] += 1
                            self._warn(
                                "DRPY 规则配对失败: {} ({})".format(
                                    real_path, exc
                                )
                            )
                            continue
                    if source_type in ("XBPQ", "CSP") and not is_manifest:
                        if real_path in local_jar_ambiguous:
                            self.status["skipped"] += 1
                            self._warn(
                                "{} JAR 绑定不明确，已跳过: {}".format(
                                    source_type, real_path
                                )
                            )
                            continue
                        paired_jar = local_jar_pairs.get(real_path)
                        if paired_jar:
                            try:
                                if source_type == "XBPQ":
                                    csp_site, dependencies, validation = (
                                        self._auto_xbpq_site(real_path, paired_jar)
                                    )
                                else:
                                    csp_site, dependencies, validation = (
                                        self._auto_csp_site(real_path, paired_jar)
                                    )
                            except Exception as exc:
                                self.status["skipped"] += 1
                                self._warn(
                                    "{} 自动配对失败: {} ({})".format(
                                        source_type, real_path, exc
                                    )
                                )
                                continue
                        elif source_type == "XBPQ":
                            ready, runtime_message = self._xbpq_runtime_status()
                            if not ready:
                                self.status["skipped"] += 1
                                self.incomplete_scan_types.add(source_type)
                                self._warn(runtime_message)
                                continue
                    if is_manifest and source_type in ("JS", "XBPQ", "CSP"):
                        try:
                            csp_site, dependencies, validation = (
                                self._parse_site_manifest(real_path, source_type)
                            )
                        except Exception as exc:
                            self.status["skipped"] += 1
                            self._warn(
                                "站点清单解析失败: {} ({})".format(
                                    real_path, exc
                                )
                            )
                            continue

                    seen_paths.add(real_path)
                    if csp_site is not None:
                        base_name = str(csp_site.get("name", "")).strip()
                    else:
                        base_name = file_name[: -len(extension)] if extension else file_name
                    source_id = "src_" + self._digest(identity, 20)
                    key = self.GENERATED_KEY_PREFIX + source_type.lower() + "_" + self._digest(
                        identity, 14
                    )
                    source = {
                        "id": source_id,
                        "identity": identity,
                        "key": key,
                        "type": source_type,
                        "path": real_path,
                        "scan_root": root,
                        "root_order": root_order,
                        "relative_in_root": relative_in_root,
                        "base_name": base_name,
                        "validation": validation,
                        "ignored": identity in self.ignored_sources,
                        "size": file_size,
                        "mtime_ns": modified_ns,
                    }
                    package_label = self._installed_package_label(root, real_path)
                    if package_label:
                        source["package_label"] = package_label
                    if csp_site is not None:
                        source["csp_site"] = csp_site
                        source["dependencies"] = dependencies
                    test_result = self.site_test_results.get(identity, {})
                    if not isinstance(test_result, dict) or test_result.get(
                        "source_signature"
                    ) != self._source_signature(source):
                        test_result = {}
                    source["test_result"] = test_result
                    if source["ignored"]:
                        ignored_sources.append(source)
                    else:
                        sources.append(source)
                if limit_reached:
                    break

        all_sources = sources + ignored_sources
        if self.block_adult_sites:
            previous_adult_blocked = set(self.adult_blocked_sources)
            previous_adult_allowed = set(self.adult_allowed_sources)
            for source in all_sources:
                identity = source["identity"]
                is_adult = self._is_adult_source(source)
                if is_adult and identity not in self.adult_allowed_sources:
                    self.adult_blocked_sources.add(identity)
                    source["adult_blocked"] = True
                    source["ignored"] = True
                    self.status["adult_filtered"] += 1
                    self._log(
                        "INFO",
                        "18+站点已加入屏蔽列表: {} ({})".format(
                            source.get("base_name", "未命名站点"),
                            source.get("relative_in_root", source.get("path", "")),
                        ),
                    )
                else:
                    self.adult_blocked_sources.discard(identity)
                    source["adult_blocked"] = False
                    source["ignored"] = identity in (
                        self.manual_ignored_sources | self.auto_blocked_sources
                    )
                    if not is_adult:
                        self.adult_allowed_sources.discard(identity)
            self._sync_ignored_sources()
            if self.status["adult_filtered"]:
                self._log(
                    "INFO",
                    "18+站点屏蔽完成: {} 个，可在屏蔽分类中手动恢复".format(
                        self.status["adult_filtered"]
                    ),
                )
            if (
                previous_adult_blocked != self.adult_blocked_sources
                or previous_adult_allowed != self.adult_allowed_sources
            ):
                try:
                    self._save_settings()
                except Exception as exc:
                    self._warn("18+站点屏蔽状态保存失败: {}".format(exc))
        else:
            for source in all_sources:
                source["adult_blocked"] = False
                source["ignored"] = source["identity"] in (
                    self.manual_ignored_sources | self.auto_blocked_sources
                )
        self._apply_display_names(all_sources)
        all_sources.sort(
            key=lambda item: (
                item["root_order"],
                self.TYPE_ORDER[item["type"]],
                item["relative_in_root"].lower(),
            )
        )

        deduplicated_sources = []
        active_fingerprints = set()
        active_proxy_wrappers = set()
        for source in all_sources:
            source["site"] = self._build_site(source)
            for proxy_path_field in ("proxy_wrapper_path", "proxy_payload_path"):
                if source.get(proxy_path_field):
                    active_proxy_wrappers.add(source[proxy_path_field])
            fingerprint = self._site_fingerprint(source["site"])
            if (
                not source["ignored"]
                and fingerprint
                and fingerprint in active_fingerprints
            ):
                self.status["duplicates"] += 1
                self._log(
                    "INFO",
                    "语义重复站点已去重: {} ({})".format(
                        source["base_name"], source["relative_in_root"]
                    ),
                )
                continue
            if not source["ignored"] and fingerprint:
                active_fingerprints.add(fingerprint)
            deduplicated_sources.append(source)

        self._cleanup_playback_proxy_wrappers(active_proxy_wrappers)
        self._cleanup_adjacent_playback_proxy_wrappers(
            active_proxy_wrappers, all_sources
        )

        all_sources = deduplicated_sources
        for source in all_sources:
            self.cache["source_index"][source["id"]] = source
            source_type = source["type"]
            counts_key = "ignored_counts" if source["ignored"] else "type_counts"
            counts = self.cache[counts_key]
            counts[source_type] = counts.get(source_type, 0) + 1

        self.cache["sources"] = [item for item in all_sources if not item["ignored"]]
        self.cache["ignored"] = [item for item in all_sources if item["ignored"]]
        deduplicated_lives = []
        seen_live_fingerprints = set()
        for item in live_sources:
            fingerprint = self._live_fingerprint(item.get("live", {}))
            if not fingerprint or fingerprint in seen_live_fingerprints:
                self.status["duplicate_lives"] += 1
                self._log(
                    "INFO",
                    "语义重复直播已去重: {}".format(
                        item.get("live", {}).get("name", "未命名直播")
                    ),
                )
                continue
            seen_live_fingerprints.add(fingerprint)
            deduplicated_lives.append(item)
        self.cache["lives"] = deduplicated_lives
        self.cache["contexts"] = root_contexts
        self.status["generated_lives"] = len(deduplicated_lives)
        self.status["included"] = len(self.cache["sources"])
        self.status["ignored"] = len(self.cache["ignored"])
        persisted_identities = (
            self.ignored_sources
            | self.adult_blocked_sources
            | self.adult_allowed_sources
        )
        stale_ignored = {
            identity
            for identity in persisted_identities
            if not limit_reached
            and identity.split("|", 1)[0] in available_types
            and not self._scan_failure_covers_identity(identity)
            and identity not in new_file_cache
        }
        if stale_ignored:
            self.manual_ignored_sources.difference_update(stale_ignored)
            self.auto_blocked_sources.difference_update(stale_ignored)
            self.adult_blocked_sources.difference_update(stale_ignored)
            self.adult_allowed_sources.difference_update(stale_ignored)
            self._sync_ignored_sources()
            self.status["stale_ignored_removed"] = len(stale_ignored)
        stale_test_results = {
            identity
            for identity in self.site_test_results
            if not limit_reached
            and identity.split("|", 1)[0] in available_types
            and not self._scan_failure_covers_identity(identity)
            and identity not in new_file_cache
        }
        for identity in stale_test_results:
            self.site_test_results.pop(identity, None)
        if stale_ignored or stale_test_results:
            try:
                self._save_settings()
            except Exception as exc:
                self._warn("过期扫描状态清理保存失败: {}".format(exc))
        for identity, cached in old_file_cache.items():
            if identity not in new_file_cache and self._scan_failure_covers_identity(identity):
                new_file_cache[identity] = cached
        try:
            self._save_scan_cache(new_file_cache)
        except Exception as exc:
            self._warn("增量扫描缓存保存失败: {}".format(exc))

    def _mark_scan_incomplete(self, source_type, path):
        source_type = str(source_type or "").upper()
        normalized = os.path.realpath(os.path.abspath(os.path.expanduser(str(path))))
        marker = (source_type, normalized)
        if marker not in self.incomplete_scan_roots:
            self.incomplete_scan_roots.append(marker)

    def _reference_path(self, reference):
        value = str(reference or "").strip()
        if not value.lower().startswith("file://"):
            return ""
        path = value[7:]
        if not os.path.isabs(path):
            path = os.path.join(self.STORAGE_ROOT, path)
        return os.path.realpath(os.path.abspath(os.path.expanduser(path)))

    def _identity_source_path(self, identity):
        parts = str(identity or "").split("|", 1)
        reference = parts[1].split("#bundle-site-", 1)[0] if len(parts) == 2 else ""
        return self._reference_path(reference) if reference else ""

    def _path_is_within(self, path, parent):
        if not path or not parent:
            return False
        try:
            return os.path.commonpath((path, parent)) == parent
        except Exception:
            return False

    def _scan_failure_covers_identity(self, identity):
        source_type = str(identity or "").split("|", 1)[0].upper()
        if source_type in self.incomplete_scan_types:
            return True
        path = self._identity_source_path(identity)
        return any(
            item_type == source_type and self._path_is_within(path, failed_path)
            for item_type, failed_path in self.incomplete_scan_roots
        )

    def _source_identity(self, source_type, path):
        return source_type + "|" + self._file_url(path)

    def _bundle_source_identity(self, source_type, bundle_path, site):
        identity = {
            "key": str(site.get("key", "")),
            "name": str(site.get("name", "")),
            "api": site.get("api", ""),
            "ext": site.get("ext", ""),
            "jar": site.get("jar", ""),
            "homePage": site.get("homePage", site.get("home_page", "")),
        }
        raw = json.dumps(
            identity, ensure_ascii=False, sort_keys=True, separators=(",", ":")
        )
        return "{}|{}#bundle-site-{}".format(
            source_type,
            self._file_url(bundle_path),
            self._digest(raw, 20),
        )

    def _live_fingerprint(self, live):
        if not isinstance(live, dict):
            return ""
        url = str(live.get("url", "")).strip()
        if url:
            if urllib.parse.urlsplit(url).scheme.lower() in ("http", "https"):
                try:
                    url = self._encoded_download_url(url)
                except Exception:
                    pass
            return "url|" + url.casefold()
        api = str(live.get("api", "")).strip()
        if api:
            data = {
                "api": api,
                "ext": live.get("ext", ""),
                "jar": live.get("jar", ""),
            }
        else:
            data = {"groups": live.get("groups", [])}
        return json.dumps(
            data, ensure_ascii=False, sort_keys=True, separators=(",", ":")
        )

    def _bundle_live_identity(self, bundle_path, live):
        return "LIVE|{}#bundle-live-{}".format(
            self._file_url(bundle_path),
            self._digest(self._live_fingerprint(live), 20),
        )

    def _bundle_package_label(self, scan_root, bundle_path):
        installed_label = self._installed_package_label(scan_root, bundle_path)
        if installed_label:
            return installed_label
        root = os.path.realpath(os.path.abspath(os.path.expanduser(str(scan_root))))
        path = os.path.realpath(os.path.abspath(os.path.expanduser(str(bundle_path))))
        try:
            relative = os.path.relpath(path, root)
        except Exception:
            relative = os.path.basename(path)
        parts = [part for part in relative.split(os.sep) if part not in ("", ".", "..")]
        if len(parts) > 1:
            label = parts[0]
        else:
            root_name = os.path.basename(root.rstrip(os.sep))
            generic_roots = {
                "xbpq", "csp", "js", "javascript", "py", "python", "html",
            }
            label = (
                os.path.splitext(os.path.basename(path))[0]
                if root_name.lower() in generic_roots
                else root_name
            )
        label = re.sub(r"[\r\n\t【】]+", " ", str(label)).strip()
        return label[:32] or os.path.splitext(os.path.basename(path))[0][:32] or "本地包"

    def _installed_package_marker(self, scan_root, source_path):
        root = os.path.realpath(os.path.abspath(os.path.expanduser(str(scan_root))))
        path = os.path.realpath(os.path.abspath(os.path.expanduser(str(source_path))))
        try:
            relative = os.path.relpath(path, root)
        except Exception:
            return {}
        parts = [part for part in relative.split(os.sep) if part not in ("", ".", "..")]
        if len(parts) < 2:
            return {}
        package_root = os.path.join(root, parts[0])
        marker = self._read_package_install_marker(package_root)
        return marker if isinstance(marker, dict) else {}

    def _installed_package_label(self, scan_root, source_path):
        marker = self._installed_package_marker(scan_root, source_path)
        label = str(marker.get("name", "")).strip()
        if not label:
            return ""
        label = re.sub(r"[\r\n\t【】]+", " ", label).strip()
        return label[:32]

    def _site_enabled_package_download_site_ids(self):
        return {
            str(item.get("id", "")).strip()
            for item in self.package_download_sites
            if self._package_download_site_enabled(item)
            and str(item.get("id", "")).strip()
        }

    def _live_enabled_package_download_site_ids(self):
        return {
            str(item.get("id", "")).strip()
            for item in self.package_download_sites
            if self._package_download_live_enabled(item)
            and str(item.get("id", "")).strip()
        }

    def _package_download_state_signature(self):
        values = sorted(
            "{}:s{}:l{}".format(
                str(item.get("id", "")).strip(),
                "1" if self._package_download_site_enabled(item) else "0",
                "1" if self._package_download_live_enabled(item) else "0",
            )
            for item in self.package_download_sites
            if str(item.get("id", "")).strip()
        )
        return self._digest("\n".join(values), 24)

    def _strip_json_comments(self, text):
        result = []
        index = 0
        in_string = False
        escaped = False
        length = len(text)
        while index < length:
            char = text[index]
            if in_string:
                result.append(char)
                if escaped:
                    escaped = False
                elif char == "\\":
                    escaped = True
                elif char == '"':
                    in_string = False
                index += 1
                continue
            if char == '"':
                in_string = True
                result.append(char)
                index += 1
                continue
            if char == "/" and index + 1 < length:
                marker = text[index + 1]
                if marker == "/":
                    index += 2
                    while index < length and text[index] not in "\r\n":
                        index += 1
                    continue
                if marker == "*":
                    index += 2
                    while index + 1 < length and text[index : index + 2] != "*/":
                        if text[index] in "\r\n":
                            result.append(text[index])
                        index += 1
                    index = min(length, index + 2)
                    continue
            result.append(char)
            index += 1
        return "".join(result)

    def _strip_json_trailing_commas(self, text):
        result = []
        index = 0
        in_string = False
        escaped = False
        length = len(text)
        while index < length:
            char = text[index]
            if in_string:
                result.append(char)
                if escaped:
                    escaped = False
                elif char == "\\":
                    escaped = True
                elif char == '"':
                    in_string = False
                index += 1
                continue
            if char == '"':
                in_string = True
                result.append(char)
                index += 1
                continue
            if char == ",":
                lookahead = index + 1
                while lookahead < length and text[lookahead].isspace():
                    lookahead += 1
                if lookahead < length and text[lookahead] in "}]":
                    index += 1
                    continue
            result.append(char)
            index += 1
        return "".join(result)

    def _load_json_compatible(self, path):
        with open(path, "r", encoding="utf-8-sig") as fp:
            text = fp.read()
        try:
            return json.loads(text)
        except json.JSONDecodeError:
            cleaned = self._strip_json_trailing_commas(
                self._strip_json_comments(text)
            )
            return json.loads(cleaned)

    def _local_file_looks_json(self, path):
        try:
            with open(path, "rb") as fp:
                prefix = fp.read(4096)
            text = prefix.decode("utf-8-sig", errors="replace").lstrip()
            return text.startswith(("{", "["))
        except Exception:
            return False

    def _parse_site_bundle(
        self, path, include_sites=True, include_lives=True,
        include_context=True,
    ):
        try:
            data = self._load_json_compatible(path)
        except Exception:
            return None
        if not isinstance(data, dict):
            return None
        raw_sites = data.get("sites", [])
        if isinstance(raw_sites, dict):
            raw_sites = [raw_sites]
        if not isinstance(raw_sites, list):
            raw_sites = []
        raw_lives_value = data.get("lives", [])
        if not raw_sites and (
            "lives" not in data
            or not isinstance(raw_lives_value, (list, dict))
        ):
            return None
        data["sites"] = raw_sites

        default_jar = data.get("spider", "")
        accepted = []
        rejected = []
        for index, raw_site in enumerate(
            data["sites"] if include_sites else []
        ):
            raw_name = "站点 #{}".format(index + 1)
            if isinstance(raw_site, dict):
                raw_name = str(
                    raw_site.get("name")
                    or raw_site.get("key")
                    or raw_name
                ).strip()
            try:
                site, dependencies, validation = self._normalize_bundle_site(
                    path, raw_site, default_jar, index
                )
                accepted.append(
                    {
                        "index": index,
                        "site": site,
                        "dependencies": dependencies,
                        "validation": validation,
                    }
                )
            except Exception as exc:
                rejected.append({"index": index, "name": raw_name, "reason": str(exc)})
        accepted_lives = []
        rejected_lives = []
        raw_lives = data.get("lives", [])
        if isinstance(raw_lives, dict):
            raw_lives = [raw_lives]
        if not isinstance(raw_lives, list):
            raw_lives = []
        raw_lives = [
            item for item in raw_lives if not isinstance(item, dict) or bool(item)
        ]
        for index, raw_live in enumerate(raw_lives if include_lives else []):
            raw_name = "直播 #{}".format(index + 1)
            if isinstance(raw_live, dict):
                raw_name = str(raw_live.get("name") or raw_name).strip()
            try:
                live, dependencies = self._normalize_bundle_live(
                    path, raw_live, default_jar, index
                )
                accepted_lives.append(
                    {
                        "index": index,
                        "live": live,
                        "dependencies": dependencies,
                    }
                )
            except Exception as exc:
                rejected_lives.append(
                    {"index": index, "name": raw_name, "reason": str(exc)}
                )
        root_context = {}
        root_context_dependencies = []
        rejected_context = []
        for field in self.PACKAGE_ROOT_CONTEXT_FIELDS:
            if not include_context:
                break
            if field not in data:
                continue
            value = data.get(field)
            if value in (None, "", [], {}):
                continue
            dependencies = []
            try:
                normalized = self._normalize_bundle_nested_refs(
                    path, copy.deepcopy(value), field, dependencies
                )
                root_context[field] = normalized
                root_context_dependencies.extend(dependencies)
            except Exception as exc:
                rejected_context.append(
                    {"field": field, "reason": str(exc)}
                )
        return {
            "total": len(data["sites"]),
            "sites": accepted,
            "rejected": rejected,
            "live_total": len(raw_lives),
            "lives": accepted_lives,
            "live_rejected": rejected_lives,
            "root_context": root_context,
            "root_context_dependencies": list(
                dict.fromkeys(root_context_dependencies)
            ),
            "root_context_rejected": rejected_context,
        }

    def _normalize_bundle_live(self, bundle_path, raw_live, default_jar, index):
        if not isinstance(raw_live, dict):
            raise ValueError("直播配置必须是 JSON 对象")
        live = copy.deepcopy(raw_live)
        name = str(live.get("name", "")).strip()
        if not name:
            raise ValueError("直播配置缺少 name")
        live["name"] = name
        dependencies = []

        for field in ("url", "api", "ext", "logo", "epg"):
            value = live.get(field, "")
            if not isinstance(value, str) or not value.strip():
                continue
            reference = self._resolve_bundle_reference(
                bundle_path, value, field
            )
            path = self._require_local_dependency(reference, field)
            if path:
                if field == "api" and path.lower().endswith((".py", ".js")):
                    source_type = "PY" if path.lower().endswith(".py") else "JS"
                    valid, detail = self._validate_source(source_type, path)
                    if not valid:
                        raise ValueError(detail)
                if (
                    field == "ext"
                    and path.lower().endswith((".json", ".jsonc"))
                    and self._local_file_looks_json(path)
                ):
                    self._load_json_compatible(path)
                dependencies.append(path)
            live[field] = reference

        jar_value = live.get("jar", "") or default_jar
        if jar_value:
            if not isinstance(jar_value, str):
                raise ValueError("直播 jar 必须是路径或 URL")
            jar_reference = self._resolve_bundle_reference(
                bundle_path, jar_value, "jar", with_md5=True
            )
            jar_path = self._require_local_dependency(
                jar_reference, "直播 jar", with_md5=True
            )
            if jar_path:
                actual_md5 = self._inspect_local_jar(jar_path)["md5"]
                jar_reference = (
                    jar_reference.split(";md5;", 1)[0].strip()
                    + ";md5;"
                    + actual_md5
                )
                dependencies.append(jar_path)
            live["jar"] = jar_reference

        has_groups = isinstance(live.get("groups"), list) and bool(
            live.get("groups")
        )
        if not str(live.get("url", "")).strip() and not str(
            live.get("api", "")
        ).strip() and not has_groups:
            raise ValueError("直播配置缺少 url、api 或 groups")
        return live, list(dict.fromkeys(dependencies))

    def _looks_like_bundle_local_reference(self, bundle_path, value, field):
        source = str(value or "").strip().split(";md5;", 1)[0].strip()
        if not source or source.startswith(("{", "[")):
            return False
        field_key = str(field or "").strip().lower()
        local_reference_fields = {
            "api", "ext", "jar", "homepage", "home_page", "filters", "filter",
            "class", "classes", "type", "config", "configs", "file", "path",
            "script", "source", "url", "logo", "epg",
        }
        if field == "api" and source.startswith("csp_"):
            return False
        scheme = urllib.parse.urlsplit(source).scheme.lower()
        if scheme in ("http", "https", "assets", "proxy"):
            return False
        if scheme in ("file", "clan"):
            return True
        if scheme:
            return False
        if source.startswith(("./", "../")):
            return True
        if os.path.isabs(source):
            storage_root = os.path.realpath(os.path.abspath(self.STORAGE_ROOT))
            real_source = os.path.realpath(os.path.abspath(source))
            local_prefixes = ("/storage/", "/sdcard/", "/data/")
            if (
                os.path.exists(source)
                or real_source == storage_root
                or real_source.startswith(storage_root + os.sep)
                or source.startswith(local_prefixes)
            ):
                return True
        sibling = os.path.join(os.path.dirname(bundle_path), source)
        if os.path.exists(sibling):
            return True
        extension = os.path.splitext(source.lower())[1]
        local_extensions = {
            ".json", ".jsonc", ".jar", ".py", ".js", ".html", ".htm",
            ".txt", ".m3u", ".m3u8",
        }
        if field == "jar":
            return True
        return extension in local_extensions and field_key in local_reference_fields

    def _resolve_bundle_reference(self, bundle_path, value, field, with_md5=False):
        source = str(value or "").strip()
        suffix = ""
        if with_md5 and ";md5;" in source:
            source, digest = source.split(";md5;", 1)
            source = source.strip()
            suffix = ";md5;" + digest.strip().lower()
        parsed = urllib.parse.urlsplit(source)
        if parsed.scheme.lower() == "clan" and parsed.hostname in (
            "localhost", "127.0.0.1"
        ):
            local_path = os.path.join(self.STORAGE_ROOT, parsed.path.lstrip("/"))
            return self._file_url(local_path) + suffix
        if not self._looks_like_bundle_local_reference(
            bundle_path, source, field
        ):
            return source + suffix
        return self._resolve_site_reference(
            bundle_path, source + suffix, with_md5=with_md5
        )

    def _require_local_dependency(self, reference, label, with_md5=False):
        path = self._site_reference_path(reference, with_md5=with_md5)
        if not path:
            return ""
        if not os.path.isfile(path) or not os.access(path, os.R_OK):
            raise ValueError("{} 不存在或不可读: {}".format(label, reference))
        if os.path.getsize(path) <= 0:
            raise ValueError("{} 是空文件: {}".format(label, reference))
        return path

    def _normalize_bundle_nested_refs(
        self, bundle_path, value, field, dependencies
    ):
        if isinstance(value, dict):
            return {
                key: self._normalize_bundle_nested_refs(
                    bundle_path, item, str(key), dependencies
                )
                for key, item in value.items()
            }
        if isinstance(value, list):
            return [
                self._normalize_bundle_nested_refs(
                    bundle_path, item, field, dependencies
                )
                for item in value
            ]
        if not isinstance(value, str) or not self._looks_like_bundle_local_reference(
            bundle_path, value, field
        ):
            return value
        with_md5 = field in ("jar", "spider")
        reference = self._resolve_bundle_reference(
            bundle_path, value, field, with_md5=with_md5
        )
        path = self._require_local_dependency(
            reference, field or "嵌套依赖", with_md5=with_md5
        )
        if path:
            if (
                path.lower().endswith((".json", ".jsonc"))
                and self._local_file_looks_json(path)
            ):
                self._load_json_compatible(path)
            dependencies.append(path)
        return reference

    def _normalize_bundle_site(self, bundle_path, raw_site, default_jar, index):
        if not isinstance(raw_site, dict):
            raise ValueError("站点必须是 JSON 对象")
        site = copy.deepcopy(raw_site)
        raw_home_page = site.get("homePage", site.get("home_page", ""))
        is_webhome = isinstance(raw_home_page, str) and bool(raw_home_page.strip())
        api = self._runtime_reference(self.html_api) if is_webhome else str(
            site.get("api", "")
        ).strip()
        if not api:
            raise ValueError("缺少 api")

        dependencies = []
        validation = []
        api_is_local = self._looks_like_bundle_local_reference(
            bundle_path, api, "api"
        )
        if not api_is_local:
            localized_api = self._valid_cached_bundle_python(api)
            if localized_api:
                api = self._file_url(localized_api)
                api_is_local = True
                validation.append("整包远程 PY 已下载到本地")
        if api_is_local:
            api = self._resolve_bundle_reference(bundle_path, api, "api")
            api_path = self._require_local_dependency(api, "api")
            lower_api = api_path.lower()
            if lower_api.endswith(".py"):
                valid, detail = self._validate_source("PY", api_path)
            elif lower_api.endswith(".js"):
                valid, detail = self._validate_source("JS", api_path)
            else:
                valid, detail = True, ""
            if not valid:
                raise ValueError(detail)
            dependencies.append(api_path)
            if detail:
                validation.append(detail)
            site["api"] = api
        else:
            scheme = urllib.parse.urlsplit(api).scheme.lower()
            if not api.startswith("csp_") and scheme not in (
                "http", "https", "assets", "proxy"
            ):
                raise ValueError("api 类型无法确认: {}".format(api))
            site["api"] = api

        home_page = raw_home_page
        if isinstance(home_page, str) and home_page.strip():
            resolved_home = self._resolve_bundle_reference(
                bundle_path, home_page, "homePage"
            )
            home_path = self._require_local_dependency(resolved_home, "homePage")
            if home_path:
                dependencies.append(home_path)
            site["homePage"] = resolved_home
            site["api"] = api
            site.pop("home_page", None)
        elif home_page not in (None, ""):
            raise ValueError("homePage 必须是路径或 URL")

        ext = "" if is_webhome else site.get("ext", "")
        if isinstance(ext, str) and ext.strip():
            resolved_ext = self._resolve_bundle_reference(bundle_path, ext, "ext")
            ext_path = self._require_local_dependency(resolved_ext, "ext")
            if ext_path:
                if (
                    ext_path.lower().endswith((".json", ".jsonc"))
                    and self._local_file_looks_json(ext_path)
                ):
                    self._load_json_compatible(ext_path)
                if api == "csp_XBPQ":
                    valid, detail = self._validate_source("XBPQ", ext_path)
                    if not valid:
                        raise ValueError(detail)
                    if detail:
                        validation.append(detail)
                dependencies.append(ext_path)
            site["ext"] = resolved_ext
        elif isinstance(ext, (dict, list)):
            site["ext"] = self._normalize_bundle_nested_refs(
                bundle_path, ext, "ext", dependencies
            )
        elif ext not in (None, "") and not isinstance(ext, (dict, list)):
            raise ValueError("ext 必须是路径、URL 或 JSON 对象")

        if is_webhome:
            site.pop("ext", None)
        jar_value = "" if is_webhome else site.get("jar", "")
        if (
            not is_webhome
            and not str(jar_value or "").strip()
            and api.startswith("csp_")
        ):
            jar_value = default_jar
        if jar_value:
            if not isinstance(jar_value, str):
                raise ValueError("jar 必须是路径或 URL")
            resolved_jar = self._resolve_bundle_reference(
                bundle_path, jar_value, "jar", with_md5=True
            )
            jar_detail = self._validate_site_jar(resolved_jar, api)
            jar_path = self._require_local_dependency(
                resolved_jar, "jar", with_md5=True
            )
            if jar_path:
                actual_md5 = self._inspect_local_jar(jar_path)["md5"]
                resolved_jar = (
                    resolved_jar.split(";md5;", 1)[0].strip()
                    + ";md5;"
                    + actual_md5
                )
                dependencies.append(jar_path)
            site["jar"] = resolved_jar
            if jar_detail:
                validation.append(jar_detail)
        elif api.startswith("csp_") and not str(site.get("homePage", "")).strip():
            raise ValueError("{} 缺少可验证的 jar".format(api))
        else:
            site.pop("jar", None)

        name = str(site.get("name") or site.get("key") or api).strip()
        if not name:
            name = "站点 #{}".format(index + 1)
        site["name"] = name
        site["type"] = int(site.get("type", 3))
        site.setdefault("searchable", 0 if is_webhome else self.DEFAULT_SEARCHABLE)
        site.setdefault("quickSearch", 0 if is_webhome else self.DEFAULT_QUICK_SEARCH)
        dependencies = list(dict.fromkeys(dependencies))
        return site, dependencies, "；".join(validation) or "整包站点本地依赖完整"

    def _manifest_owned_source_paths(self, root, source_type):
        source_type = str(source_type or "").upper()
        if source_type not in ("JS", "XBPQ", "CSP") or not os.path.isdir(root):
            return set()
        fields = ("api", "ext") if source_type == "JS" else ("ext",)
        result = set()
        try:
            for current, dirs, files in os.walk(root, topdown=True, followlinks=False):
                relative_dir = os.path.relpath(current, root)
                depth = 0 if relative_dir == "." else relative_dir.count(os.sep) + 1
                dirs[:] = [
                    name
                    for name in dirs
                    if not name.startswith(".")
                    and name.lower() not in self.SKIP_DIRS
                    and not os.path.islink(os.path.join(current, name))
                ]
                if depth >= self.max_scan_depth:
                    dirs[:] = []
                for name in files:
                    if not self._is_site_manifest_name(name):
                        continue
                    path = os.path.join(current, name)
                    if os.path.islink(path) or not os.path.isfile(path):
                        continue
                    try:
                        data = self._load_json_compatible(path)
                        if isinstance(data, dict) and isinstance(data.get("site"), dict) and not data.get("api"):
                            data = data["site"]
                        if not isinstance(data, dict):
                            continue
                        for field in fields:
                            reference = data.get(field, "")
                            if not isinstance(reference, str) or not reference.strip():
                                continue
                            resolved = self._resolve_site_reference(path, reference)
                            dependency = self._site_reference_path(resolved)
                            if dependency:
                                result.add(dependency)
                    except Exception:
                        continue
        except Exception:
            return result
        return result

    def _bundle_owned_source_paths(self, root, source_type):
        source_type = str(source_type or "").upper()
        if source_type not in ("XBPQ", "CSP") or not os.path.isdir(root):
            return set()
        result = set()

        def collect(bundle_path, value, field=""):
            if isinstance(value, dict):
                for key, item in value.items():
                    collect(bundle_path, item, str(key))
            elif isinstance(value, list):
                for item in value:
                    collect(bundle_path, item, field)
            elif isinstance(value, str) and self._looks_like_bundle_local_reference(
                bundle_path, value, field
            ):
                reference = self._resolve_bundle_reference(
                    bundle_path, value, field, with_md5=field == "jar"
                )
                dependency = self._site_reference_path(
                    reference, with_md5=field == "jar"
                )
                if dependency:
                    result.add(dependency)

        try:
            for current, dirs, files in os.walk(root, topdown=True, followlinks=False):
                relative_dir = os.path.relpath(current, root)
                depth = 0 if relative_dir == "." else relative_dir.count(os.sep) + 1
                dirs[:] = [
                    name
                    for name in dirs
                    if not name.startswith(".")
                    and name.lower() not in self.SKIP_DIRS
                    and not os.path.islink(os.path.join(current, name))
                ]
                if depth >= self.max_scan_depth:
                    dirs[:] = []
                for name in files:
                    if not name.lower().endswith((".json", ".jsonc")):
                        continue
                    path = os.path.join(current, name)
                    if os.path.islink(path) or not os.path.isfile(path):
                        continue
                    try:
                        if os.path.getsize(path) > self.max_source_size:
                            continue
                        data = self._load_json_compatible(path)
                        if not isinstance(data, dict) or not isinstance(
                            data.get("sites"), list
                        ):
                            continue
                        collect(path, data.get("spider", ""), "jar")
                        collect(path, data["sites"])
                        collect(path, data.get("lives", []))
                        for field in self.PACKAGE_ROOT_CONTEXT_FIELDS:
                            if field in data:
                                collect(path, data[field], field)
                    except Exception:
                        continue
        except Exception:
            return result
        return result

    def _discover_json_jar_pairs(self, root, manifest_owned_paths):
        pairs = {}
        ambiguous = set()
        owned = set(manifest_owned_paths or ())
        try:
            for current, dirs, files in os.walk(root, topdown=True, followlinks=False):
                relative_dir = os.path.relpath(current, root)
                depth = 0 if relative_dir == "." else relative_dir.count(os.sep) + 1
                dirs[:] = [
                    name
                    for name in dirs
                    if not name.startswith(".")
                    and name.lower() not in self.SKIP_DIRS
                    and not os.path.islink(os.path.join(current, name))
                ]
                if depth >= self.max_scan_depth:
                    dirs[:] = []
                jars = []
                rules = []
                for name in files:
                    path = os.path.join(current, name)
                    if os.path.islink(path) or not os.path.isfile(path):
                        continue
                    lower_name = name.lower()
                    real_path = os.path.realpath(path)
                    if lower_name.endswith(".jar"):
                        jars.append(real_path)
                    elif (
                        lower_name.endswith(".json")
                        and not self._is_site_manifest_name(lower_name)
                        and real_path not in owned
                    ):
                        rules.append(real_path)
                if not jars or not rules:
                    continue
                if len(jars) == 1:
                    for rule in rules:
                        pairs[rule] = jars[0]
                    continue
                jars_by_stem = {}
                for jar in jars:
                    stem = os.path.splitext(os.path.basename(jar))[0].lower()
                    jars_by_stem.setdefault(stem, []).append(jar)
                for rule in rules:
                    stem = os.path.splitext(os.path.basename(rule))[0].lower()
                    matches = jars_by_stem.get(stem, [])
                    if len(matches) == 1:
                        pairs[rule] = matches[0]
                    else:
                        ambiguous.add(rule)
        except Exception as exc:
            self._warn("JSON/JAR 配对扫描失败: {} ({})".format(root, exc))
        return pairs, ambiguous

    def _is_site_manifest_name(self, lower_name):
        value = str(lower_name or "").lower()
        return value == "site.json" or value.endswith(".site.json")

    def _adult_text_matches(self, value):
        text = str(value or "")
        lower = text.casefold()
        if any(symbol in text for symbol in self.ADULT_SYMBOLS):
            return True
        if any(keyword.casefold() in lower for keyword in self.ADULT_KEYWORDS):
            return True
        if self.ADULT_LATIN_PATTERN.search(text):
            return True
        if re.search(r"(?:^|[^0-9a-z])(?:adult|nsfw|xxx)(?:$|[^0-9a-z])", lower):
            return True
        if re.search(r"(?:^|[^0-9a-z])18(?:\+|xxx|av|j|禁)(?:$|[^0-9a-z])", lower):
            return True
        if re.search(r"(?:^|[^0-9a-z])av(?:$|[^0-9a-z])", lower):
            return True
        for token in re.findall(r"[a-z0-9]+", lower):
            if token.startswith("jav") and not token.startswith(
                ("java", "javascript")
            ):
                return True
        return False

    def _adult_content_matches(self, value):
        text = str(value or "")
        lower = text.casefold()
        if any(symbol in text for symbol in self.ADULT_SYMBOLS):
            return True
        strong_latin = bool(self.ADULT_LATIN_PATTERN.search(text))
        if re.search(r"(?:^|[^0-9a-z])18(?:\+|xxx|av|j|禁)(?:$|[^0-9a-z])", lower):
            return True
        keyword_hits = {
            keyword.casefold()
            for keyword in self.ADULT_KEYWORDS
            if keyword.casefold() in lower
        }
        weak_latin = bool(
            re.search(
                r"(?:^|[^0-9a-z])(?:adult|nsfw|xxx|av)(?:$|[^0-9a-z])",
                lower,
            )
        )
        return strong_latin or len(keyword_hits) + int(weak_latin) >= 2

    def _adult_exact_name_matches(self, value):
        name = os.path.basename(str(value or "").strip())
        stem = os.path.splitext(name)[0].strip().casefold()
        return stem in self.ADULT_EXACT_NAMES

    def _is_adult_source(self, source):
        site = source.get("csp_site", {})
        if isinstance(site, dict):
            for key in ("adult", "isAdult", "is_adult", "nsfw", "restricted18"):
                if key in site:
                    return self._as_bool(site.get(key), False)
            site_text = json.dumps(
                site, ensure_ascii=False, sort_keys=True, default=str
            )
            if self._adult_text_matches(site_text):
                return True

        values = [
            source.get("base_name", ""),
            os.path.basename(str(source.get("path", ""))),
        ]
        if not source.get("csp_site"):
            values.append(source.get("relative_in_root", ""))
        if any(self._adult_exact_name_matches(value) for value in values):
            return True
        if any(self._adult_text_matches(value) for value in values):
            return True

        paths = list(source.get("dependencies", []))
        if not source.get("csp_site"):
            paths.append(source.get("path", ""))
        seen = set()
        for path in paths:
            real_path = os.path.realpath(str(path or ""))
            if not real_path or real_path in seen or not os.path.isfile(real_path):
                continue
            seen.add(real_path)
            if not real_path.lower().endswith(
                (".json", ".jsonc", ".py", ".js", ".html", ".htm", ".txt")
            ):
                continue
            try:
                text = self._read_text(real_path, 256 * 1024)
            except Exception:
                continue
            if "@tvbox-safe" in text.casefold():
                continue
            if "@tvbox-adult" in text.casefold() or self._adult_content_matches(text):
                return True
        return False

    def _is_excluded(self, source_type, lower_name, relative_in_root):
        relative_lower = relative_in_root.lower()
        if lower_name.startswith("."):
            return True
        if (
            source_type == "JS"
            and lower_name.endswith(".json")
            and not self._is_site_manifest_name(lower_name)
        ):
            return True
        if source_type == "JS" and lower_name in self.JS_EXCLUDE:
            return True
        if source_type == "PY":
            if lower_name == "__init__.py":
                return True
            if relative_lower in self.PY_EXCLUDE_RELATIVE:
                return True
        return False

    def _is_auto_loader_python(self, source_type, lower_name):
        return (
            source_type == "PY"
            and str(lower_name or "").startswith("自动加载")
            and str(lower_name or "").endswith(".py")
        )

    def _detect_file_role(self, source_type, lower_name, path):
        try:
            text = self._read_text(path, 64 * 1024)
        except Exception:
            text = ""
        lower_text = text.lower()

        if self._is_auto_loader_python(source_type, lower_name):
            return "source"
        if "@tvbox-ignore" in lower_text:
            return "ignore"
        role_match = re.search(r"@tvbox-role\s*(?:[:=]\s*)?([a-z_-]+)", lower_text)
        if role_match:
            role = role_match.group(1)
            if role in ("manager", "extension", "library", "ignore"):
                return role
            if role == "source":
                return "forced_source"
        if "@tvbox-source" in lower_text:
            return "forced_source"

        if source_type == "JS":
            if lower_name.endswith(self.JS_EXTENSION_SUFFIXES):
                return "extension"
            extension_signatures = (
                "window.fm",
                "fm.vodinline",
                "window.fongmibridge",
                "webhomeextensions",
                "gm_addstyle",
                "document-start",
                "fmsdk",
                "@match",
            )
            looks_like_extension = any(signature in lower_text for signature in extension_signatures)
            looks_like_rule = self._has_direct_js_spider(text)
            if looks_like_extension and not looks_like_rule:
                return "extension"
        return "source"

    def _resolve_site_reference(self, manifest_path, reference, with_md5=False):
        value = str(reference or "").strip()
        if not value:
            return ""
        suffix = ""
        source = value
        if with_md5 and ";md5;" in value:
            source, digest = value.split(";md5;", 1)
            source = source.strip()
            suffix = ";md5;" + digest.strip().lower()
        lower = source.lower()
        if lower.startswith(("http://", "https://", "assets://")):
            return source + suffix
        if lower.startswith("file://"):
            file_value = source[7:]
            if file_value.startswith(("./", "../")):
                path = os.path.join(os.path.dirname(manifest_path), file_value)
                return self._file_url(path) + suffix
            return source + suffix
        if os.path.isabs(source):
            return self._file_url(source) + suffix
        path = os.path.join(os.path.dirname(manifest_path), source)
        return self._file_url(path) + suffix

    def _site_reference_path(self, reference, with_md5=False):
        value = str(reference or "").strip()
        if with_md5:
            value = value.split(";md5;", 1)[0].strip()
        return self._reference_path(value)

    def _file_md5(self, path):
        digest = hashlib.md5()
        with open(path, "rb") as fp:
            while True:
                chunk = fp.read(1024 * 1024)
                if not chunk:
                    break
                digest.update(chunk)
        return digest.hexdigest()

    def _dex_u32(self, data, offset):
        if offset < 0 or offset + 4 > len(data):
            raise ValueError("DEX 索引越界")
        return int.from_bytes(data[offset : offset + 4], "little")

    def _dex_uleb128(self, data, offset):
        value = 0
        for shift in range(0, 35, 7):
            if offset >= len(data):
                raise ValueError("DEX ULEB128 越界")
            byte = data[offset]
            offset += 1
            value |= (byte & 0x7F) << shift
            if not byte & 0x80:
                return value, offset
        raise ValueError("DEX ULEB128 格式无效")

    def _dex_defined_spider_classes(self, data):
        if len(data) < 0x70 or not data.startswith(b"dex\n"):
            raise ValueError("DEX 文件头无效")
        if self._dex_u32(data, 0x28) != 0x12345678:
            raise ValueError("DEX 字节序不受支持")
        string_ids_size = self._dex_u32(data, 0x38)
        string_ids_off = self._dex_u32(data, 0x3C)
        type_ids_size = self._dex_u32(data, 0x40)
        type_ids_off = self._dex_u32(data, 0x44)
        class_defs_size = self._dex_u32(data, 0x60)
        class_defs_off = self._dex_u32(data, 0x64)
        if string_ids_off + string_ids_size * 4 > len(data):
            raise ValueError("DEX string_ids 越界")
        if type_ids_off + type_ids_size * 4 > len(data):
            raise ValueError("DEX type_ids 越界")
        if class_defs_off + class_defs_size * 32 > len(data):
            raise ValueError("DEX class_defs 越界")

        result = set()
        prefix = b"Lcom/github/catvod/spider/"
        for index in range(class_defs_size):
            class_idx = self._dex_u32(data, class_defs_off + index * 32)
            if class_idx >= type_ids_size:
                raise ValueError("DEX class_idx 越界")
            descriptor_idx = self._dex_u32(
                data, type_ids_off + class_idx * 4
            )
            if descriptor_idx >= string_ids_size:
                raise ValueError("DEX descriptor_idx 越界")
            string_offset = self._dex_u32(
                data, string_ids_off + descriptor_idx * 4
            )
            _, value_offset = self._dex_uleb128(data, string_offset)
            end = data.find(b"\0", value_offset)
            if end < 0:
                raise ValueError("DEX 类描述符未终止")
            descriptor = data[value_offset:end]
            if descriptor.startswith(prefix) and descriptor.endswith(b";"):
                class_name = descriptor[len(prefix) : -1].decode(
                    "utf-8", errors="ignore"
                )
                if not class_name:
                    continue
                result.add(class_name.replace("/", "."))
        return result

    def _current_app_identity(self):
        if isinstance(self._app_identity_cache, tuple):
            return self._app_identity_cache
        package_name = ""
        app_label = ""
        try:
            from java import jclass

            context = None
            try:
                context = jclass(
                    "android.app.ActivityThread"
                ).currentApplication()
            except Exception:
                pass
            if context is None:
                try:
                    app_class = jclass("com.fongmi.android.tv.App")
                    for method_name in ("get", "getInstance", "instance"):
                        try:
                            method = getattr(app_class, method_name)
                            context = method() if callable(method) else method
                            if context is not None:
                                break
                        except Exception:
                            continue
                except Exception:
                    pass
            if context is not None:
                package_name = str(context.getPackageName()).strip()
                manager = context.getPackageManager()
                info = manager.getApplicationInfo(package_name, 0)
                app_label = str(manager.getApplicationLabel(info)).strip()
        except Exception:
            pass
        self._app_identity_cache = (package_name, app_label)
        return self._app_identity_cache

    def _host_supports_spider_api(self):
        if isinstance(self._host_spider_api_cache, bool):
            return self._host_spider_api_cache
        supported = False
        try:
            from java import jclass

            jclass("com.github.catvod.crawler.SpiderApi")
            spider_class = jclass("com.github.catvod.crawler.Spider")
            supported = any(
                str(method.getName()) == "initApi"
                for method in spider_class.getDeclaredMethods()
            )
        except Exception:
            supported = False
        self._host_spider_api_cache = bool(supported)
        return self._host_spider_api_cache

    def _decrypt_jar_guard_text(self, encoded, key_text):
        encrypted = base64.b64decode(encoded, validate=True)
        key = str(key_text).encode("utf-8")
        if len(key) not in (16, 24, 32) or not encrypted or len(encrypted) % 16:
            raise ValueError("AES 参数无效")
        try:
            from Crypto.Cipher import AES

            plain = AES.new(key, AES.MODE_CBC, key[:16]).decrypt(encrypted)
        except ImportError:
            from cryptography.hazmat.primitives.ciphers import (
                Cipher,
                algorithms,
                modes,
            )

            decryptor = Cipher(
                algorithms.AES(key), modes.CBC(key[:16])
            ).decryptor()
            plain = decryptor.update(encrypted) + decryptor.finalize()
        padding = plain[-1]
        if (
            padding < 1
            or padding > 16
            or plain[-padding:] != bytes([padding]) * padding
        ):
            raise ValueError("AES 填充无效")
        return plain[:-padding].decode("utf-8")

    def _inspect_dex_runtime_guard(self, dex_blobs):
        data = b"".join(dex_blobs)
        requires_spider_api = (
            b"Lcom/github/catvod/crawler/SpiderApi;" in data
            and b"initApi" in data
        )
        guard_markers = (
            b"getApplicationLabel" in data
            and b"getPackageName" in data
            and b"killProcess" in data
        )
        if not guard_markers:
            return {
                "forced_exit": False,
                "packages": set(),
                "labels": set(),
                "requires_spider_api": requires_spider_api,
            }

        decrypted = []
        key_text = "1234123412341234"
        if key_text.encode("ascii") in data:
            candidates = set(
                re.findall(rb"[A-Za-z0-9+/=]{32,4096}", data)
            )
            for candidate in candidates:
                if len(candidate) % 4:
                    continue
                try:
                    text = self._decrypt_jar_guard_text(candidate, key_text)
                    if text and all(
                        char in "\r\n\t" or ord(char) >= 32 for char in text
                    ):
                        decrypted.append(text)
                except Exception:
                    continue

        packages = set()
        labels = set()
        for text in decrypted:
            values = {
                item.strip()
                for item in text.split(",")
                if item.strip()
            }
            if len(values) < 2:
                continue
            if any("." in item and not any(ch.isspace() for ch in item) for item in values):
                packages.update(values)
            elif any(
                marker in values
                for marker in ("OK影视", "OK影视Pro", "TVBox", "影视仓")
            ):
                labels.update(values)
        exit_message = "⚠️加载失败，软件即将退出。"
        return {
            "forced_exit": exit_message in decrypted,
            "packages": packages,
            "labels": labels,
            "requires_spider_api": requires_spider_api,
        }

    def _jar_runtime_compatibility(self, inspection, api=""):
        guard = inspection.get("runtime_guard", {})
        if not isinstance(guard, dict):
            return True, ""
        if guard.get("forced_exit"):
            package_name, app_label = self._current_app_identity()
            packages = set(guard.get("packages", set()))
            labels = set(guard.get("labels", set()))
            if (
                package_name
                and app_label
                and package_name in packages
                and labels
                and app_label not in labels
            ):
                self.status["compatibility_blocked"] += 1
                return False, (
                    "JAR 内置应用名称校验不接受“{}”，运行后会主动结束 {}，"
                    "已阻止加载"
                ).format(app_label, self._app_mode_label())
        if (
            self.app_mode == self.APP_MODE_OKTV
            and api == "csp_XBPQ"
            and guard.get("requires_spider_api")
            and not self._host_supports_spider_api()
        ):
            self.status["compatibility_blocked"] += 1
            return False, (
                "该 XBPQ JAR 依赖 SpiderApi.initApi，当前 OK影视运行接口不支持，"
                "已阻止加载"
            )
        return True, ""

    def _inspect_local_jar(self, path):
        real_path = os.path.realpath(os.path.abspath(path))
        stat = os.stat(real_path)
        cache_key = (
            real_path,
            int(stat.st_size),
            int(
                getattr(
                    stat,
                    "st_mtime_ns",
                    int(stat.st_mtime * 1000000000),
                )
            ),
        )
        cached = self._jar_inspection_cache.get(cache_key)
        if isinstance(cached, dict):
            return cached
        if not zipfile.is_zipfile(real_path):
            raise ValueError("JAR 不是有效 ZIP: {}".format(real_path))
        with zipfile.ZipFile(real_path, "r") as archive:
            dex_entries = sorted(
                name
                for name in archive.namelist()
                if re.fullmatch(r"classes(?:[2-9][0-9]*)?\.dex", name)
            )
            if "classes.dex" not in dex_entries:
                raise ValueError("JAR 缺少 classes.dex: {}".format(real_path))
            remaining = self.MAX_JAR_DEX_SCAN_SIZE
            class_names = set()
            direct_classes = set()
            class_scan_complete = True
            dex_blobs = []
            for entry in dex_entries:
                info = archive.getinfo(entry)
                if remaining <= 0:
                    class_scan_complete = False
                    break
                read_limit = min(int(info.file_size), remaining)
                with archive.open(entry, "r") as dex_stream:
                    dex_data = dex_stream.read(read_limit + 1)
                if not dex_data.startswith(b"dex\n"):
                    raise ValueError(
                        "JAR 的 {} 格式无效: {}".format(entry, real_path)
                    )
                scanned = dex_data[:read_limit]
                remaining -= len(scanned)
                entry_complete = (
                    int(info.file_size) <= read_limit
                    and len(dex_data) <= read_limit
                )
                if not entry_complete:
                    class_scan_complete = False
                    continue
                dex_blobs.append(scanned)
                for class_name in self._dex_defined_spider_classes(scanned):
                    class_names.add(class_name)
                    if "." not in class_name and "$" not in class_name:
                        direct_classes.add(class_name)
        result = {
            "md5": self._file_md5(real_path),
            "classes": class_names,
            "direct_classes": direct_classes,
            "dex_entries": dex_entries,
            "class_scan_complete": class_scan_complete,
            "runtime_guard": self._inspect_dex_runtime_guard(dex_blobs),
        }
        self._jar_inspection_cache[cache_key] = result
        return result

    def _validate_site_jar(self, jar_reference, api=""):
        source, separator, expected_md5 = str(jar_reference).partition(";md5;")
        source = source.strip()
        expected_md5 = expected_md5.strip().lower() if separator else ""
        if expected_md5 and not re.fullmatch(r"[0-9a-f]{32}", expected_md5):
            raise ValueError("JAR md5 格式无效")
        lower = source.lower()
        if lower.startswith(("http://", "https://", "assets://")):
            return "远程或内置 JAR，等待 App 运行时确认"
        path = self._site_reference_path(source)
        if not path or not os.path.isfile(path) or not os.access(path, os.R_OK):
            raise ValueError("JAR 不存在或不可读: {}".format(source))
        inspection = self._inspect_local_jar(path)
        md5_detail = ""
        if expected_md5 and inspection["md5"] != expected_md5:
            md5_detail = "本地 JAR 声明 md5 不一致，已改用实际 md5"
        compatible, compatibility_detail = self._jar_runtime_compatibility(
            inspection, api
        )
        if not compatible:
            raise ValueError(compatibility_detail)
        class_name = api[len("csp_") :] if api.startswith("csp_") else ""
        confirmed = bool(class_name) and class_name in inspection["classes"]
        if confirmed:
            return "已确认 JAR 类 com.github.catvod.spider.{}{}{}".format(
                class_name,
                "；" + md5_detail if md5_detail else "",
                "；" + compatibility_detail if compatibility_detail else "",
            )
        if not class_name:
            return "JAR 结构有效{}".format(
                "；" + md5_detail if md5_detail else ""
            )
        if inspection["class_scan_complete"]:
            raise ValueError(
                "JAR 未包含类 com.github.catvod.spider.{}: {}".format(
                    class_name, path
                )
            )
        return "JAR 结构有效，类名未静态确认，等待 App 运行时验证{}".format(
            "；" + md5_detail if md5_detail else ""
        )

    def _auto_xbpq_site(self, rule_path, jar_path):
        valid, detail = self._validate_source("XBPQ", rule_path)
        if not valid:
            raise ValueError(detail)
        rule = self._load_json_compatible(rule_path)
        name = ""
        for field in ("站名", "name", "名称", "title"):
            value = rule.get(field) if isinstance(rule, dict) else ""
            if str(value or "").strip():
                name = str(value).strip()
                break
        if not name:
            name = os.path.splitext(os.path.basename(rule_path))[0]
        jar_reference = self._file_url(jar_path)
        jar_detail = self._validate_site_jar(jar_reference, "csp_XBPQ")
        jar_reference += ";md5;" + self._inspect_local_jar(jar_path)["md5"]
        site = {
            "name": name,
            "type": 3,
            "api": "csp_XBPQ",
            "ext": self._file_url(rule_path),
            "jar": jar_reference,
            "searchable": self.DEFAULT_SEARCHABLE,
            "quickSearch": self.DEFAULT_QUICK_SEARCH,
        }
        return site, [jar_path], jar_detail

    def _auto_csp_site(self, config_path, jar_path):
        config = self._load_json_compatible(config_path)
        if not isinstance(config, dict) or not config:
            raise ValueError("CSP 配置必须是非空 JSON 对象")

        config_stem = os.path.splitext(os.path.basename(config_path))[0]
        inspection = self._inspect_local_jar(jar_path)
        matching_classes = sorted(
            class_name
            for class_name in inspection["direct_classes"]
            if class_name.casefold() == config_stem.casefold()
        )
        if len(matching_classes) != 1:
            if not matching_classes:
                reason = "JAR 中未找到与 {} 匹配的顶层类".format(
                    config_stem
                )
            else:
                reason = "JAR 中匹配到多个顶层类: {}".format(
                    ", ".join(matching_classes)
                )
            raise ValueError(reason + "，请使用 site.json 显式配置 api")

        class_name = matching_classes[0]
        api = "csp_" + class_name
        jar_reference = (
            self._file_url(jar_path) + ";md5;" + inspection["md5"]
        )
        folder_name = os.path.basename(os.path.dirname(config_path)).strip()
        if not folder_name or folder_name.casefold() == "csp":
            folder_name = config_stem
        site = {
            "name": folder_name,
            "type": 3,
            "api": api,
            "ext": self._file_url(config_path),
            "jar": jar_reference,
            "searchable": self.DEFAULT_SEARCHABLE,
            "quickSearch": self.DEFAULT_QUICK_SEARCH,
        }
        if config.get("filters"):
            site["filterable"] = 1
        detail = "已根据 JSON 文件名确认 JAR 类 com.github.catvod.spider.{}".format(
            class_name
        )
        return site, [jar_path], detail

    def _parse_site_manifest(self, path, source_type):
        data = self._load_json_compatible(path)
        if not isinstance(data, dict):
            raise ValueError("站点清单顶层必须是 JSON 对象")
        if isinstance(data.get("site"), dict) and not data.get("api"):
            data = data["site"]
        site = copy.deepcopy(data)
        source_type = str(source_type or "").upper()
        api = str(site.get("api", "")).strip()
        dependencies = []
        validation_details = []
        if source_type == "JS":
            if not api:
                raise ValueError("JS 清单缺少 api")
            api_reference = self._resolve_site_reference(path, api)
            if ".js" not in api_reference.lower():
                raise ValueError("JS 清单 api 必须指向 .js 文件或URL")
            api_path = self._site_reference_path(api_reference)
            if api_path:
                if not os.path.isfile(api_path) or not os.access(api_path, os.R_OK):
                    raise ValueError("JS api 不存在或不可读: {}".format(api_reference))
                valid, detail = self._validate_source("JS", api_path)
                if not valid:
                    raise ValueError(detail)
                dependencies.append(api_path)
            site["api"] = api_reference
            validation_details.append("JS 清单有效")
        else:
            if not re.fullmatch(r"csp_[A-Za-z_$][A-Za-z0-9_.$]*", api):
                raise ValueError("{} api 必须是 csp_ 开头的有效类名".format(source_type))
            if source_type == "XBPQ" and api != "csp_XBPQ":
                raise ValueError("XBPQ 清单 api 必须是 csp_XBPQ")
            site["api"] = api
        jar_value = str(site.get("jar", "")).strip()
        if source_type in ("CSP", "XBPQ") and not jar_value:
            raise ValueError("{} 清单缺少 jar".format(source_type))
        if jar_value:
            jar_reference = self._resolve_site_reference(
                path, jar_value, with_md5=True
            )
            jar_detail = self._validate_site_jar(jar_reference, api)
            jar_path = self._site_reference_path(jar_reference, with_md5=True)
            if jar_path:
                actual_md5 = self._inspect_local_jar(jar_path)["md5"]
                jar_reference = (
                    jar_reference.split(";md5;", 1)[0].strip()
                    + ";md5;"
                    + actual_md5
                )
                dependencies.append(jar_path)
            site["jar"] = jar_reference
            validation_details.append(jar_detail)
        else:
            site.pop("jar", None)
        ext = site.get("ext", "")
        if isinstance(ext, str) and ext.strip():
            ext_reference = self._resolve_site_reference(path, ext)
            ext_path = self._site_reference_path(ext_reference)
            if ext_path:
                if not os.path.isfile(ext_path) or not os.access(ext_path, os.R_OK):
                    raise ValueError("{} ext 不存在或不可读: {}".format(source_type, ext_reference))
                if os.path.getsize(ext_path) <= 0:
                    raise ValueError("{} ext 是空文件: {}".format(source_type, ext_path))
                if ext_path.lower().endswith(".json"):
                    self._load_json_compatible(ext_path)
                if source_type == "XBPQ":
                    valid, detail = self._validate_source("XBPQ", ext_path)
                    if not valid:
                        raise ValueError(detail)
                dependencies.append(ext_path)
            site["ext"] = ext_reference
        elif ext not in (None, "") and not isinstance(ext, (dict, list)):
            raise ValueError("{} ext 必须是路径、URL或JSON对象".format(source_type))
        site["type"] = 3
        site.setdefault("searchable", self.DEFAULT_SEARCHABLE)
        site.setdefault("quickSearch", self.DEFAULT_QUICK_SEARCH)
        name = str(site.get("name", "")).strip()
        if not name:
            stem = os.path.basename(path)
            if stem.lower() == "site.json":
                stem = os.path.basename(os.path.dirname(path))
            elif stem.lower().endswith(".site.json"):
                stem = stem[: -len(".site.json")]
            fallback = api[len("csp_") :] if api.startswith("csp_") else "JS站点"
            site["name"] = stem or fallback
        return (
            site,
            list(dict.fromkeys(dependencies)),
            "；".join(item for item in validation_details if item),
        )

    def _validate_source(self, source_type, path):
        try:
            lower_name = os.path.basename(path).lower()
            if (
                source_type in ("JS", "XBPQ", "CSP")
                and self._is_site_manifest_name(lower_name)
            ):
                _, _, detail = self._parse_site_manifest(path, source_type)
                return True, detail
            if source_type == "XBPQ":
                data = self._load_json_compatible(path)
                if not isinstance(data, dict) or not data:
                    return False, "XBPQ 缺少有效的 JSON 对象: {}".format(path)
                keys = "|".join(str(key).lower() for key in data.keys())
                signatures = (
                    "url",
                    "主页",
                    "分类",
                    "搜索",
                    "二级",
                    "播放",
                    "列表",
                    "数组",
                    "标题",
                )
                if not any(signature in keys for signature in signatures):
                    return False, "XBPQ 未发现常用规则字段: {}".format(path)
            elif source_type == "PY":
                text = self._read_text(path, 256 * 1024)
                if not re.search(r"\bclass\s+Spider\s*(?:\(|:)", text):
                    return False, "PY 文件未发现 Spider 类，已按依赖库跳过: {}".format(path)
            elif source_type == "JS":
                text = self._read_text(path, 256 * 1024)
                if not (
                    self._has_direct_js_spider(text)
                    or self._has_drpy_rule(text)
                ):
                    return False, "JS 文件未发现可执行 Spider 入口，已按依赖库或数据文件跳过: {}".format(path)
            elif source_type == "HTML":
                text = self._read_text(path, 128 * 1024).lower()
                if not any(tag in text for tag in ("<!doctype html", "<html", "<body")):
                    return False, "HTML 文件未发现页面结构: {}".format(path)
        except Exception as exc:
            return False, "{} 文件检查失败: {} ({})".format(source_type, path, exc)
        return True, ""

    def _has_quickjs_export(self, text):
        return bool(
            re.search(
                r"\bexport\s+(?:default|(?:async\s+)?function|class|const|let|var|\{)",
                str(text or ""),
            )
            or "__jsEvalReturn" in str(text or "")
            or "__JS_SPIDER__" in str(text or "")
        )

    def _decoded_js_payload(self, text):
        value = str(text or "").lstrip("\ufeff").strip()
        compact = re.sub(r"\s+", "", value)
        if (
            len(compact) < 32
            or len(compact) % 4
            or not re.fullmatch(r"[A-Za-z0-9+/=]+", compact)
        ):
            return value, False
        try:
            raw = base64.b64decode(compact, validate=True)
        except Exception:
            return value, False
        decoded = b""
        try:
            decoded = gzip.decompress(raw)
        except Exception:
            try:
                decoded = raw
            except Exception:
                decoded = b""
        if not decoded:
            return value, False
        candidate = decoded.decode("utf-8", errors="ignore").strip()
        printable = sum(
            1 for char in candidate if char.isprintable() or char in "\r\n\t"
        )
        if candidate and printable >= int(len(candidate) * 0.85):
            return candidate, True
        return value, False

    def _js_entrypoint_count(self, text):
        value = str(text or "")
        names = (
            "init",
            "home",
            "homeVod",
            "homeContent",
            "homeVideoContent",
            "category",
            "categoryContent",
            "detail",
            "detailContent",
            "play",
            "playerContent",
            "search",
            "searchContent",
            "proxy",
            "proxy_rule",
        )
        return sum(
            bool(
                re.search(
                    r"\b(?:async\s+)?function\s+{}\s*\(".format(
                        re.escape(name)
                    ),
                    value,
                )
                or re.search(
                    r"\b(?:const|let|var)\s+{}\s*=\s*(?:async\s*)?(?:function\b|\([^)]*\)\s*=>|[A-Za-z_$][\w$]*\s*=>)".format(
                        re.escape(name)
                    ),
                    value,
                )
            )
            for name in names
        )

    def _has_commonjs_spider(self, text):
        value = str(text or "")
        if not re.search(r"\bmodule\.exports\s*=\s*\{", value):
            return False
        return sum(
            bool(re.search(r"\b{}\b".format(name), value))
            for name in ("home", "category", "detail", "play", "search")
        ) >= 4

    def _has_direct_js_spider(self, text):
        value, decoded = self._decoded_js_payload(text)
        if self._has_quickjs_export(value) or self._has_commonjs_spider(value):
            return True
        # WvSpider/server.js 规则会直接暴露固定入口，不带 ES module 导出。
        # 依赖库即使含有 home/play 等普通单词，也不会同时声明这些入口。
        if self._js_entrypoint_count(value) >= 4:
            return True
        return False

    def _js_rule_text(self, text):
        value, _ = self._decoded_js_payload(text)
        if self._has_plain_drpy_rule(value):
            return value
        return value

    def _has_plain_drpy_rule(self, text):
        value = str(text or "")
        if not re.search(
            r"\b(?:var|let|const)\s+rule\s*=\s*\{",
            value,
        ):
            return False
        signatures = (
            "title:",
            "host:",
            "url:",
            "searchUrl:",
            "一级:",
            "二级:",
            "推荐:",
            "搜索:",
            "lazy:",
        )
        return sum(signature in value for signature in signatures) >= 2

    def _has_drpy_rule(self, text):
        value = self._js_rule_text(text)
        if self._has_plain_drpy_rule(value):
            return True
        # drpy2.getOriginalJs 原生支持 gzip/base64/AES/RSA 等无明文头规则。
        # 只在内容是较长的纯 Base64 载荷且解码后不是其他 Spider 时配对运行时。
        compact = re.sub(r"\s+", "", str(text or "").strip())
        return bool(
            len(compact) >= 256
            and len(compact) % 4 == 0
            and re.fullmatch(r"[A-Za-z0-9+/=]+", compact)
            and not self._has_direct_js_spider(text)
        )

    def _drpy_rule_title(self, text):
        value = self._js_rule_text(text)
        match = re.search(
            r"(?:^|[,\n\r{])\s*title\s*:\s*(['\"])(.*?)\1",
            value,
            re.DOTALL,
        )
        if not match:
            return ""
        return re.sub(r"\s+", " ", match.group(2)).strip()[:80]

    def _js_import_references(self, text):
        references = []
        for match in re.finditer(
            r"\bimport\s+(?:[^;\n]*?\s+from\s*)?(['\"])([^'\"]+)\1",
            str(text or ""),
        ):
            reference = match.group(2).strip()
            if reference.startswith(("./", "../")):
                references.append(reference.split("?", 1)[0])
        return references

    def _resolve_js_import_path(self, owner_path, reference):
        candidate = os.path.realpath(
            os.path.join(os.path.dirname(owner_path), reference)
        )
        choices = [candidate]
        if not os.path.splitext(candidate)[1]:
            choices.extend((candidate + ".js", os.path.join(candidate, "index.js")))
        return next((path for path in choices if os.path.isfile(path)), "")

    def _js_runtime_dependency_paths(self, root):
        root_real = os.path.realpath(os.path.abspath(root))
        runtimes = []
        for current, dirs, files in os.walk(root_real, topdown=True):
            dirs[:] = [
                name
                for name in dirs
                if not name.startswith(".") and name.lower() not in self.SKIP_DIRS
            ]
            by_lower = {name.lower(): name for name in files}
            for runtime_name in self.JS_RUNTIME_NAMES:
                actual = by_lower.get(runtime_name)
                if actual:
                    runtimes.append(os.path.realpath(os.path.join(current, actual)))
        result = set(runtimes)
        pending = list(runtimes)
        while pending:
            owner = pending.pop()
            try:
                text = self._read_text(owner, self.max_source_size)
            except Exception:
                continue
            for reference in self._js_import_references(text):
                dependency = self._resolve_js_import_path(owner, reference)
                if (
                    dependency
                    and self._path_is_within(dependency, root_real)
                    and dependency not in result
                ):
                    result.add(dependency)
                    pending.append(dependency)
        return result

    def _find_js_runtime(self, rule_path, root):
        root_real = os.path.realpath(os.path.abspath(root))
        current = os.path.realpath(os.path.dirname(rule_path))
        while self._path_is_within(current, root_real):
            try:
                by_lower = {
                    name.lower(): name
                    for name in os.listdir(current)
                    if os.path.isfile(os.path.join(current, name))
                }
            except Exception:
                by_lower = {}
            for runtime_name in self.JS_RUNTIME_NAMES:
                actual = by_lower.get(runtime_name)
                if actual:
                    return os.path.realpath(os.path.join(current, actual))
            if current == root_real:
                break
            parent = os.path.realpath(os.path.dirname(current))
            if parent == current:
                break
            current = parent
        return ""

    def _read_text(self, path, limit):
        with open(path, "rb") as fp:
            data = fp.read(limit)
        return data.decode("utf-8", errors="ignore")

    def _source_parent_suffix(self, source):
        path = os.path.realpath(
            os.path.abspath(os.path.expanduser(str(source.get("path", ""))))
        )
        parent = os.path.basename(os.path.dirname(path)).strip()
        if not parent:
            source_type = str(source.get("type", "")).upper()
            parent = self.TYPE_LABEL.get(source_type, source_type or "本地")
        parent = re.sub(r"[\r\n\t\[\]【】|]+", " ", parent)
        parent = re.sub(r"\s+", " ", parent).strip(" ._")
        return (parent or "本地")[:32]

    def _apply_display_names(self, sources):
        counts = {}
        folder_counts = {}
        for source in sources:
            identity = (source["type"], source["base_name"].lower())
            counts[identity] = counts.get(identity, 0) + 1
            folder = os.path.dirname(source["relative_in_root"]).replace(
                os.sep, "/"
            )
            folder_identity = identity + (folder.lower(),)
            folder_counts[folder_identity] = (
                folder_counts.get(folder_identity, 0) + 1
            )

        for source in sources:
            source_type = source["type"]
            base_name = source["base_name"]
            package_label = str(source.get("package_label", "")).strip()
            identity = (source_type, base_name.lower())
            duplicate_suffix = ""
            if counts.get(identity, 0) > 1:
                folder = os.path.dirname(source["relative_in_root"]).replace(os.sep, "/")
                folder_identity = identity + (folder.lower(),)
                if folder_counts.get(folder_identity, 0) > 1:
                    original_key = str(
                        source.get("csp_site", {}).get("key", "")
                        if isinstance(source.get("csp_site"), dict)
                        else ""
                    ).strip()
                    if "#bundle-site-" in source["identity"] and original_key:
                        disambiguator = original_key
                    else:
                        relative_name = os.path.basename(source["relative_in_root"])
                        disambiguator = os.path.splitext(relative_name)[0]
                else:
                    disambiguator = folder or os.path.basename(
                        source["scan_root"]
                    )
                duplicate_suffix = " · " + disambiguator
            parent_suffix = self._source_parent_suffix(source)
            package_prefix = (
                "【{}】".format(package_label)
                if package_label
                and package_label.casefold() != parent_suffix.casefold()
                else ""
            )
            source["name"] = (
                self.TYPE_PREFIX[source_type]
                + package_prefix
                + base_name
                + duplicate_suffix
                + "|[{}]".format(parent_suffix)
            )

    def _playback_proxy_wrapper_dir(self):
        return os.path.join(
            self.local_base_dir,
            self.PLAYBACK_PROXY_MANAGED_DIR,
            "proxy",
        )

    def _playback_proxy_runtime_config_path(self):
        return os.path.join(
            self._playback_proxy_wrapper_dir(),
            self.PLAYBACK_PROXY_RUNTIME_CONFIG,
        )

    def _write_playback_proxy_runtime_config(
        self, extra_identity="", extra_prefix=""
    ):
        urls = list(self.playback_proxy_urls)
        extra_prefix = str(extra_prefix or "").strip()
        if extra_prefix and extra_prefix not in urls:
            urls.insert(0, extra_prefix)
        assignments = {}
        if self.playback_proxy_enabled or extra_prefix:
            for identity, state in self.playback_proxy_sources.items():
                if not isinstance(state, dict):
                    continue
                prefix = str(state.get("prefix", "") or "").strip()
                if prefix in urls:
                    assignments[str(identity)] = prefix
            if str(extra_identity or "").strip() and extra_prefix:
                assignments[str(extra_identity).strip()] = extra_prefix
        path = self._playback_proxy_runtime_config_path()
        if not (urls or assignments or os.path.exists(path)):
            return ""
        self._atomic_write_plain_json(
            path,
            {
                "version": 1,
                "enabled": bool(
                    (self.playback_proxy_enabled and urls) or extra_prefix
                ),
                "urls": urls,
                "assignments": assignments,
            },
        )
        return path

    def _atomic_write_text(self, path, content):
        directory = os.path.dirname(path)
        if directory and not os.path.isdir(directory):
            os.makedirs(directory, exist_ok=True)
        temp_path = path + ".tmp"
        try:
            with open(temp_path, "w", encoding="utf-8", newline="\n") as fp:
                fp.write(content)
                fp.flush()
                os.fsync(fp.fileno())
            os.replace(temp_path, path)
        except Exception:
            try:
                if os.path.exists(temp_path):
                    os.remove(temp_path)
            except Exception:
                pass
            raise

    def _render_playback_proxy_wrapper(self, source, prefix):
        source_path = str(
            source.get("playback_proxy_path", source.get("path", "")) or ""
        )
        original_path = os.path.realpath(
            os.path.abspath(os.path.expanduser(source_path))
        )
        module_name = "local_media_v71_origin_{}".format(
            self._digest(
                "{}|{}".format(source.get("identity", ""), original_path), 20
            )
        )
        template = r'''# -*- coding: utf-8 -*-
# Generated by 本地影仓 v7.1. The original source is not modified.
import base64
import collections
import hashlib
import json
import os
import re
import sys
import threading
import time
import types
import urllib.parse
from importlib.machinery import SourceFileLoader

import requests
from base.spider import Spider as BaseSpider

ORIGINAL_PATH = __ORIGINAL_PATH__
ORIGINAL_MODULE = __ORIGINAL_MODULE__
PROXY_PREFIX = __PROXY_PREFIX__
PROXY_PREFIXES = __PROXY_PREFIXES__
ROUTE_CONFIG_PATH = __ROUTE_CONFIG_PATH__
SOURCE_IDENTITY = __SOURCE_IDENTITY__
SITE_KEY = __SITE_KEY__
PROXY_SUFFIX = "[反代线路]"
MAX_RESPONSE = __MAX_RESPONSE__
FAILOVER_TOTAL_TIMEOUT = 24.0
FAILOVER_PER_LINE_TIMEOUT = 8.0
PROXY_HEADER_TTL_SECONDS = 2 * 60 * 60
PROXY_HEADER_MAX_ENTRIES = 256

_original_dir = os.path.dirname(ORIGINAL_PATH)
if _original_dir and _original_dir not in sys.path:
    sys.path.insert(0, _original_dir)
_module = SourceFileLoader(ORIGINAL_MODULE, ORIGINAL_PATH).load_module()
_RECENT_RESPONSES = []
_PROXY_FAILURE_THRESHOLD = 2
_PROXY_COOLDOWN_SECONDS = 120
_RUNTIME_MODULE_NAME = "_local_media_v71_proxy_runtime_v11"
_RUNTIME = sys.modules.get(_RUNTIME_MODULE_NAME)
if _RUNTIME is None:
    _RUNTIME = types.ModuleType(_RUNTIME_MODULE_NAME)
    _RUNTIME.lock = threading.RLock()
    _RUNTIME.proxy_states = {}
    _RUNTIME.last_success_prefix = ""
    _RUNTIME.proxy_headers = collections.OrderedDict()
    sys.modules[_RUNTIME_MODULE_NAME] = _RUNTIME
_PROXY_STATE_LOCK = _RUNTIME.lock
_PROXY_STATES = _RUNTIME.proxy_states
_PROXY_HEADERS = _RUNTIME.proxy_headers


def _runtime_proxy_route():
    try:
        with open(ROUTE_CONFIG_PATH, "r", encoding="utf-8") as fp:
            payload = json.load(fp)
        if not isinstance(payload, dict) or not bool(payload.get("enabled")):
            return "", []
        urls = [
            str(value).strip()
            for value in payload.get("urls", [])
            if str(value or "").strip()
        ]
        assignments = payload.get("assignments", {})
        preferred = str(
            assignments.get(SOURCE_IDENTITY, "")
            if isinstance(assignments, dict)
            else ""
        ).strip()
        if preferred and preferred not in urls:
            urls.insert(0, preferred)
        urls = list(dict.fromkeys(urls))
        return (preferred if preferred in urls else ""), urls
    except Exception:
        return "", []


with _PROXY_STATE_LOCK:
    for _prefix in _runtime_proxy_route()[1]:
        _PROXY_STATES.setdefault(
            _prefix, {"failures": 0, "open_until": 0.0}
        )


def _proxy_status(response):
    try:
        return int(getattr(response, "status_code", 200) or 200)
    except Exception:
        return 200


def _retryable_proxy_status(status):
    return status in (401, 403, 408, 429) or status >= 500


def _ordered_proxy_prefixes():
    now = time.monotonic()
    preferred, prefixes = _runtime_proxy_route()
    with _PROXY_STATE_LOCK:
        for prefix in prefixes:
            _PROXY_STATES.setdefault(
                prefix, {"failures": 0, "open_until": 0.0}
            )
        healthy = [
            prefix
            for prefix in prefixes
            if float(_PROXY_STATES[prefix]["open_until"]) <= now
        ]
        if healthy:
            preferred = preferred if preferred in healthy else ""
            last_success = str(_RUNTIME.last_success_prefix or "")
            if last_success in healthy:
                healthy.remove(last_success)
                healthy.insert(0, last_success)
            if preferred:
                healthy.remove(preferred)
                healthy.insert(0, preferred)
            return healthy
        if not prefixes:
            return []
        return [
            min(
                prefixes,
                key=lambda prefix: float(_PROXY_STATES[prefix]["open_until"]),
            )
        ]


def _mark_proxy_success(prefix):
    if not prefix:
        return
    with _PROXY_STATE_LOCK:
        _PROXY_STATES.setdefault(
            prefix, {"failures": 0, "open_until": 0.0}
        )
        state = _PROXY_STATES[prefix]
        state["failures"] = 0
        state["open_until"] = 0.0
        _RUNTIME.last_success_prefix = prefix


def _mark_proxy_failure(prefix, status=0):
    if not prefix:
        return
    now = time.monotonic()
    with _PROXY_STATE_LOCK:
        _PROXY_STATES.setdefault(
            prefix, {"failures": 0, "open_until": 0.0}
        )
        state = _PROXY_STATES[prefix]
        was_open = float(state["open_until"]) > 0.0
        state["failures"] = int(state["failures"]) + 1
        if int(status or 0) in (401, 403):
            state["failures"] = max(
                int(state["failures"]), _PROXY_FAILURE_THRESHOLD
            )
        if int(state["failures"]) >= _PROXY_FAILURE_THRESHOLD or was_open:
            state["open_until"] = now + _PROXY_COOLDOWN_SECONDS


def _reset_proxy_circuit_state():
    _preferred, prefixes = _runtime_proxy_route()
    with _PROXY_STATE_LOCK:
        for prefix in prefixes:
            _PROXY_STATES.setdefault(
                prefix, {"failures": 0, "open_until": 0.0}
            )
            state = _PROXY_STATES[prefix]
            state["failures"] = 0
            state["open_until"] = 0.0
        if _RUNTIME.last_success_prefix in prefixes:
            _RUNTIME.last_success_prefix = ""


def _proxy_request_candidates(url):
    value = str(url or "")
    _preferred, prefixes = _runtime_proxy_route()
    for prefix in prefixes:
        if value.startswith(prefix):
            return [(prefix, value)]
    parsed = urllib.parse.urlsplit(value)
    if parsed.scheme.lower() in ("http", "https") and parsed.netloc:
        return [
            (prefix, prefix + value)
            for prefix in _ordered_proxy_prefixes()
        ]
    return [("", value)]


def _candidate_timeout(value, remaining, attempts_left):
    budget = max(
        0.5,
        min(
            FAILOVER_PER_LINE_TIMEOUT,
            float(remaining) / max(1, int(attempts_left)),
        ),
    )
    connect = max(0.25, min(2.5, budget * 0.4))
    read = max(0.25, budget - connect)
    try:
        if isinstance(value, (tuple, list)) and len(value) >= 2:
            return (
                min(float(value[0]), connect),
                min(float(value[1]), read),
            )
        if value is not None:
            current = float(value)
            return (min(current, connect), min(current, read))
    except Exception:
        pass
    return (connect, read)


def _remember_response(response, request_kwargs=None):
    request_kwargs = request_kwargs if isinstance(request_kwargs, dict) else {}
    if bool(request_kwargs.get("stream", False)):
        return response
    try:
        headers = getattr(response, "headers", {}) or {}
        content_type = str(headers.get("Content-Type", "") or "").lower()
        textual = (
            not content_type
            or content_type.startswith("text/")
            or any(
                marker in content_type
                for marker in ("json", "xml", "javascript", "mpegurl")
            )
        )
        if not textual:
            return response
        content_length = str(headers.get("Content-Length", "") or "")
        if content_length.isdigit() and int(content_length) > 2 * 1024 * 1024:
            return response
        loaded = getattr(response, "_content", None)
        if isinstance(loaded, (bytes, bytearray)) and len(loaded) > 2 * 1024 * 1024:
            return response
        text = str(response.text or "")
        if text:
            _RECENT_RESPONSES.append(text[:2 * 1024 * 1024])
            del _RECENT_RESPONSES[:-4]
    except Exception:
        pass
    return response


def _close_response(response):
    try:
        response.close()
    except Exception:
        pass


def _request_with_failover(sender, method, url, args, kwargs):
    candidates = _proxy_request_candidates(url)
    deadline = time.monotonic() + FAILOVER_TOTAL_TIMEOUT
    last_response = None
    last_error = None
    for index, (prefix, request_url) in enumerate(candidates):
        remaining = deadline - time.monotonic()
        if remaining <= 0:
            last_error = TimeoutError("反代线路故障切换超时")
            break
        request_kwargs = dict(kwargs)
        if prefix:
            request_kwargs["timeout"] = _candidate_timeout(
                request_kwargs.get("timeout"),
                remaining,
                len(candidates) - index,
            )
        try:
            response = sender(method, request_url, *args, **request_kwargs)
        except Exception as exc:
            _mark_proxy_failure(prefix)
            last_error = exc
            continue
        status = _proxy_status(response)
        if status < 400:
            if last_response is not None:
                _close_response(last_response)
            _mark_proxy_success(prefix)
            return _remember_response(response, request_kwargs)
        if not _retryable_proxy_status(status):
            if last_response is not None:
                _close_response(last_response)
            _mark_proxy_success(prefix)
            return _remember_response(response, request_kwargs)
        _mark_proxy_failure(prefix, status)
        if last_response is not None:
            _close_response(last_response)
        last_response = response
    if last_response is not None:
        return _remember_response(last_response, kwargs)
    if last_error is not None:
        raise last_error
    return sender(method, str(url or ""), *args, **kwargs)


def _store_proxy_headers(token, headers):
    now = time.monotonic()
    clean = {
        str(key): str(value)
        for key, value in (headers or {}).items()
        if str(key).strip()
    }
    with _PROXY_STATE_LOCK:
        expired = [
            key
            for key, entry in _PROXY_HEADERS.items()
            if float(entry.get("expires_at", 0.0)) <= now
        ]
        for key in expired:
            _PROXY_HEADERS.pop(key, None)
        _PROXY_HEADERS[token] = {
            "headers": clean,
            "expires_at": now + PROXY_HEADER_TTL_SECONDS,
        }
        _PROXY_HEADERS.move_to_end(token)
        while len(_PROXY_HEADERS) > PROXY_HEADER_MAX_ENTRIES:
            _PROXY_HEADERS.popitem(last=False)


def _load_proxy_headers(token):
    now = time.monotonic()
    with _PROXY_STATE_LOCK:
        entry = _PROXY_HEADERS.get(token)
        if not isinstance(entry, dict):
            return {}
        if float(entry.get("expires_at", 0.0)) <= now:
            _PROXY_HEADERS.pop(token, None)
            return {}
        _PROXY_HEADERS.move_to_end(token)
        return dict(entry.get("headers", {}))


def _proxy_response_target(response, prefix, fallback):
    value = str(getattr(response, "url", "") or "").strip()
    candidate = ""
    if prefix and value.startswith(prefix):
        suffix = value[len(prefix):]
        if suffix.startswith("url="):
            candidate = urllib.parse.parse_qs(
                suffix, keep_blank_values=True
            ).get("url", [""])[0]
        elif prefix.endswith("="):
            candidate = urllib.parse.unquote(suffix)
        elif suffix.startswith(("http://", "https://")):
            candidate = suffix
    if not candidate:
        try:
            candidate = urllib.parse.parse_qs(
                urllib.parse.urlsplit(value).query,
                keep_blank_values=True,
            ).get("url", [""])[0]
        except Exception:
            candidate = ""
    parsed = urllib.parse.urlsplit(candidate)
    if parsed.scheme.lower() in ("http", "https") and parsed.netloc:
        return candidate
    return str(fallback or "")


class _ProxySession:
    def __init__(self, session=None):
        object.__setattr__(
            self,
            "_session",
            session if session is not None else requests.Session(),
        )

    def request(self, method, url, *args, **kwargs):
        return _request_with_failover(
            self._session.request, method, url, args, kwargs
        )

    def get(self, url, *args, **kwargs):
        return self.request("GET", url, *args, **kwargs)

    def post(self, url, *args, **kwargs):
        return self.request("POST", url, *args, **kwargs)

    def put(self, url, *args, **kwargs):
        return self.request("PUT", url, *args, **kwargs)

    def delete(self, url, *args, **kwargs):
        return self.request("DELETE", url, *args, **kwargs)

    def head(self, url, *args, **kwargs):
        return self.request("HEAD", url, *args, **kwargs)

    def __enter__(self):
        self._session.__enter__()
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        return self._session.__exit__(exc_type, exc_value, traceback)

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

    def __setattr__(self, name, value):
        if name == "_session":
            object.__setattr__(self, name, value)
            return
        setattr(self._session, name, value)


class _RequestsFacade:
    Session = _ProxySession

    def request(self, method, url, *args, **kwargs):
        return _request_with_failover(
            requests.request, method, url, args, kwargs
        )

    def get(self, url, *args, **kwargs):
        return self.request("GET", url, *args, **kwargs)

    def post(self, url, *args, **kwargs):
        return self.request("POST", url, *args, **kwargs)

    def put(self, url, *args, **kwargs):
        return self.request("PUT", url, *args, **kwargs)

    def delete(self, url, *args, **kwargs):
        return self.request("DELETE", url, *args, **kwargs)

    def head(self, url, *args, **kwargs):
        return self.request("HEAD", url, *args, **kwargs)

    def __getattr__(self, name):
        return getattr(requests, name)


_requests_facade = _RequestsFacade()
_request_functions = {
    requests.request: _requests_facade.request,
    requests.get: _requests_facade.get,
    requests.post: _requests_facade.post,
    requests.put: _requests_facade.put,
    requests.delete: _requests_facade.delete,
    requests.head: _requests_facade.head,
}
for _global_name, _global_value in list(vars(_module).items()):
    if _global_value is requests:
        setattr(_module, _global_name, _requests_facade)
        continue
    if _global_value is requests.Session:
        setattr(_module, _global_name, _ProxySession)
        continue
    if isinstance(_global_value, requests.Session):
        setattr(_module, _global_name, _ProxySession(_global_value))
        continue
    for _request_function, _facade_function in _request_functions.items():
        if _global_value is _request_function:
            setattr(_module, _global_name, _facade_function)
            break

# 有些源在类定义阶段就创建 Session 作为类属性。模块全局
# 替换不会触及这些已有实例，需要保留原 Session 状态后包装。
for _class_value in list(vars(_module).values()):
    if (
        not isinstance(_class_value, type)
        or str(getattr(_class_value, "__module__", "")) != ORIGINAL_MODULE
    ):
        continue
    for _attribute_name, _attribute_value in list(vars(_class_value).items()):
        if isinstance(_attribute_value, requests.Session):
            setattr(
                _class_value,
                _attribute_name,
                _ProxySession(_attribute_value),
            )


class Spider(BaseSpider):
    def __init__(self):
        super().__init__()
        self.origin = _module.Spider()
        self._proxy_headers = _PROXY_HEADERS
        if "fetch" not in getattr(_module.Spider, "__dict__", {}):
            # 继承的 BaseSpider.fetch 在源模块之外，替换模块 requests
            # 时不会覆盖到它，需要给源实例安装反代请求入口。
            self.origin.fetch = _requests_facade.get

    def init(self, extend=""):
        return self.origin.init(extend)

    def getName(self):
        method = getattr(self.origin, "getName", None)
        return method() if callable(method) else ""

    def getDependence(self):
        method = getattr(self.origin, "getDependence", None)
        return method() if callable(method) else []

    def homeContent(self, filter):
        return self.origin.homeContent(filter)

    def homeVideoContent(self):
        return self.origin.homeVideoContent()

    def categoryContent(self, tid, pg, filter, extend):
        return self.origin.categoryContent(tid, pg, filter, extend)

    def detailContent(self, ids):
        raw = self.origin.detailContent(ids)
        data = self._as_dict(raw)
        if not data:
            return raw
        values = data.get("list", [])
        if isinstance(values, dict):
            values = [values]
        for vod in values if isinstance(values, list) else []:
            if not isinstance(vod, dict):
                continue
            flags = str(vod.get("vod_play_from", "") or "").split("$$$")
            urls = str(vod.get("vod_play_url", "") or "").split("$$$")
            if not any(flags) or not any(urls):
                continue
            count = min(len(flags), len(urls))
            proxy_flags = [flags[index] + PROXY_SUFFIX for index in range(count)]
            vod["vod_play_from"] = "$$$".join(flags + proxy_flags)
            vod["vod_play_url"] = "$$$".join(urls + urls[:count])
        return data

    def searchContent(self, key, quick, pg="1"):
        try:
            return self.origin.searchContent(key, quick, pg)
        except TypeError:
            return self.origin.searchContent(key, quick)

    def playerContent(self, flag, id, vipFlags):
        value = str(flag or "")
        if not value.endswith(PROXY_SUFFIX):
            return self.origin.playerContent(flag, id, vipFlags)
        original_flag = value[:-len(PROXY_SUFFIX)]
        del _RECENT_RESPONSES[:]
        raw = self.origin.playerContent(original_flag, id, vipFlags)
        data = self._as_dict(raw)
        if not data:
            return raw
        target = str(data.get("url", "") or "").strip()
        try:
            parse = int(data.get("parse", data.get("jx", 0)) or 0)
        except Exception:
            parse = 0
        if not target or parse != 0:
            target = self._recover_recent_media_url()
            if target:
                data["url"] = target
                parse = 0
        parsed = urllib.parse.urlsplit(target)
        if parse != 0 or parsed.scheme.lower() not in ("http", "https") or not parsed.netloc:
            data["msg"] = "该线路不是可使用反代线路的 HTTP(S) 直链"
            return data
        headers = data.get("header", data.get("headers", {}))
        if isinstance(headers, str):
            try:
                headers = json.loads(headers)
            except Exception:
                headers = {}
        if not isinstance(headers, dict):
            headers = {}
        token = hashlib.sha256((target + json.dumps(headers, sort_keys=True)).encode("utf-8")).hexdigest()[:20]
        _store_proxy_headers(token, headers)
        local = self._local_url(target, token)
        data["parse"] = 0
        data["jx"] = 0
        data["playUrl"] = ""
        data["url"] = local
        data["header"] = {}
        data["headers"] = {}
        return data

    def liveContent(self, url):
        return self.origin.liveContent(url)

    def localProxy(self, param):
        if not isinstance(param, dict) or param.get("mode") != "v70_playback_proxy":
            method = getattr(self.origin, "localProxy", None)
            return method(param) if callable(method) else [404, "text/plain", b""]
        target = str(param.get("target", "") or "").strip()
        parsed = urllib.parse.urlsplit(target)
        if parsed.scheme.lower() not in ("http", "https") or not parsed.netloc:
            return [400, "text/plain", b"Invalid target"]
        token = str(param.get("token", "") or "")
        headers = _load_proxy_headers(token)
        range_value = str(param.get("range", param.get("Range", "")) or "").strip()
        if range_value:
            headers["Range"] = range_value
        headers.setdefault("Accept", "*/*")
        headers.setdefault("Accept-Encoding", "identity")
        try:
            response = None
            response_prefix = ""
            last_error = None
            candidates = _proxy_request_candidates(target)
            deadline = time.monotonic() + FAILOVER_TOTAL_TIMEOUT
            for index, (prefix, request_url) in enumerate(candidates):
                remaining = deadline - time.monotonic()
                if remaining <= 0:
                    last_error = TimeoutError("反代播放请求故障切换超时")
                    break
                try:
                    candidate = requests.get(
                        request_url,
                        headers=headers,
                        timeout=_candidate_timeout(
                            None, remaining, len(candidates) - index
                        ),
                        allow_redirects=True,
                        stream=True,
                    )
                    previous = response
                    response = candidate
                    if previous is not None:
                        previous.close()
                    status = _proxy_status(candidate)
                    response_prefix = prefix
                    if status < 400:
                        _mark_proxy_success(prefix)
                        break
                    if not _retryable_proxy_status(status):
                        _mark_proxy_success(prefix)
                        break
                    _mark_proxy_failure(prefix, status)
                except Exception as exc:
                    _mark_proxy_failure(prefix)
                    last_error = exc
            if response is None:
                raise last_error or RuntimeError("No playback proxy response")
            try:
                status = _proxy_status(response)
                if status in (401, 403):
                    return [
                        status,
                        "text/plain",
                        (
                            "Proxy credential rejected (HTTP {})".format(status)
                        ).encode("utf-8"),
                    ]
                content_length = str(response.headers.get("Content-Length", "") or "")
                if content_length.isdigit() and int(content_length) > MAX_RESPONSE:
                    return [413, "text/plain", b"Proxy response too large"]
                chunks = []
                total = 0
                for chunk in response.iter_content(chunk_size=64 * 1024):
                    if not chunk:
                        continue
                    total += len(chunk)
                    if total > MAX_RESPONSE:
                        return [413, "text/plain", b"Proxy response too large"]
                    chunks.append(chunk)
                body = b"".join(chunks)
                mime = str(response.headers.get("Content-Type", "application/octet-stream")).split(";", 1)[0]
                if self._is_hls(target, mime, body):
                    base_url = _proxy_response_target(
                        response, response_prefix, target
                    )
                    body = self._rewrite_hls(body, base_url, token)
                    mime = "application/vnd.apple.mpegurl"
                response_headers = {}
                for name in ("Content-Range", "Accept-Ranges", "ETag", "Last-Modified"):
                    value = response.headers.get(name)
                    if value:
                        response_headers[name] = str(value)
                return [int(response.status_code), mime, body, response_headers]
            finally:
                response.close()
        except Exception as exc:
            return [502, "text/plain", str(exc).encode("utf-8", errors="replace")[:512]]

    def isVideoFormat(self, url):
        method = getattr(self.origin, "isVideoFormat", None)
        return method(url) if callable(method) else False

    def manualVideoCheck(self):
        method = getattr(self.origin, "manualVideoCheck", None)
        return method() if callable(method) else False

    def action(self, action):
        method = getattr(self.origin, "action", None)
        return method(action) if callable(method) else {"code": 0, "msg": ""}

    def destroy(self):
        method = getattr(self.origin, "destroy", None)
        return method() if callable(method) else None

    @staticmethod
    def _as_dict(value):
        if isinstance(value, dict):
            return value
        if isinstance(value, str):
            try:
                parsed = json.loads(value)
                return parsed if isinstance(parsed, dict) else {}
            except Exception:
                return {}
        return {}

    @staticmethod
    def _is_hls(target, mime, body):
        data = bytes(body or b"").lstrip()
        if data.startswith(b"\xef\xbb\xbf"):
            data = data[3:].lstrip()
        return data.startswith(b"#EXTM3U")

    @staticmethod
    def _recover_recent_media_url():
        for text in reversed(_RECENT_RESPONSES):
            assignment = re.search(r"\bplayer_aaaa\s*=\s*", text)
            if assignment is None:
                continue
            marker = assignment.end()
            end = text.find("</script>", marker)
            if end < 0:
                end = min(len(text), marker + 16384)
            payload = text[marker:end].strip()
            payload = payload.rstrip("; \\r\\n\\t")
            try:
                data = json.JSONDecoder().raw_decode(payload)[0]
            except Exception:
                continue
            raw = str(data.get("url", "") or "").strip()
            for _ in range(3):
                decoded = urllib.parse.unquote(raw)
                if decoded == raw:
                    break
                raw = decoded
            if not raw.startswith(("http://", "https://")):
                try:
                    raw = base64.b64decode(raw).decode("utf-8")
                    raw = urllib.parse.unquote(raw)
                except Exception:
                    continue
            parsed = urllib.parse.urlsplit(raw)
            if parsed.scheme.lower() in ("http", "https") and parsed.netloc:
                return raw
        return ""

    def _local_url(self, target, token):
        return (
            self.getProxyUrl()
            + "&siteKey="
            + urllib.parse.quote(SITE_KEY, safe="")
            + "&mode=v70_playback_proxy&target="
            + urllib.parse.quote(str(target), safe="")
            + "&token="
            + urllib.parse.quote(str(token), safe="")
        )

    def _rewrite_hls(self, body, base_url, token):
        text = bytes(body or b"").decode("utf-8-sig", errors="replace")

        def replace_uri(match):
            absolute = urllib.parse.urljoin(base_url, match.group(1))
            return 'URI="{}"'.format(self._local_url(absolute, token))

        output = []
        for line in text.splitlines():
            stripped = line.strip()
            if stripped.startswith("#"):
                output.append(re.sub(r'URI="([^"]+)"', replace_uri, line))
            elif stripped:
                output.append(self._local_url(urllib.parse.urljoin(base_url, stripped), token))
            else:
                output.append(line)
        return ("\n".join(output) + "\n").encode("utf-8")
'''
        prefixes = [prefix]
        if not source.get("_health_proxy_only"):
            prefixes.extend(self.playback_proxy_urls)
        prefixes = list(
            dict.fromkeys(
                str(value)
                for value in prefixes
                if str(value or "").strip()
            )
        )
        return (
            template.replace("__ORIGINAL_PATH__", json.dumps(original_path))
            .replace("__ORIGINAL_MODULE__", json.dumps(module_name))
            .replace("__PROXY_PREFIX__", json.dumps(prefix))
            .replace("__PROXY_PREFIXES__", json.dumps(prefixes))
            .replace(
                "__ROUTE_CONFIG_PATH__",
                json.dumps(self._playback_proxy_runtime_config_path()),
            )
            .replace(
                "__SOURCE_IDENTITY__",
                json.dumps(str(source.get("identity", "") or "")),
            )
            .replace(
                "__SITE_KEY__",
                json.dumps(
                    str(
                        source.get("csp_site", {}).get("key", "")
                        if isinstance(source.get("csp_site"), dict)
                        else ""
                    )
                    or str(source.get("key", ""))
                ),
            )
            .replace("__MAX_RESPONSE__", str(self.PLAYBACK_PROXY_MAX_RESPONSE))
        )

    def _proxy_adjacent_wrapper_path(self, source, extension, kind, prefix=""):
        source_path = str(source.get("path", "") or "").strip()
        capability = self._playback_proxy_capability(source)
        source_path = str(capability.get("path", "") or source_path).strip()
        if not source_path:
            raise ValueError("反代包装缺少原始文件路径")
        directory = os.path.dirname(os.path.realpath(os.path.abspath(source_path)))
        digest = self._digest(
            "{}|{}|{}|{}|{}|{}".format(
                source.get("identity", ""),
                self._source_signature(source),
                kind,
                self.PLAYBACK_PROXY_WRAPPER_REVISION,
                self.VERSION,
                self._playback_proxy_url_identity(prefix) if prefix else "",
            ),
            32,
        )
        return os.path.join(
            directory,
            "{}{}.{}".format(
                self.PLAYBACK_PROXY_ADJACENT_PREFIX, digest, extension
            ),
        )

    def _render_js_playback_proxy_wrapper(self, source, prefix):
        capability = self._playback_proxy_capability(source)
        original_path = os.path.realpath(
            os.path.abspath(str(source.get("proxy_payload_path", "") or capability.get("path", "") or source.get("path", "")))
        )
        wrapper_path = self._proxy_adjacent_wrapper_path(
            source, "js", "js", prefix
        )
        original_reference = os.path.relpath(
            original_path, os.path.dirname(wrapper_path)
        ).replace(os.sep, "/")
        if not original_reference.startswith(("./", "../")):
            original_reference = "./" + original_reference
        route_reference = os.path.relpath(
            self._playback_proxy_runtime_config_path(),
            os.path.dirname(wrapper_path),
        ).replace(os.sep, "/")
        if not route_reference.startswith(("./", "../")):
            route_reference = "./" + route_reference
        common = {
            "__ORIGINAL_MODULE__": json.dumps(original_reference),
            "__ROUTE_CONFIG__": json.dumps(route_reference),
            "__FALLBACK_PREFIX__": json.dumps(prefix),
            "__SOURCE_IDENTITY__": json.dumps(
                str(source.get("identity", "") or "")
            ),
        }
        if capability.get("adapter") == "commonjs":
            template = r'''// Generated by 本地影仓 v7.1. The original source is not modified.
const __lmOriginal = require(__ORIGINAL_MODULE__);
const __lmRoutePath = __ROUTE_CONFIG__;
const __lmFallbackPrefix = __FALLBACK_PREFIX__;
const __lmSourceIdentity = __SOURCE_IDENTITY__;
let __lmRoute = __lmFallbackPrefix;

function __lmRelay(prefix, target) {
  try {
    const value = String(target || "");
    if (!prefix || value.indexOf(String(prefix)) === 0 || value.indexOf("/relay/") >= 0) return target;
    const url = value.match(/^(https?):\/\/([^\/?#]+)([^?#]*)?(\?[^#]*)?(?:#.*)?$/i);
    if (!url) return target;
    const match = String(prefix || "").match(/^(https?:\/\/[^/]+)\/proxy\/([^/?#]+)\/?(?:\?|$)/i);
    if (!match) return String(prefix || "") + value;
    return match[1] + "/relay/" + encodeURIComponent(match[2]) + "/" + url[1].toLowerCase() + "/" + encodeURIComponent(url[2]) + (url[3] || "/") + (url[4] || "");
  } catch (_) { return target; }
}
async function __lmRefreshRoute() {
  try {
    const response = await fetch(__lmRoutePath, {cache: "no-store"});
    const payload = await response.json();
    const assignments = payload && payload.assignments || {};
    const urls = Array.isArray(payload && payload.urls) ? payload.urls : [];
    const selected = assignments[__lmSourceIdentity] || urls[0] || __lmFallbackPrefix;
    if (selected) __lmRoute = selected;
  } catch (_) {}
  return __lmRoute;
}
function __lmCall(fn, receiver, args) {
  const nativeHttp = globalThis._http;
  let nativeReq = null;
  let nativeHttpHelper = null;
  let wrappedReq = null;
  let wrappedHttpHelper = null;
  let wrappedHttp = null;
  const restore = () => {
    if (wrappedHttp && globalThis._http === wrappedHttp) globalThis._http = nativeHttp;
    try { if (wrappedReq && typeof req === "function" && req === wrappedReq) req = nativeReq; } catch (_) {}
    try { if (wrappedHttpHelper && typeof http === "function" && http === wrappedHttpHelper) http = nativeHttpHelper; } catch (_) {}
  };
  if (typeof nativeHttp === "function" && __lmRoute) {
    wrappedHttp = function(url, options) {
      return nativeHttp.call(this, __lmRelay(__lmRoute, url), options);
    };
    globalThis._http = wrappedHttp;
  }
  try {
    if (typeof req === "function") {
      nativeReq = req;
      wrappedReq = function(url, options) {
        return nativeReq.call(this, __lmRelay(__lmRoute, url), options);
      };
      req = wrappedReq;
    }
  } catch (_) {}
  try {
    if (typeof http === "function") {
      nativeHttpHelper = http;
      wrappedHttpHelper = function(url, options) {
        return nativeHttpHelper.call(this, __lmRelay(__lmRoute, url), options);
      };
      http = wrappedHttpHelper;
    }
  } catch (_) {}
  try {
    const result = fn.apply(receiver, args);
    if (result && typeof result.then === "function") {
      return result.then((value) => { restore(); return value; }, (error) => { restore(); throw error; });
    }
    restore();
    return result;
  } catch (error) {
    restore();
    throw error;
  }
}
function __lmRewrite(value, prefix, nested) {
  if (typeof value === "string") return /^https?:\/\//i.test(value) ? __lmRelay(prefix, value) : value;
  if (Array.isArray(value)) return value.map((item) => __lmRewrite(item, prefix, true));
  if (!value || typeof value !== "object") return value;
  const output = Object.assign({}, value);
  if (Number(output.parse || output.jx || 0) !== 0) return output;
  Object.keys(output).forEach((key) => {
    if (key === "url" || (nested && /^(?:src|media|play_url)$/i.test(key))) output[key] = __lmRewrite(output[key], prefix, true);
    else if (Array.isArray(output[key]) || (output[key] && typeof output[key] === "object")) output[key] = __lmRewrite(output[key], prefix, true);
  });
  return output;
}
async function __lmInvoke(names, args) {
  await __lmRefreshRoute();
  const origin = __lmOriginal && (__lmOriginal.default || __lmOriginal);
  for (const name of names) {
    const fn = origin && origin[name] || (__lmOriginal && __lmOriginal[name]);
    if (typeof fn === "function") return __lmCall(fn, origin, args);
  }
  return undefined;
}
async function __lmPlayback(names, args) {
  const raw = await __lmInvoke(names, args);
  const prefix = await __lmRefreshRoute();
  if (typeof raw === "string") {
    try { return JSON.stringify(__lmRewrite(JSON.parse(raw), prefix, false)); } catch (_) { return __lmRewrite(raw, prefix, false); }
  }
  return __lmRewrite(raw, prefix, false);
}
const __lmNames = ["init", "home", "homeVod", "homeContent", "homeVideoContent", "category", "categoryContent", "detail", "detailContent", "search", "searchContent", "live", "proxy", "proxy_rule"];
const __lmExport = Object.assign({}, __lmOriginal || {});
__lmNames.forEach((name) => { __lmExport[name] = (...args) => __lmInvoke([name], args); });
__lmExport.play = (...args) => __lmPlayback(["play", "playerContent"], args);
__lmExport.playerContent = (...args) => __lmPlayback(["playerContent", "play"], args);
__lmExport.live = (...args) => __lmInvoke(["live", "liveContent"], args);
module.exports = __lmExport;
'''
        else:
            template = r'''// Generated by 本地影仓 v7.1. The original source is not modified.
import * as __lmImported from __ORIGINAL_MODULE__;
const __lmDefault = __lmImported.default && typeof __lmImported.default === "object" ? __lmImported.default : null;
const __lmOrigin = globalThis.__JS_SPIDER__ || __lmDefault || __lmImported;
const __lmRoutePath = __ROUTE_CONFIG__;
const __lmFallbackPrefix = __FALLBACK_PREFIX__;
const __lmSourceIdentity = __SOURCE_IDENTITY__;
let __lmRoute = __lmFallbackPrefix;

function __lmRelay(prefix, target) {
  try {
    const value = String(target || "");
    if (!prefix || value.indexOf(String(prefix)) === 0 || value.indexOf("/relay/") >= 0) return target;
    const url = value.match(/^(https?):\/\/([^\/?#]+)([^?#]*)?(\?[^#]*)?(?:#.*)?$/i);
    if (!url) return target;
    const match = String(prefix || "").match(/^(https?:\/\/[^/]+)\/proxy\/([^/?#]+)\/?(?:\?|$)/i);
    if (!match) return String(prefix || "") + value;
    return match[1] + "/relay/" + encodeURIComponent(match[2]) + "/" + url[1].toLowerCase() + "/" + encodeURIComponent(url[2]) + (url[3] || "/") + (url[4] || "");
  } catch (_) { return target; }
}
async function __lmRefreshRoute() {
  try {
    const response = await fetch(__lmRoutePath, {cache: "no-store"});
    const payload = await response.json();
    const assignments = payload && payload.assignments || {};
    const urls = Array.isArray(payload && payload.urls) ? payload.urls : [];
    const selected = assignments[__lmSourceIdentity] || urls[0] || __lmFallbackPrefix;
    if (selected) __lmRoute = selected;
  } catch (_) {}
  return __lmRoute;
}
function __lmCall(fn, receiver, args) {
  const nativeHttp = globalThis._http;
  let nativeReq = null;
  let nativeHttpHelper = null;
  let wrappedReq = null;
  let wrappedHttpHelper = null;
  let wrappedHttp = null;
  const restore = () => {
    if (wrappedHttp && globalThis._http === wrappedHttp) globalThis._http = nativeHttp;
    try { if (wrappedReq && typeof req === "function" && req === wrappedReq) req = nativeReq; } catch (_) {}
    try { if (wrappedHttpHelper && typeof http === "function" && http === wrappedHttpHelper) http = nativeHttpHelper; } catch (_) {}
  };
  if (typeof nativeHttp === "function" && __lmRoute) {
    wrappedHttp = function(url, options) {
      return nativeHttp.call(this, __lmRelay(__lmRoute, url), options);
    };
    globalThis._http = wrappedHttp;
  }
  try {
    if (typeof req === "function") {
      nativeReq = req;
      wrappedReq = function(url, options) {
        return nativeReq.call(this, __lmRelay(__lmRoute, url), options);
      };
      req = wrappedReq;
    }
  } catch (_) {}
  try {
    if (typeof http === "function") {
      nativeHttpHelper = http;
      wrappedHttpHelper = function(url, options) {
        return nativeHttpHelper.call(this, __lmRelay(__lmRoute, url), options);
      };
      http = wrappedHttpHelper;
    }
  } catch (_) {}
  try {
    const result = fn.apply(receiver, args);
    if (result && typeof result.then === "function") {
      return result.then((value) => { restore(); return value; }, (error) => { restore(); throw error; });
    }
    restore();
    return result;
  } catch (error) {
    restore();
    throw error;
  }
}
function __lmRewrite(value, prefix, nested) {
  if (typeof value === "string") return /^https?:\/\//i.test(value) ? __lmRelay(prefix, value) : value;
  if (Array.isArray(value)) return value.map((item) => __lmRewrite(item, prefix, true));
  if (!value || typeof value !== "object") return value;
  const output = Object.assign({}, value);
  if (Number(output.parse || output.jx || 0) !== 0) return output;
  Object.keys(output).forEach((key) => {
    if (key === "url" || (nested && /^(?:src|media|play_url)$/i.test(key))) output[key] = __lmRewrite(output[key], prefix, true);
    else if (Array.isArray(output[key]) || (output[key] && typeof output[key] === "object")) output[key] = __lmRewrite(output[key], prefix, true);
  });
  return output;
}
async function __lmInvoke(names, args) {
  await __lmRefreshRoute();
  for (const name of names) {
    const fn = __lmOrigin && __lmOrigin[name] || (__lmImported && __lmImported[name]);
    if (typeof fn === "function") return __lmCall(fn, __lmOrigin, args);
  }
  return undefined;
}
async function __lmPlayback(names, args) {
  const raw = await __lmInvoke(names, args);
  const prefix = await __lmRefreshRoute();
  if (typeof raw === "string") {
    try { return JSON.stringify(__lmRewrite(JSON.parse(raw), prefix, false)); } catch (_) { return __lmRewrite(raw, prefix, false); }
  }
  return __lmRewrite(raw, prefix, false);
}
export const init = (...args) => __lmInvoke(["init"], args);
export const home = (...args) => __lmInvoke(["home", "homeContent"], args);
export const homeContent = (...args) => __lmInvoke(["homeContent", "home"], args);
export const category = (...args) => __lmInvoke(["category", "categoryContent"], args);
export const categoryContent = (...args) => __lmInvoke(["categoryContent", "category"], args);
export const detail = (...args) => __lmInvoke(["detail", "detailContent"], args);
export const detailContent = (...args) => __lmInvoke(["detailContent", "detail"], args);
export const search = (...args) => __lmInvoke(["search", "searchContent"], args);
export const searchContent = (...args) => __lmInvoke(["searchContent", "search"], args);
export const homeVod = (...args) => __lmInvoke(["homeVod", "homeVideoContent"], args);
export const homeVideoContent = (...args) => __lmInvoke(["homeVideoContent", "homeVod"], args);
export const live = (...args) => __lmInvoke(["live", "liveContent"], args);
export const play = (...args) => __lmPlayback(["play", "playerContent"], args);
export const playerContent = (...args) => __lmPlayback(["playerContent", "play"], args);
export const proxy = (...args) => __lmInvoke(["proxy", "proxy_rule"], args);
export default Object.assign({}, __lmOrigin || {}, {init, home, homeContent, homeVod, homeVideoContent, category, categoryContent, detail, detailContent, search, searchContent, live, play, playerContent, proxy});
'''
        for key, value in common.items():
            template = template.replace(key, value)
        return template

    @staticmethod
    def _js_binding_declared(text, name):
        value = str(text or "")
        escaped = re.escape(name)
        return bool(
            re.search(
                r"\b(?:const|let|var|function|class)\s+(?:" + escaped + r")\b",
                value,
            )
            or re.search(
                r"\bimport\b[^;\n]*\b" + escaped + r"\b",
                value,
            )
            or re.search(
                r"\b(?:const|let|var)\s*[{[][^}\]]*\b" + escaped + r"\b",
                value,
            )
        )

    def _render_js_playback_proxy_payload(self, source, prefix):
        capability = self._playback_proxy_capability(source)
        original_path = os.path.realpath(
            os.path.abspath(str(capability.get("path", "") or source.get("path", "")))
        )
        with open(original_path, "r", encoding="utf-8-sig", errors="replace") as fp:
            original = fp.read()
        needed = {
            name: bool(
                re.search(r"\b" + name + r"\s*(?:\(|\.)", original)
                and not self._js_binding_declared(original, name)
            )
            for name in ("req", "http", "_http")
        }
        if not any(needed.values()):
            return original
        route = json.dumps(str(prefix or ""))
        prelude = r'''// Generated by 本地影仓 v7.1. The original source is copied unchanged below.
const __lmPayloadPrefix = __PREFIX__;
function __lmPayloadRelay(target) {
  try {
    const value = String(target || "");
    if (!__lmPayloadPrefix || value.indexOf(String(__lmPayloadPrefix)) === 0 || value.indexOf("/relay/") >= 0) return target;
    const url = value.match(/^(https?):\/\/([^\/?#]+)([^?#]*)?(\?[^#]*)?(?:#.*)?$/i);
    if (!url) return target;
    const match = String(__lmPayloadPrefix || "").match(/^(https?:\/\/[^/]+)\/proxy\/([^/?#]+)\/?(?:\?|$)/i);
    if (!match) return String(__lmPayloadPrefix || "") + value;
    return match[1] + "/relay/" + encodeURIComponent(match[2]) + "/" + url[1].toLowerCase() + "/" + encodeURIComponent(url[2]) + (url[3] || "/") + (url[4] || "");
  } catch (_) { return target; }
}
const __lmPayloadNativeHttp = globalThis._http;
function __lmPayloadHttp(url, options = {}) {
  const target = __lmPayloadRelay(url);
  if (options && options.async === false) return __lmPayloadNativeHttp.call(globalThis, target, options);
  return new Promise((resolve) => __lmPayloadNativeHttp.call(globalThis, target, Object.assign({complete: resolve}, options))).catch(() => ({ok: false, status: 500, url}));
}
'''.replace("__PREFIX__", route)
        if needed["_http"]:
            prelude += "const _http = (url, options = {}) => __lmPayloadNativeHttp.call(globalThis, __lmPayloadRelay(url), options);\n"
        if needed["http"]:
            prelude += "const http = (url, options = {}) => __lmPayloadHttp(url, options);\n"
        if needed["req"]:
            prelude += "const req = (url, options = {}) => __lmPayloadHttp(url, Object.assign({async: false}, options));\n"
        return prelude + original

    def _render_html_playback_proxy_wrapper(self, source, prefix):
        original_path = os.path.realpath(
            os.path.abspath(str(source.get("path", "") or ""))
        )
        wrapper_path = self._proxy_adjacent_wrapper_path(
            source, "html", "html", prefix
        )
        route_reference = os.path.relpath(
            self._playback_proxy_runtime_config_path(),
            os.path.dirname(wrapper_path),
        ).replace(os.sep, "/")
        if not route_reference.startswith(("./", "../")):
            route_reference = "./" + route_reference
        with open(original_path, "r", encoding="utf-8-sig", errors="replace") as fp:
            original = fp.read()
        script = r'''<script data-local-media-proxy="v7.1">(function(){
const identity=__IDENTITY__,routePath=__ROUTE_PATH__,fallback=__PREFIX__;let prefix=fallback;
function relay(value){try{const raw=String(value||'');const u=new URL(raw);if(!/^https?:$/.test(u.protocol)||!u.host||!prefix||raw.indexOf(String(prefix))===0||raw.indexOf('/relay/')>=0)return value;const m=String(prefix||'').match(/^(https?:\/\/[^/]+)\/proxy\/([^/?#]+)\/?(?:\?|$)/i);if(!m)return String(prefix||'')+raw;return m[1]+'/relay/'+encodeURIComponent(m[2])+'/'+u.protocol.slice(0,-1)+'/'+encodeURIComponent(u.host)+(u.pathname||'/')+(u.search||'')}catch(_){return value}}
function rewrite(value){if(typeof value==='string')return /^https?:\/\//i.test(value)?relay(value):value;if(Array.isArray(value))return value.map(rewrite);if(!value||typeof value!=='object')return value;const out=Object.assign({},value);Object.keys(out).forEach(k=>{if(k==='url'||/^(?:src|media|play_url)$/i.test(k))out[k]=rewrite(out[k]);else if(out[k]&&typeof out[k]==='object')out[k]=rewrite(out[k])});return out}
async function refresh(){try{const r=await fetch(routePath,{cache:'no-store'}),p=await r.json(),a=p&&p.assignments||{},u=Array.isArray(p&&p.urls)?p.urls:[],v=a[identity]||u[0]||fallback;if(v)prefix=v}catch(_){ }return prefix}
function patchFm(){const fm=window.fm;if(!fm||fm.__localMediaProxy)return false;if(typeof fm.play==='function'){const fn=fm.play;fm.play=function(){const args=[].slice.call(arguments).map(rewrite);return fn.apply(this,args)}}fm.__localMediaProxy=true;return true}
function patchMedia(){const originalSet=Element.prototype.setAttribute;if(!originalSet.__localMediaProxy){Element.prototype.setAttribute=function(name,value){const tag=String(this.tagName||'').toLowerCase();if((tag==='video'||tag==='source')&&String(name).toLowerCase()==='src')value=relay(value);return originalSet.call(this,name,value)};Element.prototype.setAttribute.__localMediaProxy=true}if(window.HTMLMediaElement){const d=Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype,'src');if(d&&d.set&&!d.set.__localMediaProxy){const set=d.set,get=d.get,wrapped=function(value){return set.call(this,relay(value))};wrapped.__localMediaProxy=true;Object.defineProperty(HTMLMediaElement.prototype,'src',{configurable:d.configurable,enumerable:d.enumerable,get:get,set:wrapped})}}document.querySelectorAll('video,source').forEach((el)=>{if(el.src)el.src=relay(el.src)})}
function patchHls(){const H=window.Hls;if(!H||!H.prototype||!H.prototype.loadSource||H.prototype.loadSource.__localMediaProxy)return false;const fn=H.prototype.loadSource;H.prototype.loadSource=function(url){return fn.call(this,relay(url))};H.prototype.loadSource.__localMediaProxy=true;return true}
function tick(){patchFm();patchMedia();patchHls();refresh()};tick();new MutationObserver(tick).observe(document.documentElement||document,{subtree:true,childList:true});setInterval(tick,5000)
})();</script>'''
        script = (
            script.replace("__IDENTITY__", json.dumps(str(source.get("identity", "") or "")))
            .replace("__ROUTE_PATH__", json.dumps(route_reference))
            .replace("__PREFIX__", json.dumps(str(prefix or "")))
        )
        marker = re.search(r"</head\s*>", original, re.IGNORECASE)
        if marker:
            return original[: marker.start()] + script + original[marker.start() :]
        marker = re.search(r"</body\s*>", original, re.IGNORECASE)
        if marker:
            return original[: marker.start()] + script + original[marker.start() :]
        return script + original

    def _ensure_js_playback_proxy_wrapper(self, source, prefix):
        payload_path = self._proxy_adjacent_wrapper_path(
            source, "js", "js_payload", prefix
        )
        payload = self._render_js_playback_proxy_payload(source, prefix)
        self._atomic_write_text(payload_path, payload)
        source["proxy_payload_path"] = payload_path
        path = self._proxy_adjacent_wrapper_path(source, "js", "js", prefix)
        content = self._render_js_playback_proxy_wrapper(source, prefix)
        self._atomic_write_text(path, content)
        source["proxy_wrapper_path"] = path
        return path

    def _ensure_html_playback_proxy_wrapper(self, source, prefix):
        path = self._proxy_adjacent_wrapper_path(source, "html", "html", prefix)
        content = self._render_html_playback_proxy_wrapper(source, prefix)
        self._atomic_write_text(path, content)
        source["proxy_wrapper_path"] = path
        return path

    def _ensure_playback_proxy_wrapper(self, source, prefix):
        self._write_playback_proxy_runtime_config(
            source.get("identity", ""), prefix
        )
        capability = self._playback_proxy_capability(source)
        kind = capability.get("kind")
        if kind not in ("unsupported", "py_media") and not (
            self._playback_proxy_line_compatible(source, prefix)
        ):
            raise ValueError("该来源需要支持 HTTP(S) 目标转发的反代线路")
        if kind == "js_player":
            return self._ensure_js_playback_proxy_wrapper(source, prefix)
        if kind == "html_native_play":
            return self._ensure_html_playback_proxy_wrapper(source, prefix)
        if kind != "py_media":
            raise ValueError(capability.get("reason", "当前来源不支持统一反代"))
        return self._ensure_python_playback_proxy_wrapper(source, prefix)

    def _ensure_python_playback_proxy_wrapper(self, source, prefix):
        self._write_playback_proxy_runtime_config(
            source.get("identity", ""), prefix
        )
        wrapper_dir = self._playback_proxy_wrapper_dir()
        digest = self._digest(
            "{}|{}|{}|{}|{}|{}|{}".format(
                source.get("identity", ""),
                self._source_signature(source),
                prefix,
                json.dumps(self.playback_proxy_urls, ensure_ascii=True),
                "health" if source.get("_health_proxy_only") else "runtime",
                self.PLAYBACK_PROXY_WRAPPER_REVISION,
                self.VERSION,
            ),
            24,
        )
        path = os.path.join(wrapper_dir, "proxy_{}.py".format(digest))
        content = self._render_playback_proxy_wrapper(source, prefix)
        current = ""
        try:
            with open(path, "r", encoding="utf-8") as fp:
                current = fp.read()
        except Exception:
            pass
        if current != content:
            self._atomic_write_text(path, content)
        return path

    def _cleanup_playback_proxy_wrappers(self, active_paths):
        directory = self._playback_proxy_wrapper_dir()
        if not os.path.isdir(directory):
            return 0
        active = {
            os.path.realpath(os.path.abspath(path)) for path in active_paths if path
        }
        removed = 0
        for name in os.listdir(directory):
            path = os.path.join(directory, name)
            if (
                name.startswith("proxy_")
                and name.endswith(".py")
                and os.path.isfile(path)
                and os.path.realpath(os.path.abspath(path)) not in active
            ):
                try:
                    os.remove(path)
                    removed += 1
                except Exception as exc:
                    self._warn("失效的反代线路包装清理失败: {}".format(exc))
        return removed

    def _cleanup_adjacent_playback_proxy_wrappers(self, active_paths, sources):
        active = {
            os.path.realpath(os.path.abspath(path))
            for path in active_paths
            if path
        }
        directories = {
            os.path.dirname(
                os.path.realpath(
                    os.path.abspath(str(source.get("path", "") or ""))
                )
            )
            for source in sources
            if str(source.get("path", "") or "").strip()
        }
        removed = 0
        for directory in directories:
            if not os.path.isdir(directory):
                continue
            try:
                names = os.listdir(directory)
            except Exception:
                continue
            for name in names:
                if not name.startswith(self.PLAYBACK_PROXY_ADJACENT_PREFIX):
                    continue
                if not name.lower().endswith((".js", ".html")):
                    continue
                path = os.path.realpath(os.path.join(directory, name))
                if path in active or not os.path.isfile(path):
                    continue
                try:
                    os.remove(path)
                    removed += 1
                except Exception as exc:
                    self._warn("失效的 JS/HTML 反代包装清理失败: {}".format(exc))
        return removed

    def _build_site(self, source):
        source_type = source["type"]
        file_ref = self._file_url(source["path"])
        capability = self._playback_proxy_capability(source)
        source["proxy_capability"] = capability.get("kind", "unsupported")
        source["proxy_capability_reason"] = capability.get("reason", "")
        source["proxy_capability_label"] = capability.get("label", "")
        site = {
            "key": source["key"],
            "name": source["name"],
            "type": 3,
            "searchable": self.DEFAULT_SEARCHABLE,
            "quickSearch": self.DEFAULT_QUICK_SEARCH,
        }
        if source.get("csp_site"):
            manifest_site = copy.deepcopy(source.get("csp_site", {}))
            if not isinstance(manifest_site, dict):
                manifest_site = {}
            original_key = str(manifest_site.get("key", "")).strip()
            site.update(manifest_site)
            # 整包 QuickJS/CSP 可能用配置 key 作为规则缓存或运行参数。
            # 注册表 item.id 已提供独立管理标识，无需改写运行 key。
            site["key"] = original_key or source["key"]
            site["name"] = source["name"]
            try:
                site["type"] = int(manifest_site.get("type", 3))
            except Exception:
                site["type"] = 3
            site.setdefault("searchable", self.DEFAULT_SEARCHABLE)
            site.setdefault("quickSearch", self.DEFAULT_QUICK_SEARCH)
            if self._playback_proxy_eligible(source):
                source["health_site"] = copy.deepcopy(site)
                state = self._playback_proxy_state(source)
                prefix = str(state.get("prefix", "") or "")
                if prefix and self._playback_proxy_line_compatible(
                    source, prefix
                ):
                    wrapper_path = self._ensure_playback_proxy_wrapper(
                        source, prefix
                    )
                    source["proxy_wrapper_path"] = wrapper_path
                    if source_type == "HTML":
                        site["homePage"] = self._file_url(wrapper_path)
                    else:
                        site["api"] = self._file_url(wrapper_path)
                    site["playbackProxy"] = {
                        "prefix": prefix,
                        "mode": str(state.get("mode", "auto")),
                        "capability": capability.get("label", ""),
                        "sourceType": source_type,
                    }
        elif source_type == "PY":
            site.update({"api": file_ref})
            source["health_site"] = copy.deepcopy(site)
            state = self._playback_proxy_state(source)
            prefix = str(state.get("prefix", "") or "")
            if (
                prefix
                and self._playback_proxy_eligible(source)
                and self._playback_proxy_line_compatible(source, prefix)
            ):
                wrapper_path = self._ensure_playback_proxy_wrapper(source, prefix)
                source["proxy_wrapper_path"] = wrapper_path
                site["api"] = self._file_url(wrapper_path)
                site["playbackProxy"] = {
                    "prefix": prefix,
                    "mode": str(state.get("mode", "auto")),
                    "capability": capability.get("label", ""),
                    "sourceType": source_type,
                }
        elif source_type == "JS":
            site.update(
                {
                    "api": file_ref,
                    "ext": "",
                }
            )
            source["health_site"] = copy.deepcopy(site)
            state = self._playback_proxy_state(source)
            prefix = str(state.get("prefix", "") or "")
            if (
                prefix
                and self._playback_proxy_eligible(source)
                and self._playback_proxy_line_compatible(source, prefix)
            ):
                wrapper_path = self._ensure_playback_proxy_wrapper(source, prefix)
                site["api"] = self._file_url(wrapper_path)
                site["playbackProxy"] = {
                    "prefix": prefix,
                    "mode": str(state.get("mode", "auto")),
                    "capability": capability.get("label", ""),
                    "sourceType": source_type,
                }
        elif source_type == "XBPQ":
            site.update(
                {
                    "api": self._runtime_reference(self.xbpq_api),
                    "ext": file_ref,
                    "jar": self._xbpq_jar_reference(),
                }
            )
        elif source_type == "HTML":
            site.update(
                {
                    "api": self._runtime_reference(self.html_api),
                    "homePage": file_ref,
                }
            )
            source["health_site"] = copy.deepcopy(site)
            state = self._playback_proxy_state(source)
            prefix = str(state.get("prefix", "") or "")
            if (
                prefix
                and self._playback_proxy_eligible(source)
                and self._playback_proxy_line_compatible(source, prefix)
            ):
                wrapper_path = self._ensure_playback_proxy_wrapper(source, prefix)
                site["homePage"] = self._file_url(wrapper_path)
                site["playbackProxy"] = {
                    "prefix": prefix,
                    "mode": str(state.get("mode", "auto")),
                    "capability": capability.get("label", ""),
                    "sourceType": source_type,
                }
        return site

    def _xbpq_runtime_status(self):
        jar = self._xbpq_jar_reference()
        if not jar:
            return False, (
                "XBPQ 已跳过：缺少 xbpqJar，请在 auto-loader.roots.json "
                "的 runtime 中配置包含 csp_XBPQ 的 JAR"
            )
        source = jar.split(";md5;", 1)[0].strip()
        lower = source.lower()
        if lower.startswith(("http://", "https://", "assets://")):
            return True, ""
        if lower.startswith("file://"):
            path = source[7:]
            if not os.path.isabs(path):
                path = os.path.join(self.STORAGE_ROOT, path)
        else:
            path = source
        if os.path.isfile(os.path.abspath(os.path.expanduser(path))):
            return True, ""
        return False, "XBPQ 已跳过：配置的 xbpqJar 不存在 ({})".format(source)

    def _xbpq_jar_reference(self):
        value = str(self.xbpq_jar or "").strip()
        if not value:
            return ""
        parts = value.split(";md5;", 1)
        reference = self._runtime_reference(parts[0].strip())
        if len(parts) == 1:
            return reference
        return reference + ";md5;" + parts[1].strip()

    def _file_url(self, path):
        absolute = os.path.realpath(os.path.abspath(os.path.expanduser(str(path))))
        storage_root = os.path.realpath(os.path.abspath(self.STORAGE_ROOT))
        try:
            relative = os.path.relpath(absolute, storage_root).replace(os.sep, "/")
        except Exception:
            relative = ""
        if relative and relative != ".." and not relative.startswith("../"):
            return "file://" + relative.lstrip("/")
        return "file://" + absolute

    def _runtime_reference(self, reference):
        value = str(reference or "").strip()
        if not value:
            return ""
        lower = value.lower()
        if lower.startswith(("http://", "https://", "file://", "assets://")):
            return value
        if value.startswith("csp_"):
            return value
        if os.path.isabs(value):
            return self._file_url(value)
        return self._file_url(os.path.join(self.local_base_dir, value.lstrip("./")))

    def _generate_config(self):
        base_duplicates = self.status["duplicates"]
        last_error = None
        for _ in range(3):
            registry, token = self._load_registry_snapshot()
            registry, manual_count, generated_count, duplicate_count, diff = self._merge_registry(
                registry
            )
            # v6.2：标准点播配置由完整合并结果生成（两端共用的主加载链路），
            # 写入注册表文件的只有 WebHome 注入视图。
            injection_registry, dropped = self._webhome_injection_registry(registry)
            try:
                self._atomic_write_json(injection_registry, expected_token=token)
                self.status["manual_sites"] = manual_count
                self.status["generated_sites"] = generated_count
                self.status["duplicates"] = base_duplicates + duplicate_count
                self.status["added_sites"] = diff["added"]
                self.status["updated_sites"] = diff["updated"]
                self.status["removed_sites"] = diff["removed"]
                self.status["unchanged_sites"] = diff["unchanged"]
                self._write_standard_configs(registry)
                if dropped:
                    self._log(
                        "INFO",
                        "{} 个自动项改由标准点播配置加载，未写入注入表".format(
                            dropped
                        ),
                    )
                return
            except RegistryChangedError as exc:
                last_error = exc
        raise RegistryChangedError(
            "注册表在扫描期间持续被修改，已停止写入: {}".format(last_error)
        )

    def _webhome_injection_registry(self, registry):
        """派生只含 WebHome 自动项的注入视图。

        v6.2 统一加载模型：普通站点、直播和播放上下文由标准点播配置承载，
        站点注入通道只保留 WebHTV 独有的 WebHome。手工项一律原样保留，
        OK影视不使用注入通道，因此自动项全部移除。
        """
        source = registry if isinstance(registry, dict) else {}
        items = source.get("items", [])
        if not isinstance(items, list):
            items = []
        kept = []
        dropped = 0
        for item in items:
            if not self._is_generated_registry_item(item):
                kept.append(item)
                continue
            if (
                self.app_mode == self.APP_MODE_WEBHTV
                and self._is_webhome_registry_item(item)
            ):
                kept.append(item)
                continue
            dropped += 1
        view = copy.deepcopy(source)
        view["items"] = copy.deepcopy(kept)
        kept_keys = {
            self._registry_item_key(item)
            for item in kept
            if isinstance(item, dict)
        }
        home_key = str(view.get("homeKey", "")).strip()
        if home_key.startswith(self.GENERATED_KEY_PREFIX) and home_key not in kept_keys:
            view["homeKey"] = ""
        return view, dropped

    def _merge_registry(self, registry):
        items = registry.get("items", [])
        if not isinstance(items, list):
            raise ValueError("站点注入注册表的 items 必须是数组")

        old_generated_items = [
            item for item in items if self._is_generated_registry_item(item)
        ]
        manual_items = []
        for item in items:
            if not isinstance(item, dict):
                manual_items.append(item)
                continue
            if self._is_generated_registry_item(item):
                continue
            manual_items.append(item)

        manual_fingerprints = {
            self._site_fingerprint(self._registry_item_site(item))
            for item in manual_items
            if isinstance(item, dict)
            and str(item.get("kind", "")).lower() != "live"
        }
        manual_live_fingerprints = {
            self._live_fingerprint(self._registry_item_live(item))
            for item in manual_items
            if isinstance(item, dict)
            and str(item.get("kind", "")).lower() == "live"
        }
        generated_items = []
        duplicate_count = 0
        for source in self.cache["sources"]:
            site = source["site"]
            if self._site_fingerprint(site) in manual_fingerprints:
                duplicate_count += 1
                continue
            generated_items.append(
                {
                    "id": source["key"],
                    "enabled": True,
                    "kind": self._registry_kind(source),
                    "sourceType": source.get("type", ""),
                    "packageId": str(source.get("package_id", "")),
                    "site": site,
                }
            )
        for source in self.cache.get("lives", []):
            live = copy.deepcopy(source.get("live", {}))
            fingerprint = self._live_fingerprint(live)
            if fingerprint in manual_live_fingerprints:
                duplicate_count += 1
                continue
            live[self.OK_CONFIG_MARKER] = {
                "version": self.OK_CONFIG_VERSION,
                "sourceId": source.get("id", ""),
                "packageId": source.get("package_id", ""),
            }
            generated_items.append(
                {
                    "id": source["id"],
                    "enabled": True,
                    "kind": "live",
                    "packageId": source.get("package_id", ""),
                    "sourcePath": self._file_url(source.get("path", "")),
                    "live": live,
                }
            )
        for entry in self.cache.get("contexts", []):
            if not isinstance(entry, dict):
                continue
            context = entry.get("context", {})
            if not isinstance(context, dict):
                continue
            base_id = str(entry.get("id", "")).strip()
            for field in self.PACKAGE_ROOT_CONTEXT_FIELDS:
                if field not in context:
                    continue
                generated_items.append(
                    {
                        "id": "{}{}_{}".format(
                            self.GENERATED_CONTEXT_PREFIX,
                            self._digest(base_id, 12),
                            self._digest(field, 6),
                        ),
                        "enabled": True,
                        "kind": "other",
                        "name": field,
                        "packageId": str(entry.get("package_id", "")),
                        "sourcePath": self._file_url(entry.get("path", "")),
                        "other": {field: copy.deepcopy(context[field])},
                    }
                )

        generated_identities = {
            self._registry_item_identity(item) for item in generated_items
        }
        ignored_generated_identities = {
            value
            for source in self.cache.get("ignored", [])
            for value in (
                str(source.get("id", "")).strip(),
                str(source.get("key", "")).strip(),
            )
            if value
        }
        ignored_site_fingerprints = {
            self._site_fingerprint(source.get("site", {}))
            for source in self.cache.get("ignored", [])
            if isinstance(source.get("site"), dict)
        }
        preserved_count = 0
        for item in old_generated_items:
            identity = self._registry_item_identity(item)
            if (
                identity in ignored_generated_identities
                or self._site_fingerprint(self._registry_item_site(item))
                in ignored_site_fingerprints
            ):
                # 屏蔽状态优先于“扫描根目录暂时不可用时保留旧注入项”，
                # 避免被屏蔽站点又回到 App 可选列表。
                continue
            if identity in generated_identities or not self._should_preserve_generated_item(item):
                continue
            generated_items.append(item)
            generated_identities.add(identity)
            preserved_count += 1
        if preserved_count:
            self._warn(
                "{} 个旧注入项因对应扫描目录暂时不可用而保留".format(
                    preserved_count
                )
            )

        if self.generated_insert_index is None:
            merged_items = manual_items + generated_items
        else:
            index = max(0, min(int(self.generated_insert_index), len(manual_items)))
            merged_items = manual_items[:index] + generated_items + manual_items[index:]

        registry["enabled"] = True
        registry.setdefault("insertIndex", 0)
        registry.setdefault("homeKey", "")
        registry["items"] = merged_items
        home_key = str(registry.get("homeKey", "")).strip()
        generated_site_keys = {
            self._registry_item_key(item)
            for item in generated_items
            if str(item.get("kind", "")).strip().lower()
            not in ("live", "other")
        }
        if (
            home_key.startswith(self.GENERATED_KEY_PREFIX)
            and home_key not in generated_site_keys
        ):
            registry["homeKey"] = ""
        old_map = {
            self._registry_item_identity(item): self._registry_content_fingerprint(item)
            for item in old_generated_items
        }
        new_map = {
            self._registry_item_identity(item): self._registry_content_fingerprint(item)
            for item in generated_items
        }
        shared = set(old_map) & set(new_map)
        diff = {
            "added": len(set(new_map) - set(old_map)),
            "removed": len(set(old_map) - set(new_map)),
            "updated": sum(1 for key in shared if old_map[key] != new_map[key]),
            "unchanged": sum(1 for key in shared if old_map[key] == new_map[key]),
        }
        return registry, len(manual_items), len(generated_items), duplicate_count, diff

    def _registry_kind(self, source):
        if source.get("type") == "HTML":
            return "webHome"
        site = source.get("site", {})
        if not isinstance(site, dict):
            return "csp"
        has_home = bool(str(site.get("homePage", "")).strip())
        return "webHome" if has_home else "csp"

    def _generated_item_type(self, item):
        source_type = str(item.get("sourceType", "")).strip().upper()
        if source_type in self.TYPE_ORDER:
            return source_type
        key = self._registry_item_key(item).lower()
        for source_type in self.TYPE_ORDER:
            if key.startswith(
                self.GENERATED_KEY_PREFIX.lower() + source_type.lower() + "_"
            ):
                return source_type
        return ""

    def _generated_item_reference(self, item, source_type):
        site = self._registry_item_site(item)
        if not isinstance(site, dict):
            return ""
        field = {
            "PY": "api",
            "JS": "api",
            "CSP": "jar",
            "XBPQ": "ext",
            "HTML": "homePage",
        }.get(source_type, "")
        return str(site.get(field, "")).strip() if field else ""

    def _should_preserve_generated_item(self, item):
        item_id = str(item.get("id", "")).strip()
        if item_id.startswith(self.GENERATED_CONTEXT_PREFIX):
            package_id = str(item.get("packageId", "")).strip()
            if package_id not in self._site_enabled_package_download_site_ids():
                return False
            return "XBPQ" in self.incomplete_scan_types or any(
                item_type == "XBPQ"
                for item_type, _ in self.incomplete_scan_roots
            )
        if item_id.startswith(self.GENERATED_LIVE_PREFIX):
            package_id = str(item.get("packageId", "")).strip()
            if package_id not in self._live_enabled_package_download_site_ids():
                return False
            return "XBPQ" in self.incomplete_scan_types or any(
                item_type == "XBPQ" for item_type, _ in self.incomplete_scan_roots
            )
        source_type = self._generated_item_type(item)
        if not source_type:
            return False
        package_id = str(item.get("packageId", "")).strip()
        if (
            package_id
            and package_id not in self._site_enabled_package_download_site_ids()
        ):
            return False
        if source_type in self.incomplete_scan_types:
            return True
        failed_same_type = any(
            item_type == source_type
            for item_type, _ in self.incomplete_scan_roots
        )
        reference = self._generated_item_reference(item, source_type)
        if not reference:
            return failed_same_type
        reference_value = reference.split(";md5;", 1)[0].strip()
        if not self._reference_path(reference_value):
            # 远程 JAR/assets 等引用无法反推出所属清单目录。只要同类型
            # 存在失败根目录，就保守保留旧项，避免临时权限故障删站点。
            return failed_same_type
        return self._scan_failure_covers_identity(
            source_type + "|" + reference_value
        )

    def _load_registry(self):
        return self._load_registry_snapshot()[0]

    def _load_registry_snapshot(self):
        registry_path = os.path.abspath(os.path.expanduser(self.registry_path))
        output_path = os.path.abspath(os.path.expanduser(self.output_path))
        path = registry_path if os.path.isfile(registry_path) else output_path
        if os.path.isfile(path):
            try:
                with open(path, "rb") as fp:
                    raw = fp.read()
                registry = json.loads(raw.decode("utf-8"))
            except Exception as exc:
                raise ValueError("站点注入注册表无法读取，已停止写入: {} ({})".format(path, exc))
            if not isinstance(registry, dict):
                raise ValueError("站点注入注册表顶层必须是 JSON 对象: {}".format(path))
            if "items" not in registry:
                registry = self._legacy_registry(registry)
            token = (
                hashlib.sha256(raw).hexdigest()
                if os.path.abspath(path) == output_path
                else self._registry_token(output_path)
            )
            return registry, token
        return {
            "enabled": True,
            "insertIndex": 0,
            "homeKey": "",
            "items": [],
        }, "__missing__"

    def _registry_token(self, path=None):
        path = os.path.abspath(os.path.expanduser(path or self.output_path))
        if not os.path.isfile(path):
            return "__missing__"
        with open(path, "rb") as fp:
            return hashlib.sha256(fp.read()).hexdigest()

    def _legacy_registry(self, data):
        items = []
        sites = data.get("sites", [])
        if isinstance(sites, list):
            for index, site in enumerate(sites):
                if not isinstance(site, dict):
                    continue
                key = str(site.get("key", "")).strip()
                items.append(
                    {
                        "id": key or "legacy_site_{}".format(index),
                        "enabled": True,
                        "kind": "webHome" if site.get("homePage") else "csp",
                        "site": site,
                    }
                )
        return {
            "enabled": bool(data.get("enabled", True)),
            "insertIndex": int(data.get("insertIndex", 0) or 0),
            "homeKey": str(data.get("homeKey", data.get("home", "")) or ""),
            "items": items,
        }

    def _registry_item_site(self, item):
        site = item.get("site")
        return site if isinstance(site, dict) else item

    def _registry_item_live(self, item):
        live = item.get("live")
        return live if isinstance(live, dict) else item

    def _registry_item_key(self, item):
        key = str(item.get("key", "")).strip()
        if key:
            return key
        site = item.get("site")
        site_key = str(site.get("key", "")).strip() if isinstance(site, dict) else ""
        return site_key or str(item.get("id", "")).strip()

    def _registry_item_identity(self, item):
        item_id = str(item.get("id", "")).strip()
        if self._is_generated_registry_item(item) and item_id:
            return item_id
        return self._registry_item_key(item)

    def _is_generated_registry_item(self, item):
        if not isinstance(item, dict):
            return False
        key = self._registry_item_key(item)
        item_id = str(item.get("id", "")).strip()
        return key.startswith(self.GENERATED_KEY_PREFIX) or item_id.startswith(
            self.GENERATED_KEY_PREFIX
        )

    def _clear_generated_registry(self):
        last_error = None
        for _ in range(3):
            registry, token = self._load_registry_snapshot()
            registry, removed = self._remove_generated_items(registry)
            try:
                self._atomic_write_json(registry, expected_token=token)
                self._write_standard_configs(registry)
                return removed
            except RegistryChangedError as exc:
                last_error = exc
        raise RegistryChangedError(
            "注册表在清除期间持续被修改: {}".format(last_error)
        )

    def _program_owned_download_directories(self):
        """Return immediate child directories installed and marked by this loader."""
        owned = {}

        def inspect_root(root):
            root = os.path.realpath(os.path.abspath(os.path.expanduser(root)))
            if not os.path.isdir(root) or os.path.islink(root):
                return
            with os.scandir(root) as entries:
                for entry in entries:
                    if entry.is_symlink() or not entry.is_dir(follow_symlinks=False):
                        continue
                    target = os.path.realpath(entry.path)
                    if os.path.commonpath((target, root)) != root:
                        continue
                    package_marker = self._read_package_install_marker(target)
                    if str(package_marker.get("id", "")).strip():
                        owned[target] = "package"
                        continue
                    repository_marker = self._read_repository_marker(target)
                    if (
                        repository_marker.get("format")
                        == self.ONLINE_REPOSITORY_STATE_FORMAT
                        and str(repository_marker.get("id", "")).strip()
                    ):
                        owned[target] = {
                            "kind": "repository",
                            "repository_id": str(repository_marker.get("id", "")),
                        }

        seen_roots = set()
        candidates = [self._package_xbpq_root()]
        candidates.append(_detect_child_dir(self.local_base_dir, "XBPQ"))
        candidates.extend(
            str(item.get("path", "")).strip()
            for item in self.scan_roots
            if str(item.get("path", "")).strip()
        )
        candidates.extend(
            str(item.get("path", "")).strip()
            for item in getattr(self, "configured_scan_roots", [])
            if str(item.get("path", "")).strip()
        )
        for root in candidates:
            if not root:
                continue
            root = os.path.realpath(os.path.abspath(os.path.expanduser(root)))
            if root in seen_roots:
                continue
            seen_roots.add(root)
            inspect_root(root)
        result = []
        for path in sorted(owned):
            value = owned[path]
            if isinstance(value, dict):
                result.append({"path": path, **value})
            else:
                result.append({"path": path, "kind": value})
        return result

    def _stage_program_download_deletions(self):
        token = self._digest(str(time.time_ns()), 12)
        transaction = []
        try:
            for item in self._program_owned_download_directories():
                source = item["path"]
                staged = source + ".local-media-clear-" + token
                if os.path.exists(staged):
                    raise ValueError("一键清除隔离目录已存在: {}".format(staged))
                local_files = []
                if item.get("kind") == "repository":
                    repository = self._find_online_repository(
                        item.get("repository_id", "")
                    )
                    if repository is None:
                        raise ValueError(
                            "在线仓库配置缺失，未清理目录: {}".format(source)
                        )
                    state = self._load_repository_state(repository)
                    source_type = next(
                        (
                            value
                            for value in {
                                str(entry.get("type", ""))
                                for entry in state.get("files", [])
                                if str(entry.get("type", ""))
                            }
                            if self._repository_target_directory(
                                repository, value
                            )
                            == source
                        ),
                        "",
                    )
                    if not source_type:
                        source_type = next(
                            (
                                str(spec.get("type", "")).upper()
                                for spec in self.scan_roots
                                if str(spec.get("type", "")).strip()
                                and self._repository_target_directory(
                                    repository, spec.get("type", "")
                                )
                                == source
                            ),
                            "",
                        )
                    if not source_type:
                        raise ValueError(
                            "在线仓库目录类型无法确认，未清理目录: {}".format(
                                source
                            )
                        )
                    if not state.get("files"):
                        self._warn(
                            "在线仓库状态缺失，按归属标记清理并保留无法证明归属的文件: {}".format(
                                source
                            )
                        )
                    local_files = self._repository_assert_existing_clean(
                        repository, state, {source_type}
                    ).get(source_type, [])
                os.replace(source, staged)
                transaction_item = {
                    "kind": item["kind"],
                    "original": source,
                    "staged": staged,
                }
                transaction.append(transaction_item)
                for local_file in local_files:
                    relative = str(local_file.get("relative", ""))
                    preserved_source = self._safe_archive_member_path(
                        staged, relative
                    )
                    preserved_target = self._safe_archive_member_path(
                        source, relative
                    )
                    os.makedirs(
                        os.path.dirname(preserved_target), exist_ok=True
                    )
                    shutil.copy2(preserved_source, preserved_target)
                if local_files:
                    marker_source = self._repository_marker_path(staged)
                    marker_target = self._repository_marker_path(source)
                    if not os.path.isfile(marker_source):
                        raise ValueError(
                            "在线仓库归属标记缺失，未清理目录: {}".format(source)
                        )
                    shutil.copy2(marker_source, marker_target)

            managed_proxy_path = os.path.abspath(
                os.path.join(
                    self.local_base_dir, self.PLAYBACK_PROXY_MANAGED_DIR
                )
            )
            managed_proxy_dir = os.path.realpath(managed_proxy_path)
            local_base = os.path.realpath(self.local_base_dir)
            if os.path.lexists(managed_proxy_path):
                if (
                    os.path.commonpath((managed_proxy_dir, local_base))
                    != local_base
                    or os.path.islink(managed_proxy_path)
                    or not os.path.isdir(managed_proxy_dir)
                ):
                    raise ValueError(
                        "反代受管目录不是可清理的普通目录: {}".format(
                            managed_proxy_dir
                        )
                    )
                staged = managed_proxy_path + ".local-media-clear-" + token
                if os.path.exists(staged):
                    raise ValueError(
                        "一键清除隔离目录已存在: {}".format(staged)
                    )
                os.replace(managed_proxy_path, staged)
                transaction.append(
                    {
                        "kind": "artifact",
                        "original": managed_proxy_path,
                        "staged": staged,
                    }
                )
            return transaction
        except Exception:
            self._rollback_program_download_deletions(transaction)
            raise

    @staticmethod
    def _rollback_program_download_deletions(transaction):
        for item in reversed(transaction):
            original = str(item.get("original", "")).strip()
            staged = str(item.get("staged", "")).strip()
            if not staged or not os.path.exists(staged):
                continue
            if os.path.exists(original):
                if os.path.islink(original) or not os.path.isdir(original):
                    raise ValueError(
                        "一键清除回滚目标不是普通目录: {}".format(original)
                    )
                shutil.rmtree(original)
            os.replace(staged, original)

    def _finalize_program_download_deletions(self, transaction):
        removed = {"package": 0, "repository": 0, "artifact": 0}
        warnings = []
        for item in transaction:
            staged = str(item.get("staged", "")).strip()
            if not staged or not os.path.exists(staged):
                continue
            try:
                if os.path.islink(staged) or not os.path.isdir(staged):
                    raise ValueError("隔离目标不是普通目录")
                shutil.rmtree(staged)
                kind = str(item.get("kind", ""))
                removed[kind] = removed.get(kind, 0) + 1
            except Exception as exc:
                warning = "已隔离但未能删除 {}: {}".format(staged, exc)
                warnings.append(warning)
        return removed, warnings

    def _clear_program_operation_artifacts(self):
        """Remove only temp/state files whose names or contents prove ownership."""
        removed = 0
        warnings = []

        def remove_path(path):
            nonlocal removed
            try:
                if os.path.islink(path):
                    raise ValueError("拒绝删除符号链接")
                if os.path.isdir(path):
                    shutil.rmtree(path)
                else:
                    os.remove(path)
                removed += 1
            except FileNotFoundError:
                pass
            except Exception as exc:
                warning = "程序临时内容清理失败 {}: {}".format(path, exc)
                warnings.append(warning)

        download_dir = self._package_download_directory()
        if os.path.isdir(download_dir) and not os.path.islink(download_dir):
            with os.scandir(download_dir) as entries:
                for entry in entries:
                    if re.fullmatch(
                        r"\.package-[0-9a-f]{16}\.zip\.part", entry.name
                    ):
                        remove_path(entry.path)

        package_root = self._package_xbpq_root()
        if os.path.isdir(package_root) and not os.path.islink(package_root):
            with os.scandir(package_root) as entries:
                for entry in entries:
                    if re.fullmatch(
                        r"\.package-[0-9a-f]{12}\.(?:tmp|rollback)",
                        entry.name,
                    ):
                        remove_path(entry.path)

        state_dir = os.path.realpath(
            os.path.abspath(os.path.expanduser(self.online_repository_state_dir))
        )
        if os.path.isdir(state_dir) and not os.path.islink(state_dir):
            with os.scandir(state_dir) as entries:
                for entry in entries:
                    if re.fullmatch(r"\.(?:sync|delete)-[0-9a-f]+", entry.name):
                        remove_path(entry.path)
                        continue
                    if entry.is_symlink() or not entry.is_file(follow_symlinks=False):
                        continue
                    try:
                        with open(entry.path, "r", encoding="utf-8") as fp:
                            data = json.load(fp)
                    except Exception:
                        continue
                    if (
                        isinstance(data, dict)
                        and data.get("format")
                        == self.ONLINE_REPOSITORY_STATE_FORMAT
                    ):
                        remove_path(entry.path)
            try:
                os.rmdir(state_dir)
            except OSError:
                pass
        managed_proxy_dir = os.path.join(
            self.local_base_dir, self.PLAYBACK_PROXY_MANAGED_DIR
        )
        if os.path.exists(managed_proxy_dir):
            remove_path(managed_proxy_dir)
        return removed, warnings

    def _reset_state_after_full_clear(self):
        self.manual_ignored_sources.clear()
        self.auto_blocked_sources.clear()
        self.adult_blocked_sources.clear()
        self.adult_allowed_sources.clear()
        self.ignored_sources.clear()
        self.pruned_sources.clear()
        self.site_test_results.clear()
        self.site_test_history.clear()
        self.source_metadata.clear()
        self.playback_proxy_sources.clear()
        self.playback_proxy_policies.clear()
        self.live_proxy_policies.clear()
        self.live_proxy_auto_sources.clear()
        self._retest_pending = []
        self._retest_auto_blocked.clear()
        self.auto_scan_suspended = True
        self._author_scan_surprise_shown = False
        for item in self.package_download_sites:
            item["site_count"] = 0
            item["dependency_count"] = 0
            item["live_count"] = 0
        for item in self.online_repositories:
            item["version"] = ""
            item["manifest_digest"] = ""
            item["file_count"] = 0
            item["last_sync"] = ""
            item["last_status"] = ""
            item["last_message"] = ""
        self._set_manual_idle_status("已一键清除，请重新下载或扫描")

    def _remove_generated_items(self, registry):
        items = registry.get("items", [])
        if not isinstance(items, list):
            raise ValueError("站点注入注册表的 items 必须是数组")
        generated_keys = {
            self._registry_item_key(item)
            for item in items
            if self._is_generated_registry_item(item)
        }
        kept = [item for item in items if not self._is_generated_registry_item(item)]
        removed = len(items) - len(kept)
        registry["items"] = kept
        if str(registry.get("homeKey", "")).strip() in generated_keys:
            registry["homeKey"] = ""
        return registry, removed

    def _restore_registry_file(self, backup_path):
        if not os.path.isfile(backup_path):
            raise ValueError("暂无可恢复的注册表备份")
        registry = self._validate_registry_backup(backup_path)
        current_path = os.path.abspath(os.path.expanduser(self.output_path))
        expected_token = self._registry_token(current_path)
        if os.path.isfile(current_path):
            self._create_registry_backup(current_path)
        self._atomic_write_json(
            registry,
            create_backup=False,
            expected_token=expected_token,
        )
        if self.app_mode == self.APP_MODE_OKTV:
            self._write_standard_configs(registry)
        return len(registry.get("items", []))

    def _create_registry_backup(self, source_path):
        os.makedirs(self.backup_dir, exist_ok=True)
        backup_path = self._latest_backup_path()
        temp_path = backup_path + ".tmp"
        try:
            shutil.copy2(source_path, temp_path)
            self._validate_registry_backup(temp_path)
            os.replace(temp_path, backup_path)
        finally:
            if os.path.exists(temp_path):
                try:
                    os.remove(temp_path)
                except Exception:
                    pass
        self._remove_legacy_backup_files(keep=backup_path)

    def _latest_backup_path(self):
        return os.path.join(
            os.path.abspath(os.path.expanduser(self.backup_dir)),
            "registry-latest.json",
        )

    def _backup_candidates(self):
        candidates = []
        backup_dir = os.path.abspath(os.path.expanduser(self.backup_dir))
        if os.path.isdir(backup_dir):
            candidates.extend(
                os.path.join(backup_dir, name)
                for name in os.listdir(backup_dir)
                if name.startswith("registry-")
                and name.endswith(".json")
                and os.path.isfile(os.path.join(backup_dir, name))
            )
        output_path = os.path.abspath(os.path.expanduser(self.output_path))
        for suffix in (".bak", ".before-restore.bak"):
            path = output_path + suffix
            if os.path.isfile(path):
                candidates.append(path)
        return candidates

    def _normalize_backup_storage(self):
        candidates = self._backup_candidates()
        if not candidates:
            return
        latest_path = self._latest_backup_path()
        valid_candidates = []
        for path in candidates:
            try:
                self._validate_registry_backup(path)
                valid_candidates.append(path)
            except Exception as exc:
                self._warn("忽略损坏的历史备份: {} ({})".format(path, exc))
        if not valid_candidates:
            return
        newest = max(
            valid_candidates,
            key=lambda path: (os.path.getmtime(path), os.path.basename(path)),
        )
        if os.path.abspath(newest) != os.path.abspath(latest_path):
            os.makedirs(os.path.dirname(latest_path), exist_ok=True)
            temp_path = latest_path + ".tmp"
            try:
                shutil.copy2(newest, temp_path)
                self._validate_registry_backup(temp_path)
                os.replace(temp_path, latest_path)
            finally:
                if os.path.exists(temp_path):
                    try:
                        os.remove(temp_path)
                    except Exception:
                        pass
        self._remove_legacy_backup_files(keep=latest_path)

    def _validate_registry_backup(self, path):
        registry = self._read_config_file(path, "注册表备份")
        if "items" not in registry and isinstance(registry.get("sites"), list):
            registry = self._legacy_registry(registry)
        if not isinstance(registry.get("items"), list):
            raise ValueError("注册表备份的 items 必须是数组: {}".format(path))
        return registry

    def _remove_legacy_backup_files(self, keep=None):
        keep = os.path.abspath(keep) if keep else ""
        for path in self._backup_candidates():
            if os.path.abspath(path) == keep:
                continue
            try:
                os.remove(path)
            except Exception:
                pass

    def _delete_backup_files(self):
        removed = 0
        for path in self._backup_candidates():
            try:
                os.remove(path)
                removed += 1
            except FileNotFoundError:
                pass
        return removed

    def _list_backup_files(self):
        path = self._latest_backup_path()
        if not os.path.isfile(path):
            return []
        try:
            self._validate_registry_backup(path)
            return [path]
        except Exception:
            return []

    def _read_config_file(self, path, label):
        try:
            with open(path, "r", encoding="utf-8") as fp:
                data = json.load(fp)
        except Exception as exc:
            raise ValueError("{}无法读取，已停止写入: {} ({})".format(label, path, exc))
        if not isinstance(data, dict):
            raise ValueError("{}顶层必须是 JSON 对象: {}".format(label, path))
        return data

    def _site_fingerprint(self, site):
        if not isinstance(site, dict):
            return ""
        data = {
            "type": site.get("type", 3),
            "api": site.get("api", ""),
            "ext": site.get("ext", ""),
            "jar": site.get("jar", ""),
            "homePage": site.get("homePage", site.get("home_page", "")),
        }
        return json.dumps(data, ensure_ascii=False, sort_keys=True, separators=(",", ":"))

    def _registry_content_fingerprint(self, item):
        if not isinstance(item, dict):
            return ""
        return json.dumps(
            item, ensure_ascii=False, sort_keys=True, separators=(",", ":")
        )

    def _atomic_write_json(self, config, create_backup=True, expected_token=None):
        output_path = os.path.abspath(os.path.expanduser(self.output_path))
        output_dir = os.path.dirname(output_path)
        if output_dir and not os.path.isdir(output_dir):
            os.makedirs(output_dir, exist_ok=True)

        content = json.dumps(config, ensure_ascii=False, indent=2) + "\n"
        if os.path.isfile(output_path):
            try:
                with open(output_path, "r", encoding="utf-8") as fp:
                    if fp.read() == content:
                        self.status["write_state"] = "配置内容未变化"
                        self.status["written"] = True
                        self.status["registry_changed"] = False
                        return
            except Exception:
                pass

        temp_path = output_path + ".tmp"
        try:
            with open(temp_path, "w", encoding="utf-8") as fp:
                fp.write(content)
                fp.flush()
                os.fsync(fp.fileno())
            with open(temp_path, "r", encoding="utf-8") as fp:
                check = json.load(fp)
            if not isinstance(check, dict) or not isinstance(check.get("items", []), list):
                raise ValueError("临时注册表校验失败")
            if expected_token is not None and self._registry_token(output_path) != expected_token:
                raise RegistryChangedError("注册表已被其他操作修改")
            if os.path.isfile(output_path) and self.backup_before_write and create_backup:
                self._create_registry_backup(output_path)
            os.replace(temp_path, output_path)
            self.status["write_state"] = "已写入 WebHTV 站点注入注册表"
            self.status["written"] = True
            self.status["registry_changed"] = True
        except Exception:
            try:
                if os.path.exists(temp_path):
                    os.remove(temp_path)
            except Exception:
                pass
            raise

    def _digest(self, value, length):
        return hashlib.sha256(value.encode("utf-8")).hexdigest()[:length]

    def _diagnostic_log_path(self):
        return os.path.abspath(os.path.expanduser(self.log_path))

    def _new_operation_trace_id(self, prefix):
        return "{}-{}".format(
            str(prefix or "OP").upper()[:8],
            self._digest("{}:{}".format(time.time_ns(), threading.get_ident()), 10),
        )

    def _active_operation_trace_id(self):
        return (
            self._package_download_trace_id
            or self._repository_sync_trace_id
            or "-"
        )

    def _safe_log_url(self, value):
        text = str(value or "").strip()
        if not text:
            return ""
        try:
            parsed = urllib.parse.urlsplit(text)
            if parsed.scheme not in ("http", "https"):
                return text[:240]
            query_keys = sorted(
                {
                    key
                    for key, _item in urllib.parse.parse_qsl(
                        parsed.query, keep_blank_values=True
                    )
                    if key
                }
            )
            suffix = "?{}".format(",".join(query_keys)) if query_keys else ""
            return "{}://{}{}{}".format(
                parsed.scheme, parsed.netloc, parsed.path or "/", suffix
            )[:240]
        except Exception:
            return text.split("?", 1)[0][:240]

    def _trace_log(self, trace_id, component, event, level="INFO", **fields):
        sensitive_names = {
            "authorization_key",
            "release_ticket",
            "ticket",
            "token",
            "password",
            "secret",
        }

        def sanitize(name, value, depth=0):
            key = str(name or "field")[:48]
            if key.casefold() in sensitive_names:
                return "[redacted]"
            if depth >= 3:
                return str(value)[:320]
            if isinstance(value, str):
                text = value[:320]
                return (
                    self._safe_log_url(text)
                    if text.startswith(("http://", "https://"))
                    else text
                )
            if isinstance(value, (int, float, bool)) or value is None:
                return value
            if isinstance(value, (list, tuple, set)):
                return [
                    sanitize("item", item, depth + 1)
                    for item in list(value)[:30]
                ]
            if isinstance(value, dict):
                return {
                    str(item_key)[:48]: sanitize(
                        item_key, item_value, depth + 1
                    )
                    for item_key, item_value in list(value.items())[:30]
                }
            return str(value)[:320]

        safe = {
            str(name or "field")[:48]: sanitize(name, value)
            for name, value in fields.items()
        }
        payload = json.dumps(
            safe, ensure_ascii=False, sort_keys=True, separators=(",", ":")
        )
        self._log(
            level,
            "[trace={}] component={} event={} {}".format(
                str(trace_id or "-")[:32],
                str(component or "operation")[:32],
                str(event or "state")[:48],
                payload,
            ),
        )

    def _diagnostic_log_tail(self, line_limit=60, char_limit=12000):
        path = self._diagnostic_log_path()
        if not os.path.isfile(path):
            return ""
        try:
            read_limit = max(4096, min(int(self.max_log_size), 64 * 1024))
            size = os.path.getsize(path)
            with open(path, "rb") as fp:
                fp.seek(max(0, size - read_limit))
                payload = fp.read(read_limit)
            text = payload.decode("utf-8", errors="replace")
            if size > read_limit:
                text = text.split("\n", 1)[-1]
            lines = text.splitlines()[-max(1, int(line_limit)) :]
            return "\n".join(lines)[-max(1000, int(char_limit)) :]
        except Exception:
            return ""

    def _log(self, level, message):
        """写入单文件诊断日志，仅在当前操作触发时执行。"""
        text = " ".join(str(message or "").split()).strip()
        if not text:
            return
        try:
            path = self._diagnostic_log_path()
            os.makedirs(os.path.dirname(path), exist_ok=True)
            limit = max(16 * 1024, int(self.max_log_size))
            line = "{} [{}] {}\n".format(
                time.strftime("%Y-%m-%d %H:%M:%S"),
                str(level or "INFO").upper()[:10],
                text[:4000],
            ).encode("utf-8", errors="replace")
            if len(line) > limit // 2:
                line = line[: limit // 2].decode("utf-8", errors="ignore").encode("utf-8")
                line = line.rstrip(b"\n") + b"\n"

            current_size = os.path.getsize(path) if os.path.isfile(path) else 0
            if current_size + len(line) > limit:
                header = b"... earlier log entries truncated ...\n"
                keep = max(0, limit - len(header) - len(line))
                tail = b""
                if keep and os.path.isfile(path):
                    with open(path, "rb") as fp:
                        fp.seek(max(0, current_size - keep))
                        tail = fp.read(keep)
                    newline = tail.find(b"\n")
                    if newline >= 0:
                        tail = tail[newline + 1 :]
                    else:
                        tail = b""
                temp_path = path + ".tmp"
                try:
                    with open(temp_path, "wb") as fp:
                        fp.write(header)
                        fp.write(tail)
                    os.replace(temp_path, path)
                finally:
                    try:
                        if os.path.exists(temp_path):
                            os.remove(temp_path)
                    except Exception:
                        pass
            with open(path, "ab") as fp:
                fp.write(line)
        except Exception:
            # 诊断日志失败不应影响扫描和注册表写入。
            pass

    def _warn(self, text):
        if text and text not in self.status["warnings"]:
            self.status["warnings"].append(text)
            self._log("WARN", text)

    # --------------------------------------------------------------------------
    # TVBox 标准接口
    # --------------------------------------------------------------------------
    def _limited_sources(self):
        return [
            source
            for source in self.cache.get("sources", [])
            if isinstance(source.get("test_result"), dict)
            and str(source["test_result"].get("state", "")) == "limited"
        ]

    def _source_display_sort_key(self, source):
        try:
            root_order = int(source.get("root_order", 0) or 0)
        except Exception:
            root_order = 0
        source_type = str(source.get("type", "")).upper()
        return (
            self._source_parent_suffix(source).casefold(),
            root_order,
            self.TYPE_ORDER.get(source_type, len(self.TYPE_ORDER)),
            str(source.get("relative_in_root", "")).casefold(),
            str(source.get("identity", source.get("id", ""))).casefold(),
        )

    def _sorted_sources_for_display(self, sources):
        return sorted(list(sources), key=self._source_display_sort_key)

    def homeContent(self, filter):
        self._ensure_initialized()
        # 推荐（首页 list）由 App 固定置顶为第一个标签，设置紧随其后固定为第二个标签。
        classes = [
            {
                "type_id": self.SCAN_SETTINGS_TID,
                "type_name": "设置" + (" *" if self.config_dirty else ""),
            },
            {"type_id": "all", "type_name": "全部 ({})".format(len(self.cache["sources"]))},
        ]
        favorite_count = sum(
            1
            for source in self.cache["sources"]
            if self._source_metadata_for(source).get("favorite")
        )
        if favorite_count:
            classes.append(
                {
                    "type_id": self.FAVORITES_TID,
                    "type_name": "收藏 ({})".format(favorite_count),
                }
            )
        limited_sources = self._limited_sources()
        if limited_sources:
            classes.append(
                {
                    "type_id": self.LIMITED_TID,
                    "type_name": "受限 ({})".format(len(limited_sources)),
                }
            )
        proxy_candidates = [
            source
            for source in self.cache["sources"]
            if self._playback_proxy_eligible(source)
        ]
        proxy_sources = [
            source
            for source in proxy_candidates
            if self._playback_proxy_assigned(source)
        ]
        if proxy_candidates:
            classes.append(
                {
                    "type_id": self.PLAYBACK_PROXY_TID,
                    "type_name": "反代线路 ({})".format(len(proxy_sources)),
                }
            )
        live_proxy_candidates = [
            item for item in self.cache.get("lives", [])
            if self._live_proxy_supported(item.get("live", {}))
        ]
        if live_proxy_candidates:
            live_proxy_count = sum(
                1
                for item in live_proxy_candidates
                if self._live_proxy_policy(item).get("policy")
                == "force_proxy"
            )
            classes.append(
                {
                    "type_id": self.LIVE_PROXY_TID,
                    "type_name": "直播反代 ({})".format(live_proxy_count),
                }
            )
        tag_counts = {}
        for source in self.cache["sources"]:
            for tag in self._source_metadata_for(source).get("tags", []):
                tag_counts[tag] = tag_counts.get(tag, 0) + 1
        for tag in sorted(tag_counts, key=lambda value: value.casefold()):
            classes.append(
                {
                    "type_id": self.TAG_TID_PREFIX + urllib.parse.quote(tag, safe=""),
                    "type_name": "{} ({})".format(tag, tag_counts[tag]),
                }
            )
        for source_type in self.TYPE_ORDER:
            count = self.cache["type_counts"].get(source_type, 0)
            if count:
                classes.append(
                    {
                        "type_id": "type:" + source_type,
                        "type_name": "{} ({})".format(
                            self.TYPE_LABEL.get(source_type, source_type), count
                        ),
                    }
                )
        if self.cache["ignored"]:
            classes.append(
                {
                    "type_id": "ignored",
                    "type_name": "屏蔽 ({})".format(len(self.cache["ignored"])),
                }
            )
        backup_count = len(self._list_backup_files())
        if backup_count:
            classes.append(
                {
                    "type_id": self.BACKUPS_TID,
                    "type_name": "历史备份 ({})".format(backup_count),
                }
            )
        return {"class": classes, "list": self._home_items()}

    def homeVideoContent(self):
        self._ensure_initialized()
        return {"list": self._home_items()}

    @staticmethod
    def _icon(key):
        return _UI_ICONS.get(key, "")

    def _home_items(self):
        ready = self.status["written"]
        status_name = "运行状态" if ready else "待扫描"
        items = [
            {
                "vod_id": self.STATUS_ID,
                "vod_name": status_name,
                "vod_pic": self._icon("status"),
                "vod_remarks": "{} 个站点 · {} 个直播".format(
                    len(self.cache["sources"]),
                    len(self.cache.get("lives", [])),
                ),
            },
        ]
        enabled_download_sites = self._enabled_package_download_sites()
        if self._package_download_running():
            download_remarks = self._package_download_message or "下载安装中"
        elif self._package_download_state in (
            "partial", "incompatible", "error", "denied"
        ):
            download_remarks = self._package_download_message[:140]
        elif enabled_download_sites:
            download_remarks = "已启用 {} 个接口：{}".format(
                len(enabled_download_sites),
                "、".join(
                    "{}[{}]".format(
                        "{}·站{}·播{}".format(
                            str(site.get("name", "本地包")),
                            "开" if self._package_download_site_enabled(site) else "关",
                            "开" if self._package_download_live_enabled(site) else "关",
                        ),
                        self._package_download_site_type_label(site),
                    )
                    for site in enabled_download_sites
                ),
            )
        else:
            download_remarks = (
                "未配置解析服务，请在 location.json 中设置"
                if not self.package_service_url
                else "未配置本地包接口，请在设置中添加"
                if not self.package_download_sites
                else "无已启用接口，请在设置中开启站点或直播"
            )
        items.append(
            {
                "vod_id": self.DOWNLOAD_PACKAGE_ID,
                "vod_name": "下载本地包",
                "vod_pic": self._icon("download"),
                "vod_remarks": download_remarks,
                "action": self.ACTION_DOWNLOAD_PACKAGE,
            }
        )
        if self._repository_sync_running():
            repository_remarks = self._repository_sync_message or "在线仓库同步中"
        elif self._repository_sync_state in ("partial", "error", "denied"):
            repository_remarks = self._repository_sync_message[:140]
        elif self.online_repositories:
            repository_remarks = self._online_repositories_summary()
        else:
            repository_remarks = "未配置仓库，请在设置中添加备注名和地址"
        items.append(
            {
                "vod_id": self.SYNC_REPOSITORIES_ID,
                "vod_name": "同步在线仓库",
                "vod_pic": self._icon("download"),
                "vod_remarks": repository_remarks,
                "action": self.ACTION_SYNC_REPOSITORIES,
            }
        )
        items.extend(
            [
            {
                "vod_id": self.RESCAN_ID,
                "vod_name": "扫描并加载",
                "vod_pic": self._icon("scan"),
                "vod_remarks": "扫描目录并重载配置",
                "action": self.ACTION_RESCAN,
            },
            {
                "vod_id": self.TEST_SITES_ID,
                "vod_name": "站点功能测活",
                "vod_pic": self._icon("test"),
                "vod_remarks": "{} 线程，单批 {}；检测首页 / 分类 / 搜索".format(
                    self.site_test_concurrency, self.site_test_batch_limit
                ),
                "action": self.ACTION_TEST_SITES,
            },
            {
                "vod_id": self.RETEST_SITES_ID,
                "vod_name": "全部重新检测",
                "vod_pic": self._icon("retest"),
                "vod_remarks": "清除缓存后重新检测",
                "action": self.ACTION_RETEST_SITES,
            },
            {
                "vod_id": self.AUTO_CLASSIFY_ID,
                "vod_name": "自动分类",
                "vod_pic": self._icon("classify"),
                "vod_remarks": "顶层文件归入类型子目录",
                "action": self.ACTION_AUTO_CLASSIFY,
            },
            {
                "vod_id": self.CLEAR_SITES_ID,
                "vod_name": "清除自动站点",
                "vod_pic": self._icon("trash"),
                "vod_remarks": "保留手工注入站点",
                "action": self.ACTION_CLEAR_SITES,
            },
            {
                "vod_id": self.FULL_CLEAR_ID,
                "vod_name": "一键清除",
                "vod_pic": self._icon("reset"),
                "vod_remarks": "清除加载、下载、缓存和收藏；保留用户原始文件",
                "action": self.ACTION_FULL_CLEAR,
            },
            ]
        )
        return items

    def categoryContent(self, tid, pg, filter, ext):
        self._ensure_initialized()
        page = self._page_number(pg)
        if tid == "all":
            items = self._sorted_sources_for_display(self.cache["sources"])
        elif tid == self.FAVORITES_TID:
            items = self._sorted_sources_for_display(
                item for item in self.cache["sources"]
                if self._source_metadata_for(item).get("favorite")
            )
        elif tid == self.LIMITED_TID:
            items = self._sorted_sources_for_display(self._limited_sources())
        elif tid == self.PLAYBACK_PROXY_TID:
            disabled_candidates = self._batch_playback_proxy_candidates()
            enabled_candidates = self._batch_disable_playback_proxy_candidates()
            items = [
                {
                    "id": self.MANAGE_PLAYBACK_PROXY_ID,
                    "name": "单站点连接策略",
                    "type": "MANAGE_PLAYBACK_PROXY",
                    "relative_in_root": "为任意独立本地站点选择自动判断、强制反代或强制直连",
                    "manage_playback_proxy": True,
                },
                {
                    "id": self.BATCH_ENABLE_PLAYBACK_PROXY_ID,
                    "name": "批量启用反代线路",
                    "type": "BATCH_PLAYBACK_PROXY",
                    "relative_in_root": "选择多个未启用站点，一次保存和加载 · {}".format(
                        self._playback_proxy_candidate_summary(disabled_candidates)
                    ),
                    "batch_playback_proxy": True,
                },
                {
                    "id": self.BATCH_DISABLE_PLAYBACK_PROXY_ID,
                    "name": "批量取消反代线路",
                    "type": "BATCH_DISABLE_PLAYBACK_PROXY",
                    "relative_in_root": "选择多个已分配站点并强制直连，不删除源文件 · {}".format(
                        self._playback_proxy_candidate_summary(enabled_candidates)
                    ),
                    "batch_disable_playback_proxy": True,
                }
            ]
        elif tid == self.LIVE_PROXY_TID:
            supported = sum(
                1
                for item in self.cache.get("lives", [])
                if self._live_proxy_supported(item.get("live", {}))
            )
            items = [
                {
                    "id": self.MANAGE_LIVE_PROXY_ID,
                    "name": "单直播源连接策略",
                    "type": "MANAGE_LIVE_PROXY",
                    "relative_in_root": (
                        "选择自动判断、强制反代或强制直连 · 当前可设置 {} 个"
                    ).format(supported),
                    "manage_live_proxy": True,
                },
                {
                    "id": self.TEST_LIVE_PROXY_ID,
                    "name": "检测直播反代",
                    "type": "TEST_LIVE_PROXY",
                    "relative_in_root": "连续两次直连失败后，用 relay 验证代表频道",
                    "test_live_proxy": True,
                },
                {
                    "id": self.BATCH_PROXY_LIVES_ID,
                    "name": "批量强制直播反代",
                    "type": "BATCH_LIVE_PROXY",
                    "relative_in_root": "选中后统一使用首条 relay 线路",
                    "batch_live_proxy_policy": "force_proxy",
                },
                {
                    "id": self.BATCH_DIRECT_LIVES_ID,
                    "name": "批量强制直播直连",
                    "type": "BATCH_LIVE_DIRECT",
                    "relative_in_root": "持续覆盖自动检测结果",
                    "batch_live_proxy_policy": "force_direct",
                },
                {
                    "id": self.BATCH_AUTO_LIVES_ID,
                    "name": "批量恢复自动判断",
                    "type": "BATCH_LIVE_AUTO",
                    "relative_in_root": "移除人工覆盖，保留已有检测证据",
                    "batch_live_proxy_policy": "auto",
                },
            ]
        elif str(tid).startswith(self.TAG_TID_PREFIX):
            tag = urllib.parse.unquote(
                str(tid)[len(self.TAG_TID_PREFIX) :]
            ).casefold()
            items = self._sorted_sources_for_display(
                item for item in self.cache["sources"]
                if tag in {
                    str(value).casefold()
                    for value in self._source_metadata_for(item).get("tags", [])
                }
            )
        elif str(tid).startswith("type:"):
            source_type = str(tid).split(":", 1)[1].upper()
            items = self._sorted_sources_for_display(
                item
                for item in self.cache["sources"]
                if item["type"] == source_type
            )
        elif tid == "ignored":
            delete_candidates = self._deletable_blocked_sources()
            items = [
                {
                    "id": self.BATCH_UNBLOCK_ID,
                    "name": "批量解除屏蔽",
                    "type": "BATCH_UNBLOCK",
                    "relative_in_root": "选择多个站点，一次保存和加载",
                    "batch_unblock": True,
                },
            ]
            if delete_candidates:
                items.append(
                    {
                        "id": self.DELETE_BLOCKED_FILES_ID,
                        "name": "删除屏蔽文件",
                        "type": "DELETE_BLOCKED_FILES",
                        "relative_in_root": "可删除 {} 个独立本地文件".format(
                            len(delete_candidates)
                        ),
                        "delete_blocked_files": True,
                    }
                )
            items.extend(
                [
                {
                    "id": self.UNBLOCK_ALL_ID,
                    "name": "一键全部解除",
                    "type": "UNBLOCK_ALL",
                    "relative_in_root": "解除当前全部 {} 个屏蔽站点".format(
                        len(self.cache["ignored"])
                    ),
                    "unblock_all": True,
                },
                ]
            )
            items.extend(self._sorted_sources_for_display(self.cache["ignored"]))
        elif tid == self.SCAN_SETTINGS_TID:
            return self._paged_result(self._scan_setting_items(), page)
        elif tid == self.BACKUPS_TID:
            return self._paged_result(self._backup_items(), page)
        else:
            items = []
        return self._paged_result(items, page)

    def _scan_setting_items(self):
        items = [
            {
                "id": self.SCAN_BASE_PATH_ID,
                "name": "扫描与测活参数",
                "type": "PATH",
                "relative_in_root": "{}；线程 {}；单批 {}".format(
                    self.scan_base_path
                    or "自动探测：{}".format(self.local_base_dir),
                    self.site_test_concurrency,
                    self.site_test_batch_limit,
                ),
                "settings": True,
                "scan_base_path": True,
            },
            {
                "id": self.RESET_SCAN_BASE_ID,
                "name": "恢复默认目录",
                "type": "RESET_PATH",
                "relative_in_root": self.LOCAL_BASE_DIR,
                "settings": True,
                "reset_scan_base": True,
            },
            {
                "id": "setting_scan_types",
                "name": "扫描类型",
                "type": "TYPES",
                "relative_in_root": "已启用：{}".format(
                    "、".join(
                        self.TYPE_LABEL.get(source_type, source_type)
                        for source_type in self.TYPE_ORDER
                        if self.pending_type_enabled.get(
                            source_type,
                            self.type_enabled.get(source_type, True),
                        )
                    )
                    or "无"
                ) + " · 18+：{}".format(
                    "屏蔽" if self.pending_block_adult_sites else "加载"
                ),
                "settings": True,
                "scan_types": True,
            },
            {
                "id": "setting_playback_proxy",
                "name": "反代线路",
                "type": "PLAYBACK_PROXY",
                "relative_in_root": self._playback_proxy_summary(),
                "settings": True,
                "playback_proxy": True,
            },
            {
                "id": "setting_apply",
                "name": "应用并扫描"
                if self.config_dirty
                else "扫描并加载",
                "type": "APPLY",
                "relative_in_root": "有待应用的类型变更"
                if self.config_dirty
                else "按当前设置扫描",
                "settings": True,
                "apply": True,
                "enabled": bool(self.config_dirty),
            },
            {
                "id": "setting_package_download_url",
                "name": "添加本地包接口",
                "type": "PACKAGE_DOWNLOAD_URL",
                "relative_in_root": (
                    "未配置解析服务，仅支持 ZIP 直链"
                    if not self.package_external_configured
                    else "支持 JSON 接口与 ZIP 直链"
                ) + " · 已配置 {} 个接口".format(len(self.package_download_sites)),
                "settings": True,
                "package_download_url": True,
            },
            {
                "id": "setting_package_download_switches",
                "name": "接口开关",
                "type": "PACKAGE_DOWNLOAD_SWITCHES",
                "relative_in_root": self._package_download_sites_summary(),
                "settings": True,
                "package_download_switches": True,
            },
            {
                "id": "setting_package_download_delete",
                "name": "删除接口",
                "type": "PACKAGE_DOWNLOAD_DELETE",
                "relative_in_root": "同时清理该接口的本地包、站点与直播",
                "settings": True,
                "package_download_delete": True,
            },
            {
                "id": "setting_online_repository_url",
                "name": "添加在线仓库",
                "type": "ONLINE_REPOSITORY_URL",
                "relative_in_root": "填写备注名和 repository.json 地址 · 已配置 {} 个".format(
                    len(self.online_repositories)
                ),
                "settings": True,
                "online_repository_url": True,
            },
            {
                "id": "setting_online_repository_switches",
                "name": "在线仓库开关",
                "type": "ONLINE_REPOSITORY_SWITCHES",
                "relative_in_root": self._online_repositories_summary(),
                "settings": True,
                "online_repository_switches": True,
            },
            {
                "id": "setting_online_repository_delete",
                "name": "删除在线仓库",
                "type": "ONLINE_REPOSITORY_DELETE",
                "relative_in_root": "同时清理该仓库托管的类型子目录",
                "settings": True,
                "online_repository_delete": True,
            },
            {
                "id": "setting_auto_scan",
                "name": "自动补扫",
                "type": "AUTO_SCAN",
                "relative_in_root": (
                    "已暂停，手动扫描一次后恢复"
                    if self.auto_scan_on_empty and self.auto_scan_suspended
                    else "无扫描快照时进入自动扫描一次"
                ),
                "settings": True,
                "auto_scan": True,
                "enabled": bool(self.auto_scan_on_empty),
            },
            {
                "id": "setting_auto_prune",
                "name": "自动清理屏蔽站点",
                "type": "AUTO_PRUNE",
                "relative_in_root": (
                    "测活结束后把屏蔽站点移出配置，源文件保留，已清理 {} 个".format(
                        len(self.pruned_sources)
                    )
                    if self.auto_prune_blocked
                    else "关闭：测活只屏蔽不移除"
                ),
                "settings": True,
                "auto_prune": True,
                "enabled": bool(self.auto_prune_blocked),
            },
            {
                "id": "setting_source_metadata",
                "name": "收藏与标签",
                "type": "SOURCE_METADATA",
                "relative_in_root": "已管理 {} 个站点".format(
                    len(self.source_metadata)
                ),
                "settings": True,
                "source_metadata": True,
            },
            {
                "id": "setting_export_profile",
                "name": "导出管理配置",
                "type": "EXPORT_PROFILE",
                "relative_in_root": self._management_profile_path(),
                "settings": True,
                "export_profile": True,
            },
            {
                "id": "setting_import_profile",
                "name": "导入管理配置",
                "type": "IMPORT_PROFILE",
                "relative_in_root": "从 local-media-profile.json 导入，导入前自动备份",
                "settings": True,
                "import_profile": True,
            },
        ]
        return items

    def _backup_items(self):
        items = []
        for path in self._list_backup_files():
            try:
                registry = self._validate_registry_backup(path)
                count = len(registry.get("items", []))
                modified = time.strftime(
                    "%Y-%m-%d %H:%M:%S", time.localtime(os.path.getmtime(path))
                )
                items.append(
                    {
                        "id": "backup_" + self._digest(os.path.basename(path), 12),
                        "name": "恢复至 " + modified,
                        "type": "BACKUP",
                        "relative_in_root": "{} 个条目".format(count),
                        "backup": True,
                        "path": path,
                    }
                )
            except Exception as exc:
                self._warn("历史备份读取失败：{}（{}）".format(path, exc))
        if items:
            items.append(
                {
                    "id": self.DELETE_BACKUPS_ID,
                    "name": "删除历史备份",
                    "type": "DELETE_BACKUP",
                    "relative_in_root": "仅保留最近 1 份，点击删除",
                    "delete_backup": True,
                }
            )
        return items

    def detailContent(self, array):
        self._ensure_initialized()
        source_id = str(array[0]) if isinstance(array, (list, tuple)) and array else str(array or "")
        if source_id == self.STATUS_ID:
            return {"list": [self._status_detail()]}
        if source_id == self.DOWNLOAD_PACKAGE_ID or source_id.startswith(
            self.DOWNLOAD_PACKAGE_ID_PREFIX
        ):
            site_id = (
                source_id[len(self.DOWNLOAD_PACKAGE_ID_PREFIX) :]
                if source_id.startswith(self.DOWNLOAD_PACKAGE_ID_PREFIX)
                else ""
            )
            started, message = self._start_package_download(site_id)
            detail = self._status_detail()
            detail["vod_name"] = "下载本地包"
            detail["vod_remarks"] = message
            detail["vod_content"] = message + "\n\n" + detail.get(
                "vod_content", ""
            )
            return {"list": [detail]}
        if source_id == self.SYNC_REPOSITORIES_ID:
            started, message = self._start_repository_sync()
            detail = self._status_detail()
            detail["vod_name"] = "同步在线仓库"
            detail["vod_remarks"] = message
            detail["vod_content"] = message + "\n\n" + detail.get(
                "vod_content", ""
            )
            return {"list": [detail]}
        if source_id == self.AUTO_CLASSIFY_ID:
            # 详情页是 action() 之外的旁路入口，需自行复用同一套并发守卫，
            # 否则后台测活或下载进行中时会与其争用 cache 和目录。
            with self.lock:
                if self._site_test_is_running():
                    blocked = "后台检测进行中，请待本批完成后再执行"
                elif self._package_download_running():
                    blocked = "本地包下载安装中，请完成后再执行"
                elif self._repository_sync_running():
                    blocked = "在线仓库同步中，请完成后再执行"
                else:
                    blocked = ""
                if not blocked:
                    summary, content = self._run_auto_classify()
            if blocked:
                detail = self._status_detail()
                detail["vod_name"] = "自动分类"
                detail["vod_pic"] = self._icon("classify")
                detail["vod_remarks"] = blocked
                detail["vod_content"] = blocked + "\n\n" + detail.get("vod_content", "")
                return {"list": [detail]}
            detail = self._status_detail()
            detail["vod_name"] = "自动分类"
            detail["vod_pic"] = self._icon("classify")
            detail["vod_remarks"] = summary
            detail["vod_content"] = content + "\n\n" + detail.get("vod_content", "")
            return {"list": [detail]}
        if source_id == self.RESCAN_ID:
            allowed, duplicate_message = self._begin_manual_scan_request()
            if not allowed:
                detail = self._status_detail()
                detail["vod_remarks"] = duplicate_message
                detail["vod_content"] = duplicate_message + "\n\n" + detail.get(
                    "vod_content", ""
                )
                return {"list": [detail]}
            try:
                with self.lock:
                    if self._site_test_is_running():
                        detail = self._status_detail()
                        detail["vod_remarks"] = "后台检测进行中"
                        detail["vod_content"] = (
                            "后台检测进行中，请待本批完成后再扫描。\n\n"
                            + detail.get("vod_content", "")
                        )
                        return {"list": [detail]}
                    if self._package_download_running():
                        detail = self._status_detail()
                        detail["vod_remarks"] = "本地包下载安装中"
                        detail["vod_content"] = (
                            "本地包下载安装中，请完成后再扫描。\n\n"
                            + detail.get("vod_content", "")
                        )
                        return {"list": [detail]}
                    if self._repository_sync_running():
                        detail = self._status_detail()
                        detail["vod_remarks"] = "在线仓库同步中"
                        detail["vod_content"] = (
                            "在线仓库同步中，请完成后再扫描。\n\n"
                            + detail.get("vod_content", "")
                        )
                        return {"list": [detail]}
                    if self._refresh_locked(
                        allow_empty=not any(self.type_enabled.values()),
                        accept_complete_empty=True,
                    ):
                        self._show_author_scan_surprise()
                        self._reload_app_vod_config(
                            expected_keys=self._generated_registry_keys()
                        )
                    self.inited = True
                return {"list": [self._status_detail()]}
            finally:
                self._finish_manual_scan_request()

        source = self.cache["source_index"].get(source_id)
        if not source:
            return {"list": [{"vod_name": "站点不存在", "vod_content": "请重新扫描后再试。"}]}

        site_text = self._hide_playback_proxy_urls(
            json.dumps(source["site"], ensure_ascii=False, indent=2)
        )
        validation = source.get("validation") or "静态检查未发现明显问题"
        test_result = source.get("test_result", {})
        test_text = (
            "{} · {} · {} · {} · {} ms · {}".format(
                self._test_result_label(test_result),
                test_result.get("checked_at", "-"),
                test_result.get("test_stage", "-") or "-",
                test_result.get("playback_state", "-") or "-",
                test_result.get("elapsed_ms", "-"),
                test_result.get("detail", ""),
            )
            if isinstance(test_result, dict) and test_result
            else "未检测"
        )
        test_text = self._hide_playback_proxy_urls(test_text)
        proxy_text = self._playback_proxy_policy_label(source)
        content = (
            "类型：{type}\n"
            "文件：{path}\n"
            "相对路径：{relative}\n"
            "稳定标识：{identity}\n"
            "检查：{validation}\n\n"
            "功能测活：{test_result}\n\n"
            "反代线路：{proxy_state}\n\n"
            "最近检测历史：\n{test_history}\n\n"
            "收藏：{favorite}\n"
            "标签：{tags}\n\n"
            "生成的站点配置：\n{site}\n\n"
            "运行环境：{app_mode}\n"
            "本地配置：{output}\n"
            "配置变更后 App 将自动重载站点列表。"
        ).format(
            type=self.TYPE_LABEL.get(source["type"], source["type"]),
            path=source["path"],
            relative=source["relative_in_root"],
            identity=source["identity"],
            validation=validation,
            test_result=test_text,
            proxy_state=proxy_text,
            test_history=self._site_test_history_text(source["identity"]),
            favorite="是" if self._source_metadata_for(source).get("favorite") else "否",
            tags="、".join(self._source_metadata_for(source).get("tags", [])) or "无",
            site=site_text,
            app_mode=self._app_mode_label(),
            output=(
                "{} / {}".format(self.ok_config_a, self.ok_config_b)
                if self.app_mode == self.APP_MODE_OKTV
                else self.output_path
            ),
        )
        return {
            "list": [
                {
                    "vod_id": source_id,
                    "vod_name": source["name"],
                    "vod_pic": "",
                    "vod_remarks": self.TYPE_LABEL.get(
                        source["type"], source["type"]
                    ),
                    "vod_content": content,
                }
            ]
        }

    def _status_detail(self):
        warning_text = "\n".join("- " + item for item in self.status["warnings"][:20]) or "无"
        error_text = self.status["error"] or "无"
        recent_log = self._diagnostic_log_tail() or "无"
        test_counts = {"available": 0, "unavailable": 0, "limited": 0}
        playback_counts = {}
        for result in list(self.site_test_results.values()):
            state = str(result.get("state", "")) if isinstance(result, dict) else ""
            if state in test_counts:
                test_counts[state] += 1
            if isinstance(result, dict):
                playback = str(result.get("playback_state", "") or "").strip()
                if playback:
                    playback_counts[playback] = playback_counts.get(playback, 0) + 1
        playback_text = " · ".join(
            "{} {}".format(name, count)
            for name, count in sorted(playback_counts.items())
        ) or "无"
        content = (
            "版本：{version}\n"
            "运行环境：{app_mode}\n"
            "扫描方式：手动 + 自动补扫（{auto_scan}）\n"
            "严格识别：{strict}\n"
            "待应用配置：{dirty}\n"
            "类型开关：{types}\n"
            "18+ 站点：{adult_mode}\n"
            "扫描时间：{scan_time}\n"
            "发现文件：{found}\n"
            "有效站点：{included}\n"
            "集成直播：{generated_lives}\n"
            "直播去重：{duplicate_lives}\n"
            "播放上下文：{context_packages} 包 / {context_fields} 字段\n"
            "屏蔽站点：{ignored}\n"
            "清理过期屏蔽项：{stale_ignored}\n"
            "跳过文件：{skipped}\n"
            "自动屏蔽 18+ 站点：{adult_filtered}\n"
            "兼容性拦截：{compatibility_blocked}\n"
            "重复项：{duplicates}\n"
            "缓存命中 / 重检：{cache_hits}/{cache_misses}\n"
            "功能测活：可用 {test_available} · 失效 {test_unavailable} · 受限 {test_limited}\n"
            "播放验证：{playback_text}\n"
            "保留注入项：{manual}\n"
            "自动注入项：{generated}\n"
            "变更预览：+{added} ~{updated} -{removed} ={unchanged}\n"
            "写入状态：{state}\n"
            "错误：{error}\n\n"
            "警告：\n{warnings}\n\n"
            "本地配置输出：{output}\n\n"
            "扫描开关设置：{settings}\n\n"
            "扫描目录配置：{roots_config}\n"
            "诊断日志：{log_path}（上限 {max_log_kb} KB）\n"
            "扫描根目录：{scan_base}\n"
            "XBPQ JAR：{xbpq_jar}\n"
            "解析服务：{package_service}\n"
            "接口开关：{package_download}\n"
            "接口网址：\n{package_url}\n"
            "下载状态：{package_state}\n"
            "安装目录：{package_target}\n"
            "在线仓库：{repository_summary}\n"
            "仓库地址：\n{repository_urls}\n"
            "仓库同步：{repository_state}\n"
            "仓库安装规则：<类型目录>/<备注名>/<相对路径>\n"
            "扫描上限：文件 {max_files} · 深度 {max_depth} · 单文件 {max_size} 字节\n\n"
            "最近任务日志：\n{recent_log}\n\n"
            "扫描结果已写入当前 App 的本地配置，原基础配置与手工注入项保留。\n"
            "配置变更将在操作返回后自动重载 App。\n\n"
            "----------------\n"
            "秋色正好，江 晚枫来过。"
        ).format(
            version=self.VERSION,
            app_mode=self._app_mode_label(),
            auto_scan="暂停"
            if self.auto_scan_on_empty and self.auto_scan_suspended
            else ("开" if self.auto_scan_on_empty else "关"),
            strict="开启" if self.strict_recognition else "关闭",
            dirty="是" if self.config_dirty else "否",
            types=" ".join(
                "{}:{}{}".format(
                    self.TYPE_LABEL.get(source_type, source_type),
                    "开" if self.type_enabled.get(source_type, True) else "关",
                    "->{}".format(
                        "开"
                        if self.pending_type_enabled.get(
                            source_type, self.type_enabled.get(source_type, True)
                        )
                        else "关"
                    )
                    if self.pending_type_enabled.get(
                        source_type, self.type_enabled.get(source_type, True)
                    )
                    != self.type_enabled.get(source_type, True)
                    else "",
                )
                for source_type in self.TYPE_ORDER
            ),
            adult_mode="{}{}".format(
                "屏蔽" if self.block_adult_sites else "加载",
                " -> {}".format(
                    "屏蔽" if self.pending_block_adult_sites else "加载"
                )
                if self.pending_block_adult_sites != self.block_adult_sites
                else "",
            ),
            scan_time=self.status["scan_time"],
            found=self.status["found"],
            included=self.status["included"],
            generated_lives=self.status["generated_lives"],
            duplicate_lives=self.status["duplicate_lives"],
            context_packages=len(self.cache.get("contexts", [])),
            context_fields=sum(
                len(item.get("context", {}))
                for item in self.cache.get("contexts", [])
                if isinstance(item, dict)
                and isinstance(item.get("context"), dict)
            ),
            ignored=self.status["ignored"],
            stale_ignored=self.status["stale_ignored_removed"],
            skipped=self.status["skipped"],
            adult_filtered=self.status["adult_filtered"],
            compatibility_blocked=self.status["compatibility_blocked"],
            duplicates=self.status["duplicates"],
            cache_hits=self.status["cache_hits"],
            cache_misses=self.status["cache_misses"],
            test_available=test_counts["available"],
            test_unavailable=test_counts["unavailable"],
            test_limited=test_counts["limited"],
            playback_text=playback_text,
            manual=self.status["manual_sites"],
            generated=self.status["generated_sites"],
            added=self.status["added_sites"],
            updated=self.status["updated_sites"],
            removed=self.status["removed_sites"],
            unchanged=self.status["unchanged_sites"],
            state=self.status["write_state"],
            error=error_text,
            warnings=warning_text,
            output=(
                "{} / {}".format(self.ok_config_a, self.ok_config_b)
                if self.app_mode == self.APP_MODE_OKTV
                else self.output_path
            ),
            settings=self.settings_path,
            roots_config=self.roots_config_path,
            log_path=self._diagnostic_log_path(),
            max_log_kb=max(1, int(self.max_log_size) // 1024),
            scan_base=self.scan_base_path or "自动探测 ({})".format(self.local_base_dir),
            xbpq_jar="已配置" if self.xbpq_jar else "未配置",
            package_service=self.package_service_url or "未配置",
            package_download=self._package_download_sites_summary(),
            package_url="\n".join(
                "- {} [{}]: {}".format(
                    item.get("name", "未命名"),
                    self._package_download_site_type_label(item),
                    item.get("url", ""),
                )
                for item in self.package_download_sites
            ) or "- 无",
            package_state="{}{}".format(
                self._package_download_state,
                " · " + self._package_download_message
                if self._package_download_message
                else "",
            ),
            package_target=os.path.join(self._package_xbpq_root(), "<接口备注名>"),
            repository_summary=self._online_repositories_summary(),
            repository_urls="\n".join(
                "- {} [{}] {} 个文件: {}".format(
                    item.get("name", "未命名"),
                    "开" if self._as_bool(item.get("enabled", True), True) else "关",
                    int(item.get("file_count", 0) or 0),
                    item.get("url", ""),
                )
                for item in self.online_repositories
            ) or "- 无",
            repository_state="{}{}".format(
                self._repository_sync_state,
                " · " + self._repository_sync_message
                if self._repository_sync_message
                else "",
            ),
            recent_log=recent_log,
            max_files=self.max_scan_files,
            max_depth=self.max_scan_depth,
            max_size=self.max_source_size,
        )
        return {
            "vod_id": self.STATUS_ID,
            "vod_name": "运行状态与诊断",
            "vod_pic": self._icon("status"),
            "vod_remarks": self.status["write_state"],
            "vod_content": content,
        }

    def searchContent(self, key, quick, pg="1"):
        self._ensure_initialized()
        keyword = str(key or "").strip().lower()
        page = self._page_number(pg)
        if not keyword:
            items = []
        else:
            items = self._sorted_sources_for_display(
                source
                for source in self.cache["sources"]
                if keyword in source["name"].lower()
                or keyword in source["relative_in_root"].lower()
                or keyword in source["type"].lower()
                or any(
                    keyword in str(tag).lower()
                    for tag in self._source_metadata_for(source).get("tags", [])
                )
            )
        return self._paged_result(items, page)

    def _paged_result(self, items, page):
        total = len(items)
        page_size = max(1, int(self.page_size))
        page_count = max(1, (total + page_size - 1) // page_size)
        if page > page_count:
            page_items = []
        else:
            start = (page - 1) * page_size
            page_items = items[start : start + page_size]
        return {
            "page": page,
            "pagecount": page_count,
            "limit": page_size,
            "total": total,
            "list": [self._source_vod(item) for item in page_items],
        }

    def _source_state_icon(self, source):
        if source.get("adult_blocked"):
            return "🔞 "
        result = source.get("test_result", {})
        state = str(result.get("state", "")) if isinstance(result, dict) else ""
        if state == "unavailable":
            return "⛔ "
        if state == "limited":
            return "⚠ "
        return "🚫 " if source.get("ignored") else ""

    def _source_vod(self, source):
        if source.get("batch_live_proxy_policy"):
            policy = str(source["batch_live_proxy_policy"])
            action = {
                "force_proxy": self.ACTION_BATCH_PROXY_LIVES,
                "force_direct": self.ACTION_BATCH_DIRECT_LIVES,
                "auto": self.ACTION_BATCH_AUTO_LIVES,
            }[policy]
            return {
                "vod_id": source["id"],
                "vod_name": source["name"],
                "vod_pic": self._icon("switches"),
                "vod_remarks": source["relative_in_root"],
                "action": action,
            }
        if source.get("test_live_proxy"):
            return {
                "vod_id": source["id"],
                "vod_name": source["name"],
                "vod_pic": self._icon("test"),
                "vod_remarks": source["relative_in_root"],
                "action": self.ACTION_TEST_LIVE_PROXY,
            }
        if source.get("manage_live_proxy"):
            return {
                "vod_id": source["id"],
                "vod_name": source["name"],
                "vod_pic": self._icon("switches"),
                "vod_remarks": source["relative_in_root"],
                "action": self.ACTION_MANAGE_LIVE_PROXY,
            }
        if source.get("batch_disable_playback_proxy"):
            return {
                "vod_id": source["id"],
                "vod_name": source["name"],
                "vod_pic": self._icon("toggle_off"),
                "vod_remarks": source["relative_in_root"],
                "action": self.ACTION_BATCH_DISABLE_PLAYBACK_PROXY,
            }
        if source.get("manage_playback_proxy"):
            return {
                "vod_id": source["id"],
                "vod_name": source["name"],
                "vod_pic": self._icon("switches"),
                "vod_remarks": source["relative_in_root"],
                "action": self.ACTION_MANAGE_PLAYBACK_PROXY,
            }
        if source.get("batch_playback_proxy"):
            return {
                "vod_id": source["id"],
                "vod_name": source["name"],
                "vod_pic": self._icon("switches"),
                "vod_remarks": source["relative_in_root"],
                "action": self.ACTION_BATCH_ENABLE_PLAYBACK_PROXY,
            }
        if source.get("batch_unblock"):
            return {
                "vod_id": source["id"],
                "vod_name": source["name"],
                "vod_pic": self._icon("switches"),
                "vod_remarks": source["relative_in_root"],
                "action": self.ACTION_BATCH_UNBLOCK,
            }
        if source.get("unblock_all"):
            return {
                "vod_id": source["id"],
                "vod_name": source["name"],
                "vod_pic": self._icon("undo"),
                "vod_remarks": source["relative_in_root"],
                "action": self.ACTION_UNBLOCK_ALL,
            }
        if source.get("delete_blocked_files"):
            return {
                "vod_id": source["id"],
                "vod_name": source["name"],
                "vod_pic": self._icon("trash"),
                "vod_remarks": source["relative_in_root"],
                "action": self.ACTION_DELETE_BLOCKED_FILES,
            }
        if source.get("delete_backup"):
            return {
                "vod_id": source["id"],
                "vod_name": source["name"],
                "vod_pic": self._icon("trash"),
                "vod_remarks": source["relative_in_root"],
                "action": self.ACTION_DELETE_BACKUPS,
            }
        if source.get("backup"):
            return {
                "vod_id": source["id"],
                "vod_name": source["name"],
                "vod_pic": self._icon("undo"),
                "vod_remarks": source["relative_in_root"],
                "action": self.ACTION_RESTORE_SNAPSHOT_PREFIX
                + os.path.basename(source["path"]),
            }
        if source.get("settings"):
            if source.get("reset_scan_base"):
                return {
                    "vod_id": source["id"],
                    "vod_name": source["name"],
                    "vod_pic": self._icon("reset"),
                    "vod_remarks": source["relative_in_root"],
                    "action": self.ACTION_RESET_SCAN_BASE,
                }
            if source.get("scan_base_path"):
                return {
                    "vod_id": source["id"],
                    "vod_name": source["name"],
                    "vod_pic": self._icon("folder"),
                    "vod_remarks": source["relative_in_root"],
                    "action": self.ACTION_EDIT_SCAN_BASE,
                }
            if source.get("scan_types"):
                return {
                    "vod_id": source["id"],
                    "vod_name": source["name"],
                    "vod_pic": self._icon("types"),
                    "vod_remarks": source["relative_in_root"],
                    "action": self.ACTION_EDIT_SCAN_TYPES,
                }
            if source.get("playback_proxy"):
                return {
                    "vod_id": source["id"],
                    "vod_name": source["name"],
                    "vod_pic": self._icon("switches"),
                    "vod_remarks": source["relative_in_root"],
                    "action": self.ACTION_EDIT_PLAYBACK_PROXY,
                }
            if source.get("package_download_url"):
                return {
                    "vod_id": source["id"],
                    "vod_name": source["name"],
                    "vod_pic": self._icon("add"),
                    "vod_remarks": source["relative_in_root"],
                    "action": self.ACTION_EDIT_DOWNLOAD_URL,
                }
            if source.get("package_download_switches"):
                return {
                    "vod_id": source["id"],
                    "vod_name": source["name"],
                    "vod_pic": self._icon("switches"),
                    "vod_remarks": source["relative_in_root"],
                    "action": self.ACTION_EDIT_DOWNLOAD_SWITCHES,
                }
            if source.get("package_download_delete"):
                return {
                    "vod_id": source["id"],
                    "vod_name": source["name"],
                    "vod_pic": self._icon("trash"),
                    "vod_remarks": source["relative_in_root"],
                    "action": self.ACTION_DELETE_DOWNLOAD_SITES,
                }
            if source.get("online_repository_url"):
                return {
                    "vod_id": source["id"],
                    "vod_name": source["name"],
                    "vod_pic": self._icon("add"),
                    "vod_remarks": source["relative_in_root"],
                    "action": self.ACTION_EDIT_REPOSITORY_URL,
                }
            if source.get("online_repository_switches"):
                return {
                    "vod_id": source["id"],
                    "vod_name": source["name"],
                    "vod_pic": self._icon("switches"),
                    "vod_remarks": source["relative_in_root"],
                    "action": self.ACTION_EDIT_REPOSITORY_SWITCHES,
                }
            if source.get("online_repository_delete"):
                return {
                    "vod_id": source["id"],
                    "vod_name": source["name"],
                    "vod_pic": self._icon("trash"),
                    "vod_remarks": source["relative_in_root"],
                    "action": self.ACTION_DELETE_REPOSITORIES,
                }
            if source.get("apply"):
                return {
                    "vod_id": source["id"],
                    "vod_name": source["name"],
                    "vod_pic": self._icon("scan"),
                    "vod_remarks": source["relative_in_root"],
                    "action": self.ACTION_APPLY_SCAN_CONFIG,
                }
            if source.get("auto_scan"):
                enabled = bool(source.get("enabled"))
                return {
                    "vod_id": source["id"],
                    "vod_name": source["name"],
                    "vod_pic": self._icon("toggle_on" if enabled else "toggle_off"),
                    "vod_remarks": "{} · 点击切换 · {}".format(
                        "已启用" if enabled else "已停用",
                        source["relative_in_root"],
                    ),
                    "action": self.ACTION_TOGGLE_AUTO_SCAN,
                }
            if source.get("auto_prune"):
                enabled = bool(source.get("enabled"))
                return {
                    "vod_id": source["id"],
                    "vod_name": source["name"],
                    "vod_pic": self._icon("toggle_on" if enabled else "toggle_off"),
                    "vod_remarks": "{} · 点击切换 · {}".format(
                        "已启用" if enabled else "已停用",
                        source["relative_in_root"],
                    ),
                    "action": self.ACTION_TOGGLE_AUTO_PRUNE,
                }
            if source.get("source_metadata"):
                return {
                    "vod_id": source["id"],
                    "vod_name": source["name"],
                    "vod_pic": self._icon("classify"),
                    "vod_remarks": source["relative_in_root"],
                    "action": self.ACTION_EDIT_SOURCE_METADATA,
                }
            if source.get("export_profile"):
                return {
                    "vod_id": source["id"],
                    "vod_name": source["name"],
                    "vod_pic": self._icon("download"),
                    "vod_remarks": source["relative_in_root"],
                    "action": self.ACTION_EXPORT_PROFILE,
                }
            if source.get("import_profile"):
                return {
                    "vod_id": source["id"],
                    "vod_name": source["name"],
                    "vod_pic": self._icon("undo"),
                    "vod_remarks": source["relative_in_root"],
                    "action": self.ACTION_IMPORT_PROFILE,
                }
            enabled = bool(source.get("enabled"))
            return {
                "vod_id": source["id"],
                "vod_name": source["name"],
                "vod_pic": self._icon("toggle_on" if enabled else "toggle_off"),
                "vod_remarks": "{} · 点击切换".format(
                    "已启用" if enabled else "已停用"
                ),
                "action": self.ACTION_TOGGLE_TYPE_PREFIX + source["type"],
            }
        if source.get("proxy_management"):
            state = self._playback_proxy_state(source)
            prefix = str(state.get("prefix", "") or "")
            enabled = bool(prefix)
            remarks = (
                "{} · 当前首选 {} · 点击设置连接策略".format(
                    self._playback_proxy_policy_label(source),
                    self._playback_proxy_line_label(prefix),
                )
                if prefix
                else "{} · 点击设置连接策略".format(
                    self._playback_proxy_policy_label(source)
                )
            )
            return {
                "vod_id": source["id"],
                "vod_name": "{} {}".format(
                    "【已启用】" if enabled else "【未启用】",
                    source["name"],
                ),
                "vod_pic": self._icon("toggle_on" if enabled else "toggle_off"),
                "vod_remarks": remarks,
                "action": self.ACTION_SET_PLAYBACK_PROXY_PREFIX + source["id"],
            }
        if source.get("adult_blocked"):
            ignore_action = "18+ 已屏蔽 · 点击恢复"
        elif source.get("ignored"):
            ignore_action = "点击恢复"
        else:
            ignore_action = "点击屏蔽"
        return {
            "vod_id": source["id"],
            "vod_name": "{}{}{}".format(
                self._source_state_icon(source),
                "★ " if self._source_metadata_for(source).get("favorite") else "",
                source["name"],
            ),
            "vod_pic": "",
            "vod_remarks": "{} · {} · {} · {}{}{}".format(
                source["type"],
                source["relative_in_root"],
                ignore_action,
                self._test_result_label(source.get("test_result")),
                " · " + "、".join(self._source_metadata_for(source).get("tags", []))
                if self._source_metadata_for(source).get("tags") else "",
                " · " + self._playback_proxy_policy_label(source)
                if self._playback_proxy_eligible(source) else "",
            ),
            "action": self.ACTION_TOGGLE_IGNORE_PREFIX + source["id"],
        }

    def _page_number(self, value):
        try:
            return max(1, int(value))
        except Exception:
            return 1

    def action(self, action):
        action = str(action)
        self._log("INFO", "用户操作: {}".format(action))
        protected = (
            action not in (self.ACTION_TEST_SITES, self.ACTION_RETEST_SITES)
            and not action.startswith(self.ACTION_SOURCE_PREFIX)
        )
        if protected:
            with self.lock:
                if self._site_test_is_running():
                    return {
                        "code": 0,
                        "msg": "后台检测进行中，请待本批完成后再修改设置",
                    }
                if (
                    self._package_download_running()
                    and not self._is_package_download_action(action)
                ):
                    return {
                        "code": 0,
                        "msg": "本地包下载安装中，请完成后再修改配置",
                    }
                if (
                    self._repository_sync_running()
                    and not self._is_repository_sync_action(action)
                ):
                    return {
                        "code": 0,
                        "msg": "在线仓库同步中，请完成后再修改配置",
                    }
                return self._action_impl(action)
        return self._action_impl(action)

    def _action_impl(self, action):
        if self._is_repository_sync_action(action):
            started, message = self._start_repository_sync()
            if started or self._repository_sync_state != "denied":
                self._notify_app(message)
            return {"code": 0, "msg": message}
        if action == self.ACTION_EDIT_REPOSITORY_URL:
            opened, message = self._open_repository_url_dialog()
            if not opened:
                self._log("WARN", "在线仓库设置未打开: {}".format(message))
            return {"code": 0, "msg": "" if opened else message}
        if action == self.ACTION_EDIT_REPOSITORY_SWITCHES:
            opened, message = self._open_repository_switches_dialog()
            if not opened:
                self._log("WARN", "在线仓库开关未打开: {}".format(message))
            return {"code": 0, "msg": "" if opened else message}
        if action == self.ACTION_DELETE_REPOSITORIES:
            opened, message = self._open_repository_delete_dialog()
            if not opened:
                self._log("WARN", "在线仓库删除界面未打开: {}".format(message))
            return {"code": 0, "msg": "" if opened else message}
        if self._is_package_download_action(action):
            site_id = (
                action[len(self.ACTION_DOWNLOAD_PACKAGE_PREFIX) :]
                if action.startswith(self.ACTION_DOWNLOAD_PACKAGE_PREFIX)
                else ""
            )
            started, message = self._start_package_download(site_id)
            if started or self._package_download_state != "denied":
                self._notify_app(message)
            return {"code": 0, "msg": message}
        if action == self.ACTION_EDIT_DOWNLOAD_URL:
            opened, message = self._open_package_download_url_dialog()
            if not opened:
                self._log("WARN", "下载地址设置未打开: {}".format(message))
            return {"code": 0, "msg": "" if opened else message}
        if action in (
            self.ACTION_EDIT_DOWNLOAD_SWITCHES,
            self.ACTION_TOGGLE_DOWNLOAD,
        ):
            opened, message = self._open_package_download_switches_dialog()
            if not opened:
                self._log("WARN", "下载站点开关未打开: {}".format(message))
            return {"code": 0, "msg": "" if opened else message}
        if action == self.ACTION_DELETE_DOWNLOAD_SITES:
            opened, message = self._open_package_download_delete_dialog()
            if not opened:
                self._log("WARN", "下载站点删除界面未打开: {}".format(message))
            return {"code": 0, "msg": "" if opened else message}
        if action == self.ACTION_EDIT_SOURCE_METADATA:
            opened, message = self._open_source_metadata_dialog()
            if not opened:
                self._log("WARN", "收藏与标签界面未打开: {}".format(message))
            return {"code": 0, "msg": "" if opened else message}
        if action == self.ACTION_EDIT_PLAYBACK_PROXY:
            opened, message = self._open_playback_proxy_dialog()
            if not opened:
                self._log("WARN", "反代线路设置未打开: {}".format(message))
            return {"code": 0, "msg": "" if opened else message}
        if action == self.ACTION_MANAGE_PLAYBACK_PROXY:
            opened, message = self._open_playback_proxy_source_dialog()
            if not opened:
                self._log("WARN", "单站点线路管理界面未打开: {}".format(message))
            return {"code": 0, "msg": "" if opened else message}
        if action == self.ACTION_MANAGE_LIVE_PROXY:
            opened, message = self._open_live_proxy_source_dialog()
            if not opened:
                self._log("WARN", "直播反代管理界面未打开: {}".format(message))
            return {"code": 0, "msg": "" if opened else message}
        if action == self.ACTION_TEST_LIVE_PROXY:
            started, message = self._start_live_proxy_test()
            self._notify_app(message)
            if not started:
                self._log("WARN", message)
            return {"code": 0, "msg": message}
        live_batch_actions = {
            self.ACTION_BATCH_PROXY_LIVES: "force_proxy",
            self.ACTION_BATCH_DIRECT_LIVES: "force_direct",
            self.ACTION_BATCH_AUTO_LIVES: "auto",
        }
        if action in live_batch_actions:
            opened, message = self._open_batch_live_proxy_dialog(
                live_batch_actions[action]
            )
            if not opened:
                self._log("WARN", "批量直播反代界面未打开: {}".format(message))
            return {"code": 0, "msg": "" if opened else message}
        if action.startswith(self.ACTION_SET_PLAYBACK_PROXY_PREFIX):
            source_id = action[len(self.ACTION_SET_PLAYBACK_PROXY_PREFIX) :]
            source = self.cache["source_index"].get(source_id)
            if not source:
                return {"code": 0, "msg": "站点不存在，请重新扫描"}
            opened, message = self._open_source_playback_proxy_policy_dialog(
                source
            )
            if not opened:
                self._log("WARN", "单站点反代策略界面未打开: {}".format(message))
            return {"code": 0, "msg": "" if opened else message}
        if action.startswith(self.ACTION_CYCLE_PLAYBACK_PROXY_PREFIX):
            source_id = action[len(self.ACTION_CYCLE_PLAYBACK_PROXY_PREFIX) :]
            source = self.cache["source_index"].get(source_id)
            if not source:
                return {"code": 0, "msg": "站点不存在，请重新扫描"}
            try:
                started, message = self._start_source_playback_proxy_cycle(source)
                if started:
                    self._notify_app(message)
                return {"code": 0, "msg": message}
            except Exception as exc:
                self._log("ERROR", "反代线路切换失败: {}".format(exc))
                return {"code": 0, "msg": "反代线路切换失败：{}".format(exc)}
        if action == self.ACTION_EXPORT_PROFILE:
            with self.lock:
                try:
                    path = self._export_management_profile()
                    return {"code": 0, "msg": "管理配置已导出：{}".format(path)}
                except Exception as exc:
                    self._log("ERROR", "管理配置导出失败: {}".format(exc))
                    return {"code": 0, "msg": "管理配置导出失败：{}".format(exc)}
        if action == self.ACTION_IMPORT_PROFILE:
            with self.lock:
                try:
                    path, backup = self._import_management_profile()
                    self._schedule_manager_page_refresh()
                    return {
                        "code": 0,
                        "msg": "管理配置已导入：{}{}；请点击“扫描并加载”应用".format(
                            path,
                            "；原设置备份：{}".format(backup) if backup else "",
                        ),
                    }
                except Exception as exc:
                    self._log("ERROR", "管理配置导入失败: {}".format(exc))
                    return {"code": 0, "msg": "管理配置导入失败：{}".format(exc)}
        if action == self.ACTION_EDIT_SCAN_BASE:
            opened, message = self._open_scan_base_dialog()
            if not opened:
                self._log("WARN", "扫描路径设置未打开: {}".format(message))
            return {
                "code": 0,
                "msg": "" if opened else message,
            }
        if action == self.ACTION_RESET_SCAN_BASE:
            with self.lock:
                try:
                    self._set_scan_base_path("")
                    return {
                        "code": 0,
                        "msg": "扫描目录已恢复为默认：{}".format(
                            self.local_base_dir
                        ),
                    }
                except Exception as exc:
                    self._log("ERROR", "扫描目录恢复失败: {}".format(exc))
                    return {
                        "code": 0,
                        "msg": "扫描目录恢复失败：{}".format(exc),
                    }
        if action == self.ACTION_EDIT_SCAN_TYPES:
            opened, message = self._open_scan_types_dialog()
            if not opened:
                self._log("WARN", "扫描类型设置未打开: {}".format(message))
            return {
                "code": 0,
                "msg": "" if opened else message,
            }
        if action == self.ACTION_BATCH_ENABLE_PLAYBACK_PROXY:
            opened, message = self._open_batch_playback_proxy_dialog()
            if not opened:
                self._log("WARN", "批量启用反代线路界面未打开: {}".format(message))
            return {"code": 0, "msg": "" if opened else message}
        if action == self.ACTION_BATCH_DISABLE_PLAYBACK_PROXY:
            opened, message = self._open_batch_playback_proxy_dialog(disable=True)
            if not opened:
                self._log("WARN", "批量取消反代线路界面未打开: {}".format(message))
            return {"code": 0, "msg": "" if opened else message}
        if action == self.ACTION_BATCH_UNBLOCK:
            opened, message = self._open_blocked_sources_dialog()
            if not opened:
                self._log("WARN", "批量解除屏蔽界面未打开: {}".format(message))
            return {"code": 0, "msg": "" if opened else message}
        if action == self.ACTION_DELETE_BLOCKED_FILES:
            opened, message = self._open_blocked_sources_dialog(
                delete_files=True
            )
            if not opened:
                self._log(
                    "WARN", "删除屏蔽文件界面未打开: {}".format(message)
                )
            return {"code": 0, "msg": "" if opened else message}
        if action == self.ACTION_UNBLOCK_ALL:
            with self.lock:
                source_ids = [
                    source.get("id", "")
                    for source in self.cache.get("ignored", [])
                ]
                try:
                    restored, reload_detail = self._restore_blocked_sources(
                        source_ids
                    )
                    return {
                        "code": 0,
                        "msg": "已解除全部 {} 个屏蔽站点；{}".format(
                            len(restored), reload_detail
                        ),
                    }
                except Exception as exc:
                    self._log("ERROR", "一键解除屏蔽失败: {}".format(exc))
                    return {"code": 0, "msg": "一键解除屏蔽失败：{}".format(exc)}
        if action.startswith(self.ACTION_TOGGLE_IGNORE_PREFIX):
            source_id = action[len(self.ACTION_TOGGLE_IGNORE_PREFIX) :]
            source = self.cache["source_index"].get(source_id)
            if not source:
                return {"code": 0, "msg": "站点不存在，请重新扫描"}
            if source.get("ignored"):
                with self.lock:
                    try:
                        restored, reload_detail = self._restore_blocked_sources(
                            [source_id]
                        )
                        return {
                            "code": 0,
                            "msg": "已恢复并重新加入 App 站点列表：{}；{}".format(
                                restored[0]["name"], reload_detail
                            ),
                        }
                    except Exception as exc:
                        self._log("ERROR", "解除屏蔽失败: {}".format(exc))
                        return {"code": 0, "msg": "解除屏蔽失败：{}".format(exc)}
            with self.lock:
                try:
                    previous_registry = copy.deepcopy(self._load_registry())
                except Exception as exc:
                    self._log("ERROR", "屏蔽前注册表快照失败: {}".format(exc))
                    return {"code": 0, "msg": "屏蔽设置保存失败：{}".format(exc)}
                identity = source["identity"]
                ignored = True
                previous_ignored = set(self.ignored_sources)
                previous_manual_ignored = set(self.manual_ignored_sources)
                previous_auto_blocked = set(self.auto_blocked_sources)
                previous_adult_blocked = set(self.adult_blocked_sources)
                previous_adult_allowed = set(self.adult_allowed_sources)
                previous_results = dict(self.site_test_results)
                previous_cache = copy.deepcopy(self.cache)
                previous_status = copy.deepcopy(self.status)
                expected_registry = None
                self.manual_ignored_sources.add(identity)
                self._sync_ignored_sources()
                try:
                    self._save_settings()
                    self._set_cached_source_ignored(source, ignored)
                    expected_registry = self._merge_registry(
                        copy.deepcopy(previous_registry)
                    )[0]
                    self._generate_config()
                    try:
                        self._save_scan_snapshot()
                    except Exception as exc:
                        self._warn("屏蔽后扫描列表快照保存失败: {}".format(exc))
                except Exception as exc:
                    self._log("ERROR", "屏蔽设置保存失败: {}".format(exc))
                    self.ignored_sources = previous_ignored
                    self.manual_ignored_sources = previous_manual_ignored
                    self.auto_blocked_sources = previous_auto_blocked
                    self.adult_blocked_sources = previous_adult_blocked
                    self.adult_allowed_sources = previous_adult_allowed
                    self.site_test_results = previous_results
                    self.cache = previous_cache
                    self.status = previous_status
                    try:
                        self._save_settings()
                    except Exception:
                        pass
                    rollback_error = ""
                    try:
                        current_registry = self._load_registry()
                        if (
                            expected_registry is not None
                            and current_registry == expected_registry
                        ):
                            current_token = self._registry_token(self.output_path)
                            self._atomic_write_json(
                                previous_registry,
                                create_backup=False,
                                expected_token=current_token,
                            )
                            if self.app_mode == self.APP_MODE_OKTV:
                                self._ok_write_generated_configs(previous_registry)
                        elif current_registry != previous_registry:
                            rollback_error = "注册表已被其他操作修改，未覆盖"
                    except Exception as rollback_exc:
                        rollback_error = "注册表回滚失败：{}".format(rollback_exc)
                    self.status = previous_status
                    if rollback_error:
                        self._log("ERROR", rollback_error)
                    return {
                        "code": 0,
                        "msg": "屏蔽设置保存失败：{}{}".format(
                            exc,
                            "；" + rollback_error if rollback_error else "",
                        ),
                    }
                _, reload_detail = self._reload_app_vod_config(
                    expected_keys=self._generated_registry_keys(),
                    refresh_types=("category",),
                )
                return {
                    "code": 0,
                    "msg": "{}；{}".format(
                        "已屏蔽并从 App 站点列表移除：{}".format(source["name"]),
                        reload_detail,
                    ),
                }
        if action.startswith(self.ACTION_SOURCE_PREFIX):
            source_id = action[len(self.ACTION_SOURCE_PREFIX) :]
            source = self.cache["source_index"].get(source_id)
            if not source:
                return {"code": 0, "msg": "站点不存在，请重新扫描"}
            return {
                "code": 0,
                "msg": "{} · {}；已写入 {} 本地配置".format(
                    source["type"],
                    source["relative_in_root"],
                    self._app_mode_label(),
                ),
            }
        if action in (self.ACTION_TEST_SITES, self.ACTION_RETEST_SITES):
            with self._site_test_control_lock:
                worker = self._site_test_thread
                if worker is not None and worker.is_alive():
                    return {
                        "code": 0,
                        "msg": "后台检测进行中，进度将合并通知",
                    }
            with self.lock:
                if not self.cache["sources"] and not self.cache["ignored"]:
                    return {"code": 0, "msg": "暂无扫描结果，请先执行“扫描并加载”"}
            try:
                self._authorize_release("site_health")
            except ReleaseAuthorizationError as exc:
                self._authorization_last_error = str(exc)
                self._log("WARN", "手动测活授权失败: {}".format(exc))
                message = "当前版本未获得测活授权，已拒绝测活：{}".format(exc)
                self._notify_app(message)
                return {
                    "code": 0,
                    "msg": message,
                }
            force = action == self.ACTION_RETEST_SITES
            if not self._start_site_test_worker(force=force):
                active = self._active_background_operation()
                return {
                    "code": 0,
                    "msg": (
                        self._background_operation_conflict(active)
                        if active and active != "site_test"
                        else "后台检测进行中，进度将合并通知"
                    ),
                }
            return {
                "code": 0,
                "msg": "已启动{}，每批最多检测 {} 个站点，剩余站点再次点击继续".format(
                    "全部重新检测" if force else "站点功能测活",
                    self.site_test_batch_limit,
                ),
            }
        if action == self.ACTION_AUTO_CLASSIFY:
            with self.lock:
                summary, _ = self._run_auto_classify()
            return {"code": 0, "msg": summary}
        if action == self.ACTION_FULL_CLEAR:
            with self.lock:
                transaction = []
                try:
                    previous_registry = copy.deepcopy(self._load_registry())
                    expected_cleared_registry, _ = self._remove_generated_items(
                        copy.deepcopy(previous_registry)
                    )
                except Exception as exc:
                    self._log("ERROR", "一键清除前注册表快照失败: {}".format(exc))
                    return {"code": 0, "msg": "一键清除失败：{}".format(exc)}
                previous = {
                    "manual_ignored_sources": set(self.manual_ignored_sources),
                    "auto_blocked_sources": set(self.auto_blocked_sources),
                    "adult_blocked_sources": set(self.adult_blocked_sources),
                    "adult_allowed_sources": set(self.adult_allowed_sources),
                    "ignored_sources": set(self.ignored_sources),
                    "pruned_sources": set(self.pruned_sources),
                    "site_test_results": copy.deepcopy(self.site_test_results),
                    "site_test_history": copy.deepcopy(self.site_test_history),
                    "source_metadata": copy.deepcopy(self.source_metadata),
                    "playback_proxy_sources": copy.deepcopy(
                        self.playback_proxy_sources
                    ),
                    "playback_proxy_policies": copy.deepcopy(
                        self.playback_proxy_policies
                    ),
                    "live_proxy_policies": copy.deepcopy(
                        self.live_proxy_policies
                    ),
                    "live_proxy_auto_sources": copy.deepcopy(
                        self.live_proxy_auto_sources
                    ),
                    "retest_pending": list(self._retest_pending),
                    "retest_auto_blocked": set(self._retest_auto_blocked),
                    "auto_scan_suspended": self.auto_scan_suspended,
                    "author_scan_surprise_shown": self._author_scan_surprise_shown,
                    "package_download_sites": copy.deepcopy(
                        self.package_download_sites
                    ),
                    "online_repositories": copy.deepcopy(
                        self.online_repositories
                    ),
                    "cache": copy.deepcopy(self.cache),
                    "status": copy.deepcopy(self.status),
                }
                try:
                    transaction = self._stage_program_download_deletions()
                    removed_registry = self._clear_generated_registry()
                    self._reset_state_after_full_clear()
                    self._save_settings()
                except Exception as exc:
                    self.manual_ignored_sources = previous[
                        "manual_ignored_sources"
                    ]
                    self.auto_blocked_sources = previous["auto_blocked_sources"]
                    self.adult_blocked_sources = previous[
                        "adult_blocked_sources"
                    ]
                    self.adult_allowed_sources = previous[
                        "adult_allowed_sources"
                    ]
                    self.ignored_sources = previous["ignored_sources"]
                    self.pruned_sources = previous["pruned_sources"]
                    self.site_test_results = previous["site_test_results"]
                    self.site_test_history = previous["site_test_history"]
                    self.source_metadata = previous["source_metadata"]
                    self.playback_proxy_sources = previous[
                        "playback_proxy_sources"
                    ]
                    self.playback_proxy_policies = previous[
                        "playback_proxy_policies"
                    ]
                    self.live_proxy_policies = previous[
                        "live_proxy_policies"
                    ]
                    self.live_proxy_auto_sources = previous[
                        "live_proxy_auto_sources"
                    ]
                    self._retest_pending = previous["retest_pending"]
                    self._retest_auto_blocked = previous[
                        "retest_auto_blocked"
                    ]
                    self.auto_scan_suspended = previous[
                        "auto_scan_suspended"
                    ]
                    self._author_scan_surprise_shown = previous[
                        "author_scan_surprise_shown"
                    ]
                    self.package_download_sites = previous[
                        "package_download_sites"
                    ]
                    self.online_repositories = previous["online_repositories"]
                    self.cache = previous["cache"]
                    self.status = previous["status"]
                    rollback_errors = []
                    try:
                        current_registry = self._load_registry()
                        if current_registry == expected_cleared_registry:
                            current_token = self._registry_token(self.output_path)
                            self._atomic_write_json(
                                previous_registry,
                                create_backup=False,
                                expected_token=current_token,
                            )
                            self._write_standard_configs(previous_registry)
                        elif current_registry != previous_registry:
                            rollback_errors.append(
                                "注册表已被其他操作修改，未覆盖"
                            )
                    except Exception as rollback_exc:
                        rollback_errors.append(
                            "注册表回滚失败：{}".format(rollback_exc)
                        )
                    try:
                        self._rollback_program_download_deletions(transaction)
                    except Exception as rollback_exc:
                        rollback_errors.append(
                            "下载目录回滚失败：{}".format(rollback_exc)
                        )
                    try:
                        self._save_settings()
                    except Exception as rollback_exc:
                        rollback_errors.append(
                            "设置回滚失败：{}".format(rollback_exc)
                        )
                    detail = "；".join(rollback_errors)
                    self._log(
                        "ERROR",
                        "一键清除失败: {}{}".format(
                            exc, "；" + detail if detail else ""
                        ),
                    )
                    return {
                        "code": 0,
                        "msg": "一键清除失败：{}{}".format(
                            exc, "；" + detail if detail else ""
                        ),
                    }

                removed_downloads, cleanup_warnings = (
                    self._finalize_program_download_deletions(transaction)
                )
                try:
                    cache_files = self._clear_scan_cache_file()
                except Exception as exc:
                    cache_files = 0
                    cleanup_warnings.append("扫描缓存清理失败：{}".format(exc))
                try:
                    backup_files = self._delete_backup_files()
                except Exception as exc:
                    backup_files = 0
                    cleanup_warnings.append("历史备份清理失败：{}".format(exc))
                artifact_files, artifact_warnings = (
                    self._clear_program_operation_artifacts()
                )
                cleanup_warnings.extend(artifact_warnings)
                for warning in cleanup_warnings:
                    self._warn(warning)
                self._set_manual_idle_status("已一键清除，请重新下载或扫描")
                try:
                    _, reload_detail = self._reload_app_vod_config(
                        expected_keys=set()
                    )
                except Exception as exc:
                    reload_detail = "App 重载请求失败：{}".format(exc)
                    self._warn(reload_detail)
                self.inited = True
                summary = (
                    "已清除 {} 个加载项、{} 个本地包目录、{} 个在线仓库目录、"
                    "{} 个缓存/临时文件和 {} 个历史备份；用户原始文件及接口设置保留；{}"
                ).format(
                    removed_registry,
                    removed_downloads.get("package", 0),
                    removed_downloads.get("repository", 0),
                    cache_files
                    + artifact_files
                    + removed_downloads.get("artifact", 0),
                    backup_files,
                    reload_detail,
                )
                if cleanup_warnings:
                    summary += "；{} 项残留清理失败，详见日志".format(
                        len(cleanup_warnings)
                    )
                self._log("INFO", summary)
                return {"code": 0, "msg": summary}
        if action == self.ACTION_CLEAR_SITES:
            with self.lock:
                try:
                    previous_registry = copy.deepcopy(self._load_registry())
                    expected_cleared_registry, _ = self._remove_generated_items(
                        copy.deepcopy(previous_registry)
                    )
                except Exception as exc:
                    self._log("ERROR", "清除前注册表快照失败: {}".format(exc))
                    return {"code": 0, "msg": "清除失败：{}".format(exc)}
                previous_ignored = set(self.ignored_sources)
                previous_manual_ignored = set(self.manual_ignored_sources)
                previous_auto_blocked = set(self.auto_blocked_sources)
                previous_adult_blocked = set(self.adult_blocked_sources)
                previous_adult_allowed = set(self.adult_allowed_sources)
                previous_results = dict(self.site_test_results)
                previous_pruned = set(self.pruned_sources)
                previous_cache = self.cache
                previous_status = self.status
                previous_retest_pending = list(self._retest_pending)
                previous_retest_auto_blocked = set(
                    self._retest_auto_blocked
                )
                previous_auto_scan_suspended = self.auto_scan_suspended
                try:
                    self.manual_ignored_sources.clear()
                    self.auto_blocked_sources.clear()
                    self.adult_blocked_sources.clear()
                    self.adult_allowed_sources.clear()
                    self.ignored_sources.clear()
                    # 一并复位自动清理名单：这是被移除站点唯一的恢复入口。
                    pruned_restored = len(self.pruned_sources)
                    self.pruned_sources.clear()
                    self.site_test_results.clear()
                    self._retest_pending = []
                    self._retest_auto_blocked.clear()
                    self.auto_scan_suspended = True
                    self._save_settings()
                    removed = self._clear_generated_registry()
                    self._set_manual_idle_status(
                        "已清除 {} 个自动注入项及扫描状态".format(removed)
                    )
                    self._clear_scan_cache_file()
                    _, detail = self._reload_app_vod_config(expected_keys=set())
                    self.inited = True
                    return {
                        "code": 0,
                        "msg": "已清除 {} 个自动注入站点及屏蔽状态、检测缓存{}，手工站点与类型配置保留；{}".format(
                            removed,
                            "，{} 个已清理站点恢复扫描".format(pruned_restored)
                            if pruned_restored
                            else "",
                            detail,
                        ),
                    }
                except Exception as exc:
                    self._log("ERROR", "清除自动站点失败: {}".format(exc))
                    self.ignored_sources = previous_ignored
                    self.manual_ignored_sources = previous_manual_ignored
                    self.auto_blocked_sources = previous_auto_blocked
                    self.adult_blocked_sources = previous_adult_blocked
                    self.adult_allowed_sources = previous_adult_allowed
                    self.site_test_results = previous_results
                    self.pruned_sources = previous_pruned
                    self.cache = previous_cache
                    self.status = previous_status
                    self._retest_pending = previous_retest_pending
                    self._retest_auto_blocked = previous_retest_auto_blocked
                    self.auto_scan_suspended = previous_auto_scan_suspended
                    try:
                        self._save_settings()
                    except Exception:
                        pass
                    rollback_error = ""
                    try:
                        output_path = os.path.abspath(
                            os.path.expanduser(self.output_path)
                        )
                        if os.path.isfile(output_path):
                            current_registry = self._read_config_file(
                                output_path, "站点注入注册表"
                            )
                            if current_registry == expected_cleared_registry:
                                current_token = self._registry_token(output_path)
                                self._atomic_write_json(
                                    previous_registry,
                                    create_backup=False,
                                    expected_token=current_token,
                                )
                                if self.app_mode == self.APP_MODE_OKTV:
                                    self._ok_write_generated_configs(
                                        previous_registry
                                    )
                            elif current_registry != previous_registry:
                                rollback_error = "注册表已被其他操作修改，未覆盖"
                    except Exception as rollback_exc:
                        rollback_error = "注册表回滚失败：{}".format(
                            rollback_exc
                        )
                    if rollback_error:
                        self._log("ERROR", rollback_error)
                    return {
                        "code": 0,
                        "msg": "清除失败：{}{}".format(
                            exc,
                            "；" + rollback_error if rollback_error else "",
                        ),
                    }
        if action == self.ACTION_DELETE_BACKUPS:
            with self.lock:
                try:
                    removed = self._delete_backup_files()
                    return {
                        "code": 0,
                        "msg": "已删除历史备份"
                        if removed
                        else "暂无历史备份",
                    }
                except Exception as exc:
                    self._log("ERROR", "历史备份删除失败: {}".format(exc))
                    return {"code": 0, "msg": "历史备份删除失败：{}".format(exc)}
        if action.startswith(self.ACTION_RESTORE_SNAPSHOT_PREFIX):
            name = os.path.basename(
                action[len(self.ACTION_RESTORE_SNAPSHOT_PREFIX) :]
            )
            path = os.path.join(self.backup_dir, name)
            with self.lock:
                try:
                    if not name.startswith("registry-") or not name.endswith(".json"):
                        raise ValueError("历史备份名称无效")
                    count = self._restore_registry_file(path)
                    self._suspend_auto_scan()
                    self._set_manual_idle_status(
                        "已恢复历史备份，请点击“扫描并加载”"
                    )
                    _, detail = self._reload_app_vod_config(
                        expected_keys=self._generated_registry_keys()
                    )
                    return {
                        "code": 0,
                        "msg": "已恢复历史备份（{} 个条目）；{}".format(
                            count,
                            detail,
                        ),
                    }
                except Exception as exc:
                    self._log("ERROR", "历史备份恢复失败: {}".format(exc))
                    return {"code": 0, "msg": "历史备份恢复失败：{}".format(exc)}
        if action == self.ACTION_TOGGLE_AUTO_SCAN:
            with self.lock:
                previous_enabled = self.auto_scan_on_empty
                previous_suspended = self.auto_scan_suspended
                try:
                    self.auto_scan_on_empty = not previous_enabled
                    if self.auto_scan_on_empty:
                        self.auto_scan_suspended = False
                    self._save_settings()
                    return {
                        "code": 0,
                        "msg": "自动补扫已{}".format(
                            "启用，无扫描快照时进入自动扫描一次"
                            if self.auto_scan_on_empty
                            else "停用，仅手动扫描"
                        ),
                    }
                except Exception as exc:
                    self._log("ERROR", "自动补扫开关保存失败: {}".format(exc))
                    self.auto_scan_on_empty = previous_enabled
                    self.auto_scan_suspended = previous_suspended
                    return {"code": 0, "msg": "自动补扫开关保存失败：{}".format(exc)}
        if action == self.ACTION_TOGGLE_AUTO_PRUNE:
            with self.lock:
                previous_enabled = self.auto_prune_blocked
                try:
                    self.auto_prune_blocked = not previous_enabled
                    self._save_settings()
                    return {
                        "code": 0,
                        "msg": "自动清理屏蔽站点已{}".format(
                            "启用，测活结束后把屏蔽站点移出配置（源文件保留，"
                            "可用“清除自动站点”恢复）"
                            if self.auto_prune_blocked
                            else "停用，测活只屏蔽不移除"
                        ),
                    }
                except Exception as exc:
                    self._log("ERROR", "自动清理开关保存失败: {}".format(exc))
                    self.auto_prune_blocked = previous_enabled
                    return {"code": 0, "msg": "自动清理开关保存失败：{}".format(exc)}
        if action.startswith(self.ACTION_TOGGLE_TYPE_PREFIX):
            source_type = action[len(self.ACTION_TOGGLE_TYPE_PREFIX) :].upper()
            if source_type not in self.TYPE_ORDER:
                return {"code": 0, "msg": "未知站点类型"}
            with self.lock:
                previous = self.pending_type_enabled.get(
                    source_type, self.type_enabled.get(source_type, True)
                )
                try:
                    self._set_pending_type_settings(
                        {source_type: not previous}
                    )
                    return {
                        "code": 0,
                        "msg": "{} 类型扫描已{}，待应用".format(
                            source_type,
                            "启用"
                            if self.pending_type_enabled[source_type]
                            else "停用",
                        ),
                    }
                except Exception as exc:
                    self._log("ERROR", "类型开关保存失败: {}".format(exc))
                    return {"code": 0, "msg": "类型开关保存失败：{}".format(exc)}
        if action == self.ACTION_APPLY_SCAN_CONFIG:
            action = self.ACTION_RESCAN
        if action != self.ACTION_RESCAN:
            return {"code": 0, "msg": "未知操作"}
        allowed, duplicate_message = self._begin_manual_scan_request()
        if not allowed:
            return {"code": 0, "msg": duplicate_message}
        try:
            with self.lock:
                if self.config_dirty:
                    try:
                        self._apply_pending_type_settings()
                    except Exception as exc:
                        self._log("ERROR", "扫描配置应用失败: {}".format(exc))
                        return {"code": 0, "msg": "扫描配置应用失败：{}".format(exc)}
                ok = self._refresh_locked(
                    allow_empty=not any(self.type_enabled.values()),
                    accept_complete_empty=True,
                )
                self.inited = True
                if ok:
                    self._show_author_scan_surprise()
                    _, detail = self._reload_app_vod_config(
                        expected_keys=self._generated_registry_keys()
                    )
                    if self.status["compatibility_blocked"]:
                        message = (
                            "兼容检查完成：已拦截 {} 个不兼容站点，"
                            "当前加载 {} 个站点；{}"
                        ).format(
                            self.status["compatibility_blocked"],
                            len(self.cache["sources"]),
                            detail,
                        )
                    else:
                        message = "扫描完成：{} 个站点、{} 个直播，{}；{}".format(
                            len(self.cache["sources"]),
                            len(self.cache.get("lives", [])),
                            "{} (+{} ~{} -{})".format(
                                self.status["write_state"],
                                self.status["added_sites"],
                                self.status["updated_sites"],
                                self.status["removed_sites"],
                            ),
                            detail,
                        )
                else:
                    message = "扫描未完成：{}".format(
                        self.status["error"] or self.status["write_state"]
                    )
                return {"code": 0, "msg": message}
        finally:
            self._finish_manual_scan_request()

    def playerContent(self, flag, id, vipFlags):
        return {
            "parse": 0,
            "url": "",
            "header": {},
            "msg": "该条目为配置管理项，不可播放。",
        }

    def destroy(self):
        self._destroyed = True
        self._site_test_cancel.set()
        self._playback_proxy_cancel.set()
        if self._background_reload_in_progress.is_set():
            self._background_completion_allowed.set()
        else:
            self._package_download_cancel.set()
            self._repository_sync_cancel.set()
        self._reload_generation += 1
        self._page_refresh_generation += 1
        return "destroy"
