Biliarchiver

一键上传b站视频到webarchive

Du musst eine Erweiterung wie Tampermonkey, Greasemonkey oder Violentmonkey installieren, um dieses Skript zu installieren.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

Sie müssten eine Skript Manager Erweiterung installieren damit sie dieses Skript installieren können

(Ich habe schon ein Skript Manager, Lass mich es installieren!)

Advertisement:

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

Advertisement:

// ==UserScript==
// @name         Biliarchiver
// @namespace    https://github.com/saveweb/biliarchiver
// @version      0.2.12
// @description  一键上传b站视频到webarchive
// @author       saveweb/biliarchiver contributors
// @match        *://*.bilibili.com/video/*
// @match        *://*.bilibili.com/list/*
// @match        *://www.bilibili.com/*
// @run-at       document-idle
// @grant        GM_xmlhttpRequest
// @grant        GM_registerMenuCommand
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @grant        GM_addStyle
// @grant        unsafeWindow
// @connect      api.bilibili.com
// @connect      www.bilibili.com
// @connect      bilibili.com
// @connect      hdslb.com
// @connect      bilivideo.com
// @connect      bilivideo.cn
// @connect      biliapi.net
// @connect      biliapi.com
// @connect      bilicdn1.com
// @connect      acgvideo.com
// @connect      akamaized.net
// @connect      archive.org
// @connect      s3.us.archive.org
// @connect      cdn.jsdelivr.net
// @connect      *
// @license      AGPL-3.0-or-later
// ==/UserScript==

(function () {
  'use strict';

  const APP = Object.freeze({
    name: 'Biliarchiver',
    version: '0.2.12',
    scanner: 'biliarchiver-userscript v0.2.12',
    iaS3Base: 'https://s3.us.archive.org',
    iaMetaBase: 'https://archive.org/metadata',
    iaCheckIdentifier: 'https://archive.org/services/check_identifier.php?output=json&identifier=',
    storagePrefix: 'biliarchiver_userscript_',
    mp4boxCdns: Object.freeze([
      'https://cdn.jsdelivr.net/npm/[email protected]/dist/mp4box.all.min.js',
      'https://cdn.jsdelivr.net/npm/[email protected]/dist/mp4box.all.min.js',
    ]),
  });

  const DEFAULT_SETTINGS = Object.freeze({
    iaAccessKey: '',
    iaSecretKey: '',
    collection: 'opensource_movies',
    qn: 64,
    codecPreference: 'av1', // av1 | hevc | avc | bandwidth
    danmakuSource: 'xml', // xml | protobuf
    queueDerive: true,
    overwriteExisting: false,
    strictDashMerge: true,
  });


  const QUALITY_OPTIONS = Object.freeze([
    { qn: 16, label: '360P 流畅', maxHeight: 360 },
    { qn: 32, label: '480P 清晰', maxHeight: 480 },
    { qn: 64, label: '720P 高清', maxHeight: 720 },
    { qn: 74, label: '720P60 高帧率', maxHeight: 720 },
    { qn: 80, label: '1080P 高清', maxHeight: 1080 },
    { qn: 112, label: '1080P+ 高码率', maxHeight: 1080 },
    { qn: 116, label: '1080P60 高帧率', maxHeight: 1080 },
    { qn: 120, label: '4K 超清', maxHeight: 2160 },
    { qn: 125, label: 'HDR 真彩色', maxHeight: 2160 },
    { qn: 126, label: '杜比视界', maxHeight: 2160 },
    { qn: 127, label: '8K 超高清', maxHeight: 4320 },
  ]);

  const QUALITY_LABELS = Object.freeze(Object.fromEntries(QUALITY_OPTIONS.map(item => [item.qn, item.label])));

  const CODEC_LABELS = Object.freeze({
    av1: 'AV1 优先,默认推荐',
    hevc: 'HEVC/H.265 优先,体积通常较小',
    avc: 'AVC/H.264 优先,兼容性最高',
    bandwidth: '同清晰度内最高码率优先',
  });

  const CODEC_FALLBACKS = Object.freeze({
    av1: ['av1', 'hevc', 'avc', 'other'],
    hevc: ['hevc', 'av1', 'avc', 'other'],
    avc: ['avc', 'hevc', 'av1', 'other'],
    bandwidth: ['other'],
  });

  const state = {
    busy: false,
    currentTask: null,
    wakeWanted: false,
    wakeLock: null,
    wakeLockSupported: 'wakeLock' in navigator,
  };

  const VIDEO_ID_REGEX = /(?:video\/|bvid=)(BV[a-zA-Z0-9]+|av\d+)/i;
  const BV_REGEX = /(BV[0-9A-Za-z]+)/;
  function storageKey(key) {
    return APP.storagePrefix + key;
  }

  function getSetting(key) {
    return GM_getValue(storageKey(key), DEFAULT_SETTINGS[key]);
  }

  function setSetting(key, value) {
    GM_setValue(storageKey(key), value);
  }

  function getSettings() {
    const result = {};
    for (const key of Object.keys(DEFAULT_SETTINGS)) result[key] = getSetting(key);
    return result;
  }

  function sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
  }

  function nowIso() {
    return new Date().toISOString();
  }

  function clamp(n, min, max) {
    return Math.max(min, Math.min(max, n));
  }

  function formatBytes(bytes) {
    if (!Number.isFinite(bytes) || bytes < 0) return '未知大小';
    const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
    let n = bytes;
    let i = 0;
    while (n >= 1024 && i < units.length - 1) {
      n /= 1024;
      i++;
    }
    return `${n.toFixed(i === 0 ? 0 : 2)} ${units[i]}`;
  }

  function safeFileName(input, fallback = 'untitled') {
    const text = String(input || fallback)
      .replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_')
      .replace(/\s+/g, ' ')
      .trim()
      .slice(0, 180);
    return text || fallback;
  }

  function getCookie(name) {
    const value = `; ${document.cookie}`;
    const parts = value.split(`; ${name}=`);
    if (parts.length === 2) return parts.pop().split(';').shift();
    return null;
  }

  function xmlCharsLegalizeString(str) {
    return String(str ?? '').replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]/g, '');
  }

  function xmlCharsLegalize(obj) {
    if (typeof obj === 'string') return xmlCharsLegalizeString(obj);
    if (Array.isArray(obj)) return obj.map(xmlCharsLegalize);
    if (obj && typeof obj === 'object') {
      const out = {};
      for (const [k, v] of Object.entries(obj)) out[k] = xmlCharsLegalize(v);
      return out;
    }
    return obj;
  }

  function humanReadableUpperPartMap(string, backward = true) {
    let s = String(string || '');
    if (backward) s = s.split('').reverse().join('');
    let result = '';
    let steps = 0;
    for (const ch of s) {
      if (ch >= 'A' && ch <= 'Z') {
        result += steps === 0 ? ch : `${steps}${ch}`;
        steps = 0;
      } else {
        steps++;
      }
    }
    return result;
  }

  function buildIdentifier(bvid, pageNumber) {
    return `BiliBili-${bvid}_p${pageNumber}-${humanReadableUpperPartMap(bvid, true)}`;
  }

  function getUnsafeWindow() {
    try { return typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; }
    catch (_) { return window; }
  }

  function extractBvidFromUrl() {
    const match = window.location.href.match(VIDEO_ID_REGEX) || window.location.href.match(BV_REGEX);
    if (!match) return null;
    const raw = match[1];
    if (/^av\d+$/i.test(raw)) return null;
    return raw;
  }

  function getPageNumberFromUrl() {
    const p = Number(new URL(location.href).searchParams.get('p') || '1');
    return Number.isFinite(p) && p > 0 ? Math.floor(p) : 1;
  }

  function gmRequest(details) {
    return new Promise((resolve, reject) => {
      const req = GM_xmlhttpRequest({
        method: details.method || 'GET',
        url: details.url,
        headers: details.headers || {},
        data: details.data,
        responseType: details.responseType,
        timeout: details.timeout || 0,
        fetch: details.fetch,
        anonymous: details.anonymous,
        onprogress: details.onprogress,
        onloadstart: details.onloadstart,
        onload: (res) => {
          const ok = res.status >= 200 && res.status < 300;
          if (!ok) {
            const text = res.responseText || res.statusText || '';
            reject(new Error(`${details.method || 'GET'} ${details.url} HTTP ${res.status}: ${text.slice(0, 500)}`));
            return;
          }
          resolve(res);
        },
        ontimeout: () => reject(new Error(`${details.method || 'GET'} ${details.url} 请求超时`)),
        onabort: () => reject(new Error(`${details.method || 'GET'} ${details.url} 请求被取消`)),
        onerror: (err) => {
          const rawMessage = String(err?.error || err?.message || 'unknown');
          let hint = '';
          if (/not a part of the @connect list|not permitted|refused to connect/i.test(rawMessage)) {
            let host = '未知域名';
            try { host = new URL(details.url).hostname; } catch (_) {}
            hint = `
提示:Tampermonkey 拦截了跨域请求,请确认脚本头部 @connect 已包含 ${host} 所属根域,或安装 v0.2.1 及以上版本。`;
          }
          reject(new Error(`${details.method || 'GET'} ${details.url} 网络错误:${rawMessage}${hint}`));
        },
      });
      if (details.signal) {
        details.signal.addEventListener('abort', () => {
          try { req.abort(); } catch (_) {}
        }, { once: true });
      }
    });
  }

  async function gmJson(url, options = {}) {
    const res = await gmRequest({
      method: options.method || 'GET',
      url,
      headers: {
        Accept: 'application/json, text/plain, */*',
        Referer: location.href,
        Origin: 'https://www.bilibili.com',
        ...(options.headers || {}),
      },
      data: options.data,
      responseType: 'json',
      timeout: options.timeout || 30000,
    });
    if (res.response && typeof res.response === 'object') return res.response;
    return JSON.parse(res.responseText);
  }

  async function gmText(url, options = {}) {
    const res = await gmRequest({
      method: options.method || 'GET',
      url,
      headers: options.headers || {},
      responseType: 'text',
      timeout: options.timeout || 30000,
    });
    return res.responseText || res.response;
  }

  async function gmArrayBuffer(url, options = {}) {
    const res = await gmRequest({
      method: options.method || 'GET',
      url,
      headers: {
        Referer: 'https://www.bilibili.com/',
        Origin: 'https://www.bilibili.com',
        ...(options.headers || {}),
      },
      responseType: 'arraybuffer',
      timeout: options.timeout || 0,
      onprogress: options.onprogress,
      signal: options.signal,
    });
    return res.response;
  }

  async function gmBlob(url, options = {}) {
    const res = await gmRequest({
      method: options.method || 'GET',
      url,
      headers: options.headers || {},
      responseType: 'blob',
      timeout: options.timeout || 0,
      onprogress: options.onprogress,
      signal: options.signal,
    });
    return res.response;
  }

  const UI = {
    ensure() {
      if (document.getElementById('biliarchiver-popup')) return;
      GM_addStyle(`
        :root {
          --ba-bg: #16181d;
          --ba-panel: #20232b;
          --ba-panel-2: #2b303b;
          --ba-text: #f4f5f7;
          --ba-sub: #aab0bd;
          --ba-border: rgba(255,255,255,0.10);
          --ba-accent: #00aeec;
          --ba-good: #31c48d;
          --ba-warn: #f59e0b;
          --ba-bad: #ef4444;
          --ba-shadow: rgba(0,0,0,.45);
        }
        #biliarchiver-popup {
          position: fixed;
          right: 24px;
          top: 90px;
          width: min(430px, calc(100vw - 36px));
          z-index: 2147483647;
          font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
          color: var(--ba-text);
          background: var(--ba-bg);
          border: 1px solid var(--ba-border);
          border-radius: 16px;
          box-shadow: 0 18px 50px var(--ba-shadow);
          overflow: hidden;
          transform: translateZ(0);
        }
        #biliarchiver-popup.ba-hidden { display: none; }
        .ba-head {
          display: flex;
          align-items: center;
          justify-content: space-between;
          padding: 14px 16px 10px;
          background: linear-gradient(135deg, rgba(0,174,236,.20), rgba(32,35,43,0));
          border-bottom: 1px solid var(--ba-border);
        }
        .ba-title { font-weight: 700; font-size: 15px; line-height: 1.35; }
        .ba-subtitle { color: var(--ba-sub); font-size: 12px; margin-top: 3px; }
        .ba-close, .ba-btn {
          border: 0;
          border-radius: 8px;
          cursor: pointer;
          font-size: 13px;
        }
        .ba-close { background: transparent; color: var(--ba-sub); font-size: 22px; width: 30px; height: 30px; }
        .ba-close:hover { color: var(--ba-text); background: rgba(255,255,255,.06); }
        .ba-body { padding: 14px 16px 16px; }
        .ba-line { margin: 8px 0; color: var(--ba-sub); font-size: 13px; line-height: 1.5; overflow-wrap: anywhere; }
        .ba-line strong { color: var(--ba-text); }
        .ba-progress-wrap { margin: 12px 0; }
        .ba-progress-label { display: flex; justify-content: space-between; margin-bottom: 6px; font-size: 12px; color: var(--ba-sub); }
        .ba-progress {
          height: 9px;
          border-radius: 999px;
          background: rgba(255,255,255,.10);
          overflow: hidden;
        }
        .ba-progress > div {
          height: 100%; width: 0%; background: var(--ba-accent); transition: width .25s ease;
        }
        .ba-detail-progress > div { background: var(--ba-good); }
        .ba-progress.ba-indeterminate > div {
          width: 42% !important;
          background: linear-gradient(90deg, rgba(52,211,153,.15), var(--ba-good), rgba(52,211,153,.15));
          animation: ba-indeterminate 1.2s ease-in-out infinite;
        }
        @keyframes ba-indeterminate {
          0% { transform: translateX(-120%); }
          100% { transform: translateX(260%); }
        }
        .ba-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
        .ba-btn {
          padding: 8px 11px;
          color: var(--ba-text);
          background: var(--ba-panel-2);
        }
        .ba-btn:hover { filter: brightness(1.12); }
        .ba-btn-primary { background: var(--ba-accent); color: #fff; }
        .ba-btn-danger { background: rgba(239,68,68,.20); color: #fecaca; }
        .ba-btn-good { background: rgba(49,196,141,.20); color: #b7f7dc; }
        .ba-log {
          margin-top: 12px;
          max-height: 180px;
          overflow: auto;
          background: rgba(0,0,0,.22);
          border: 1px solid var(--ba-border);
          border-radius: 10px;
          padding: 9px;
          color: #cbd5e1;
          font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
          font-size: 11px;
          line-height: 1.45;
          white-space: pre-wrap;
        }
        .ba-status-good { color: var(--ba-good); }
        .ba-status-warn { color: var(--ba-warn); }
        .ba-status-bad { color: var(--ba-bad); }
        #biliarchiver-modal {
          position: fixed;
          inset: 0;
          z-index: 2147483647;
          background: rgba(0,0,0,.62);
          display: flex;
          align-items: center;
          justify-content: center;
          font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
        }
        .ba-modal-box {
          width: min(560px, calc(100vw - 30px));
          background: var(--ba-bg);
          color: var(--ba-text);
          border: 1px solid var(--ba-border);
          border-radius: 16px;
          box-shadow: 0 18px 50px var(--ba-shadow);
          padding: 20px;
        }
        .ba-modal-box h3 { margin: 0 0 14px; font-size: 18px; }
        .ba-field { margin: 12px 0; }
        .ba-field label { display:block; color: var(--ba-sub); font-size: 13px; margin-bottom: 6px; }
        .ba-input, .ba-select {
          width: 100%; box-sizing: border-box;
          border: 1px solid var(--ba-border);
          border-radius: 10px;
          padding: 10px 11px;
          background: var(--ba-panel);
          color: var(--ba-text);
          outline: none;
        }
        .ba-check { display:flex; align-items:center; gap: 8px; color: var(--ba-sub); font-size: 13px; margin: 8px 0; }
        .ba-modal-actions { display:flex; justify-content:flex-end; gap:10px; margin-top: 16px; }
      `);
      const el = document.createElement('div');
      el.id = 'biliarchiver-popup';
      el.className = 'ba-hidden';
      el.innerHTML = `
        <div class="ba-head">
          <div>
            <div class="ba-title">Biliarchiver</div>
            <div class="ba-subtitle" id="ba-subtitle">待命</div>
          </div>
          <button class="ba-close" id="ba-close" title="隐藏">×</button>
        </div>
        <div class="ba-body">
          <div class="ba-line" id="ba-phase"><strong>状态:</strong>空闲</div>
          <div class="ba-line" id="ba-detail">等待从 Tampermonkey 菜单启动。</div>
          <div class="ba-progress-wrap">
            <div class="ba-progress-label"><span>总体进度</span><span id="ba-total-label">0%</span></div>
            <div class="ba-progress"><div id="ba-total-bar"></div></div>
          </div>
          <div class="ba-progress-wrap">
            <div class="ba-progress-label"><span>当前步骤</span><span id="ba-step-label">0%</span></div>
            <div class="ba-progress ba-detail-progress"><div id="ba-step-bar"></div></div>
          </div>
          <div class="ba-actions" id="ba-actions"></div>
          <div class="ba-log" id="ba-log"></div>
        </div>`;
      document.documentElement.appendChild(el);
      document.getElementById('ba-close').onclick = () => el.classList.add('ba-hidden');
    },
    show() {
      UI.ensure();
      document.getElementById('biliarchiver-popup').classList.remove('ba-hidden');
    },
    setSubtitle(text) {
      UI.ensure();
      document.getElementById('ba-subtitle').textContent = text;
    },
    setPhase(text, cls = '') {
      UI.ensure();
      const el = document.getElementById('ba-phase');
      el.className = 'ba-line ' + cls;
      el.innerHTML = `<strong>状态:</strong>${escapeHtml(text)}`;
    },
    setDetail(text) {
      UI.ensure();
      document.getElementById('ba-detail').textContent = text;
    },
    setTotal(percent) {
      UI.ensure();
      const p = clamp(percent, 0, 100);
      document.getElementById('ba-total-bar').style.width = `${p}%`;
      document.getElementById('ba-total-label').textContent = `${p.toFixed(1)}%`;
    },
    setStep(percent) {
      UI.ensure();
      const p = clamp(percent, 0, 100);
      const wrap = document.querySelector('.ba-detail-progress');
      if (wrap) wrap.classList.remove('ba-indeterminate');
      const bar = document.getElementById('ba-step-bar');
      bar.style.transform = '';
      bar.style.width = `${p}%`;
      document.getElementById('ba-step-label').textContent = `${p.toFixed(1)}%`;
    },
    setStepIndeterminate(label = '上传中') {
      UI.ensure();
      const wrap = document.querySelector('.ba-detail-progress');
      const bar = document.getElementById('ba-step-bar');
      if (wrap) wrap.classList.add('ba-indeterminate');
      bar.style.width = '42%';
      document.getElementById('ba-step-label').textContent = label;
    },
    log(message) {
      UI.ensure();
      const box = document.getElementById('ba-log');
      const line = `[${new Date().toLocaleTimeString()}] ${message}`;
      box.textContent += (box.textContent ? '\n' : '') + line;
      box.scrollTop = box.scrollHeight;
      console.log(`[BiliarchiverUserscript] ${message}`);
    },
    clearLog() {
      UI.ensure();
      document.getElementById('ba-log').textContent = '';
    },
    setActions(actions) {
      UI.ensure();
      const box = document.getElementById('ba-actions');
      box.innerHTML = '';
      for (const action of actions) {
        const btn = document.createElement('button');
        btn.className = `ba-btn ${action.className || ''}`;
        btn.textContent = action.text;
        btn.onclick = action.onclick;
        box.appendChild(btn);
      }
    },
    toast(msg, bad = false) {
      UI.show();
      UI.setPhase(msg, bad ? 'ba-status-bad' : 'ba-status-good');
      UI.log(msg);
    },
  };

  function escapeHtml(text) {
    return String(text ?? '').replace(/[&<>"']/g, ch => ({
      '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
    }[ch]));
  }

  const WakeLockManager = {
    async acquire() {
      state.wakeWanted = true;
      if (!('wakeLock' in navigator)) {
        UI.log('当前浏览器不支持 Screen Wake Lock API;上传仍会继续,但无法请求阻止息屏。');
        return;
      }
      if (document.visibilityState !== 'visible') {
        UI.log('当前标签页不可见,暂不申请 Wake Lock;回到标签页时会自动重试。');
        return;
      }
      try {
        if (state.wakeLock && !state.wakeLock.released) return;
        state.wakeLock = await navigator.wakeLock.request('screen');
        state.wakeLock.addEventListener('release', () => UI.log('Wake Lock 已被浏览器/系统释放。'));
        UI.log('已申请 Screen Wake Lock:当前标签页可见时会尽量阻止息屏。');
      } catch (err) {
        UI.log(`申请 Wake Lock 失败:${err.message || err}`);
      }
    },
    async release() {
      state.wakeWanted = false;
      if (state.wakeLock && !state.wakeLock.released) {
        try { await state.wakeLock.release(); } catch (_) {}
      }
      state.wakeLock = null;
    },
  };

  document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'visible' && state.wakeWanted) {
      WakeLockManager.acquire();
    } else if (document.visibilityState !== 'visible' && state.wakeWanted) {
      UI.log('已切换到其它标签页:Web 标准不保证隐藏页继续持有 Wake Lock;任务状态会保留并继续尝试上传。');
    }
  });

  async function getCurrentVideoContext() {
    const w = getUnsafeWindow();
    const initial = w.__INITIAL_STATE__ || {};
    let bvid = initial.bvid || initial.videoData?.bvid || w.vd?.bvid || extractBvidFromUrl();
    if (!bvid) throw new Error('无法从当前页面识别 BV 号;请确认在 Bilibili 视频页。');
    if (!/^BV[0-9A-Za-z]+$/.test(bvid)) throw new Error(`当前只支持 BV 号页面,识别到:${bvid}`);

    const pageNumber = Number(initial.p || w.vd?.embedPlayer?.p || getPageNumberFromUrl() || 1);
    const view = await getBiliView(bvid, initial.videoData);
    const page = (view.pages || []).find(p => Number(p.page) === Number(pageNumber)) || (view.pages || [])[0];
    if (!page) throw new Error('无法取得视频分 P / cid 信息。');

    const tags = await getBiliTags(bvid).catch(err => {
      UI.log(`获取 tag 失败,继续上传:${err.message}`);
      return [];
    });

    return {
      bvid,
      aid: view.aid,
      cid: page.cid,
      pageNumber: Number(page.page || pageNumber || 1),
      pagePart: page.part || `P${pageNumber}`,
      view,
      tags,
      owner: view.owner || {},
      title: view.title || bvid,
      desc: view.desc || '',
      pubdate: view.pubdate,
      pic: view.pic,
      identifier: buildIdentifier(bvid, Number(page.page || pageNumber || 1)),
    };
  }

  async function getBiliView(bvid, maybeInitialVideoData) {
    if (maybeInitialVideoData && maybeInitialVideoData.bvid === bvid && maybeInitialVideoData.pages) {
      return maybeInitialVideoData;
    }
    const res = await gmJson(`https://api.bilibili.com/x/web-interface/view?bvid=${encodeURIComponent(bvid)}`);
    if (res.code !== 0) throw new Error(`Bilibili view API 返回错误:${res.message || res.code}`);
    return res.data;
  }

  async function getBiliTags(bvid) {
    const res = await gmJson(`https://api.bilibili.com/x/tag/archive/tags?bvid=${encodeURIComponent(bvid)}`);
    if (res.code !== 0) throw new Error(`Bilibili tag API 返回错误:${res.message || res.code}`);
    return Array.isArray(res.data) ? res.data : [];
  }

  async function getPlayInfo(ctx, settings) {
    const w = getUnsafeWindow();
    const pagePlayInfo = w.__playinfo__?.data || w.__playinfo__;
    const targetQn = normaliseTargetQn(settings.qn);
    const params = new URLSearchParams({
      bvid: ctx.bvid,
      cid: String(ctx.cid),
      qn: String(targetQn),
      fnval: String(buildFnval(settings)),
      fnver: '0',
      fourk: targetQn >= 120 ? '1' : '0',
      otype: 'json',
      type: '',
    });
    const url = `https://api.bilibili.com/x/player/playurl?${params.toString()}`;
    try {
      UI.log(`请求播放流:目标 ${qualityLabel(targetQn)},编码偏好 ${codecPreferenceLabel(settings.codecPreference)}。`);
      const res = await gmJson(url, { headers: { Cookie: document.cookie || '' } });
      if (res.code !== 0) throw new Error(`Bilibili playurl API 返回错误:${res.message || res.code}`);
      return res.data;
    } catch (err) {
      if (pagePlayInfo?.dash?.video?.length && pagePlayInfo?.dash?.audio?.length) {
        UI.log(`播放流 API 失败,退回页面内 __playinfo__:${err?.message || err}`);
        return pagePlayInfo;
      }
      throw err;
    }
  }

  function buildFnval(settings) {
    const qn = normaliseTargetQn(settings.qn);
    let fnval = 16; // DASH
    if (String(settings.codecPreference || '').toLowerCase() === 'av1') fnval |= 2048;
    if (qn >= 120) fnval |= 128;
    if (qn === 125) fnval |= 64;
    if (qn === 126) fnval |= 512;
    if (qn === 127) fnval |= 1024;
    return fnval;
  }

  function streamUrl(media) {
    return media?.baseUrl || media?.base_url || media?.url || media?.backupUrl?.[0] || media?.backup_url?.[0] || null;
  }

  function selectDashStreams(playInfo, settings) {
    const dash = playInfo?.dash;
    if (!dash?.video?.length || !dash?.audio?.length) {
      throw new Error('未取得 DASH 音视频分离流;为满足“必须音视频合并”,本脚本不会上传 durl/flv 单流。');
    }
    const videos = dash.video.filter(v => streamUrl(v));
    const audios = dash.audio.filter(a => streamUrl(a));
    if (!videos.length || !audios.length) throw new Error('DASH 信息缺少可用的视频或音频 URL。');

    const targetQn = normaliseTargetQn(settings.qn);
    const qualityCandidates = selectQualityBucket(videos, targetQn);
    const videoCandidates = [...qualityCandidates].sort((a, b) => compareVideoStreams(a, b, settings));
    const audioCandidates = [...audios].sort((a, b) => Number(b.bandwidth || 0) - Number(a.bandwidth || 0) || Number(b.id || 0) - Number(a.id || 0));
    const video = videoCandidates[0];
    const audio = audioCandidates[0];
    const actualQn = streamQuality(video);
    if (actualQn !== targetQn) {
      UI.log(`目标清晰度 ${qualityLabel(targetQn)} 不完全可用,实际选择 ${qualityLabel(actualQn)}。`);
    }
    return { video, audio };
  }

  function selectQualityBucket(videos, targetQn) {
    const exact = videos.filter(v => streamQuality(v) === targetQn);
    if (exact.length) return exact;
    const lower = videos.filter(v => streamQuality(v) <= targetQn);
    if (lower.length) {
      const bestLowerQn = Math.max(...lower.map(streamQuality));
      return lower.filter(v => streamQuality(v) === bestLowerQn);
    }
    const higher = videos.filter(v => streamQuality(v) > targetQn);
    if (higher.length) {
      const nearestHigherQn = Math.min(...higher.map(streamQuality));
      return higher.filter(v => streamQuality(v) === nearestHigherQn);
    }
    return videos;
  }

  function compareVideoStreams(a, b, settings) {
    if (settings.codecPreference === 'bandwidth') {
      return Number(b.bandwidth || 0) - Number(a.bandwidth || 0) || codecRank(a, settings) - codecRank(b, settings);
    }
    return codecRank(a, settings) - codecRank(b, settings) || Number(b.bandwidth || 0) - Number(a.bandwidth || 0);
  }

  function codecRank(media, settings) {
    const pref = String(settings.codecPreference || DEFAULT_SETTINGS.codecPreference).toLowerCase();
    const fallbacks = CODEC_FALLBACKS[pref] || CODEC_FALLBACKS.av1;
    const codec = normaliseCodec(media);
    const rank = fallbacks.indexOf(codec);
    return rank === -1 ? 999 : rank;
  }

  function normaliseCodec(media) {
    const codecid = Number(media?.codecid || media?.codec_id || 0);
    if (codecid === 13) return 'av1';
    if (codecid === 12) return 'hevc';
    if (codecid === 7) return 'avc';
    const codec = String(media?.codecs || media?.codec || '').toLowerCase();
    if (codec.startsWith('av01')) return 'av1';
    if (codec.startsWith('hev') || codec.startsWith('hvc')) return 'hevc';
    if (codec.startsWith('avc')) return 'avc';
    return 'other';
  }

  function streamQuality(media) {
    const qn = Number(media?.id || media?.quality || media?.qn || 0);
    if (Number.isFinite(qn) && qn > 0) return qn;
    const height = Number(media?.height || 0);
    if (height <= 360) return 16;
    if (height <= 480) return 32;
    if (height <= 720) return 64;
    if (height <= 1080) return 80;
    if (height <= 2160) return 120;
    return 127;
  }

  function normaliseTargetQn(value) {
    const qn = Number(value || DEFAULT_SETTINGS.qn);
    return QUALITY_LABELS[qn] ? qn : DEFAULT_SETTINGS.qn;
  }

  function qualityLabel(qn) {
    const value = Number(qn || 0);
    return QUALITY_LABELS[value] || `qn=${value || 'unknown'}`;
  }

  function codecPreferenceLabel(pref) {
    return CODEC_LABELS[String(pref || '').toLowerCase()] || CODEC_LABELS[DEFAULT_SETTINGS.codecPreference];
  }

  function normaliseDanmakuSource(value) {
    const source = String(value || DEFAULT_SETTINGS.danmakuSource).toLowerCase();
    return source === 'protobuf' ? 'protobuf' : 'xml';
  }

  function danmakuSourceLabel(value) {
    return normaliseDanmakuSource(value) === 'protobuf' ? 'protobuf 分段接口' : 'XML 实时弹幕池';
  }

  function streamCodecLabel(media) {
    const codec = normaliseCodec(media);
    if (codec === 'av1') return 'AV1';
    if (codec === 'hevc') return 'HEVC/H.265';
    if (codec === 'avc') return 'AVC/H.264';
    return media?.codecs || media?.codec || `codecid=${media?.codecid || 'unknown'}`;
  }

  async function checkIdentifier(identifier) {
    const res = await gmJson(APP.iaCheckIdentifier + encodeURIComponent(identifier), { headers: { Origin: 'https://archive.org' } });
    return res;
  }

  function buildSourceUrl(ctx) {
    return `https://www.bilibili.com/video/${ctx.bvid}/?p=${ctx.pageNumber}`;
  }

  const WBI_MIXIN_KEY_ENC_TAB = Object.freeze([
    46, 47, 18, 2, 53, 8, 23, 32,
    15, 50, 10, 31, 58, 3, 45, 35,
    27, 43, 5, 49, 33, 9, 42, 19,
    29, 28, 14, 39, 12, 38, 41, 13,
    37, 48, 7, 16, 24, 55, 40, 61,
    26, 17, 0, 1, 60, 51, 30, 4,
    22, 25, 54, 21, 56, 59, 6, 63,
    57, 62, 11, 36, 20, 34, 44, 52,
  ]);

  const wbiKeyCache = { key: '', ts: 0 };

  function extractWbiKeyFromUrl(url) {
    if (!url) return '';
    try {
      const absolute = new URL(String(url), location.href);
      const file = absolute.pathname.split('/').pop() || '';
      return file.split('.')[0] || '';
    } catch (_) {
      const file = String(url).split('/').pop() || '';
      return file.split('.')[0] || '';
    }
  }

  async function getWbiMixinKey() {
    const now = Date.now();
    if (wbiKeyCache.key && now - wbiKeyCache.ts < 10 * 60 * 1000) return wbiKeyCache.key;
    const nav = await gmJson('https://api.bilibili.com/x/web-interface/nav', {
      headers: { Cookie: document.cookie || '' },
      timeout: 30000,
    });
    if (nav.code !== 0) throw new Error(`Bilibili nav API 返回错误:${nav.message || nav.code}`);
    const imgKey = extractWbiKeyFromUrl(nav?.data?.wbi_img?.img_url);
    const subKey = extractWbiKeyFromUrl(nav?.data?.wbi_img?.sub_url);
    const rawKey = `${imgKey}${subKey}`;
    if (rawKey.length < 64) throw new Error('Bilibili WBI key 不完整,无法签名字幕请求。');
    const mixinKey = WBI_MIXIN_KEY_ENC_TAB.map(i => rawKey[i]).join('').slice(0, 32);
    wbiKeyCache.key = mixinKey;
    wbiKeyCache.ts = now;
    return mixinKey;
  }

  function md5(input) {
    function rotateLeft(value, shift) { return (value << shift) | (value >>> (32 - shift)); }
    function addUnsigned(x, y) {
      const x4 = x & 0x40000000;
      const y4 = y & 0x40000000;
      const x8 = x & 0x80000000;
      const y8 = y & 0x80000000;
      const result = (x & 0x3fffffff) + (y & 0x3fffffff);
      if (x4 & y4) return result ^ 0x80000000 ^ x8 ^ y8;
      if (x4 | y4) return (result & 0x40000000) ? result ^ 0xc0000000 ^ x8 ^ y8 : result ^ 0x40000000 ^ x8 ^ y8;
      return result ^ x8 ^ y8;
    }
    function f(x, y, z) { return (x & y) | (~x & z); }
    function g(x, y, z) { return (x & z) | (y & ~z); }
    function h(x, y, z) { return x ^ y ^ z; }
    function i(x, y, z) { return y ^ (x | ~z); }
    function ff(a, b, c, d, x, s, ac) { a = addUnsigned(a, addUnsigned(addUnsigned(f(b, c, d), x), ac)); return addUnsigned(rotateLeft(a, s), b); }
    function gg(a, b, c, d, x, s, ac) { a = addUnsigned(a, addUnsigned(addUnsigned(g(b, c, d), x), ac)); return addUnsigned(rotateLeft(a, s), b); }
    function hh(a, b, c, d, x, s, ac) { a = addUnsigned(a, addUnsigned(addUnsigned(h(b, c, d), x), ac)); return addUnsigned(rotateLeft(a, s), b); }
    function ii(a, b, c, d, x, s, ac) { a = addUnsigned(a, addUnsigned(addUnsigned(i(b, c, d), x), ac)); return addUnsigned(rotateLeft(a, s), b); }
    function convertToWordArray(str) {
      const byteCount = str.length;
      const wordCount = (((byteCount + 8) - ((byteCount + 8) % 64)) / 64 + 1) * 16;
      const words = new Array(wordCount - 1).fill(0);
      let bytePosition = 0;
      let byteIndex = 0;
      while (byteIndex < byteCount) {
        const wordIndex = (byteIndex - (byteIndex % 4)) / 4;
        bytePosition = (byteIndex % 4) * 8;
        words[wordIndex] |= str.charCodeAt(byteIndex) << bytePosition;
        byteIndex++;
      }
      const wordIndex = (byteIndex - (byteIndex % 4)) / 4;
      bytePosition = (byteIndex % 4) * 8;
      words[wordIndex] |= 0x80 << bytePosition;
      words[wordCount - 2] = byteCount << 3;
      words[wordCount - 1] = byteCount >>> 29;
      return words;
    }
    function wordToHex(value) {
      let out = '';
      for (let count = 0; count <= 3; count++) {
        out += (`0${((value >>> (count * 8)) & 255).toString(16)}`).slice(-2);
      }
      return out;
    }

    const x = convertToWordArray(unescape(encodeURIComponent(String(input))));
    let a = 0x67452301;
    let b = 0xefcdab89;
    let c = 0x98badcfe;
    let d = 0x10325476;

    for (let k = 0; k < x.length; k += 16) {
      const aa = a, bb = b, cc = c, dd = d;
      a = ff(a, b, c, d, x[k + 0], 7, 0xd76aa478); d = ff(d, a, b, c, x[k + 1], 12, 0xe8c7b756); c = ff(c, d, a, b, x[k + 2], 17, 0x242070db); b = ff(b, c, d, a, x[k + 3], 22, 0xc1bdceee);
      a = ff(a, b, c, d, x[k + 4], 7, 0xf57c0faf); d = ff(d, a, b, c, x[k + 5], 12, 0x4787c62a); c = ff(c, d, a, b, x[k + 6], 17, 0xa8304613); b = ff(b, c, d, a, x[k + 7], 22, 0xfd469501);
      a = ff(a, b, c, d, x[k + 8], 7, 0x698098d8); d = ff(d, a, b, c, x[k + 9], 12, 0x8b44f7af); c = ff(c, d, a, b, x[k + 10], 17, 0xffff5bb1); b = ff(b, c, d, a, x[k + 11], 22, 0x895cd7be);
      a = ff(a, b, c, d, x[k + 12], 7, 0x6b901122); d = ff(d, a, b, c, x[k + 13], 12, 0xfd987193); c = ff(c, d, a, b, x[k + 14], 17, 0xa679438e); b = ff(b, c, d, a, x[k + 15], 22, 0x49b40821);
      a = gg(a, b, c, d, x[k + 1], 5, 0xf61e2562); d = gg(d, a, b, c, x[k + 6], 9, 0xc040b340); c = gg(c, d, a, b, x[k + 11], 14, 0x265e5a51); b = gg(b, c, d, a, x[k + 0], 20, 0xe9b6c7aa);
      a = gg(a, b, c, d, x[k + 5], 5, 0xd62f105d); d = gg(d, a, b, c, x[k + 10], 9, 0x02441453); c = gg(c, d, a, b, x[k + 15], 14, 0xd8a1e681); b = gg(b, c, d, a, x[k + 4], 20, 0xe7d3fbc8);
      a = gg(a, b, c, d, x[k + 9], 5, 0x21e1cde6); d = gg(d, a, b, c, x[k + 14], 9, 0xc33707d6); c = gg(c, d, a, b, x[k + 3], 14, 0xf4d50d87); b = gg(b, c, d, a, x[k + 8], 20, 0x455a14ed);
      a = gg(a, b, c, d, x[k + 13], 5, 0xa9e3e905); d = gg(d, a, b, c, x[k + 2], 9, 0xfcefa3f8); c = gg(c, d, a, b, x[k + 7], 14, 0x676f02d9); b = gg(b, c, d, a, x[k + 12], 20, 0x8d2a4c8a);
      a = hh(a, b, c, d, x[k + 5], 4, 0xfffa3942); d = hh(d, a, b, c, x[k + 8], 11, 0x8771f681); c = hh(c, d, a, b, x[k + 11], 16, 0x6d9d6122); b = hh(b, c, d, a, x[k + 14], 23, 0xfde5380c);
      a = hh(a, b, c, d, x[k + 1], 4, 0xa4beea44); d = hh(d, a, b, c, x[k + 4], 11, 0x4bdecfa9); c = hh(c, d, a, b, x[k + 7], 16, 0xf6bb4b60); b = hh(b, c, d, a, x[k + 10], 23, 0xbebfbc70);
      a = hh(a, b, c, d, x[k + 13], 4, 0x289b7ec6); d = hh(d, a, b, c, x[k + 0], 11, 0xeaa127fa); c = hh(c, d, a, b, x[k + 3], 16, 0xd4ef3085); b = hh(b, c, d, a, x[k + 6], 23, 0x04881d05);
      a = hh(a, b, c, d, x[k + 9], 4, 0xd9d4d039); d = hh(d, a, b, c, x[k + 12], 11, 0xe6db99e5); c = hh(c, d, a, b, x[k + 15], 16, 0x1fa27cf8); b = hh(b, c, d, a, x[k + 2], 23, 0xc4ac5665);
      a = ii(a, b, c, d, x[k + 0], 6, 0xf4292244); d = ii(d, a, b, c, x[k + 7], 10, 0x432aff97); c = ii(c, d, a, b, x[k + 14], 15, 0xab9423a7); b = ii(b, c, d, a, x[k + 5], 21, 0xfc93a039);
      a = ii(a, b, c, d, x[k + 12], 6, 0x655b59c3); d = ii(d, a, b, c, x[k + 3], 10, 0x8f0ccc92); c = ii(c, d, a, b, x[k + 10], 15, 0xffeff47d); b = ii(b, c, d, a, x[k + 1], 21, 0x85845dd1);
      a = ii(a, b, c, d, x[k + 8], 6, 0x6fa87e4f); d = ii(d, a, b, c, x[k + 15], 10, 0xfe2ce6e0); c = ii(c, d, a, b, x[k + 6], 15, 0xa3014314); b = ii(b, c, d, a, x[k + 13], 21, 0x4e0811a1);
      a = ii(a, b, c, d, x[k + 4], 6, 0xf7537e82); d = ii(d, a, b, c, x[k + 11], 10, 0xbd3af235); c = ii(c, d, a, b, x[k + 2], 15, 0x2ad7d2bb); b = ii(b, c, d, a, x[k + 9], 21, 0xeb86d391);
      a = addUnsigned(a, aa); b = addUnsigned(b, bb); c = addUnsigned(c, cc); d = addUnsigned(d, dd);
    }
    return `${wordToHex(a)}${wordToHex(b)}${wordToHex(c)}${wordToHex(d)}`.toLowerCase();
  }

  function makeQueryString(params) {
    return Object.keys(params)
      .sort()
      .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(String(params[key]).replace(/[!'()*]/g, ''))}`)
      .join('&');
  }

  async function makeSignedWbiQuery(params) {
    const mixinKey = await getWbiMixinKey();
    const signedParams = { ...params, wts: Math.round(Date.now() / 1000) };
    const query = makeQueryString(signedParams);
    return `${query}&w_rid=${md5(query + mixinKey)}`;
  }

  function normalizeSubtitleUrl(url) {
    if (!url) return '';
    const text = String(url);
    if (text.startsWith('//')) return `https:${text}`;
    try { return new URL(text, location.href).href; }
    catch (_) { return text; }
  }

  function safeSubtitleLang(input, fallback = 'und') {
    const text = String(input || fallback).replace(/[^0-9A-Za-z_-]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 32);
    return text || fallback;
  }

  function formatSrtTime(seconds) {
    const totalMs = Math.max(0, Math.round(Number(seconds || 0) * 1000));
    const ms = totalMs % 1000;
    const totalSeconds = Math.floor(totalMs / 1000);
    const s = totalSeconds % 60;
    const totalMinutes = Math.floor(totalSeconds / 60);
    const m = totalMinutes % 60;
    const h = Math.floor(totalMinutes / 60);
    const pad2 = n => String(n).padStart(2, '0');
    const pad3 = n => String(n).padStart(3, '0');
    return `${pad2(h)}:${pad2(m)}:${pad2(s)},${pad3(ms)}`;
  }

  function subtitleJsonToSrt(json) {
    const body = Array.isArray(json?.body) ? json.body : [];
    return body.map((line, idx) => {
      const start = formatSrtTime(line.from);
      const end = formatSrtTime(line.to);
      const content = xmlCharsLegalizeString(line.content || '').replace(/\r\n?/g, '\n').trim();
      return `${idx + 1}\n${start} --> ${end}\n${content}\n`;
    }).join('\n');
  }

  async function getBiliSubtitleInfo(ctx) {
    const baseParams = { bvid: ctx.bvid, cid: String(ctx.cid) };
    const candidates = [];
    try {
      const signedQuery = await makeSignedWbiQuery(baseParams);
      candidates.push(`https://api.bilibili.com/x/player/wbi/v2?${signedQuery}`);
    } catch (err) {
      UI.log(`WBI 签名字幕请求准备失败,将尝试无签名接口:${err.message || err}`);
    }
    candidates.push(`https://api.bilibili.com/x/player/wbi/v2?${makeQueryString(baseParams)}`);
    candidates.push(`https://api.bilibili.com/x/player/v2?${makeQueryString(baseParams)}`);

    let lastErr = null;
    for (const url of candidates) {
      try {
        const info = await gmJson(url, {
          headers: { Cookie: document.cookie || '' },
          timeout: 30000,
        });
        if (info.code !== 0) throw new Error(`Bilibili player API 返回错误:${info.message || info.code}`);
        return info.data || {};
      } catch (err) {
        lastErr = err;
        UI.log(`字幕信息接口失败,尝试备用:${err.message || err}`);
      }
    }
    throw lastErr || new Error('无法取得字幕信息。');
  }

  async function fetchSubtitleFiles(ctx) {
    UI.setPhase('获取 CC 字幕', 'ba-status-warn');
    UI.setStepIndeterminate('检查字幕');
    const playerInfo = await getBiliSubtitleInfo(ctx);
    if (playerInfo.need_login_subtitle) {
      UI.log('该视频字幕接口提示需要登录;当前浏览器 Cookie 不足时可能无法取得字幕。');
    }
    const subtitles = Array.isArray(playerInfo?.subtitle?.subtitles) ? playerInfo.subtitle.subtitles : [];
    const available = subtitles.filter(item => item?.subtitle_url);
    if (!available.length) {
      UI.log('未发现可下载的 CC/AI 字幕,跳过字幕文件。');
      UI.setStep(100);
      return [];
    }

    const files = [];
    for (let idx = 0; idx < available.length; idx++) {
      const item = available[idx];
      const lan = safeSubtitleLang(item.lan || item.lan_doc || `sub${idx + 1}`);
      const url = normalizeSubtitleUrl(item.subtitle_url);
      try {
        UI.setDetail(`下载字幕:${lan} (${idx + 1}/${available.length})`);
        const json = await gmJson(url, {
          headers: { Referer: buildSourceUrl(ctx), Origin: 'https://www.bilibili.com' },
          timeout: 60000,
        });
        const srt = subtitleJsonToSrt(json);
        if (!srt.trim()) {
          UI.log(`字幕 ${lan} 内容为空,已跳过。`);
          continue;
        }
        const name = `${safeFileName(`BiliBili-${ctx.bvid}_p${ctx.pageNumber}`)}.${lan}.srt`;
        files.push({
          name,
          blob: new Blob([srt], { type: 'application/x-subrip;charset=utf-8' }),
          meta: {
            id: item.id || item.id_str || '',
            lan,
            lan_doc: item.lan_doc || '',
            ai_status: item.ai_status,
            ai_type: item.ai_type,
            original_url: url,
          },
        });
        UI.log(`字幕已转换为 SRT:${name}`);
      } catch (err) {
        UI.log(`字幕 ${lan} 下载/转换失败,已跳过:${err.message || err}`);
      }
      UI.setStep(((idx + 1) / available.length) * 100);
    }
    return files;
  }


  async function fetchReplyFile(ctx) {
    UI.setPhase('获取视频评论', 'ba-status-warn');
    UI.setStepIndeterminate('获取评论');
    const params = new URLSearchParams({
      type: '1',
      oid: String(ctx.aid),
      sort: '1',
      ps: '20',
      pn: '1',
    });
    const url = `https://api.bilibili.com/x/v2/reply?${params.toString()}`;
    const json = await gmJson(url, {
      headers: { Cookie: document.cookie || '' },
      timeout: 30000,
    });
    if (json.code !== 0) {
      throw new Error(`Bilibili 评论 API 返回错误:${json.message || json.code}`);
    }
    const replies = Array.isArray(json?.data?.replies) ? json.data.replies : [];
    const hots = Array.isArray(json?.data?.hots) ? json.data.hots : [];
    const name = `${safeFileName(`BiliBili-${ctx.bvid}_p${ctx.pageNumber}`)}.replies.json`;
    const text = JSON.stringify(json, null, 2);
    UI.setStep(100);
    UI.log(`视频评论已保存:${name},普通评论 ${replies.length} 条,热评 ${hots.length} 条。`);
    return {
      name,
      blob: new Blob([text], { type: 'application/json;charset=utf-8' }),
      meta: {
        source: url,
        sort: '1',
        ps: 20,
        pn: 1,
        replies_count: replies.length,
        hot_replies_count: hots.length,
        cursor_all_count: json?.data?.cursor?.all_count,
      },
    };
  }

  function parseBiliDanmakuXml(xmlText) {
    const doc = new DOMParser().parseFromString(String(xmlText || ''), 'application/xml');
    if (doc.querySelector('parsererror')) throw new Error('弹幕 XML 解析失败。');
    const nodes = Array.from(doc.getElementsByTagName('d'));
    const items = [];
    for (const node of nodes) {
      const p = String(node.getAttribute('p') || '').split(',');
      const time = Number(p[0]);
      const mode = Number(p[1] || 1);
      const fontsize = Number(p[2] || 25);
      const color = Number(p[3] || 16777215);
      const content = xmlCharsLegalizeString(node.textContent || '').trim();
      if (!Number.isFinite(time) || !content) continue;
      items.push({
        time,
        mode,
        fontsize: Number.isFinite(fontsize) && fontsize > 0 ? fontsize : 25,
        color: Number.isFinite(color) ? color : 16777215,
        timestamp: Number(p[4] || 0),
        pool: Number(p[5] || 0),
        midHash: p[6] || '',
        id: p[7] || '',
        content,
      });
    }
    items.sort((a, b) => a.time - b.time || Number(a.id || 0) - Number(b.id || 0));
    return items;
  }

  function readProtoVarint(bytes, offset) {
    let value = 0n;
    let shift = 0n;
    while (offset < bytes.length) {
      const b = bytes[offset++];
      value |= BigInt(b & 0x7f) << shift;
      if ((b & 0x80) === 0) return { value, offset };
      shift += 7n;
      if (shift > 70n) throw new Error('protobuf varint 过长。');
    }
    throw new Error('protobuf varint 截断。');
  }

  function skipProtoField(bytes, offset, wireType) {
    if (wireType === 0) return readProtoVarint(bytes, offset).offset;
    if (wireType === 1) return offset + 8;
    if (wireType === 2) {
      const len = readProtoVarint(bytes, offset);
      return len.offset + Number(len.value);
    }
    if (wireType === 5) return offset + 4;
    throw new Error(`不支持的 protobuf wire type:${wireType}`);
  }

  const protoUtf8Decoder = new TextDecoder('utf-8');

  function protoBytesToString(bytes, start, length) {
    return protoUtf8Decoder.decode(bytes.subarray(start, start + length));
  }

  function parseBiliDanmakuProtoElem(bytes) {
    const item = {
      time: 0,
      mode: 1,
      fontsize: 25,
      color: 16777215,
      timestamp: 0,
      pool: 0,
      midHash: '',
      id: '',
      content: '',
    };
    let offset = 0;
    while (offset < bytes.length) {
      const tag = readProtoVarint(bytes, offset);
      offset = tag.offset;
      const field = Number(tag.value >> 3n);
      const wireType = Number(tag.value & 7n);
      if (wireType === 0) {
        const v = readProtoVarint(bytes, offset);
        offset = v.offset;
        if (field === 1) item.id = v.value.toString();
        else if (field === 2) item.time = Number(v.value) / 1000;
        else if (field === 3) item.mode = Number(v.value);
        else if (field === 4) item.fontsize = Number(v.value);
        else if (field === 5) item.color = Number(v.value);
        else if (field === 8) item.timestamp = Number(v.value);
        else if (field === 11) item.pool = Number(v.value);
      } else if (wireType === 2) {
        const len = readProtoVarint(bytes, offset);
        offset = len.offset;
        const n = Number(len.value);
        if (offset + n > bytes.length) throw new Error('protobuf 字符串字段截断。');
        if (field === 6) item.midHash = protoBytesToString(bytes, offset, n);
        else if (field === 7) item.content = xmlCharsLegalizeString(protoBytesToString(bytes, offset, n)).trim();
        else if (field === 12) item.id = protoBytesToString(bytes, offset, n) || item.id;
        offset += n;
      } else {
        offset = skipProtoField(bytes, offset, wireType);
      }
    }
    return item;
  }

  function parseBiliDanmakuProto(buffer) {
    const bytes = new Uint8Array(buffer || new ArrayBuffer(0));
    const items = [];
    let offset = 0;
    while (offset < bytes.length) {
      const tag = readProtoVarint(bytes, offset);
      offset = tag.offset;
      const field = Number(tag.value >> 3n);
      const wireType = Number(tag.value & 7n);
      if (field === 1 && wireType === 2) {
        const len = readProtoVarint(bytes, offset);
        offset = len.offset;
        const n = Number(len.value);
        if (offset + n > bytes.length) throw new Error('protobuf elems 字段截断。');
        const item = parseBiliDanmakuProtoElem(bytes.subarray(offset, offset + n));
        if (Number.isFinite(item.time) && item.content) items.push(item);
        offset += n;
      } else {
        offset = skipProtoField(bytes, offset, wireType);
      }
    }
    items.sort((a, b) => Number(a.time || 0) - Number(b.time || 0) || Number(a.timestamp || 0) - Number(b.timestamp || 0));
    return items;
  }

  function dedupeDanmakuItems(items) {
    const seen = new Set();
    const out = [];
    for (const item of items) {
      const key = item.id || `${item.time}|${item.mode}|${item.fontsize}|${item.color}|${item.timestamp}|${item.pool}|${item.content}`;
      if (seen.has(key)) continue;
      seen.add(key);
      out.push(item);
    }
    out.sort((a, b) => Number(a.time || 0) - Number(b.time || 0) || Number(a.timestamp || 0) - Number(b.timestamp || 0));
    return out;
  }

  function currentPageDurationSeconds(ctx) {
    const page = (ctx?.view?.pages || []).find(p => Number(p.page) === Number(ctx.pageNumber));
    const candidates = [page?.duration, ctx?.view?.duration, ctx?.duration];
    for (const value of candidates) {
      const n = Number(value);
      if (Number.isFinite(n) && n > 0) return n;
    }
    return 360;
  }

  function secondsToAssTime(seconds) {
    const totalCs = Math.max(0, Math.round(Number(seconds || 0) * 100));
    const cs = totalCs % 100;
    const totalSeconds = Math.floor(totalCs / 100);
    const s = totalSeconds % 60;
    const totalMinutes = Math.floor(totalSeconds / 60);
    const m = totalMinutes % 60;
    const h = Math.floor(totalMinutes / 60);
    return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}.${String(cs).padStart(2, '0')}`;
  }

  function assColorFromRgbInt(color) {
    const value = Number(color) >>> 0;
    const r = (value >> 16) & 255;
    const g = (value >> 8) & 255;
    const b = value & 255;
    const hex = n => n.toString(16).padStart(2, '0').toUpperCase();
    return `&H${hex(b)}${hex(g)}${hex(r)}&`;
  }

  function escapeAssText(text) {
    return String(text ?? '')
      .replace(/[{}]/g, ch => ch === '{' ? '{' : '}')
      .replace(/\\/g, '\')
      .replace(/\r\n?|\n/g, '\\N');
  }

  function estimateDanmakuTextWidth(text, fontSize) {
    const lines = String(text || '').split(/\r\n?|\n/);
    let maxLen = 0;
    for (const line of lines) maxLen = Math.max(maxLen, Array.from(line).length);
    return maxLen * fontSize;
  }

  function assBlackOutlineOverride(color) {
    return (Number(color) >>> 0) === 0x000000 ? '\\3c&H666666&' : '';
  }

  function danmakuInternalMode(mode) {
    if (mode === 5) return 1; // top still
    if (mode === 4) return 2; // bottom still
    if (mode === 6) return 3; // reverse marquee
    if (mode === 7) return 1; // keep previous userscript behaviour for special danmaku
    return 0; // normal marquee
  }

  function makeDanmakuRows(height, reserveBlank) {
    return Array.from({ length: 3 }, () =>
      Array.from({ length: 4 }, () => new Array(Math.max(1, height - reserveBlank + 1)).fill(null))
    );
  }

  function markDanmakuRows(rows, comment, row) {
    const poolRows = rows[comment.pool]?.[comment.mode] || rows[0][comment.mode];
    const limit = Math.min(poolRows.length, row + Math.ceil(comment.partSize));
    for (let i = row; i < limit; i++) poolRows[i] = comment;
  }

  function unmarkDanmakuRows(rows, pool, mode) {
    const poolRows = rows[pool]?.[mode] || rows[0][mode];
    for (let i = 0; i < poolRows.length; i++) poolRows[i] = null;
  }

  function testDanmakuFreeRow(rows, comment, row, width, height, reserveBlank) {
    let free = 0;
    const rowMax = height - reserveBlank;
    const modeRows = rows[comment.pool]?.[comment.mode] || rows[0][comment.mode];
    let targetRow = null;
    if (comment.mode === 1 || comment.mode === 2) {
      while (row < rowMax && free < comment.partSize) {
        if (targetRow !== modeRows[row]) {
          targetRow = modeRows[row];
          if (targetRow !== null && targetRow.progress + targetRow.duration - 0.1 > comment.progress) break;
        }
        row++;
        free++;
      }
    } else {
      let div = comment.maxLen + width;
      const thresholdTime = div !== 0 ? comment.progress - comment.duration * (1 - width / div) : comment.progress - comment.duration;
      while (row < rowMax && free < comment.partSize) {
        if (targetRow !== modeRows[row]) {
          targetRow = modeRows[row];
          if (targetRow !== null) {
            div = targetRow.maxLen + width;
            if (div !== 0 && (targetRow.progress > thresholdTime || targetRow.progress + targetRow.maxLen * targetRow.duration / div > comment.progress)) break;
          }
        }
        row++;
        free++;
      }
    }
    return free;
  }

  function findAlternativeDanmakuRow(rows, comment, height, reserveBlank) {
    const modeRows = rows[comment.pool]?.[comment.mode] || rows[0][comment.mode];
    let result = 0;
    const rowLimit = height - reserveBlank - Math.ceil(comment.partSize);
    for (let row = 0; row < rowLimit; row++) {
      if (modeRows[row] === null) return row;
      if (modeRows[row].progress < modeRows[result].progress) result = row;
    }
    return result;
  }

  function placeDanmakuComment(rows, comment, width, height, reserveBlank) {
    let row = 0;
    const rowMax = height - reserveBlank - comment.partSize;
    if (rowMax <= 0) {
      if (comment.mode === 0 || comment.mode === 3) {
        comment.row = Math.round((height - reserveBlank) / 2);
        comment.align = 4;
      } else {
        comment.row = 0;
      }
      return;
    }

    let occupied = true;
    while (row <= rowMax) {
      const freeRow = testDanmakuFreeRow(rows, comment, row, width, height, reserveBlank);
      if (freeRow >= comment.partSize) {
        markDanmakuRows(rows, comment, row);
        occupied = false;
        break;
      }
      row += freeRow || 1;
    }
    if (occupied) {
      row = findAlternativeDanmakuRow(rows, comment, height, reserveBlank);
      if (row === 0) unmarkDanmakuRows(rows, comment.pool, comment.mode);
      markDanmakuRows(rows, comment, row);
    }
    comment.row = row;
  }

  function danmakuItemsToAss(items, options = {}) {
    const width = Math.max(320, Math.round(Number(options.width || 1920)));
    const height = Math.max(240, Math.round(Number(options.height || 1080)));
    const reserveBlank = 0;
    const baseFontSize = Math.max(18, Math.round(width / 40));
    const lines = [];
    const header = [
      '[Script Info]',
      '; Generated by Biliarchiver userscript',
      'ScriptType: v4.00+',
      `PlayResX: ${width}`,
      `PlayResY: ${height}`,
      'WrapStyle: 2',
      'ScaledBorderAndShadow: yes',
      'YCbCr Matrix: TV.709',
      '',
      '[V4+ Styles]',
      'Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding',
      `Style: Danmaku,Microsoft YaHei,${baseFontSize},&H00FFFFFF,&H00FFFFFF,&H00000000,&H96000000,0,0,0,0,100,100,0,0,1,1.2,0,7,20,20,20,1`,
      '',
      '[Events]',
      'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
    ];

    const comments = [...items].sort((a, b) => Number(a.time || 0) - Number(b.time || 0) || Number(a.timestamp || 0) - Number(b.timestamp || 0));
    const rows = makeDanmakuRows(height, reserveBlank);

    for (const item of comments) {
      const start = Number(item.time || 0);
      const mode = danmakuInternalMode(item.mode);
      const duration = mode === 1 || mode === 2 ? 4.5 : 7.5;
      const end = start + duration;
      const fontSize = Math.max(14, Math.round(baseFontSize * clamp(item.fontsize / 25, 0.72, 1.6)));
      const rawContent = String(item.content || '');
      const parts = rawContent.split(/\r\n?|\n/);
      const comment = {
        progress: start,
        duration,
        content: rawContent,
        mode,
        pool: Math.max(0, Math.min(2, Math.floor(Number(item.pool || 0)))),
        size: fontSize,
        color: Number(item.color) >>> 0,
        row: 0,
        lines: Math.max(1, parts.length),
        partSize: Math.max(1, fontSize * Math.max(1, parts.length)),
        maxLen: estimateDanmakuTextWidth(rawContent, fontSize),
        align: 0,
        deltaL: 0,
      };
      placeDanmakuComment(rows, comment, width, height, reserveBlank);

      const colour = assColorFromRgbInt(comment.color);
      const blackOutline = assBlackOutlineOverride(comment.color);
      const text = escapeAssText(comment.content);
      const styles = [];
      if (comment.mode === 1) {
        if (comment.lines > 1) {
          styles.push(`\\pos(${Math.round((width - comment.maxLen) / 2)},${Math.round(comment.row)})`);
        } else {
          styles.push(`\\an8\\pos(${Math.round(width / 2)},${Math.round(comment.row)})`);
        }
      } else if (comment.mode === 2) {
        const y = height - reserveBlank - comment.row;
        if (comment.lines > 1) {
          styles.push(`\\an1\\pos(${Math.round((width - comment.maxLen) / 2)},${Math.round(y)})`);
        } else {
          styles.push(`\\an2\\pos(${Math.round(width / 2)},${Math.round(y)})`);
        }
      } else if (comment.mode === 3) {
        if (comment.align === 4) styles.push('\\an4');
        styles.push(`\\move(${Math.round(-comment.maxLen - 20 + comment.deltaL)},${Math.round(comment.row)},${Math.round(width + 20)},${Math.round(comment.row)})`);
      } else {
        if (comment.align === 4) styles.push('\\an4');
        styles.push(`\\move(${Math.round(width + 20 - comment.deltaL)},${Math.round(comment.row)},${Math.round(-comment.maxLen - 20)},${Math.round(comment.row)})`);
      }
      styles.push(`\\fs${fontSize}`);
      styles.push(`\\c${colour}`);
      if (blackOutline) styles.push(blackOutline);
      lines.push(`Dialogue: 0,${secondsToAssTime(start)},${secondsToAssTime(end)},Danmaku,,0000,0000,0000,,{${styles.join('')}}${text}`);
    }
    return `${header.concat(lines).join('\n')}\n`;
  }

  function selectedVideoResolution(selectedStreams) {
    const video = selectedStreams?.video || {};
    const width = Number(video.width || video.video_width || video.track_width || 1920);
    const height = Number(video.height || video.video_height || video.track_height || 1080);
    return {
      width: Number.isFinite(width) && width > 0 ? width : 1920,
      height: Number.isFinite(height) && height > 0 ? height : 1080,
    };
  }

  async function fetchDanmakuFiles(ctx, selectedStreams, settings = getSettings()) {
    const source = normaliseDanmakuSource(settings.danmakuSource);
    if (source === 'protobuf') return fetchDanmakuProtobufFiles(ctx, selectedStreams);
    return fetchDanmakuXmlFiles(ctx, selectedStreams);
  }

  async function fetchDanmakuXmlFiles(ctx, selectedStreams) {
    UI.setPhase('获取弹幕', 'ba-status-warn');
    UI.setStepIndeterminate('下载 XML 弹幕');
    const url = `https://api.bilibili.com/x/v1/dm/list.so?oid=${encodeURIComponent(ctx.cid)}`;
    const xmlText = await gmText(url, {
      headers: { Referer: buildSourceUrl(ctx), Origin: 'https://www.bilibili.com', Cookie: document.cookie || '' },
      timeout: 60000,
    });
    if (!String(xmlText || '').trim()) throw new Error('弹幕 XML 内容为空。');
    const baseName = safeFileName(`BiliBili-${ctx.bvid}_p${ctx.pageNumber}`);
    const files = [{
      name: `${baseName}.danmaku.xml`,
      blob: new Blob([xmlText], { type: 'application/xml;charset=utf-8' }),
      meta: { source: url, format: 'bilibili-xml' },
    }];

    const items = parseBiliDanmakuXml(xmlText);
    if (!items.length) {
      UI.log('弹幕 XML 中没有可转换条目,只上传原始 XML。');
      UI.setStep(100);
      return files;
    }
    const resolution = selectedVideoResolution(selectedStreams);
    UI.setDetail(`转换弹幕 ASS:${items.length} 条,画布 ${resolution.width}x${resolution.height}`);
    const assText = danmakuItemsToAss(items, resolution);
    const estimatedWorkingSet = Math.max(0, String(xmlText).length * 2 + assText.length * 2 + items.length * 360);
    const assName = `${baseName}.danmaku.ass`;
    files.push({
      name: assName,
      blob: new Blob([assText], { type: 'text/x-ssa;charset=utf-8' }),
      meta: {
        source: url,
        format: 'ass',
        danmaku_source: 'xml',
        danmaku_count: items.length,
        width: resolution.width,
        height: resolution.height,
        estimated_working_set_bytes: estimatedWorkingSet,
      },
    });
    UI.setStep(100);
    UI.log(`弹幕已保存:${baseName}.danmaku.xml,并转换为 ASS:${assName}(${items.length} 条)。`);
    return files;
  }

  async function fetchDanmakuProtobufFiles(ctx, selectedStreams) {
    UI.setPhase('获取弹幕', 'ba-status-warn');
    const duration = currentPageDurationSeconds(ctx);
    const segmentCount = Math.max(1, Math.ceil(duration / 360));
    const baseName = safeFileName(`BiliBili-${ctx.bvid}_p${ctx.pageNumber}`);
    const allItems = [];
    const sources = [];
    UI.log(`使用 protobuf 分段弹幕接口:预计 ${segmentCount} 个 6 分钟分包。`);
    for (let segmentIndex = 1; segmentIndex <= segmentCount; segmentIndex++) {
      const params = new URLSearchParams({
        type: '1',
        oid: String(ctx.cid),
        segment_index: String(segmentIndex),
      });
      if (ctx.aid) params.set('pid', String(ctx.aid));
      const url = `https://api.bilibili.com/x/v2/dm/web/seg.so?${params.toString()}`;
      UI.setDetail(`下载 protobuf 弹幕分包:${segmentIndex}/${segmentCount}`);
      const buffer = await gmArrayBuffer(url, {
        headers: { Referer: buildSourceUrl(ctx), Origin: 'https://www.bilibili.com', Cookie: document.cookie || '' },
        timeout: 60000,
      });
      const items = parseBiliDanmakuProto(buffer);
      allItems.push(...items);
      sources.push(url);
      UI.setStep((segmentIndex / segmentCount) * 70);
    }

    const items = dedupeDanmakuItems(allItems);
    const jsonText = JSON.stringify({
      source: 'https://api.bilibili.com/x/v2/dm/web/seg.so',
      bvid: ctx.bvid,
      aid: ctx.aid,
      cid: ctx.cid,
      page: ctx.pageNumber,
      duration,
      segment_count: segmentCount,
      danmaku_count: items.length,
      items,
    }, null, 2);
    const files = [{
      name: `${baseName}.danmaku.protobuf.json`,
      blob: new Blob([jsonText], { type: 'application/json;charset=utf-8' }),
      meta: { source: 'https://api.bilibili.com/x/v2/dm/web/seg.so', format: 'bilibili-protobuf-json', segment_count: segmentCount, danmaku_count: items.length },
    }];

    if (!items.length) {
      UI.log('protobuf 分段弹幕中没有可转换条目,只上传解析 JSON。');
      UI.setStep(100);
      return files;
    }
    const resolution = selectedVideoResolution(selectedStreams);
    UI.setDetail(`转换 protobuf 弹幕 ASS:${items.length} 条,画布 ${resolution.width}x${resolution.height}`);
    const assText = danmakuItemsToAss(items, resolution);
    const estimatedWorkingSet = Math.max(0, jsonText.length * 2 + assText.length * 2 + items.length * 360);
    const assName = `${baseName}.danmaku.ass`;
    files.push({
      name: assName,
      blob: new Blob([assText], { type: 'text/x-ssa;charset=utf-8' }),
      meta: {
        source: 'https://api.bilibili.com/x/v2/dm/web/seg.so',
        format: 'ass',
        danmaku_source: 'protobuf',
        danmaku_count: items.length,
        segment_count: segmentCount,
        width: resolution.width,
        height: resolution.height,
        estimated_working_set_bytes: estimatedWorkingSet,
      },
    });
    UI.setStep(100);
    UI.log(`protobuf 弹幕已保存:${baseName}.danmaku.protobuf.json,并转换为 ASS:${assName}(${items.length} 条,${segmentCount} 个分包)。`);
    return files;
  }

  function normalizeTitleForCompare(text) {
    return String(text ?? '')
      .normalize('NFKC')
      .replace(/[\u3000\s]+/g, ' ')
      .replace(/[:﹕︓]/g, ':')
      .trim();
  }

  function buildArchiveTitle(ctx) {
    const mainTitle = String(ctx.title || ctx.bvid).trim();
    const pageNumber = Number(ctx.pageNumber || 1);
    const pagePart = String(ctx.pagePart || `P${pageNumber}`).trim();
    const mainCmp = normalizeTitleForCompare(mainTitle);
    const partCmp = normalizeTitleForCompare(pagePart);
    if (!pagePart || partCmp === mainCmp || partCmp === `P${pageNumber}`) {
      return `${mainTitle} P${pageNumber}`;
    }
    return `${mainTitle} P${pageNumber} ${pagePart}`;
  }

  function buildMetadata(ctx, settings, uploadState = 'uploading') {
    const tags = ['BiliBili', 'video', ...ctx.tags.map(t => t.tag_name).filter(Boolean)];
    const creators = [];
    const mids = [];
    if (Array.isArray(ctx.view.staff) && ctx.view.staff.length) {
      for (const staff of ctx.view.staff) {
        if (staff.name && !creators.includes(staff.name)) creators.push(staff.name);
        if (staff.mid && !mids.includes(staff.mid)) mids.push(staff.mid);
      }
    } else {
      if (ctx.owner.name) creators.push(ctx.owner.name);
      if (ctx.owner.mid) mids.push(ctx.owner.mid);
    }
    const externalIds = [
      `urn:bilibili:video:aid:${ctx.aid}`,
      `urn:bilibili:video:bvid:${ctx.bvid}`,
      `urn:bilibili:video:cid:${ctx.cid}`,
      ...mids.map(mid => `urn:bilibili:video:mid:${mid}`),
    ];
    const date = ctx.pubdate ? new Date(ctx.pubdate * 1000).toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, '') : '';
    const source = buildSourceUrl(ctx);
    return xmlCharsLegalize({
      mediatype: 'movies',
      collection: settings.collection || 'opensource_movies',
      title: buildArchiveTitle(ctx),
      description: uploadState === 'uploaded' ? ctx.desc : `${ctx.identifier} uploading...`,
      creator: creators.length > 1 ? creators : (creators[0] || ctx.owner.name || 'unknown'),
      date,
      subject: tags.join('; '),
      'external-identifier': externalIds,
      'upload-state': uploadState,
      source,
      originalurl: source,
      scanner: APP.scanner,
    });
  }

  function iaMetadataHeaders(metadata) {
    const headers = {};
    let serial = 1;
    const add = (key, value) => {
      const normalizedKey = key.replace(/_/g, '--');
      headers[`x-archive-meta${String(serial).padStart(2, '0')}-${normalizedKey}`] = `uri(${encodeURIComponent(String(value ?? ''))})`;
      serial++;
    };
    for (const [key, value] of Object.entries(metadata)) {
      if (Array.isArray(value)) {
        for (const item of value) add(key, item);
      } else {
        add(key, value);
      }
    }
    return headers;
  }

  function normalizeMp4BoxExport(value) {
    const seen = new Set();
    const queue = [value];
    while (queue.length) {
      const item = queue.shift();
      if (!item || (typeof item !== 'object' && typeof item !== 'function') || seen.has(item)) continue;
      seen.add(item);
      if (typeof item.createFile === 'function') return item;
      queue.push(item.default, item.MP4Box, item.mp4box, item.exports);
    }
    return null;
  }

  function findLoadedMp4Box() {
    const uw = getUnsafeWindow();
    return normalizeMp4BoxExport(globalThis.MP4Box) ||
      normalizeMp4BoxExport(uw.MP4Box) ||
      normalizeMp4BoxExport(globalThis.mp4box) ||
      normalizeMp4BoxExport(uw.mp4box) ||
      null;
  }

  function exposeMp4Box(lib) {
    if (!lib?.createFile) return null;
    globalThis.MP4Box = lib;
    try { getUnsafeWindow().MP4Box = lib; } catch (_) {}
    return lib;
  }

  function getMp4Box() {
    const lib = findLoadedMp4Box();
    if (!lib?.createFile) {
      throw new Error('MP4Box.js 未加载;请检查网络/CDN,或确认脚本版本不再使用 @require。');
    }
    return exposeMp4Box(lib);
  }

  function evaluateMp4BoxBundle(code, sourceUrl) {
    const uw = getUnsafeWindow();
    const run = new Function('unsafeGlobal', `
      var exports = {};
      var module = { exports: exports };
      var self = unsafeGlobal || globalThis;
      var window = unsafeGlobal || globalThis;
      var global = unsafeGlobal || globalThis;
      ${code}
      return (typeof MP4Box !== 'undefined' && MP4Box) ||
        (typeof mp4box !== 'undefined' && mp4box) ||
        (module && module.exports) ||
        exports ||
        (window && window.MP4Box) ||
        (self && self.MP4Box) ||
        (globalThis && globalThis.MP4Box) ||
        null;
      //# sourceURL=${sourceUrl}
    `);
    return normalizeMp4BoxExport(run.call(globalThis, uw));
  }

  async function ensureMp4BoxLoaded() {
    const existing = findLoadedMp4Box();
    if (existing?.createFile) return exposeMp4Box(existing);

    const cdns = APP.mp4boxCdns || [];
    const errors = [];
    for (const url of cdns) {
      UI.log(`正在懒加载 MP4Box.js:${url}`);
      let code;
      try {
        const res = await gmRequest({
          method: 'GET',
          url,
          responseType: 'text',
          timeout: 30000,
        });
        code = typeof res.response === 'string' ? res.response : String(res.response || '');
      } catch (err) {
        errors.push(`${url} 下载失败:${err?.message || err}`);
        UI.log(`MP4Box.js 候选下载失败,尝试下一个:${err?.message || err}`);
        continue;
      }

      if (!code || code.length < 1000) {
        errors.push(`${url} 下载内容异常`);
        UI.log('MP4Box.js 下载内容异常,尝试下一个候选。');
        continue;
      }

      try {
        const lib = evaluateMp4BoxBundle(code, url);
        if (lib?.createFile) {
          exposeMp4Box(lib);
          UI.log(`MP4Box.js 已加载:${url}`);
          return lib;
        }
        errors.push(`${url} 未暴露 createFile`);
        UI.log('该 MP4Box.js 候选未暴露 createFile,尝试下一个。');
      } catch (err) {
        errors.push(`${url} 执行失败:${err?.message || err}`);
        UI.log(`MP4Box.js 候选执行失败,尝试下一个:${err?.message || err}`);
      }
    }

    throw new Error(`MP4Box.js 已下载/尝试但没有可用的 createFile。已尝试 ${cdns.length} 个候选:${errors.join(';')}`);
  }

  function cloneArrayBuffer(buffer) {
    if (buffer instanceof ArrayBuffer) return buffer.slice(0);
    if (ArrayBuffer.isView(buffer)) {
      return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
    }
    throw new Error('无法复制非 ArrayBuffer 数据。');
  }

  function boxToBuffer(box) {
    try {
      if (typeof DataStream !== 'undefined') {
        const stream = new DataStream(undefined, 0, DataStream.BIG_ENDIAN);
        box.write(stream);
        return stream.buffer.slice(0, stream.position || stream.byteOffset || stream.buffer.byteLength);
      }
    } catch (_) {}
    return null;
  }

  function cloneMp4BoxBox(box) {
    if (!box) return null;
    const bytes = boxToBuffer(box);
    if (!bytes || typeof MP4BoxStream === 'undefined' || typeof BoxParser === 'undefined') return box;
    try {
      const stream = new MP4BoxStream(bytes);
      const parsed = BoxParser.parseOneBox(stream);
      return parsed?.box || box;
    } catch (_) {
      return box;
    }
  }

  function findChildBox(box, names) {
    if (!box || !Array.isArray(names)) return null;
    for (const name of names) {
      if (box[name]) return box[name];
    }
    const children = [];
    if (Array.isArray(box.boxes)) children.push(...box.boxes);
    if (Array.isArray(box.entries)) children.push(...box.entries);
    for (const child of children) {
      if (names.includes(child?.type)) return child;
    }
    return null;
  }

  function findDescriptionBoxes(description, trackType) {
    const videoConfigNames = ['avcC', 'hvcC', 'av1C', 'vpcC', 'dvcC'];
    const audioConfigNames = ['esds', 'dac3', 'dec3', 'dfLa', 'dOps', 'wave'];
    const names = trackType === 'audio' ? audioConfigNames : videoConfigNames;
    const boxes = [];
    for (const name of names) {
      const box = findChildBox(description, [name]);
      if (box) boxes.push(cloneMp4BoxBox(box));
    }
    return boxes.filter(Boolean);
  }

  function sampleEntryType(track, sample) {
    const fromDesc = sample?.description?.type;
    if (fromDesc && /^[A-Za-z0-9 ]{4}$/.test(fromDesc)) return fromDesc;
    const codec = String(track?.codec || '').trim();
    if (codec.startsWith('avc1') || codec.startsWith('avc3')) return codec.slice(0, 4);
    if (codec.startsWith('hev1') || codec.startsWith('hvc1')) return codec.slice(0, 4);
    if (codec.startsWith('av01')) return 'av01';
    if (codec.startsWith('vp09')) return 'vp09';
    if (codec.startsWith('mp4a')) return 'mp4a';
    return track?.type === 'audio' ? 'mp4a' : 'avc1';
  }

  function sampleDataBuffer(sample) {
    const data = sample?.data;
    if (data instanceof ArrayBuffer) return data;
    if (ArrayBuffer.isView(data)) return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
    throw new Error('MP4Box.js 返回了无法识别的 sample.data。');
  }

  function parseMp4TrackSamples(buffer, wantedType, label, progressStart, progressWeight) {
    const MP4Box = getMp4Box();
    return new Promise((resolve, reject) => {
      const file = MP4Box.createFile();
      const samples = [];
      let readyInfo = null;
      let track = null;
      let done = false;

      const fail = (err) => {
        if (done) return;
        done = true;
        reject(err instanceof Error ? err : new Error(String(err)));
      };

      file.onError = (e) => fail(new Error(`${label} 解析失败:${e?.message || e}`));
      file.onReady = (info) => {
        readyInfo = info;
        const typedTracks = wantedType === 'video' ? info.videoTracks : info.audioTracks;
        track = (typedTracks && typedTracks[0]) || info.tracks?.find(t => t.type === wantedType);
        if (!track) return fail(new Error(`${label} 中没有 ${wantedType === 'video' ? '视频' : '音频'} track。`));
        UI.log(`${label} MP4 信息:track=${track.id}, codec=${track.codec || 'unknown'}, samples=${track.nb_samples || 'unknown'}`);
        file.setExtractionOptions(track.id, null, { nbSamples: 1000, rapAlignement: false });
        file.start();
      };
      file.onSamples = (id, user, batch) => {
        if (!track || id !== track.id) return;
        samples.push(...batch);
        const total = Number(track.nb_samples || 0);
        if (total > 0) {
          UI.setStep(clamp(progressStart + (samples.length / total) * progressWeight, 0, 100));
        }
      };

      try {
        const ab = cloneArrayBuffer(buffer);
        ab.fileStart = 0;
        file.appendBuffer(ab);
        file.flush();
        if (!readyInfo || !track) return fail(new Error(`${label} 未能解析出 moov/track 信息。`));
        if (!samples.length) return fail(new Error(`${label} 没有抽取到 sample;该 DASH 片段可能不是 MP4Box.js 可处理的 fMP4。`));
        done = true;
        resolve({ info: readyInfo, track, samples, file });
      } catch (err) {
        fail(err);
      }
    });
  }

  function buildTrackOptions(parsed, type, id) {
    const first = parsed.samples[0];
    const track = parsed.track;
    const desc = first.description;
    const options = {
      id,
      type: sampleEntryType(track, first),
      hdlr: type === 'video' ? 'vide' : 'soun',
      name: type === 'video' ? 'VideoHandler' : 'SoundHandler',
      language: track.language || 'und',
      timescale: track.timescale || first.timescale || (type === 'video' ? 90000 : 48000),
      duration: track.movie_duration || track.duration || 0,
      media_duration: track.duration || 0,
      nb_samples: parsed.samples.length,
      brands: ['isom', 'iso6', 'mp41'],
      description_boxes: findDescriptionBoxes(desc, type),
    };
    if (type === 'video') {
      options.width = Math.round(track.video?.width || track.track_width || desc?.width || 1920);
      options.height = Math.round(track.video?.height || track.track_height || desc?.height || 1080);
    } else {
      options.channel_count = track.audio?.channel_count || desc?.channel_count || 2;
      options.samplesize = track.audio?.sample_size || desc?.samplesize || desc?.sample_size || 16;
      const sampleRate = track.audio?.sample_rate || desc?.samplerate || desc?.sample_rate || 48000;
      options.samplerate = sampleRate > 65535 ? sampleRate : (sampleRate << 16);
      options.width = 0;
      options.height = 0;
    }
    const avcC = findChildBox(desc, ['avcC']);
    const hvcC = findChildBox(desc, ['hvcC']);
    if (avcC) {
      const buf = boxToBuffer(avcC);
      if (buf) options.avcDecoderConfigRecord = buf;
    }
    if (hvcC) {
      const buf = boxToBuffer(hvcC);
      if (buf) options.hevcDecoderConfigRecord = buf;
    }
    return options;
  }

  function addSampleCompat(output, trackId, sample) {
    const data = sampleDataBuffer(sample);
    const opts = {
      duration: sample.duration || 1,
      dts: sample.dts || 0,
      cts: Number.isFinite(sample.cts) ? sample.cts : (sample.dts || 0),
      is_sync: Boolean(sample.is_sync || sample.is_rap),
      is_leading: sample.is_leading || 0,
      depends_on: sample.depends_on || 0,
      is_depended_on: sample.is_depended_on || 0,
      has_redundancy: sample.has_redundancy || 0,
      degradation_priority: sample.degradation_priority || 0,
      subsamples: sample.subsamples,
    };
    try {
      return output.addSample(trackId, data, opts);
    } catch (err) {
      if (/Cannot read|undefined|byteLength|data/i.test(err?.message || String(err))) {
        return output.addSample(trackId, { data, ...opts });
      }
      throw err;
    }
  }

  function outputFileToArrayBuffer(output) {
    if (typeof output.getBuffer === 'function') {
      const buffer = output.getBuffer();
      if (buffer instanceof ArrayBuffer) return buffer;
      if (ArrayBuffer.isView(buffer)) return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
    }
    if (typeof DataStream !== 'undefined' && typeof output.write === 'function') {
      const stream = new DataStream(undefined, 0, DataStream.BIG_ENDIAN);
      output.write(stream);
      return stream.buffer.slice(0, stream.position || stream.byteOffset || stream.buffer.byteLength);
    }
    throw new Error('MP4Box.js 当前构建不支持 getBuffer/write,无法导出 MP4。');
  }

  async function mergeDashToMp4(videoBuffer, audioBuffer, ctx, settings) {
    UI.setPhase('合并音视频', 'ba-status-warn');
    UI.setDetail('正在用 MP4Box.js 抽取 DASH 视频/音频 sample 并重新封装为单个 MP4,不进行转码。');
    UI.setStep(0);
    await ensureMp4BoxLoaded();
    UI.log('加载 MP4Box.js muxer:无需 SharedArrayBuffer,不依赖 FFmpeg.wasm。');

    const videoParsed = parseMp4TrackSamples(videoBuffer, 'video', '视频流', 0, 35);
    const audioParsed = parseMp4TrackSamples(audioBuffer, 'audio', '音频流', 35, 25);
    const [video, audio] = await Promise.all([videoParsed, audioParsed]);
    UI.setStep(60);

    const MP4Box = getMp4Box();
    const output = MP4Box.createFile();
    if (typeof output.onError !== 'undefined') output.onError = (e) => { throw new Error(`MP4Box.js 输出失败:${e}`); };

    const videoTrackId = output.addTrack(buildTrackOptions(video, 'video', 1));
    const audioTrackId = output.addTrack(buildTrackOptions(audio, 'audio', 2));
    if (!videoTrackId || !audioTrackId) throw new Error('MP4Box.js 创建输出 track 失败;当前编码的 sample entry 可能暂不支持。');
    UI.log(`MP4Box.js 输出 track:video=${videoTrackId}, audio=${audioTrackId}`);

    const queue = [];
    for (const sample of video.samples) queue.push({ kind: 'video', time: (sample.dts || 0) / (sample.timescale || video.track.timescale || 1), sample });
    for (const sample of audio.samples) queue.push({ kind: 'audio', time: (sample.dts || 0) / (sample.timescale || audio.track.timescale || 1), sample });
    queue.sort((a, b) => a.time - b.time || (a.kind === 'video' ? -1 : 1));

    let added = 0;
    for (const item of queue) {
      addSampleCompat(output, item.kind === 'video' ? videoTrackId : audioTrackId, item.sample);
      added++;
      if (added % 500 === 0 || added === queue.length) {
        UI.setStep(60 + (added / queue.length) * 35);
        UI.setDetail(`MP4Box.js 正在写入 sample:${added} / ${queue.length}`);
        await sleep(0);
      }
    }

    const mp4Buffer = outputFileToArrayBuffer(output);
    UI.setStep(100);
    UI.log(`MP4Box.js 合并完成:${formatBytes(mp4Buffer.byteLength)},samples=${queue.length}`);
    return new Blob([mp4Buffer], { type: 'video/mp4' });
  }

  function mimeFromFileName(name) {
    const lower = name.toLowerCase();
    if (lower.endsWith('.mp4')) return 'video/mp4';
    if (lower.endsWith('.json')) return 'application/json; charset=utf-8';
    if (lower.endsWith('.srt')) return 'application/x-subrip; charset=utf-8';
    if (lower.endsWith('.ass') || lower.endsWith('.ssa')) return 'text/x-ssa; charset=utf-8';
    if (lower.endsWith('.xml')) return 'application/xml; charset=utf-8';
    if (lower.endsWith('.vtt')) return 'text/vtt; charset=utf-8';
    if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg';
    if (lower.endsWith('.png')) return 'image/png';
    if (lower.endsWith('.webp')) return 'image/webp';
    return 'application/octet-stream';
  }


  function xhrPutUpload(url, headers, blob, signal, onProgress) {
    return new Promise((resolve, reject) => {
      const xhr = new XMLHttpRequest();
      let started = false;
      let loaded = 0;
      let lastProgressAt = Date.now();
      let watchdog = null;

      const cleanup = () => {
        if (watchdog) clearInterval(watchdog);
      };

      try {
        xhr.open('PUT', url, true);
        xhr.responseType = 'text';
        xhr.timeout = 0;
        xhr.withCredentials = false;
        for (const [key, value] of Object.entries(headers || {})) {
          if (value === undefined || value === null || value === '') continue;
          xhr.setRequestHeader(key, String(value));
        }
      } catch (err) {
        cleanup();
        reject(err);
        return;
      }

      xhr.upload.onloadstart = () => {
        started = true;
        lastProgressAt = Date.now();
        UI.log('原生 XHR 上传已开始;如果浏览器允许 CORS,将显示真实上传进度。');
      };
      xhr.upload.onprogress = (ev) => {
        started = true;
        lastProgressAt = Date.now();
        if (ev.lengthComputable && ev.total > 0) {
          loaded = ev.loaded;
          onProgress?.(ev.loaded, ev.total, true);
        } else if (ev.loaded) {
          loaded = ev.loaded;
          onProgress?.(ev.loaded, blob.size || 0, false);
        }
      };
      xhr.onload = () => {
        cleanup();
        if (xhr.status >= 200 && xhr.status < 300) {
          resolve({ status: xhr.status, responseText: xhr.responseText || '' });
        } else {
          reject(new Error(`原生 XHR PUT HTTP ${xhr.status}: ${(xhr.responseText || xhr.statusText || '').slice(0, 500)}`));
        }
      };
      xhr.onerror = () => {
        cleanup();
        reject(new Error('原生 XHR 上传失败:可能是 IA S3 CORS/preflight 被拒,或网络连接被浏览器阻断。'));
      };
      xhr.onabort = () => {
        cleanup();
        reject(new Error('原生 XHR 上传被取消'));
      };
      xhr.ontimeout = () => {
        cleanup();
        reject(new Error('原生 XHR 上传超时'));
      };

      watchdog = setInterval(() => {
        const silentMs = Date.now() - lastProgressAt;
        if (!started && silentMs > 45000) {
          cleanup();
          try { xhr.abort(); } catch (_) {}
          reject(new Error('原生 XHR 上传 45 秒内没有开始发送数据,已切换备用上传通道。'));
        } else if (started && loaded === 0 && silentMs > 90000) {
          cleanup();
          try { xhr.abort(); } catch (_) {}
          reject(new Error('原生 XHR 上传 90 秒内没有上传任何字节,已切换备用上传通道。'));
        }
      }, 5000);

      if (signal) {
        signal.addEventListener('abort', () => {
          try { xhr.abort(); } catch (_) {}
        }, { once: true });
      }

      try {
        xhr.send(blob);
      } catch (err) {
        cleanup();
        reject(err);
      }
    });
  }

  async function gmPutUpload(url, headers, blob, signal, onProgress, options = {}) {
    await gmRequest({
      method: 'PUT',
      url,
      headers,
      data: blob,
      timeout: 0,
      fetch: options.fetch === true,
      anonymous: options.anonymous === true,
      signal,
      onprogress: (ev) => {
        if (ev.lengthComputable && ev.total > 0) {
          onProgress?.(ev.loaded, ev.total, true);
        } else if (ev.loaded) {
          onProgress?.(ev.loaded, blob.size || 0, false);
        }
      },
    });
  }

  async function uploadToIa(identifier, fileName, blob, settings, metadata, isFirstFile, progressBase, progressWeight) {
    const url = `${APP.iaS3Base}/${encodeURIComponent(identifier)}/${encodeURIComponent(fileName)}`;
    const headers = {
      Authorization: `LOW ${settings.iaAccessKey}:${settings.iaSecretKey}`,
      'Content-Type': mimeFromFileName(fileName),
      'x-archive-queue-derive': settings.queueDerive ? '1' : '0',
      'x-archive-keep-old-version': '1',
      ...(isFirstFile ? { 'x-archive-auto-make-bucket': '1', 'x-archive-size-hint': String(blob.size || 0), ...iaMetadataHeaders(metadata) } : {}),
    };
    UI.setPhase(`上传 ${fileName}`, 'ba-status-warn');
    UI.setDetail(`${fileName}:${formatBytes(blob.size)}`);
    UI.setStepIndeterminate('准备上传');
    UI.setTotal(progressBase);
    UI.log(`PUT ${fileName} -> ${identifier}`);
    UI.log('上传通道:优先使用原生 XMLHttpRequest(可显示真实 upload 进度);失败后自动切换 Tampermonkey fetch/GM 备用通道。');

    const uploadStartedAt = Date.now();
    let sawComputableProgress = false;
    let lastLoaded = 0;
    let lastTotal = blob.size || 0;
    let currentTransport = '准备中';
    let lastUploadDetailText = '';
    const setUploadDetail = (text) => {
      if (text === lastUploadDetailText) return;
      lastUploadDetailText = text;
      UI.setDetail(text);
    };
    const formatUploadProgressDetail = () => `${fileName}:${formatBytes(lastLoaded)} / ${formatBytes(lastTotal)}(${currentTransport})`;
    const updateProgress = (loaded, total, computable) => {
      lastLoaded = Number(loaded || 0);
      lastTotal = Number(total || lastTotal || blob.size || 0);
      if (computable && lastTotal > 0) {
        sawComputableProgress = true;
        const pct = clamp(lastLoaded / lastTotal, 0, 1);
        UI.setStep(pct * 100);
        UI.setTotal(progressBase + pct * progressWeight);
        setUploadDetail(formatUploadProgressDetail());
      } else if (lastLoaded > 0) {
        UI.setStepIndeterminate('上传中');
        setUploadDetail(`${fileName}:已发送至少 ${formatBytes(lastLoaded)}(${currentTransport})`);
      }
    };

    const heartbeat = setInterval(() => {
      const elapsed = Math.max(1, Math.round((Date.now() - uploadStartedAt) / 1000));
      if (!sawComputableProgress && lastLoaded <= 0) {
        UI.setStepIndeterminate('上传中');
        setUploadDetail(`${fileName}:${formatBytes(blob.size)},${currentTransport},已等待 ${elapsed}s;若系统上行长期接近 0,说明通道可能未真正发送 body。`);
      } else if (!sawComputableProgress) {
        UI.setStepIndeterminate('上传中');
        setUploadDetail(`${fileName}:已发送至少 ${formatBytes(lastLoaded)},${currentTransport},已等待 ${elapsed}s`);
      } else if (currentTransport === '原生 XHR') {
        // XHR 已经能给出真实 upload progress 时,界面只保留字节数/进度条;
        // 这里必须与 updateProgress 使用完全相同的文案,避免 heartbeat 与 progress 事件抢写造成闪烁。
        setUploadDetail(formatUploadProgressDetail());
      } else {
        setUploadDetail(`${fileName}:${formatBytes(lastLoaded)} / ${formatBytes(lastTotal)},${currentTransport},已等待 ${elapsed}s`);
      }
    }, 5000);

    try {
      const transports = [
        {
          name: '原生 XHR',
          run: () => xhrPutUpload(url, headers, blob, state.currentTask?.abortController?.signal, updateProgress),
        },
        {
          name: 'Tampermonkey fetch',
          run: () => gmPutUpload(url, headers, blob, state.currentTask?.abortController?.signal, updateProgress, { fetch: true, anonymous: true }),
        },
        {
          name: 'Tampermonkey GM_xmlhttpRequest',
          run: () => gmPutUpload(url, headers, blob, state.currentTask?.abortController?.signal, updateProgress, { fetch: false }),
        },
      ];
      let lastErr = null;
      for (let i = 0; i < transports.length; i++) {
        const transport = transports[i];
        currentTransport = transport.name;
        UI.log(`尝试上传通道:${transport.name}`);
        UI.setStepIndeterminate('上传中');
        try {
          await transport.run();
          lastErr = null;
          break;
        } catch (err) {
          lastErr = err;
          const msg = err?.message || String(err);
          if (/被取消|abort/i.test(msg)) throw err;
          UI.log(`${transport.name} 上传失败:${msg}`);
          if (i < transports.length - 1) {
            UI.log('切换到下一个上传通道……');
            lastLoaded = 0;
            sawComputableProgress = false;
          }
        }
      }
      if (lastErr) throw lastErr;
    } finally {
      clearInterval(heartbeat);
    }
    UI.setStep(100);
    UI.setTotal(progressBase + progressWeight);
    UI.log(`上传完成:${fileName}`);
  }

  async function modifyMetadataUploaded(identifier, ctx, settings) {
    UI.setPhase('更新 IA 元数据', 'ba-status-warn');
    UI.setStep(0);
    const source = buildSourceUrl(ctx);
    const patch = [
      { op: 'add', path: '/upload-state', value: 'uploaded' },
      { op: 'add', path: '/description', value: xmlCharsLegalizeString(ctx.desc || '') },
      { op: 'add', path: '/source', value: source },
      { op: 'add', path: '/originalurl', value: source },
      { op: 'add', path: '/scanner', value: APP.scanner },
    ];
    const body = new URLSearchParams();
    body.set('-target', 'metadata');
    body.set('-patch', JSON.stringify(patch));
    body.set('access', settings.iaAccessKey);
    body.set('secret', settings.iaSecretKey);
    const res = await gmJson(`${APP.iaMetaBase}/${encodeURIComponent(identifier)}`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
        Authorization: `LOW ${settings.iaAccessKey}:${settings.iaSecretKey}`,
        Origin: 'https://archive.org',
      },
      data: body.toString(),
      timeout: 60000,
    });
    if (res.success !== true) throw new Error(`IA 元数据更新失败:${res.error || JSON.stringify(res)}`);
    UI.setStep(100);
    UI.log(`元数据更新任务已提交:${res.task_id}`);
  }

  async function fetchCoverBlob(ctx) {
    if (!ctx.pic) return null;
    try {
      const blob = await gmBlob(ctx.pic, { timeout: 60000 });
      const suffix = new URL(ctx.pic).pathname.split('.').pop()?.split('?')[0] || 'jpg';
      return { blob, suffix: suffix.toLowerCase() };
    } catch (err) {
      UI.log(`封面下载失败,跳过封面:${err.message}`);
      return null;
    }
  }

  function buildInfoJson(ctx, playInfo, selectedStreams, subtitleSummary = [], danmakuSummary = [], repliesSummary = null) {
    const source = buildSourceUrl(ctx);
    return JSON.stringify({
      generated_at: nowIso(),
      generator: APP.scanner,
      source,
      original_url: source,
      identifier: ctx.identifier,
      data: {
        View: ctx.view,
        Tags: ctx.tags,
        SelectedStreams: {
          video: sanitizeMediaForJson(selectedStreams.video),
          audio: sanitizeMediaForJson(selectedStreams.audio),
        },
        PlayInfoSummary: {
          format: playInfo.format,
          quality: playInfo.quality,
          accept_quality: playInfo.accept_quality,
          support_formats: playInfo.support_formats,
        },
        Subtitles: subtitleSummary,
        Danmaku: danmakuSummary,
        Replies: repliesSummary,
      },
    }, null, 2);
  }

  function sanitizeMediaForJson(media) {
    if (!media) return null;
    const clone = { ...media };
    for (const key of ['baseUrl', 'base_url', 'url', 'backupUrl', 'backup_url']) {
      if (clone[key]) clone[key] = '[redacted: signed media url]';
    }
    return clone;
  }

  async function runUploadCurrentVideo() {
    if (state.busy) {
      UI.toast('已有上传任务正在运行。', true);
      return;
    }
    const settings = getSettings();
    UI.show();
    UI.clearLog();
    UI.setTotal(0);
    UI.setStep(0);
    UI.setActions([]);

    if (!settings.iaAccessKey || !settings.iaSecretKey) {
      UI.toast('请先配置 Internet Archive S3 Access Key / Secret Key。', true);
      openSettingsModal();
      return;
    }

    state.busy = true;
    await WakeLockManager.acquire();
    let ctx = null;
    let videoBuffer = null;
    let audioBuffer = null;
    let mp4Blob = null;
    let infoBlob = null;
    let cover = null;
    let files = [];
    let subtitleFiles = [];
    let danmakuFiles = [];
    let replyFile = null;
    try {
      UI.setPhase('读取当前视频', 'ba-status-warn');
      UI.setDetail('正在识别 BV、分 P、cid 与 Bilibili 元数据。');
      ctx = await getCurrentVideoContext();
      state.currentTask = { bvid: ctx.bvid, pageNumber: ctx.pageNumber, identifier: ctx.identifier, startedAt: nowIso() };
      GM_setValue(storageKey('last_task'), state.currentTask);
      UI.setSubtitle(`${ctx.bvid} P${ctx.pageNumber}`);
      UI.log(`当前视频:${ctx.title} / P${ctx.pageNumber} ${ctx.pagePart}`);
      UI.log(`IA identifier:${ctx.identifier}`);
      UI.setTotal(4);

      UI.setPhase('检查 IA identifier', 'ba-status-warn');
      const idCheck = await checkIdentifier(ctx.identifier);
      const available = idCheck.code === 'available';
      if (!available && !settings.overwriteExisting) {
        throw new Error(`IA item 已存在,且未开启“允许更新已存在 item”:${ctx.identifier}`);
      }
      UI.log(available ? 'IA identifier 可用。' : 'IA identifier 已存在,将按设置继续上传/更新。');
      UI.setTotal(8);

      UI.setPhase('解析播放流', 'ba-status-warn');
      const playInfo = await getPlayInfo(ctx, settings);
      const selected = selectDashStreams(playInfo, settings);
      UI.log(`选择视频流:${qualityLabel(streamQuality(selected.video))},编码=${streamCodecLabel(selected.video)},bandwidth=${selected.video.bandwidth || 'unknown'}`);
      UI.log(`选择音频流:id=${selected.audio.id}, codec=${selected.audio.codecs || selected.audio.codec || 'unknown'}, bandwidth=${selected.audio.bandwidth || 'unknown'}`);
      UI.setTotal(12);

      const videoUrl = streamUrl(selected.video);
      const audioUrl = streamUrl(selected.audio);
      const controller = new AbortController();
      UI.setActions([{ text: '取消任务', className: 'ba-btn-danger', onclick: () => controller.abort() }]);

      UI.setPhase('下载视频流', 'ba-status-warn');
      videoBuffer = await gmArrayBuffer(videoUrl, {
        signal: controller.signal,
        onprogress: ev => {
          if (ev.lengthComputable) {
            const p = ev.loaded / ev.total;
            UI.setStep(p * 100);
            UI.setTotal(12 + p * 18);
            UI.setDetail(`视频流:${formatBytes(ev.loaded)} / ${formatBytes(ev.total)}`);
          }
        },
      });
      UI.log(`视频流下载完成:${formatBytes(videoBuffer.byteLength)}`);
      UI.setTotal(30);

      UI.setPhase('下载音频流', 'ba-status-warn');
      audioBuffer = await gmArrayBuffer(audioUrl, {
        signal: controller.signal,
        onprogress: ev => {
          if (ev.lengthComputable) {
            const p = ev.loaded / ev.total;
            UI.setStep(p * 100);
            UI.setTotal(30 + p * 14);
            UI.setDetail(`音频流:${formatBytes(ev.loaded)} / ${formatBytes(ev.total)}`);
          }
        },
      });
      UI.log(`音频流下载完成:${formatBytes(audioBuffer.byteLength)}`);
      UI.setTotal(44);

      mp4Blob = await mergeDashToMp4(videoBuffer, audioBuffer, ctx, settings);
      videoBuffer = null;
      audioBuffer = null;
      UI.log('已释放 DASH 原始音视频缓冲引用,等待浏览器按需回收内存。');
      UI.setTotal(68);

      UI.setPhase('准备随附文件', 'ba-status-warn');
      const metadata = buildMetadata(ctx, settings, 'uploading');
      UI.log(`IA title metadata:${metadata.title}`);
      UI.log(`IA source metadata:${metadata.source}`);
      const baseName = safeFileName(`BiliBili-${ctx.bvid}_p${ctx.pageNumber}`);
      subtitleFiles = await fetchSubtitleFiles(ctx).catch(err => {
        UI.log(`CC 字幕获取失败,继续上传其它文件:${err.message || err}`);
        return [];
      });
      replyFile = await fetchReplyFile(ctx).catch(err => {
        UI.log(`视频评论获取失败,继续上传其它文件:${err.message || err}`);
        return null;
      });
      danmakuFiles = await fetchDanmakuFiles(ctx, selected, settings).catch(err => {
        UI.log(`弹幕获取/转换失败,继续上传其它文件:${err.message || err}`);
        return [];
      });
      const subtitleSummary = subtitleFiles.map(item => item.meta || { name: item.name });
      const danmakuSummary = danmakuFiles.map(item => item.meta || { name: item.name });
      const repliesSummary = replyFile?.meta || null;
      const infoJson = buildInfoJson(ctx, playInfo, selected, subtitleSummary, danmakuSummary, repliesSummary);
      infoBlob = new Blob([infoJson], { type: 'application/json;charset=utf-8' });
      files = [];
      files.push({ name: `${baseName}.mp4`, blob: mp4Blob });
      files.push({ name: `${baseName}.info.json`, blob: infoBlob });
      for (const subtitle of subtitleFiles) files.push({ name: subtitle.name, blob: subtitle.blob });
      if (replyFile) files.push({ name: replyFile.name, blob: replyFile.blob });
      for (const danmaku of danmakuFiles) files.push({ name: danmaku.name, blob: danmaku.blob });

      cover = await fetchCoverBlob(ctx);
      if (cover) {
        files.push({ name: `${ctx.bvid}_p${ctx.pageNumber}.${cover.suffix}`, blob: cover.blob });
        files.push({ name: `${ctx.bvid}_p${ctx.pageNumber}_itemimage.${cover.suffix}`, blob: cover.blob });
      }
      UI.log(`待上传文件:${files.map(f => `${f.name}(${formatBytes(f.blob.size)})`).join(', ')}`);
      UI.setTotal(70);

      const uploadWeight = 24 / files.length;
      for (let i = 0; i < files.length; i++) {
        const file = files[i];
        await uploadToIa(ctx.identifier, file.name, file.blob, settings, metadata, i === 0, 70 + i * uploadWeight, uploadWeight);
      }

      await modifyMetadataUploaded(ctx.identifier, ctx, settings);
      UI.setTotal(98);
      UI.setPhase('验证上传结果', 'ba-status-warn');
      await sleep(2000);
      const iaMeta = await gmJson(`${APP.iaMetaBase}/${encodeURIComponent(ctx.identifier)}`, { headers: { Origin: 'https://archive.org' }, timeout: 60000 });
      const uploadedNames = new Set((iaMeta.files || []).map(f => f.name));
      const missing = files.filter(f => !uploadedNames.has(f.name)).map(f => f.name);
      if (missing.length) UI.log(`IA metadata 暂未列出这些文件,可能仍在入库队列中:${missing.join(', ')}`);
      UI.setTotal(100);
      const detailsUrl = `https://archive.org/details/${ctx.identifier}`;
      UI.setPhase('上传完成', 'ba-status-good');
      UI.setDetail(`完成:${detailsUrl}`);
      UI.log(`完成:${detailsUrl}`);
      UI.setActions([
        { text: '打开 IA 页面', className: 'ba-btn-good', onclick: () => window.open(detailsUrl, '_blank', 'noopener') },
        { text: '复制链接', className: 'ba-btn-primary', onclick: () => navigator.clipboard?.writeText(detailsUrl) },
        { text: '隐藏', onclick: () => document.getElementById('biliarchiver-popup')?.classList.add('ba-hidden') },
      ]);
      GM_setValue(storageKey('last_task'), { ...state.currentTask, finishedAt: nowIso(), status: 'finished', url: detailsUrl });
    } catch (err) {
      const message = err?.message || String(err);
      UI.setPhase('上传失败', 'ba-status-bad');
      UI.setDetail(message);
      UI.log(`错误:${message}`);
      UI.setActions([
        { text: '重试当前视频', className: 'ba-btn-primary', onclick: () => runUploadCurrentVideo() },
        { text: '打开设置', onclick: () => openSettingsModal() },
      ]);
      if (ctx) GM_setValue(storageKey('last_task'), { ...state.currentTask, failedAt: nowIso(), status: 'failed', error: message });
    } finally {
      const hadLargeRefs = Boolean(videoBuffer || audioBuffer || mp4Blob || infoBlob || cover || replyFile || files.length || subtitleFiles.length || danmakuFiles.length);
      try {
        if (Array.isArray(files)) files.length = 0;
        if (Array.isArray(subtitleFiles)) subtitleFiles.length = 0;
        if (Array.isArray(danmakuFiles)) danmakuFiles.length = 0;
        videoBuffer = null;
        audioBuffer = null;
        mp4Blob = null;
        infoBlob = null;
        cover = null;
        replyFile = null;
        if (hadLargeRefs) UI.log('已清理残留引用');
      } catch (_) {}
      state.busy = false;
      await WakeLockManager.release();
    }
  }

  function openSettingsModal() {
    UI.ensure();
    const settings = getSettings();
    const old = document.getElementById('biliarchiver-modal');
    if (old) old.remove();
    const modal = document.createElement('div');
    modal.id = 'biliarchiver-modal';
    modal.innerHTML = `
      <div class="ba-modal-box">
        <h3>配置 Biliarchiver</h3>
        <div class="ba-line">IA 密钥只保存在 Tampermonkey 本地存储中;不要在不可信浏览器环境中使用。</div>
        <div class="ba-field">
          <label>Internet Archive S3 Access Key</label>
          <input class="ba-input" id="ba-set-access" autocomplete="off" value="${escapeHtml(settings.iaAccessKey)}">
        </div>
        <div class="ba-field">
          <label>Internet Archive S3 Secret Key</label>
          <input class="ba-input" id="ba-set-secret" type="password" autocomplete="off" value="${escapeHtml(settings.iaSecretKey)}">
        </div>
        <div class="ba-field">
          <label>IA collection</label>
          <input class="ba-input" id="ba-set-collection" value="${escapeHtml(settings.collection)}">
        </div>
        <div class="ba-field">
          <label>目标清晰度</label>
          <select class="ba-select" id="ba-set-qn">
            ${QUALITY_OPTIONS.map(item => `<option value="${item.qn}" ${normaliseTargetQn(settings.qn) === item.qn ? 'selected' : ''}>${item.label} / qn=${item.qn}</option>`).join('')}
          </select>
          <div class="ba-line">DASH 会返回多条流;这里是选择器目标值,脚本优先选目标清晰度,缺失时向下回退。</div>
        </div>
        <div class="ba-field">
          <label>视频编码偏好</label>
          <select class="ba-select" id="ba-set-codec">
            <option value="av1" ${settings.codecPreference === 'av1' ? 'selected' : ''}>AV1 优先,默认推荐</option>
            <option value="hevc" ${settings.codecPreference === 'hevc' ? 'selected' : ''}>HEVC/H.265 优先,体积通常较小</option>
            <option value="avc" ${settings.codecPreference === 'avc' ? 'selected' : ''}>AVC/H.264 优先,兼容性最高</option>
            <option value="bandwidth" ${settings.codecPreference === 'bandwidth' ? 'selected' : ''}>同清晰度内最高码率优先</option>
          </select>
        </div>
        <div class="ba-field">
          <label>弹幕来源</label>
          <select class="ba-select" id="ba-set-danmaku-source">
            <option value="xml" ${normaliseDanmakuSource(settings.danmakuSource) === 'xml' ? 'selected' : ''}>XML 实时弹幕池(默认)</option>
            <option value="protobuf" ${normaliseDanmakuSource(settings.danmakuSource) === 'protobuf' ? 'selected' : ''}>protobuf 分段接口(6 分钟一包)</option>
          </select>
          <div class="ba-line">XML 保持旧行为;protobuf 会遍历 6 分钟分包,通常可取得更多弹幕。</div>
        </div>
        <label class="ba-check"><input type="checkbox" id="ba-set-derive" ${settings.queueDerive ? 'checked' : ''}> 上传后请求 IA 派生文件</label>
        <label class="ba-check"><input type="checkbox" id="ba-set-overwrite" ${settings.overwriteExisting ? 'checked' : ''}> 允许更新已存在的 IA item</label>
        <label class="ba-check"><input type="checkbox" id="ba-set-strict" ${settings.strictDashMerge ? 'checked' : ''} disabled> 严格要求 DASH 音视频合并后才上传</label>
        <div class="ba-modal-actions">
          <button class="ba-btn" id="ba-set-cancel">取消</button>
          <button class="ba-btn ba-btn-danger" id="ba-set-clear">清除密钥</button>
          <button class="ba-btn ba-btn-primary" id="ba-set-save">保存</button>
        </div>
      </div>`;
    document.documentElement.appendChild(modal);
    document.getElementById('ba-set-cancel').onclick = () => modal.remove();
    document.getElementById('ba-set-clear').onclick = () => {
      setSetting('iaAccessKey', '');
      setSetting('iaSecretKey', '');
      document.getElementById('ba-set-access').value = '';
      document.getElementById('ba-set-secret').value = '';
      UI.log('已清除 IA 密钥。');
    };
    document.getElementById('ba-set-save').onclick = () => {
      setSetting('iaAccessKey', document.getElementById('ba-set-access').value.trim());
      setSetting('iaSecretKey', document.getElementById('ba-set-secret').value.trim());
      setSetting('collection', document.getElementById('ba-set-collection').value.trim() || DEFAULT_SETTINGS.collection);
      setSetting('qn', normaliseTargetQn(document.getElementById('ba-set-qn').value));
      setSetting('codecPreference', document.getElementById('ba-set-codec').value);
      setSetting('danmakuSource', normaliseDanmakuSource(document.getElementById('ba-set-danmaku-source').value));
      setSetting('queueDerive', document.getElementById('ba-set-derive').checked);
      setSetting('overwriteExisting', document.getElementById('ba-set-overwrite').checked);
      setSetting('strictDashMerge', true);
      modal.remove();
      UI.toast('设置已保存。');
    };
  }

  function showLastTask() {
    UI.show();
    UI.clearLog();
    const task = GM_getValue(storageKey('last_task'));
    if (!task) {
      UI.setPhase('暂无任务记录');
      UI.setDetail('还没有上传任务。');
      return;
    }
    UI.setPhase(`最近任务:${task.status || 'unknown'}`);
    UI.setDetail(`${task.bvid || ''} P${task.pageNumber || ''} / ${task.identifier || ''}`);
    UI.log(JSON.stringify(task, null, 2));
    if (task.url) UI.setActions([{ text: '打开 IA 页面', className: 'ba-btn-good', onclick: () => window.open(task.url, '_blank', 'noopener') }]);
  }

  function registerMenus() {
    if (typeof GM_registerMenuCommand !== 'function') {
      console.error('[Biliarchiver] GM_registerMenuCommand 不可用:请确认脚本 @grant 未被修改。');
      return;
    }
    GM_registerMenuCommand('上传当前视频到 Internet Archive', runUploadCurrentVideo);
    GM_registerMenuCommand('配置 IA 密钥与上传选项', openSettingsModal);
    GM_registerMenuCommand('查看最近上传任务', showLastTask);
    GM_registerMenuCommand('显示上传 popup', () => UI.show());
  }

  try {
    registerMenus();
    UI.ensure();
    console.info(`[Biliarchiver] ${APP.version} 初始化完成,菜单已注册。`);
  } catch (err) {
    console.error('[Biliarchiver] 初始化失败:', err);
  }
})();