Biliarchiver

一键上传b站视频到webarchive

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

Advertisement:

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

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);
  }
})();