// ==UserScript==
// @name 光鸭助手
// @namespace guangyaHelper
// @version 0.1.5
// @description 光鸭云盘的辅助助手插件,支持批量整理电影、剧集、批量重命名、导入导出秒传文件,支持云雷云盘导出秒传文件。
// @author 唐生
// @match https://guangyapan.com/*
// @match https://*.guangyapan.com/*
// @match https://pan.xunlei.com/*
// @icon https://www.guangyapan.com/icon.png
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_info
// @connect *
// @connect api.themoviedb.org
// @run-at document-start
// @noframes
// @license Apache-2.0
// ==/UserScript==
(function () {
'use strict';
const ROOT_ID = 'guangya-helper-root';
const STYLE_ID = 'guangya-helper-styles';
const isXunleiPan = /^pan\.xunlei\.com$/i.test(window.location.hostname);
const TMDB_KEY_STORAGE = 'gyh-tmdb-key';
const MOVIE_NAMING_RULE_STORAGE = 'gyh-movie-naming-rule';
const TV_NAMING_RULE_STORAGE = 'gyh-tv-naming-rule';
const ACE_CORE_URL = 'https://s4.zstatic.net/ajax/libs/ace/1.43.2/ace.min.js';
const ACE_TOOLS_URL = 'https://s4.zstatic.net/ajax/libs/ace/1.43.2/ext-language_tools.min.js';
const ACE_MODE_JSON_URL = 'https://s4.zstatic.net/ajax/libs/ace/1.43.2/mode-json.js';
const ACE_THEME_TOMORROW_URL = 'https://s4.zstatic.net/ajax/libs/ace/1.43.2/theme-tomorrow.js';
const ACE_BASE_URL = 'https://s4.zstatic.net/ajax/libs/ace/1.43.2/';
const XUNLEI_API_BASE = 'https://api-pan.xunlei.com';
const XUNLEI_CLIENT_ID = 'Xqp0kJBXWhwaTpB6';
const API = Object.freeze({
tmdb: 'https://api.themoviedb.org/3',
guangya: 'https://api.guangyapan.com',
xunlei: XUNLEI_API_BASE,
});
const VIDEO_EXTENSION_RE = /\.(mp4|mkv|avi|mov|wmv|flv|webm|m4v|ts|m2ts|mpg|mpeg)$/i;
const MIN_MEDIA_SIZE = 10 * 1024 * 1024;
const strokeIcon = (content, width = '1.8') => ``;
const ICONS = Object.freeze({
close: strokeIcon(''),
back: strokeIcon(''),
import: strokeIcon(''),
uploadFile: strokeIcon(''),
export: strokeIcon(''),
movie: strokeIcon(''),
tv: strokeIcon(''),
rename: strokeIcon(''),
renameAction: strokeIcon(''),
transfer: strokeIcon(''),
assistant: strokeIcon(''),
correct: strokeIcon(''),
details: strokeIcon(''),
tmdb: strokeIcon(''),
grip: '',
remove: strokeIcon('', '2'),
info: strokeIcon('', '2'),
success: strokeIcon('', '2'),
error: strokeIcon('', '2'),
});
let aceLoadPromise = null;
const xunleiContext = { token: '', deviceId: '', captchaToken: '', clientId: '', shareToken: '' };
let xunleiRetryNotifier = null;
if (isXunleiPan) installXunleiRequestCapture();
const SCRIPT_VERSION = typeof GM_info !== 'undefined' && GM_info?.script?.version ? GM_info.script.version : '0.1.0';
const styles = ` #${ROOT_ID}{position:fixed;top:50%;right:22px;z-index:2147483000;transform:translateY(-50%);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;}#${ROOT_ID} *,#${ROOT_ID} *::before,#${ROOT_ID} *::after{box-sizing:border-box;}.gyh-trigger{width:52px;height:52px;padding:0;color:#ffffff;background:linear-gradient(145deg,#2e82f5,#245fd2);border:1px solid rgba(255,255,255,0.35);border-radius:16px;box-shadow:0 10px 28px rgba(36,95,210,0.3),0 2px 7px rgba(18,51,106,0.18);cursor:pointer;display:grid;place-items:center;align-content:center;gap:1px;transition:transform 180ms ease,box-shadow 180ms ease,background 180ms ease;}.gyh-trigger:hover{background:linear-gradient(145deg,#4296ff,#2869e6);box-shadow:0 13px 32px rgba(36,95,210,0.36),0 3px 9px rgba(18,51,106,0.2);transform:translateY(-2px);}.gyh-trigger:focus-visible,.gyh-action:focus-visible{outline:3px solid rgba(45,130,245,0.35);outline-offset:3px;}.gyh-trigger svg{width:21px;height:21px;transition:transform 200ms ease;}.gyh-trigger-label{font-size:10px;font-weight:650;line-height:13px;letter-spacing:0;}.gyh-trigger[aria-expanded="true"] svg{transform:rotate(-10deg) scale(0.93);}.gyh-panel{position:absolute;top:50%;right:64px;width:264px;padding:14px;color:#17233a;background:rgba(255,255,255,0.96);border:1px solid rgba(213,225,241,0.92);border-radius:14px;box-shadow:0 18px 48px rgba(36,66,112,0.18),0 2px 8px rgba(36,66,112,0.08);opacity:0;pointer-events:none;transform:translateY(-50%) translateX(10px) scale(0.98);transform-origin:right center;transition:opacity 160ms ease,transform 160ms ease;}.gyh-panel::after{position:absolute;top:50%;right:-7px;width:12px;height:12px;content:"";background:#ffffff;border-top:1px solid rgba(213,225,241,0.92);border-right:1px solid rgba(213,225,241,0.92);transform:translateY(-50%) rotate(45deg);}.gyh-panel.is-open{opacity:1;pointer-events:auto;transform:translateY(-50%) translateX(0) scale(1);}.gyh-panel-header{display:flex;align-items:flex-start;justify-content:space-between;padding:3px 2px 12px;}.gyh-title{margin:0;font-size:15px;font-weight:700;line-height:21px;letter-spacing:0;}.gyh-subtitle{margin:2px 0 0;color:#8290a7;font-size:12px;line-height:17px;letter-spacing:0;}.gyh-status{padding:3px 7px;color:#2a78dd;background:#edf5ff;border-radius:5px;font-size:11px;font-weight:600;line-height:16px;white-space:nowrap;}.gyh-actions{display:grid;gap:7px;}.gyh-action{width:100%;height:48px;padding:5px 10px;text-align:left;color:#20304a;background:#f7faff;border:1px solid #e5edf8;border-radius:8px;cursor:pointer;display:grid;grid-template-columns:34px minmax(0,1fr) 17px;align-items:center;gap:9px;transition:background 160ms ease,border-color 160ms ease;}.gyh-action:hover{background:#f1f7ff;border-color:#c8def8;}.gyh-action-icon{width:34px;height:34px;color:#2877de;background:#e8f2ff;border-radius:8px;display:grid;place-items:center;}.gyh-action-icon svg{width:18px;height:18px;}.gyh-action:nth-child(2) .gyh-action-icon{color:#8a63cc;background:#f2ecff;}.gyh-action:nth-child(3) .gyh-action-icon{color:#de7a34;background:#fff1e8;}.gyh-action-name{display:block;font-size:13px;font-weight:650;line-height:18px;letter-spacing:0;}.gyh-action-note{display:block;margin-top:1px;color:#8491a5;font-size:11px;line-height:15px;letter-spacing:0;}.gyh-action-arrow{color:#a6b4c8;font-size:18px;font-weight:400;line-height:18px;text-align:center;}.gyh-rename-overlay{position:fixed;inset:0;z-index:2147483001;padding:16px;background:rgba(19,32,53,0.42);backdrop-filter:blur(4px);display:flex;align-items:center;justify-content:center;animation:gyh-rename-fade-in 160ms ease;}.gyh-rename-modal{width:min(680px,calc(100vw - 32px));max-height:min(640px,calc(100vh - 32px));overflow:hidden;color:#17233a;background:#ffffff;border:1px solid rgba(213,225,241,0.96);border-radius:16px;box-shadow:0 24px 70px rgba(19,44,82,0.28),0 4px 14px rgba(19,44,82,0.12);display:flex;flex-direction:column;animation:gyh-rename-rise-in 180ms ease;}.gyh-rename-header{padding:10px;border-bottom:1px solid #edf1f7;display:flex;align-items:flex-start;justify-content:space-between;gap:16px;}.gyh-rename-heading{display:flex;align-items:center;gap:11px;min-width:0;}.gyh-rename-heading-icon{width:36px;height:36px;flex:0 0 auto;color:#2877de;background:#e8f2ff;border-radius:10px;display:grid;place-items:center;}.gyh-rename-heading-icon svg{width:19px;height:19px;}.gyh-rename-title{margin:0;font-size:16px;font-weight:700;line-height:22px;}.gyh-rename-subtitle{margin:2px 0 0;color:#8491a5;font-size:12px;line-height:17px;}.gyh-rename-close{width:30px;height:30px;flex:0 0 auto;padding:0;color:#8491a5;background:transparent;border:0;border-radius:7px;cursor:pointer;display:grid;place-items:center;transition:background 160ms ease,color 160ms ease;}.gyh-rename-close:hover{color:#263b5d;background:#f3f6fa;}.gyh-rename-close svg{width:17px;height:17px;}.gyh-rename-body{min-height:0;padding:10px;overflow-y:auto;}.gyh-rename-section{margin-top:12px;padding:10px;border:1px solid #e7edf6;border-radius:10px;}.gyh-rename-section:first-of-type{margin-top:0;}.gyh-rename-section-head{min-height:22px;margin-bottom:5px;display:flex;align-items:center;justify-content:space-between;gap:12px;}.gyh-rename-section-title{margin:0;color:#263b5d;font-size:13px;font-weight:700;line-height:20px;}.gyh-rename-section-hint{color:#8491a5;font-size:11px;line-height:17px;}.gyh-rename-modes{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:5px;}.gyh-rename-mode{min-height:34px;padding:5px 7px;color:#5e6f87;background:#f7f9fc;border:1px solid transparent;border-radius:6px;cursor:pointer;font-size:12px;line-height:16px;white-space:nowrap;transition:background 160ms ease,border-color 160ms ease,color 160ms ease;}.gyh-rename-mode:hover{color:#2961d9;background:#f1f6ff;}.gyh-rename-mode.is-active{color:#2961d9;background:#edf5ff;border-color:#c9defa;font-weight:650;}.gyh-rename-mode:disabled{color:#a6b1c1;background:#f4f6f9;border-color:transparent;cursor:not-allowed;opacity:0.72;}.gyh-rename-fields{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;}.gyh-rename-field{min-width:0;position:relative;}.gyh-rename-field-label{position:absolute;top:-7px;left:9px;padding:0 4px;color:#8491a5;background:#ffffff;font-size:10px;line-height:14px;}.gyh-rename-input{width:100%;height:36px;padding:8px 10px;color:#263b5d;background:#ffffff;border:1px solid #dfe7f1;border-radius:7px;outline:0;font:inherit;font-size:12px;transition:border-color 160ms ease,box-shadow 160ms ease;}.gyh-rename-input:focus{border-color:#77a9ed;box-shadow:0 0 0 3px rgba(45,130,245,0.12);}.gyh-rename-input:disabled{color:#9aa6b7;background:#f5f7fa;border-color:#e4eaf2;cursor:not-allowed;}.gyh-rename-preview{overflow:hidden;border:1px solid #e7edf6;border-radius:9px;}.gyh-rename-preview-body{max-height:180px;overflow-x:hidden;}.gyh-rename-preview-head,.gyh-rename-row{display:grid;grid-template-columns:minmax(0,1fr) 24px minmax(0,1fr) 44px;align-items:center;gap:8px;}.gyh-rename-preview-head{padding:6px 10px;color:#8491a5;background:#f8faff;font-size:10px;line-height:14px;}.gyh-rename-preview-head span:nth-child(3){color:#6f8fbd;}.gyh-rename-row{min-height:36px;padding:4px 10px;border-top:1px solid #eef2f7;font-size:11px;line-height:16px;}.gyh-rename-row-index{color:#8491a5;font-variant-numeric:tabular-nums;}.gyh-rename-row-name{min-width:0;overflow:hidden;color:#5e6f87;text-overflow:ellipsis;white-space:nowrap;}.gyh-rename-row-name.is-new{color:#2961d9;font-weight:600;}.gyh-rename-row-arrow{color:#a7b5c8;font-size:15px;text-align:center;}.gyh-rename-row-status{padding:2px 5px;color:#8491a5;background:#f4f6f9;border-radius:4px;font-size:10px;line-height:14px;text-align:center;white-space:nowrap;}.gyh-rename-row.is-processing .gyh-rename-row-status{color:#2961d9;background:#edf5ff;}.gyh-rename-row.is-success .gyh-rename-row-status{color:#27895f;background:#eaf8f1;}.gyh-rename-row.is-failed .gyh-rename-row-status{color:#d64545;background:#fff0f0;}.gyh-rename-row.is-processing{background:#f8faff;}.gyh-rename-row.is-success .gyh-rename-row-name.is-new{color:#27895f;}.gyh-rename-row.is-failed .gyh-rename-row-name.is-new{color:#d64545;}.gyh-rename-footer{padding:12px 20px;border-top:1px solid #edf1f7;display:flex;align-items:center;justify-content:space-between;gap:12px;}.gyh-rename-footer-note{color:#8491a5;font-size:11px;line-height:17px;}.gyh-rename-footer-actions{display:flex;align-items:center;gap:7px;}.gyh-rename-btn{min-width:70px;height:32px;padding:5px 12px;color:#66758c;background:#ffffff;border:1px solid #dce5f0;border-radius:7px;cursor:pointer;font:inherit;font-size:12px;line-height:20px;}.gyh-rename-btn-primary{color:#ffffff;background:#2961d9;border-color:#2961d9;cursor:not-allowed;opacity:0.58;}.gyh-rename-btn-primary:not(:disabled){cursor:pointer;opacity:1;}.gyh-rename-btn:hover:not(:disabled){color:#2961d9;border-color:#a9c9f4;background:#f6f9ff;}.gyh-rename-video-filter{color:#60718b;cursor:pointer;font-size:11px;line-height:17px;display:inline-flex;align-items:center;gap:5px;user-select:none;}.gyh-rename-video-filter input{width:13px;height:13px;margin:0;accent-color:#2961d9;cursor:pointer;}.gyh-toast{position:fixed;top:50%;left:50%;z-index:2147483003;width:max-content;max-width:calc(100vw - 32px);padding:9px 14px;color:#31445f;background:rgba(244,247,252,0.96);border:1px solid #dbe4f0;border-radius:8px;box-shadow:none;display:flex;align-items:center;gap:9px;font-size:13px;line-height:20px;pointer-events:none;backdrop-filter:blur(10px);transform:translate(-50%,-46%) scale(0.98);opacity:0;animation:gyh-toast-in 160ms ease forwards;}.gyh-toast-icon{width:20px;height:20px;flex:0 0 auto;color:#3f6ea8;background:transparent;border-radius:0;display:grid;place-items:center;}.gyh-toast-icon svg{width:18px;height:18px;}.gyh-toast-message{overflow:hidden;color:#31445f;font-size:13px;font-weight:500;line-height:20px;text-overflow:ellipsis;white-space:nowrap;}.gyh-toast.is-error{background:#fff3ee;border-color:#ffccbc;box-shadow:none;}.gyh-toast.is-error .gyh-toast-icon{color:#ff8a65;background:transparent;}.gyh-toast.is-error .gyh-toast-message{color:#b85c40;}.gyh-toast.is-success{background:rgba(239,249,243,0.97);border-color:#ccebd9;box-shadow:none;}.gyh-toast.is-success .gyh-toast-icon{color:#2f9c72;background:transparent;}.gyh-toast.is-success .gyh-toast-message{color:#276747;}@keyframes gyh-toast-in{to{opacity:1;transform:translate(-50%,-50%) scale(1);}}@keyframes gyh-toast-out{to{opacity:0;transform:translate(-50%,-55%) scale(0.96);}}@keyframes gyh-rename-fade-in{from{opacity:0;}to{opacity:1;}}@keyframes gyh-rename-rise-in{from{opacity:0;transform:translateY(8px) scale(0.985);}to{opacity:1;transform:translateY(0) scale(1);}}@media (max-width:640px){#${ROOT_ID}{right:14px;}.gyh-trigger{width:48px;height:48px;border-radius:14px;}.gyh-panel{right:58px;width:min(264px,calc(100vw - 86px));}.gyh-rename-overlay{padding:8px;}.gyh-rename-modal{width:calc(100vw - 16px);max-height:calc(100vh - 16px);}.gyh-rename-header,.gyh-rename-body,.gyh-rename-footer{padding-left:10px;padding-right:10px;}.gyh-rename-modes{grid-template-columns:repeat(2,minmax(0,1fr));}.gyh-rename-fields{grid-template-columns:1fr;}.gyh-rename-footer{align-items:stretch;flex-direction:column;}.gyh-rename-footer-actions{justify-content:flex-end;}}#${ROOT_ID} .gyh-trigger-label{font-size:11px;}#${ROOT_ID} .gyh-panel-header{display:block;}#${ROOT_ID} .gyh-panel-title-row{display:flex;align-items:center;justify-content:space-between;gap:12px;}#${ROOT_ID} .gyh-subtitle{width:100%;display:flex;align-items:center;justify-content:space-between;gap:12px;}#${ROOT_ID} .gyh-author{color:#9aa8ba;font-size:10px;white-space:nowrap;}#${ROOT_ID} .gyh-action:nth-child(4) .gyh-action-icon{color:#168476;background:#e7f7f3;}#${ROOT_ID} .gyh-submenu-heading{display:flex;align-items:center;gap:7px;min-width:0;}#${ROOT_ID} .gyh-submenu-back{width:24px;height:24px;flex:0 0 auto;padding:0;color:#5b86c6;background:#f1f6ff;border:1px solid #dce7f6;border-radius:6px;cursor:pointer;display:grid;place-items:center;}#${ROOT_ID} .gyh-submenu-back:hover{color:#2961d9;background:#e7f1ff;border-color:#c9defa;}#${ROOT_ID} .gyh-submenu-back svg{width:15px;height:15px;}#${ROOT_ID} .gyh-transfer-panel .gyh-action:nth-child(1) .gyh-action-icon{color:#168476;background:#e7f7f3;}#${ROOT_ID} .gyh-transfer-panel .gyh-action:nth-child(2) .gyh-action-icon{color:#9c5cab;background:#f6ebf8;}.gyh-movie-organizer-modal .gyh-rename-heading-icon{color:#2877de;background:#e8f2ff;}.gyh-tv-organizer-modal .gyh-rename-heading-icon{color:#8a63cc;background:#f2ecff;}.gyh-batch-rename-modal .gyh-rename-heading-icon{color:#de7a34;background:#fff1e8;}`;
const movieStyles = `.gyh-movie-modal{width:min(760px,calc(100vw - 32px));max-height:min(680px,calc(100vh - 32px))}.gyh-movie-key-modal{width:min(500px,calc(100vw - 32px));max-height:min(440px,calc(100vh - 32px))}.gyh-movie-key-note{padding:10px 12px;color:#536985;background:#f5f8fd;border:1px solid #e2eaf4;border-radius:8px;font-size:12px;line-height:18px}.gyh-movie-key-note strong{color:#263b5d}.gyh-movie-key-input{letter-spacing:.3px}.gyh-movie-key-status{min-height:15px;margin-top:5px;color:#8491a5;font-size:11px;line-height:15px}.gyh-movie-key-status.is-error{color:#b85c40}.gyh-movie-key-status.is-success{color:#8491a5}.gyh-movie-correct-count{color:#27895f}.gyh-movie-scope{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:7px}.gyh-movie-scope-item{min-width:0;padding:6px 9px;background:#f8faff;border:1px solid #e5edf8;border-radius:8px;display:flex;align-items:center;gap:7px}.gyh-movie-scope-label{display:inline;color:#8491a5;font-size:10px;line-height:16px;white-space:nowrap}.gyh-movie-scope-value{display:block;margin-top:0;min-width:0;color:#263b5d;font-size:12px;font-weight:650;line-height:16px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.gyh-movie-rule-head{display:flex;align-items:center;justify-content:space-between;gap:10px}.gyh-movie-rule-list{display:flex;flex-wrap:wrap;gap:5px}.gyh-movie-rule-options{display:flex;flex-wrap:wrap;gap:5px;margin-top:7px}.gyh-movie-rule-token{height:26px;padding:0 6px;color:#2961d9;background:#edf5ff;border:1px solid #c9defa;border-radius:6px;display:inline-flex;align-items:center;gap:4px;font-size:11px;line-height:16px;cursor:grab;user-select:none;transition:background .15s,border-color .15s,opacity .15s,transform .15s}.gyh-movie-rule-token:active{cursor:grabbing}.gyh-movie-rule-token.is-dragging{opacity:.45;transform:scale(.98)}.gyh-movie-rule-token.is-drag-over{background:#dcecff;border-color:#8db8ee}.gyh-movie-rule-grip{width:12px;height:16px;color:#78a0d1;display:grid;place-items:center;pointer-events:none}.gyh-movie-rule-grip svg{width:11px;height:15px}.gyh-movie-rule-tool{width:18px;height:18px;margin-right:-3px;padding:0;color:#5b86c6;background:transparent;border:0;border-radius:4px;cursor:pointer;display:grid;place-items:center}.gyh-movie-rule-tool:hover{color:#2961d9;background:#dcecff}.gyh-movie-rule-tool:disabled{color:#aac2e0;background:transparent;cursor:not-allowed}.gyh-movie-rule-tool svg{width:12px;height:12px}.gyh-movie-rule-option{height:24px;padding:3px 8px;color:#60718b;background:#f7f9fc;border:1px solid #e3eaf3;border-radius:6px;cursor:pointer;font:inherit;font-size:11px;line-height:16px}.gyh-movie-rule-option:hover{color:#2961d9;background:#f1f6ff;border-color:#c9defa}.gyh-movie-rule-option.is-selected{color:#a5b0bf;background:#f4f6f9;border-color:#edf0f4;cursor:not-allowed}.gyh-movie-rule-preview{display:block;min-height:17px;margin-top:8px;overflow:hidden;color:#6f8fbd;font-size:11px;line-height:17px;text-overflow:ellipsis;white-space:nowrap}.gyh-movie-preview-head,.gyh-movie-preview-row{display:grid;grid-template-columns:minmax(0,1.3fr) minmax(0,1fr) 76px;align-items:center;gap:8px}.gyh-movie-preview{overflow:hidden;border:1px solid #e7edf6;border-radius:9px}.gyh-movie-preview-head{padding:6px 10px;color:#8491a5;background:#f8faff;font-size:10px;line-height:14px}.gyh-movie-preview-body{max-height:220px}.gyh-movie-preview-row{min-height:38px;padding:5px 10px;border-top:1px solid #eef2f7;font-size:11px;line-height:16px}.gyh-movie-preview-name{min-width:0;overflow:hidden;color:#5e6f87;text-overflow:ellipsis;white-space:nowrap}.gyh-movie-preview-match{min-width:0;overflow:hidden;color:#8491a5;text-overflow:ellipsis;white-space:nowrap}.gyh-movie-preview-status{padding:2px 5px;color:#8491a5;background:#f4f6f9;border-radius:4px;font-size:10px;line-height:14px;text-align:center;white-space:nowrap}.gyh-movie-footer-note{flex:1;min-width:0;overflow:hidden;color:#8491a5;font-size:11px;line-height:17px;text-overflow:ellipsis;white-space:nowrap}.gyh-count-success{color:#27895f;font-weight:400}.gyh-count-failed{color:#d64545;font-weight:400}.gyh-movie-modal [data-tmdb-settings]{height:28px;padding:5px 10px}@media (max-width:640px){.gyh-movie-modal{width:calc(100vw - 16px);max-height:calc(100vh - 16px)}.gyh-movie-key-modal{width:calc(100vw - 16px);max-height:calc(100vh - 16px)}.gyh-movie-scope{grid-template-columns:1fr}.gyh-movie-preview-head,.gyh-movie-preview-row{grid-template-columns:minmax(0,1fr) 80px}}`;
const movieFolderModeStyles = `.gyh-movie-folder-mode{height:28px;margin-top:8px;width:100%;display:flex;align-items:center}.gyh-movie-folder-mode-options{width:100%;min-width:0;margin:0;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px}.gyh-movie-folder-option{height:28px;min-width:0;padding:4px 9px;color:#60718b;background:#f8faff;border:1px solid #dce6f1;border-radius:5px;cursor:pointer;display:grid;grid-template-columns:12px minmax(0,1fr) auto;align-items:center;gap:5px;font-size:11px;line-height:16px;white-space:nowrap;transition:background .15s,border-color .15s,color .15s}.gyh-movie-folder-option:hover{color:#2961d9;background:#f1f6ff;border-color:#bdd8f7}.gyh-movie-folder-option.is-selected{color:#2961d9;background:#edf5ff;border-color:#8db8ee}.gyh-movie-folder-option input{width:12px;height:12px;margin:0;accent-color:#2961d9}.gyh-movie-folder-option-text{min-width:0;overflow:hidden;font-size:11px;font-weight:600;line-height:16px;text-overflow:ellipsis}.gyh-movie-folder-option-note{min-width:0;overflow:hidden;color:#8a98ab;font-size:10px;font-weight:400;line-height:14px;text-align:right;text-overflow:ellipsis}.gyh-movie-folder-option.is-selected .gyh-movie-folder-option-note{color:#6f8fbd}@media(max-width:640px){.gyh-movie-folder-mode{height:auto}.gyh-movie-folder-mode-options{grid-template-columns:1fr;gap:4px}.gyh-movie-folder-option{height:28px}}`;
const movieCorrectionStyles = `.gyh-movie-preview-correct{width:35px;height:22px;padding:0;color:#5b86c6;background:#f7faff;border:1px solid #dce7f6;border-radius:5px;cursor:pointer;font:inherit;font-size:10px;line-height:14px;white-space:nowrap}.gyh-movie-preview-correct:hover{color:#2961d9;background:#edf5ff;border-color:#c9defa}.gyh-movie-preview-body.is-organizing .gyh-movie-preview-correct{display:none}.gyh-movie-preview-body.is-organizing .gyh-movie-preview-match{grid-column:span 2}.gyh-movie-correct-modal{width:min(430px,calc(100vw - 32px))}.gyh-movie-correct-modal .gyh-rename-body{overflow:hidden}.gyh-movie-correct-results{max-height:138px;margin-top:8px;padding-right:12px;overflow-y:scroll;scrollbar-gutter:stable;display:grid;gap:5px}.gyh-movie-correct-results::-webkit-scrollbar{width:8px}.gyh-movie-correct-results::-webkit-scrollbar-track{background:#f1f5fa;border-radius:8px}.gyh-movie-correct-results::-webkit-scrollbar-thumb{background:#c3d2e4;border:2px solid #f1f5fa;border-radius:8px;background-clip:padding-box}.gyh-movie-correct-option{width:100%;min-height:42px;padding:5px 8px;color:#536985;background:#f8faff;border:1px solid #e2eaf4;border-radius:7px;cursor:pointer;font:inherit;text-align:left;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:8px}.gyh-movie-correct-option:hover{background:#f1f6ff;border-color:#c9defa}.gyh-movie-correct-option.is-selected{color:#2961d9;background:#edf5ff;border-color:#8db8ee}.gyh-movie-correct-option-main{min-width:0;display:grid;gap:1px}.gyh-movie-correct-option-title{display:block;overflow:hidden;color:inherit;font-size:12px;font-weight:650;line-height:15px;text-overflow:ellipsis;white-space:nowrap}.gyh-movie-correct-option-original{display:block;overflow:hidden;color:#8491a5;font-size:10px;line-height:13px;text-overflow:ellipsis;white-space:nowrap}.gyh-movie-correct-option-year{padding:2px 5px;color:#6f8fbd;background:#ffffff;border:1px solid #dce7f6;border-radius:4px;font-size:10px;line-height:14px;white-space:nowrap}.gyh-movie-correct-option.is-selected .gyh-movie-correct-option-year{color:#2961d9;border-color:#b8d3f5}`;
const tvStyles = `.gyh-tv-preview-target{width:100%;padding:0;color:#2961d9;background:transparent;border:0;cursor:pointer;font:inherit;text-align:left}.gyh-tv-preview-target:hover{color:#1f55c5;text-decoration:underline}.gyh-tv-preview-target:disabled{color:#8491a5;cursor:default;text-decoration:none}.gyh-movie-preview-body.is-organizing .gyh-tv-preview-target{grid-column:span 2}`;
const rapidTransferStyles = `.gyh-rapid-import-modal,.gyh-rapid-export-modal{width:min(720px,calc(100vw - 32px));max-height:min(660px,calc(100vh - 32px))}.gyh-rapid-import-modal .gyh-rename-heading-icon{color:#168476;background:#e7f7f3}.gyh-rapid-export-modal .gyh-rename-heading-icon,.gyh-rapid-export-progress-modal .gyh-rename-heading-icon{color:#9c5cab;background:#f6ebf8}.gyh-rapid-import-file-row{height:15px;min-height:15px;margin-bottom:5px;display:flex;align-items:center;gap:8px}.gyh-rapid-import-tools{display:flex;align-items:center;gap:6px}.gyh-rapid-import-file-button{height:28px;min-width:0;padding:4px 10px;line-height:18px;display:inline-flex;align-items:center;gap:5px}.gyh-rapid-import-file-button svg{width:14px;height:14px}.gyh-rapid-import-file-button.is-disabled,.gyh-rapid-import-clear:disabled{color:#a6b1c1;background:#f4f6f9;border-color:#e4eaf2;cursor:not-allowed;pointer-events:none}.gyh-rapid-import-file-input{display:none}.gyh-rapid-import-file-name{min-width:0;overflow:hidden;color:#8491a5;font-size:11px;line-height:15px;text-overflow:ellipsis;white-space:nowrap}.gyh-rapid-import-editor,.gyh-rapid-export-code{height:300px;overflow:hidden;border:1px solid #dfe7f1;border-radius:8px}.gyh-rapid-import-editor:focus-within{border-color:#77a9ed;box-shadow:0 0 0 3px rgba(45,130,245,.12)}.gyh-rapid-import-editor .ace_scrollbar::-webkit-scrollbar,.gyh-rapid-export-code .ace_scrollbar::-webkit-scrollbar{width:8px;height:8px}.gyh-rapid-import-editor .ace_scrollbar::-webkit-scrollbar-track,.gyh-rapid-export-code .ace_scrollbar::-webkit-scrollbar-track{background:transparent}.gyh-rapid-import-editor .ace_scrollbar::-webkit-scrollbar-thumb,.gyh-rapid-export-code .ace_scrollbar::-webkit-scrollbar-thumb{background:#c3d2e4;border:2px solid transparent;border-radius:8px;background-clip:padding-box}.gyh-rapid-import-editor .ace_scrollbar::-webkit-scrollbar-thumb:hover,.gyh-rapid-export-code .ace_scrollbar::-webkit-scrollbar-thumb:hover{background:#9fb8d5;border:2px solid transparent;background-clip:padding-box}.gyh-rapid-import-fallback,.gyh-rapid-export-fallback{width:100%;height:100%;padding:10px;color:#263b5d;background:#fafcff;border:0;outline:0;resize:none;font:12px/18px ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace}.gyh-rapid-import-modal .ace_editor,.gyh-rapid-export-modal .ace_editor{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace!important;font-size:12px!important;line-height:18px!important}.gyh-rapid-import-progress{overflow:hidden;border:1px solid #e7edf6;border-radius:9px}.gyh-rapid-import-progress-head,.gyh-rapid-import-progress-row{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.3fr) 44px;align-items:center;gap:8px}.gyh-rapid-import-progress-head{padding:6px 10px;color:#8491a5;background:#f8faff;font-size:10px;line-height:14px}.gyh-rapid-import-progress-head span:nth-child(2){color:#6f8fbd}.gyh-rapid-import-progress-body{max-height:300px;overflow-x:hidden}.gyh-rapid-import-progress-row{grid-template-columns:minmax(0,1fr) minmax(0,1.3fr) 44px}.gyh-rapid-import-progress-directory,.gyh-rapid-import-progress-file{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.gyh-rapid-import-progress-directory{color:#5e6f87}.gyh-rapid-import-progress-file{color:#2961d9;font-weight:600}.gyh-rapid-import-progress-row.is-success .gyh-rapid-import-progress-file{color:#27895f}.gyh-rapid-import-progress-row.is-failed .gyh-rapid-import-progress-file{color:#d64545}.gyh-rapid-export-summary{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:7px}.gyh-rapid-export-summary-item{min-width:0;padding:7px 9px;background:#f8faff;border:1px solid #e5edf8;border-radius:8px}.gyh-rapid-export-summary-label{display:block;color:#8491a5;font-size:10px;line-height:15px}.gyh-rapid-export-summary-value{display:block;overflow:hidden;color:#263b5d;font-size:12px;font-weight:650;line-height:17px;text-overflow:ellipsis;white-space:nowrap}.gyh-rapid-export-note{color:#8491a5;font-size:11px;line-height:17px}.gyh-rapid-export-progress-modal{width:min(390px,calc(100vw - 32px));max-height:min(340px,calc(100vh - 32px))}.gyh-rapid-export-progress-main{padding:26px 14px 24px;text-align:center}.gyh-rapid-export-spinner{width:42px;height:42px;margin:0 auto 15px;border:3px solid #eee2f2;border-top-color:#9c5cab;border-radius:50%;display:block;animation:gyh-rapid-export-spin .8s linear infinite}.gyh-rapid-export-progress-title{margin:0;color:#263b5d;font-size:14px;font-weight:700;line-height:21px}.gyh-rapid-export-progress-text{min-height:18px;margin:5px 0 0;color:#6f8fbd;font-size:12px;line-height:18px}.gyh-rapid-export-progress-count{min-height:16px;margin:3px 0 0;color:#8491a5;font-size:11px;line-height:16px}.gyh-rapid-export-progress-modal .gyh-rename-footer{justify-content:center;padding:9px 14px}.gyh-rapid-export-progress-note{color:#8491a5;font-size:11px;line-height:17px}@keyframes gyh-rapid-export-spin{to{transform:rotate(360deg)}}@media(max-width:640px){.gyh-rapid-import-progress-head,.gyh-rapid-import-progress-row{grid-template-columns:minmax(0,1fr) minmax(0,1fr) 44px}.gyh-rapid-export-summary{grid-template-columns:1fr}}`;
const componentStyles = `.gyh-organizer-grid{grid-template-columns:minmax(0,1fr) minmax(0,1fr) 35px 44px}.gyh-organizer-grid-head span:nth-child(2){grid-column:span 2}.gyh-movie-scope-item{height:34px}.gyh-movie-scope-value{margin-left:auto;text-align:right}.gyh-movie-modal [data-tmdb-settings]{line-height:16px}.gyh-preview-two-column{grid-template-columns:minmax(0,1fr) minmax(0,1fr)}.gyh-preview-single-column{grid-template-columns:1fr}.gyh-preview-compact{max-height:160px}.gyh-movie-key-field{display:block;margin-top:12px}.gyh-rule-section-head{justify-content:flex-start;gap:10px}.gyh-rule-section-head .gyh-movie-rule-head{flex:1;min-width:0;justify-content:flex-start}.gyh-rule-section-head .gyh-movie-rule-preview{min-width:0;margin-top:0;flex:1;text-align:left}.gyh-rename-preview-body,.gyh-movie-preview-body,.gyh-rapid-import-progress-body{overflow-y:auto;scrollbar-width:thin;scrollbar-color:#b7c8de transparent}.gyh-rename-preview-body::-webkit-scrollbar,.gyh-movie-preview-body::-webkit-scrollbar,.gyh-rapid-import-progress-body::-webkit-scrollbar{width:8px}.gyh-rename-preview-body::-webkit-scrollbar-track,.gyh-movie-preview-body::-webkit-scrollbar-track,.gyh-rapid-import-progress-body::-webkit-scrollbar-track{background:transparent}.gyh-rename-preview-body::-webkit-scrollbar-thumb,.gyh-movie-preview-body::-webkit-scrollbar-thumb,.gyh-rapid-import-progress-body::-webkit-scrollbar-thumb{background:#c3d2e4;border:2px solid transparent;border-radius:8px;background-clip:padding-box}.gyh-rename-preview-body::-webkit-scrollbar-thumb:hover,.gyh-rapid-import-progress-body::-webkit-scrollbar-thumb:hover{background:#9fb8d5;border:2px solid transparent;background-clip:padding-box}.gyh-toast.is-leaving{animation:gyh-toast-out 180ms ease forwards}.gyh-overlay-elevated{z-index:2147483002}.gyh-fields-two-column{grid-template-columns:repeat(2,minmax(0,1fr))}.gyh-is-hidden{display:none!important}.gyh-copy-buffer{position:fixed;opacity:0;pointer-events:none}.gyh-editor-readonly .ace_cursor{opacity:0!important}`;
// 注入组件样式
function addStyles() {
if (document.getElementById(STYLE_ID)) return;
const allStyles = styles + movieStyles + movieFolderModeStyles + movieCorrectionStyles + tvStyles + rapidTransferStyles + componentStyles;
if (typeof GM_addStyle === 'function') {
const style = GM_addStyle(allStyles);
if (style) style.id = STYLE_ID;
return;
}
const style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = allStyles;
document.head.appendChild(style);
}
const renameModes = [
{ key: 'replace', label: '查找替换', fields: [['查找内容', '输入要替换的文字'], ['替换为', '输入新的文字'], ['匹配选项', '忽略大小写']], prepare: values => values[0] && values[2].includes('忽略') ? new RegExp(escapeRegExp(values[0]), 'gi') : null, apply: ({ file, values, prepared }) => !values[0] ? file.name : prepared ? file.name.replace(prepared, values[1]) : file.name.split(values[0]).join(values[1]) },
{ key: 'sequence', label: '按序号', fields: [['前缀', '例如:第'], ['起始编号', '例如:01'], ['后缀', '例如:集']], prepare: values => ({ start: Number.parseInt(values[1], 10) || 1, width: values[1].length }), apply: ({ index, values, extension, prepared }) => `${values[0]}${String(prepared.start + index).padStart(prepared.width, '0')}${values[2]}${extension}` },
{ key: 'append', label: '追加', fields: [['前缀', '追加到文件名前'], ['后缀', '追加到文件名后'], ['扩展名', '保留原扩展名']], apply: ({ values, baseName, extension }) => `${values[0]}${baseName}${values[1]}${extension}` },
{ key: 'regex', label: '正则替换', fields: [['正则表达式', '例如:\\s+'], ['替换为', '输入替换内容'], ['匹配选项', '全局匹配']], prepare: values => values[0] ? new RegExp(values[0], 'g') : null, apply: ({ file, values, prepared }) => prepared ? file.name.replace(prepared, values[1]) : file.name },
{ key: 'format', label: '格式替换', fields: [['新格式名', '例如:mp4'], ['命名规则', '保留原文件名'], ['预览方式', '仅修改扩展名']], apply: ({ file, values, baseName }) => values[0] ? `${baseName}.${values[0].replace(/^\./, '')}` : file.name },
];
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// 显示居中的状态提示
function showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.className = `gyh-toast${type === 'error' ? ' is-error' : type === 'success' ? ' is-success' : ''}`;
const icon = ICONS[type] || ICONS.info;
toast.innerHTML = `${icon}${escapeHtml(message)}`;
document.body.appendChild(toast);
window.setTimeout(() => {
toast.classList.add('is-leaving');
window.setTimeout(() => toast.remove(), 180);
}, 2200);
}
// 按需加载 Ace 编辑器
function loadAceEditor() {
if (typeof ace !== 'undefined' && typeof ace.edit === 'function') return Promise.resolve(true);
if (aceLoadPromise) return aceLoadPromise;
const loadScript = url => new Promise(resolve => {
GM_xmlhttpRequest({ method: 'GET', url, onload(response) { try { if (response.status < 200 || response.status >= 300) return resolve(false); (0, eval)(response.responseText); resolve(true); } catch (error) { resolve(false); } }, onerror() { resolve(false); }, ontimeout() { resolve(false); } });
});
aceLoadPromise = (async () => {
if (!await loadScript(ACE_CORE_URL)) return false;
ace.config.set('basePath', ACE_BASE_URL);
ace.config.set('modePath', ACE_BASE_URL);
ace.config.set('themePath', ACE_BASE_URL);
ace.config.set('workerPath', ACE_BASE_URL);
await loadScript(ACE_TOOLS_URL);
await loadScript(ACE_MODE_JSON_URL);
await loadScript(ACE_THEME_TOMORROW_URL);
return typeof ace !== 'undefined' && typeof ace.edit === 'function';
})();
return aceLoadPromise;
}
// 创建 JSON 编辑器或降级输入框
function createJsonEditor(host, options = {}) {
if (typeof ace !== 'undefined' && typeof ace.edit === 'function') {
const editor = ace.edit(host);
editor.setTheme('ace/theme/tomorrow');
editor.session.setMode('ace/mode/json');
editor.setOptions({ fontSize: '12px', showPrintMargin: false, highlightActiveLine: false, showGutter: true, enableBasicAutocompletion: options.autocomplete === true, enableLiveAutocompletion: false });
editor.session.setUseWrapMode(true);
editor.session.setUseWorker(false);
if (options.value !== undefined) editor.setValue(options.value, -1);
if (options.readOnly) { editor.setReadOnly(true); host.classList.add('gyh-editor-readonly'); }
return { getValue: () => editor.getValue(), setValue: value => editor.setValue(value, -1), focus: () => editor.focus(), resize: () => editor.resize() };
}
const input = document.createElement('textarea');
input.className = options.fallbackClass || 'gyh-rapid-import-fallback';
input.placeholder = options.placeholder || '';
input.value = options.value || '';
input.readOnly = options.readOnly === true;
host.appendChild(input);
return { getValue: () => input.value, setValue: value => { input.value = value; }, focus: () => input.focus(), resize: () => {} };
}
// 等待指定时长
function delay(milliseconds) {
return new Promise(resolve => window.setTimeout(resolve, milliseconds));
}
function getStoredValue(key, fallback) {
try {
if (typeof GM_getValue === 'function') return GM_getValue(key, fallback);
const value = localStorage.getItem(key);
if (value == null) return fallback;
return typeof fallback === 'string' ? value : JSON.parse(value);
} catch (error) {
return fallback;
}
}
function setStoredValue(key, value) {
if (typeof GM_setValue === 'function') return GM_setValue(key, value);
localStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value));
}
function readJsonResponse(response) {
if (response.response) return response.response;
try { return JSON.parse(response.responseText); } catch (error) { return null; }
}
function requestJson({ method = 'GET', url, headers = {}, body, timeout = 0, emptyObject = false, errorMessage = '请求失败', networkMessage = '网络请求失败,请稍后重试', timeoutMessage = '请求超时,请稍后重试', abortMessage = '', getError }) {
return new Promise((resolve, reject) => {
const options = {
method,
url,
headers,
responseType: 'json',
onload(response) {
const data = readJsonResponse(response);
if (response.status >= 200 && response.status < 300) {
resolve(emptyObject ? (data || {}) : data);
return;
}
const error = new Error(getError?.(data, response.status) || `${errorMessage}:${response.status}`);
error.status = response.status;
reject(error);
},
onerror() { reject(new Error(networkMessage)); },
};
if (body !== undefined) options.data = typeof body === 'string' ? body : JSON.stringify(body);
if (timeout) {
options.timeout = timeout;
options.ontimeout = () => reject(new Error(timeoutMessage));
}
if (abortMessage) options.onabort = () => reject(new Error(abortMessage));
GM_xmlhttpRequest(options);
});
}
async function pollTask({ request, inspect, attempts, interval = 500, timeoutMessage }) {
for (let attempt = 0; attempt < attempts; attempt += 1) {
const response = await request(attempt);
const result = inspect(response);
if (result?.error) throw new Error(result.error);
if (result?.done) return result.value;
await delay(interval);
}
throw new Error(timeoutMessage);
}
async function walkResources(resources, { getId = resource => String(resource?.id || ''), isFile, getChildren, acceptFile = () => true, mapFile = (resource, path) => ({ ...resource, path }), nextPath = (path, resource) => [...path, resource.name], onFile, onFolder }) {
const result = [];
const visited = new Set();
const walk = async (resource, path = []) => {
const id = getId(resource);
if (!id || visited.has(id)) return;
visited.add(id);
if (isFile(resource)) {
if (!acceptFile(resource, path)) return;
const entry = mapFile(resource, path);
result.push(entry);
onFile?.(entry, result.length);
return;
}
const children = await getChildren(resource, path);
for (const child of children) await walk(child, nextPath(path, resource));
onFolder?.(resource, result.length);
};
for (const resource of resources) await walk(resource);
return result;
}
function bindOverlay(overlay, close, { modal = '.gyh-rename-modal', focus } = {}) {
const onKeydown = event => { if (event.key === 'Escape') close(); };
overlay._gyhOnKeydown = onKeydown;
overlay.addEventListener('click', event => { if (event.target === overlay) close(); });
overlay.querySelector(modal)?.addEventListener('click', event => event.stopPropagation());
document.body.appendChild(overlay);
document.addEventListener('keydown', onKeydown);
focus?.focus();
return overlay;
}
function modalHeader({ id, icon, title, subtitle, closeLabel }) {
return ``;
}
function modalFooter(note, actions) {
return ``;
}
function assistantAction({ action, attribute = 'data-action', icon, name, note, label = name }) {
const actionAttribute = action == null ? attribute : `${attribute}="${action}"`;
return ``;
}
// 读取 TMDB 密钥
async function getTmdbKey() {
return String(await getStoredValue(TMDB_KEY_STORAGE, '') || '').trim();
}
// 保存 TMDB 密钥
async function saveTmdbKey(value) {
const key = String(value || '').trim();
await setStoredValue(TMDB_KEY_STORAGE, key);
return key;
}
// 规范 TMDB 密钥格式
function normalizeTmdbKey(value) {
return String(value || '').trim().replace(/^Bearer\s+/i, '').trim();
}
// 请求 TMDB 接口
function requestTmdb(url, key) {
const isBearer = /^eyJ/i.test(key);
const requestUrl = isBearer ? url : `${url}${url.includes('?') ? '&' : '?'}api_key=${encodeURIComponent(key)}`;
return requestJson({
url: requestUrl,
headers: isBearer ? { Authorization: `Bearer ${key}` } : {},
timeout: 15000,
errorMessage: 'TMDB 验证失败',
networkMessage: 'TMDB 网络请求失败',
timeoutMessage: 'TMDB 请求超时,请尝试使用代理',
abortMessage: 'TMDB 请求已取消',
getError: (data, status) => data?.status_message || `TMDB 验证失败(${status})`,
});
}
// 验证 TMDB 密钥
async function validateTmdbKey(key) {
const data = await requestTmdb(`${API.tmdb}/configuration`, key);
if (!data || data.images === undefined) throw new Error('TMDB 密钥无效');
return true;
}
const movieNamingFields = ['影片名', '上映时间', '来源', '分辨率', '视频编码', '音频编码', 'TMDB'];
const defaultMovieNamingFields = ['影片名', '上映时间', '分辨率', '来源', '视频编码', 'TMDB'];
const tvNamingFields = ['剧集名', '季集信息', '上映时间', '来源', '分辨率', '视频编码', '音频编码', 'TMDB'];
const defaultTvNamingFields = ['剧集名', '季集信息', '上映时间', '分辨率', '来源', '视频编码'];
// 移除站点推广标签
function stripMovieSiteTags(value) {
return String(value || '').replace(/(?:【[^】]*(?:https?:\/\/|www\.|(?:[a-z0-9-]+\.)+(?:com|net|org|cn|edu|gov|info|biz|io|co|me|tv|cc|vip|top|xyz|site|online|club|live|app|pro|mobi|ai|cloud|fun|link|wiki|name|work|store|shop|tech|dev|icu|one|video|movie))[^】]*】|\[[^\]]*(?:https?:\/\/|www\.|(?:[a-z0-9-]+\.)+(?:com|net|org|cn|edu|gov|info|biz|io|co|me|tv|cc|vip|top|xyz|site|online|club|live|app|pro|mobi|ai|cloud|fun|link|wiki|name|work|store|shop|tech|dev|icu|one|video|movie))[^\]]*\]|『[^』]*(?:https?:\/\/|www\.|(?:[a-z0-9-]+\.)+(?:com|net|org|cn|edu|gov|info|biz|io|co|me|tv|cc|vip|top|xyz|site|online|club|live|app|pro|mobi|ai|cloud|fun|link|wiki|name|work|store|shop|tech|dev|icu|one|video|movie))[^』]*』)/ig, ' ');
}
// 提取文件夹中的中文片名
function extractMovieTitleHint(value) {
const source = stripMovieSiteTags(value).replace(/【[^】]*】/g, ' ');
const text = source.replace(/\[[^\]]*\]|([^)]*)|\([^)]*\)|『[^』]*』|「[^」]*」/g, ' ');
const chinese = text.match(/[\u4e00-\u9fff][\u4e00-\u9fff·\d:]*/g)?.map(item => item.trim()).filter(item => item.length > 1 && !/(?:高清|剧集|发布|国语|配音|字幕|音轨|全集|更新|视频|帧率|版本|杜比|视界|高码|蓝光|原盘|码率|内嵌|简繁|中字|特效)/.test(item)) || [];
if (chinese.length) return chinese.sort((a, b) => b.length - a.length)[0];
const bracketTitles = [...source.matchAll(/\[([^\]]+)\]|(([^)]+))|\(([^)]+)\)/g)].map(match => (match[1] || match[2] || match[3] || '').trim()).filter(title => title && !/(?:国语|国粤|配音|字幕|音轨|多音|中字|杜比|HDR|画质|高码|码率|版本|蓝光|原盘|UHD|4K|HQ|DV|Audio)/i.test(title) && !/^(?:全?\d+集|(?:19|20)\d{2}|\d{3,4}p|s\d{1,2}e?\d{0,3})$/i.test(title));
return bracketTitles[0] || '';
}
// 规范片源标记
function extractMovieSource(value) {
const text = String(value || '').replace(/[\[\](){}【】]/g, '.');
if (/(?:^|[.\s_-])web[.\s_-]?dl(?:$|[.\s_-])/i.test(text)) return 'WEB-DL';
if (/(?:^|[.\s_-])(?:blu[.\s_-]?ray|bdrip|bdremux)(?:$|[.\s_-])/i.test(text)) return 'BluRay';
return '';
}
// 规范分辨率标记
function extractMovieResolution(value) {
const text = String(value || '').replace(/[\[\](){}【】]/g, '.');
const match = text.match(/(?:^|[.\s_-])(2160|1080|720|540)p(?:$|[.\s_-])/i);
if (match) return match[1] + 'p';
return /(?:^|[.\s_-])4k(?:$|[.\s_-])/i.test(text) ? '2160p' : '';
}
// 规范视频编码标记
function extractMovieVideoCodec(value) {
const text = String(value || '').replace(/[\[\](){}【】]/g, '.');
if (/(?:^|[.\s_-])(?:hevc|hecv|x[.\s_-]?265|h[.\s_-]?265)(?:$|[.\s_-])/i.test(text)) return 'H265';
if (/(?:^|[.\s_-])(?:avc|x[.\s_-]?264|h[.\s_-]?264)(?:$|[.\s_-])/i.test(text)) return 'H264';
return '';
}
// 提取动态范围标记
function extractMovieDynamicRange(value) {
const text = String(value || '').replace(/[\[\](){}【】]/g, '.');
const hasDv = /(?:dolby[.\s_-]?vision|(?:^|[.\s_-])(?:dv|dovi)(?:$|[.\s_-]))/i.test(text);
const hasHdr = /(?:^|[.\s_-])hdr(?:10\+?)?(?:$|[.\s_-])/i.test(text);
if (hasDv && hasHdr) return 'DV.HDR';
if (hasDv) return 'DV';
return hasHdr ? 'HDR' : 'SDR';
}
// 提取视频帧率标记
function extractMovieFrameRate(value) {
const text = String(value || '').replace(/[\[\](){}【】]/g, '.');
const match = text.match(/(?:^|[.\s_-])(\d{2,3})[.\s_-]?fps(?:$|[.\s_-])/i);
return match ? match[1] + 'fps' : '';
}
// 提取音频编码标记
function extractMovieAudioCodec(value) {
const text = String(value || '').replace(/[\[\](){}【】]/g, '.').replace(/([a-z])(?=\d)/ig, '$1.');
let codec = '';
if (/(?:^|[.\s_-])true[.\s_-]?hd(?:$|[.\s_-])/i.test(text)) codec = 'TrueHD';
else if (/(?:^|[.\s_-])dts[.\s_-]?hd[.\s_-]?ma(?:$|[.\s_-])/i.test(text)) codec = 'DTS-HD.MA';
else if (/(?:^|[.\s_-])dts[.\s_-]?x(?:$|[.\s_-])/i.test(text)) codec = 'DTS-X';
else if (/(?:^|[.\s_-])dts(?:$|[.\s_-])/i.test(text)) codec = 'DTS';
else if (/(?:^|[.\s_-])(?:ddp|e[.\s_-]?ac[.\s_-]?3|dd\+)(?:$|[.\s_-])/i.test(text)) codec = 'DDP';
else if (/(?:^|[.\s_-])(?:dd|ac[.\s_-]?3)(?:$|[.\s_-])/i.test(text)) codec = 'DD';
else if (/(?:^|[.\s_-])aac(?:$|[.\s_-])/i.test(text)) codec = 'AAC';
else if (/(?:^|[.\s_-])flac(?:$|[.\s_-])/i.test(text)) codec = 'FLAC';
else if (/(?:^|[.\s_-])(?:l?pcm)(?:$|[.\s_-])/i.test(text)) codec = 'PCM';
if (!codec) return '';
const channels = text.match(/(?:^|[.\s_-])(\d[.]\d)(?:$|[.\s_-])/);
const atmos = /(?:^|[.\s_-])atmos(?:$|[.\s_-])/i.test(text);
return codec + (channels ? channels[1] : '') + (atmos ? '.Atmos' : '');
}
function parseMediaTags(value) {
return {
source: extractMovieSource(value),
resolution: extractMovieResolution(value),
videoCodec: extractMovieVideoCodec(value),
dynamicRange: extractMovieDynamicRange(value),
frameRate: extractMovieFrameRate(value),
audioCodec: extractMovieAudioCodec(value),
hq: /高码版|(?:^|[.\s_-])hq(?:$|[.\s_-])/i.test(value) ? 'HQ' : '',
};
}
// 解析电影文件名
function parseMovieName(fileName, path = []) {
const cleanName = stripMovieSiteTags(fileName);
const extensionMatch = cleanName.match(/(\.[^.]+)$/);
const extension = extensionMatch ? extensionMatch[1] : '';
const base = extension ? cleanName.slice(0, -extension.length) : cleanName;
const yearMatch = [...base.matchAll(/(?:19|20)\d{2}/g)].filter(match => Number(match[0]) <= new Date().getFullYear() + 1).at(-1);
const year = yearMatch ? yearMatch[0] : '';
const beforeYear = yearMatch ? base.slice(0, yearMatch.index) : base;
const parentName = path.length ? path[path.length - 1] : '';
const parentHint = extractMovieTitleHint(parentName);
const pathHint = /^(?:来自|云添加|下载|电影|影视|视频|资源|文件|新建文件夹|整理完成)$/.test(parentHint) ? '' : parentHint;
const bracketHint = extractMovieTitleHint(beforeYear);
const withoutBrackets = beforeYear.replace(/【[^】]*】|\[[^\]]*\]|『[^』]*』|([^)]*)|\([^)]*\)/g, ' ');
const chinese = withoutBrackets.match(/[\u4e00-\u9fff][\u4e00-\u9fff·\s]*/g)?.map(value => value.trim()).filter(value => value.length > 1) || [];
const englishBase = withoutBrackets.replace(/[._]+/g, ' ').replace(/\s+/g, ' ').trim();
const fallbackTitle = englishBase.split(' ').filter((value, index, values) => index === 0 || value !== values[index - 1]).join(' ');
const title = (pathHint || bracketHint || chinese[chinese.length - 1] || fallbackTitle || base).replace(/^[\s._-]+|[\s._-]+$/g, '').trim();
const metadata = [base, ...path.map(stripMovieSiteTags)].join('.');
return { title, year, ...parseMediaTags(metadata), extension: extension.toLowerCase(), rawName: fileName };
}
// 规范电影检索文本
function normalizeMovieText(value) {
return String(value || '').toLowerCase().replace(/[._·::/\\()[\]{}【】「」"'’‘-]+/g, ' ').replace(/\s+/g, ' ').trim();
}
// 整理 TMDB 候选并评分
function buildTmdbCandidates(data, info, type) {
const isMovie = type === 'movie';
const queryText = normalizeMovieText(info.title);
return (data?.results || []).map(item => {
const title = item[isMovie ? 'title' : 'name'] || item[isMovie ? 'original_title' : 'original_name'] || '';
const originalTitle = item[isMovie ? 'original_title' : 'original_name'] || '';
const year = String(item[isMovie ? 'release_date' : 'first_air_date'] || '').slice(0, 4);
const normalizedTitle = normalizeMovieText(title);
const normalizedOriginal = normalizeMovieText(originalTitle);
const exactTitle = normalizedTitle === queryText || normalizedOriginal === queryText;
let score = exactTitle ? 1000 : (normalizedTitle.includes(queryText) || normalizedOriginal.includes(queryText) || queryText.includes(normalizedTitle) || queryText.includes(normalizedOriginal) ? 300 : 0);
if (info.year && year) { const gap = Math.abs(Number(info.year) - Number(year)); score += gap === 0 ? 120 : gap === 1 ? 96 : Math.max(-80, 40 - gap * 8); }
score += Math.min(12, Number(item.vote_count || 0) > 0 ? Math.log(Number(item.vote_count) + 1) * 2 : 0);
return { id: item.id, title, originalTitle, year, score, exactTitle };
}).sort((a, b) => b.score - a.score).filter(item => item.exactTitle || item.score >= 300).slice(0, 10);
}
// 获取 TMDB 电影候选
async function searchTmdbMovieCandidates(info) {
const key = await getTmdbKey();
if (!key || !info.title) return null;
const query = encodeURIComponent(info.title);
return buildTmdbCandidates(await requestTmdb(`${API.tmdb}/search/movie?language=zh-CN®ion=CN&query=${query}`, key), info, 'movie');
}
// 获取最佳 TMDB 电影候选
async function searchTmdbMovie(info) {
const candidates = await searchTmdbMovieCandidates(info);
return candidates?.[0] || null;
}
// 提取剧集的季集编号
function extractTvSeasonEpisode(value) {
const text = String(value || '').replace(/[【】[\](){}]/g, '.');
const match = text.match(/(?:^|[.\s_-])s(\d{1,2})[.\s_-]?e(\d{1,3})(?:$|[.\s_-])/i) || text.match(/第\s*(\d{1,2})\s*季\D{0,8}第?\s*(\d{1,3})\s*集/i);
if (!match) return { season: '', episode: '', label: '' };
const season = String(Number(match[1])).padStart(2, '0');
const episode = String(Number(match[2])).padStart(2, '0');
return { season, episode, label: 'S' + season + 'E' + episode };
}
// 提取剧集剧名
function extractTvTitle(value) {
const source = stripMovieSiteTags(value).replace(/【[^】]*】/g, ' ');
const text = source.replace(/\[[^\]]*\]|([^)]*)|\([^)]*\)|『[^』]*』|「[^」]*」/g, ' ').replace(/(?:^|[.\s_-])s\d{1,2}(?:[.\s_-]?e\d{1,3})?(?:$|[.\s_-]).*$/i, ' ');
const chinese = text.match(/[\u4e00-\u9fff][\u4e00-\u9fff·\d:]*/g)?.map(item => item.trim()).filter(item => item.length > 1 && !/(?:高清|剧集|发布|国语|配音|字幕|音轨|全集|更新|视频|帧率|版本|杜比|视界|高码|蓝光|原盘|码率|内嵌|简繁|中字|特效)/.test(item)) || [];
if (chinese.length) return chinese.sort((a, b) => b.length - a.length)[0];
return extractMovieTitleHint(source) || parseMovieName(source).title;
}
// 解析剧集剧集信息
function parseTvEpisode(file, sourceFolder) {
const fileInfo = parseMovieName(file.name);
const folderTitle = extractTvTitle(sourceFolder?.name || '');
const fileTitle = extractTvTitle(file.name);
const fileSeasonEpisode = extractTvSeasonEpisode(file.name);
const seasonEpisode = fileSeasonEpisode.label ? fileSeasonEpisode : extractTvSeasonEpisode(sourceFolder?.name);
return { ...fileInfo, title: folderTitle || fileTitle || fileInfo.title, season: seasonEpisode.season, episode: seasonEpisode.episode, seasonEpisode: seasonEpisode.label };
}
// 获取 TMDB 剧集候选
async function searchTmdbTvCandidates(info) {
const key = await getTmdbKey();
if (!key || !info.title) return [];
const query = encodeURIComponent(info.title);
return buildTmdbCandidates(await requestTmdb(`${API.tmdb}/search/tv?language=zh-CN®ion=CN&query=${query}`, key), info, 'tv');
}
// 获取最佳 TMDB 剧集候选
async function searchTmdbTv(info) {
const candidates = await searchTmdbTvCandidates(info);
return candidates[0] || null;
}
// 获取剧集各季总集数
async function getTmdbTvSeasons(tvId) {
const key = await getTmdbKey();
if (!key || !tvId) return [];
const data = await requestTmdb(`${API.tmdb}/tv/${encodeURIComponent(tvId)}?language=zh-CN`, key);
return (data?.seasons || []).map(season => ({ season: Number(season.season_number), total: Number(season.episode_count) || 0, name: season.name || '', year: String(season.air_date || '').slice(0, 4) })).filter(season => Number.isFinite(season.season) && season.season >= 0);
}
// 判断视频文件大小
function getMovieFileSize(file) {
const value = file.size ?? file.fileSize ?? file.file_size ?? file.fileSizeByte ?? 0;
if (typeof value === 'number') return value;
const match = String(value).match(/([\d.]+)\s*(KB|MB|GB|TB)?/i);
if (!match) return 0;
const units = { KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3, TB: 1024 ** 4 };
return Number(match[1]) * (units[String(match[2] || '').toUpperCase()] || 1);
}
// 递归收集大视频文件
async function collectMovieVideos(resources, onProgress) {
return walkResources(resources, {
isFile: resource => resource.resType === 1,
getChildren: resource => getGuangyaFiles(resource.id),
acceptFile: resource => isVideoFile(resource) && getMovieFileSize(resource) > MIN_MEDIA_SIZE,
onFolder: (resource, count) => onProgress?.(count),
});
}
// 查找或创建电影目录
async function ensureMovieDirectory(parentId, directoryName) {
const files = await getGuangyaFiles(parentId);
const existing = files.find(file => file.resType !== 1 && file.name === directoryName);
if (existing) return existing.id;
let response;
try {
response = await requestGuangyaApi(`${API.guangya}/nd.bizuserres.s/v1/file/create_dir`, { dirName: directoryName, parentId, failIfNameExist: true });
await waitGuangyaTask(response, '创建' + directoryName + '文件夹');
} catch (error) {
const refreshed = await getGuangyaFiles(parentId);
const created = refreshed.find(file => file.resType !== 1 && file.name === directoryName);
if (created) return created.id;
throw error;
}
const findId = value => {
if (!value || typeof value !== 'object') return '';
if (value.fileId || value.dirId || value.id) return String(value.fileId || value.dirId || value.id);
for (const child of Object.values(value)) { const id = findId(child); if (id) return id; }
return '';
};
const createdId = findId(response);
if (createdId) return createdId;
const refreshed = await getGuangyaFiles(parentId);
const created = refreshed.find(file => file.resType !== 1 && file.name === directoryName);
if (created) return created.id;
throw new Error(response?.msg || '创建' + directoryName + '文件夹失败');
}
// 批量移动光鸭资源
async function moveGuangyaFiles(fileIds, parentId) {
const ids = [...new Set(fileIds.map(id => String(id || '')).filter(Boolean))];
if (!ids.length) return;
const response = await requestGuangyaApi(`${API.guangya}/nd.bizuserres.s/v1/file/move_file`, { fileIds: ids, parentId: String(parentId) });
const message = response?.msg || response?.data?.msg;
if (message && message !== 'success') throw new Error(message);
await waitGuangyaTask(response, '移动文件');
return response;
}
// 移动单个光鸭资源
async function moveGuangyaFile(fileId, parentId) {
return moveGuangyaFiles([fileId], parentId);
}
// 读取光鸭登录令牌
function getGuangyaAuthToken() {
try {
const key = Object.keys(localStorage).find(item => item.startsWith('credentials_'));
const credentials = key ? JSON.parse(localStorage.getItem(key)) : null;
return credentials?.token_type && credentials?.access_token
? `${credentials.token_type} ${credentials.access_token}`
: null;
} catch (error) {
return null;
}
}
// 记录迅雷页面请求中的鉴权信息
function installXunleiRequestCapture() {
const root = document.documentElement;
if (!isXunleiPan || root?.dataset.gyhXunleiCapture) return;
if (!root) {
document.addEventListener('DOMContentLoaded', installXunleiRequestCapture, { once: true });
return;
}
root.dataset.gyhXunleiCapture = '1';
const eventName = 'gyh:xunlei-context';
document.addEventListener(eventName, event => {
const detail = event.detail || {};
if (detail.token) xunleiContext.token = String(detail.token);
if (detail.deviceId) xunleiContext.deviceId = String(detail.deviceId);
if (detail.captchaToken) xunleiContext.captchaToken = String(detail.captchaToken);
if (detail.clientId) xunleiContext.clientId = String(detail.clientId);
if (detail.shareToken) xunleiContext.shareToken = String(detail.shareToken);
});
const script = document.createElement('script');
script.textContent = `(() => { const eventName = ${JSON.stringify(eventName)}; const read = (headers, name) => { if (!headers) return ''; if (typeof headers.get === 'function') return headers.get(name) || ''; for (const key of Object.keys(headers)) if (key.toLowerCase() === name.toLowerCase()) return headers[key] || ''; return ''; }; const capture = (url, headers) => { const value = String(url || ''); if (!value.includes('api-pan.xunlei.com')) return; const authorization = String(read(headers, 'authorization') || ''); let shareToken = ''; try { shareToken = new URL(value, location.href).searchParams.get('pass_code_token') || ''; } catch (error) {} const detail = { token: authorization.replace(/^Bearer\\s+/i, ''), deviceId: read(headers, 'x-device-id') || read(headers, 'x-guid'), captchaToken: read(headers, 'x-captcha-token'), clientId: read(headers, 'x-client-id'), shareToken }; if (detail.token || detail.deviceId || detail.captchaToken || detail.clientId || detail.shareToken) document.dispatchEvent(new CustomEvent(eventName, { detail })); }; const originalFetch = window.fetch; window.fetch = function(input, init) { capture(typeof input === 'string' ? input : input?.url, init?.headers || input?.headers); return originalFetch.apply(this, arguments); }; const open = XMLHttpRequest.prototype.open; const setHeader = XMLHttpRequest.prototype.setRequestHeader; const send = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.open = function(method, url) { this.__gyhUrl = url; this.__gyhHeaders = {}; return open.apply(this, arguments); }; XMLHttpRequest.prototype.setRequestHeader = function(name, value) { if (this.__gyhHeaders) this.__gyhHeaders[name] = value; return setHeader.apply(this, arguments); }; XMLHttpRequest.prototype.send = function() { capture(this.__gyhUrl, this.__gyhHeaders); return send.apply(this, arguments); }; })();`;
document.documentElement.appendChild(script);
script.remove();
}
// 从迅雷站点存储中读取上下文值
function getXunleiStoredValue(pattern) {
let result = '';
const inspect = (value, key = '', depth = 0) => {
if (depth > 3 || value == null) return;
if (typeof value === 'string') {
if (pattern.test(key) && value.length > result.length) result = value.replace(/^Bearer\s+/i, '').trim();
return;
}
if (typeof value !== 'object') return;
Object.entries(value).forEach(([childKey, childValue]) => inspect(childValue, childKey, depth + 1));
};
[localStorage, sessionStorage].forEach(storage => {
try {
for (let index = 0; index < storage.length; index += 1) {
const key = storage.key(index) || '';
const raw = storage.getItem(key) || '';
inspect(raw, key);
if (/^[{\[]/.test(raw)) { try { inspect(JSON.parse(raw), key); } catch (error) {} }
}
} catch (error) {}
});
return result;
}
// 提取接口返回的验证码
function findXunleiCaptchaToken(value, depth = 0) {
if (depth > 4 || value == null) return '';
if (typeof value !== 'object') return '';
for (const [key, child] of Object.entries(value)) {
if (/captcha[_-]?token/i.test(key) && typeof child === 'string' && child) return child;
const token = findXunleiCaptchaToken(child, depth + 1);
if (token) return token;
}
return '';
}
// 发送迅雷 GET 请求
function requestXunleiGet(url, headers, label) {
return requestJson({
url,
headers,
timeout: 30000,
emptyObject: true,
errorMessage: `${label}请求失败`,
networkMessage: `${label}网络请求失败,请稍后重试`,
timeoutMessage: `${label}请求超时,请稍后重试`,
getError: (data, status) => data?.message || data?.msg || data?.error_description || data?.error || data?.error_details?.map(item => item?.detail || item?.message).filter(Boolean).join(' ') || `${label}请求失败:${status}`,
});
}
// 尝试刷新迅雷页面凭证
async function refreshXunleiCredentials() {
const deviceId = xunleiContext.deviceId || getXunleiStoredValue(/device|guid|(^|_)did$/i);
const token = xunleiContext.token || getXunleiStoredValue(/bearer|access[_-]?token|auth[_-]?token|user[_-]?token/i);
const clientId = xunleiContext.clientId || getXunleiStoredValue(/client[_-]?id/i) || XUNLEI_CLIENT_ID;
if (!deviceId) return false;
xunleiContext.deviceId = deviceId;
if (token) xunleiContext.token = token;
xunleiContext.clientId = clientId;
const headers = { 'x-client-id': clientId, Referer: 'https://pan.xunlei.com/', 'x-device-id': deviceId, 'x-guid': deviceId };
if (token) headers.Authorization = `Bearer ${token}`;
try {
const response = await requestXunleiGet(`${XUNLEI_API_BASE}/drive/v1/captcha/init?client_id=${encodeURIComponent(clientId)}&device_id=${encodeURIComponent(deviceId)}&action=share`, headers, '迅雷云盘');
const captchaToken = findXunleiCaptchaToken(response);
if (captchaToken) xunleiContext.captchaToken = captchaToken;
} catch (error) {}
return Boolean(xunleiContext.deviceId && xunleiContext.captchaToken);
}
// 判断是否需要刷新迅雷凭证
function isXunleiCredentialError(error) {
return /captcha|device.?id|凭证|登录态|authorization|token|unauth|401|403/i.test(String(error?.message || error || ''));
}
// 判断是否可以重试迅雷请求
function isXunleiRetryableError(error) {
return isXunleiCredentialError(error) || /网络|超时|429|5\d\d/i.test(String(error?.message || error || ''));
}
// 执行带凭证刷新的迅雷请求
async function requestXunleiWithRetry(request) {
let lastError = null;
let refreshed = false;
for (let attempt = 1; attempt <= 3; attempt += 1) {
try {
return await request();
} catch (error) {
lastError = error;
const credentialError = isXunleiCredentialError(error);
if (credentialError && !refreshed) {
refreshed = true;
if (xunleiRetryNotifier) xunleiRetryNotifier({ phase: 'refreshing' });
await refreshXunleiCredentials();
continue;
}
if (attempt < 3 && isXunleiRetryableError(error)) {
if (xunleiRetryNotifier) xunleiRetryNotifier({ phase: 'retrying', attempt: attempt + 1 });
await delay(400 * attempt);
continue;
}
break;
}
}
if (isXunleiCredentialError(lastError)) throw new Error('迅雷云盘凭证刷新失败,请刷新页面后重试');
throw lastError || new Error('迅雷云盘请求失败');
}
// 获取迅雷接口请求头
function getXunleiHeaders() {
const token = xunleiContext.token || getXunleiStoredValue(/bearer|access[_-]?token|auth[_-]?token|user[_-]?token/i);
const deviceId = xunleiContext.deviceId || getXunleiStoredValue(/device|guid|(^|_)did$/i);
const captchaToken = xunleiContext.captchaToken;
const clientId = xunleiContext.clientId || getXunleiStoredValue(/client[_-]?id/i) || XUNLEI_CLIENT_ID;
if (!token) throw new Error('未检测到迅雷云盘登录态,请登录后重试');
if (!deviceId || !captchaToken) throw new Error('正在获取迅雷云盘凭证,请刷新页面后重试');
xunleiContext.token = token;
xunleiContext.deviceId = deviceId;
xunleiContext.clientId = clientId;
const headers = { 'Content-Type': 'application/json', 'x-client-id': clientId, Referer: 'https://pan.xunlei.com/', Authorization: `Bearer ${token}` };
if (deviceId) {
headers['x-device-id'] = deviceId;
headers['x-guid'] = deviceId;
}
if (captchaToken) headers['x-captcha-token'] = captchaToken;
return { headers, deviceId };
}
// 请求迅雷云盘接口
function requestXunleiApi(url) {
return requestXunleiWithRetry(() => {
const context = getXunleiHeaders();
const requestUrl = new URL(url);
requestUrl.searchParams.set('device_id', context.deviceId);
requestUrl.searchParams.set('did', context.deviceId);
return requestXunleiGet(requestUrl.toString(), context.headers, '迅雷云盘');
});
}
// 判断是否处于迅雷分享页面
function isXunleiSharePage() {
return isXunleiPan && /^\/s\//.test(window.location.pathname);
}
// 获取当前迅雷分享上下文
function getXunleiShareContext() {
const shareId = window.location.pathname.match(/^\/s\/([^/?#]+)/)?.[1] || '';
const passCodeToken = xunleiContext.shareToken || new URLSearchParams(window.location.search).get('pwd') || '';
if (!shareId || !passCodeToken) throw new Error('无法读取迅雷分享凭证,请刷新分享页面后重试');
return { shareId, passCodeToken };
}
// 请求迅雷分享接口
function requestXunleiShareApi(url) {
return requestXunleiWithRetry(() => {
const deviceId = xunleiContext.deviceId || getXunleiStoredValue(/device|guid|(^|_)did$/i);
const captchaToken = xunleiContext.captchaToken;
if (!deviceId || !captchaToken) throw new Error('正在获取迅雷分享凭证,请刷新页面后重试');
xunleiContext.deviceId = deviceId;
const requestUrl = new URL(url);
requestUrl.searchParams.set('device_id', deviceId);
requestUrl.searchParams.set('did', deviceId);
const headers = { 'Content-Type': 'application/json', 'x-client-id': xunleiContext.clientId || XUNLEI_CLIENT_ID, Referer: 'https://pan.xunlei.com/', 'x-device-id': deviceId, 'x-guid': deviceId, 'x-captcha-token': captchaToken };
return requestXunleiGet(requestUrl.toString(), headers, '迅雷分享');
});
}
// 标准化迅雷文件对象
function normalizeXunleiFile(file, path = '') {
const name = String(file?.name ?? file?.fileName ?? '');
return { ...file, id: String(file?.id ?? file?.fileId ?? ''), name, parentId: String(file?.parent_id ?? file?.parentId ?? ''), kind: String(file?.kind ?? ''), size: Number(file?.size ?? file?.fileSize ?? 0) || 0, path, hash: String(file?.hash ?? file?.gcid ?? ''), md5: String(file?.md5_checksum ?? file?.md5 ?? file?.etag ?? '') };
}
// 获取迅雷目录内容
async function getXunleiFiles(parentId) {
const files = [];
let pageToken = '';
const filters = encodeURIComponent(JSON.stringify({ phase: { eq: 'PHASE_TYPE_COMPLETE' }, trashed: { eq: false } }));
do {
const response = await requestXunleiApi(`${XUNLEI_API_BASE}/drive/v1/files?parent_id=${encodeURIComponent(parentId)}&usage=DISPLAY&filters=${filters}&with_audit=true&limit=100&page_token=${encodeURIComponent(pageToken)}`);
files.push(...(response?.files || []).map(file => normalizeXunleiFile(file)));
pageToken = String(response?.next_page_token || '');
} while (pageToken);
return files;
}
// 获取迅雷分享目录内容
async function getXunleiShareFiles(parentId, context) {
const files = [];
let pageToken = '';
do {
const response = await requestXunleiShareApi(`${XUNLEI_API_BASE}/drive/v1/share/detail?share_id=${encodeURIComponent(context.shareId)}&parent_id=${encodeURIComponent(parentId)}&pass_code_token=${encodeURIComponent(context.passCodeToken)}&limit=100&page_token=${encodeURIComponent(pageToken)}&with_audit=true&thumbnail_size=SIZE_LARGE&usage=CONSUME`);
files.push(...(response?.files || response?.data?.files || []).map(file => normalizeXunleiFile(file)));
pageToken = String(response?.next_page_token || response?.data?.next_page_token || '');
} while (pageToken);
return files;
}
// 获取迅雷文件详情补充哈希
async function getXunleiFileDetail(file) {
const response = await requestXunleiApi(`${XUNLEI_API_BASE}/drive/v1/files/${encodeURIComponent(file.id)}?space=&usage=CONSUME`);
return normalizeXunleiFile(response?.file || response?.data || response, file.path);
}
// 获取迅雷分享文件详情
async function getXunleiShareFileDetail(file, context) {
const response = await requestXunleiShareApi(`${XUNLEI_API_BASE}/drive/v1/share/file_info?share_id=${encodeURIComponent(context.shareId)}&file_id=${encodeURIComponent(file.id)}&pass_code_token=${encodeURIComponent(context.passCodeToken)}&usage=CONSUME`);
return normalizeXunleiFile(response?.file_info || response?.file || response?.data || response, file.path);
}
// 读取迅雷页面选择的资源
async function getSelectedXunleiResources() {
if (isXunleiSharePage()) {
const context = getXunleiShareContext();
const files = await getXunleiShareFiles('', context);
const selectedRows = [...document.querySelectorAll("li[class*='SourceListItem__item']")].filter(row => row.querySelector("input[type='checkbox']:checked"));
if (!selectedRows.length) return [];
return files.filter(file => selectedRows.some(row => row.textContent.includes(file.name)));
}
const items = [...document.querySelectorAll("[class*='SourceListItem__item']")];
const store = items.map(item => item.__vue__?.$store).find(Boolean);
const selected = items.map(item => item.__vue__?.$props?.selected).find(value => Array.isArray(value) && value.length) || [];
const all = store?.state?.drive?.all || {};
return selected.map(item => typeof item === 'object' ? item : all[item]).filter(Boolean).map(file => normalizeXunleiFile(file));
}
// 递归收集导出文件
async function collectXunleiFiles(resources, getChildren, onProgress) {
return walkResources(resources, {
isFile: resource => resource.kind !== 'drive#folder',
getChildren: resource => getChildren(resource.id),
onFile: (file, count) => onProgress?.({ phase: 'scanning', count }),
});
}
// 递归收集迅雷所选资源
async function collectXunleiExportFiles(resources, onProgress) {
return collectXunleiFiles(resources, getXunleiFiles, onProgress);
}
// 递归收集迅雷分享所选资源
async function collectXunleiShareExportFiles(resources, context, onProgress) {
return collectXunleiFiles(resources, id => getXunleiShareFiles(id, context), onProgress);
}
async function buildRapidExportData(sourceFiles, { source, phase, onProgress, resolveFile, extraFields }) {
const files = [];
const skipped = [];
for (let index = 0; index < sourceFiles.length; index += 1) {
let file = sourceFiles[index];
let hash = getGuangyaRapidHash(file);
onProgress?.({ phase, index: index + 1, total: sourceFiles.length });
if (!hash.gcid && !hash.md5 && resolveFile) {
try {
file = { ...file, ...await resolveFile(file) };
hash = getGuangyaRapidHash(file);
} catch (error) {}
}
if (!hash.gcid && !hash.md5) {
skipped.push(file.name);
continue;
}
const entry = { path: [...file.path, file.name].filter(Boolean).join('/'), size: Math.max(0, Number(file.size) || 0), ...extraFields?.(file) };
entry.md5 = hash.md5 || hash.gcid.slice(0, 32).toLowerCase();
if (hash.gcid) entry.gcid = hash.gcid;
if (hash.cid) entry.cid = hash.cid;
if (hash.wholeCid) entry.wholeCid = hash.wholeCid;
files.push(entry);
}
if (!files.length) throw new Error('选中的文件缺少秒传信息,暂无法导出');
return { data: { version: '1.0', source, createdAt: new Date().toISOString(), files }, total: sourceFiles.length, skipped };
}
// 构建迅雷秒传导出数据
async function buildXunleiRapidExportData(resources, onProgress) {
const shareContext = isXunleiSharePage() ? getXunleiShareContext() : null;
const sourceFiles = shareContext ? await collectXunleiShareExportFiles(resources, shareContext, onProgress) : await collectXunleiExportFiles(resources, onProgress);
return buildRapidExportData(sourceFiles, {
source: 'xunlei',
phase: 'checking',
onProgress,
resolveFile: file => shareContext ? getXunleiShareFileDetail(file, shareContext) : getXunleiFileDetail(file),
extraFields: file => ({ sourceXunlei: true, fileId: file.id, parentId: file.parentId }),
});
}
// 获取当前目录 ID
function getCurrentGuangyaDirectoryId() {
const segments = window.location.hash.replace(/^#\/home\/all\/?/, '').split('/').filter(Boolean);
return segments.length ? segments[segments.length - 1].split('-')[0] : '';
}
// 发送光鸭 POST 请求
function requestGuangyaPost(url, body, options = {}) {
const token = getGuangyaAuthToken();
if (!token) return Promise.reject(new Error('未登录,请先登录光鸭云盘'));
return requestJson({
method: 'POST',
url,
headers: { 'Content-Type': options.contentType || 'application/json', Authorization: token, ...options.headers },
body,
timeout: options.timeout,
emptyObject: options.emptyObject,
getError: (data, status) => data?.msg || `请求失败:${status}`,
});
}
// 请求光鸭开放接口
function requestGuangyaApi(url, body) {
return requestGuangyaPost(url, body);
}
// 请求秒传接口
function requestGuangyaRapidApi(url, body) {
return requestGuangyaPost(url, body, { contentType: 'application/json;charset=utf-8', headers: { dt: '4' }, timeout: 20000, emptyObject: true });
}
// 读取异步任务编号
function findTaskId(value) {
if (!value || typeof value !== 'object') return '';
if (value.taskId || value.task_id) return String(value.taskId || value.task_id);
for (const child of Object.values(value)) { const id = findTaskId(child); if (id) return id; }
return '';
}
// 提取标准 MD5
function getRapidMd5(value) {
const match = String(value || '').match(/[a-f\d]{32}/i);
return match ? match[0].toLowerCase() : '';
}
// 提取标准 GCID
function getRapidGcid(value) {
const match = String(value || '').match(/[a-f\d]{40}/i);
return match ? match[0].toUpperCase() : '';
}
// 规范导入文件路径
function normalizeRapidImportEntry(entry, index) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) throw new Error(`第 ${index + 1} 项不是文件对象`);
const rawPath = String(entry.path || entry.name || '').trim().replace(/\\/g, '/');
if (!rawPath) throw new Error(`第 ${index + 1} 项缺少 path`);
if (/\/$/.test(rawPath)) throw new Error(`第 ${index + 1} 项的 path 不能是目录`);
const parts = rawPath.replace(/^\/+/, '').split('/').map(part => part.trim()).filter(Boolean);
if (!parts.length || parts.some(part => part === '.' || part === '..' || /[\0\r\n]/.test(part))) throw new Error(`第 ${index + 1} 项的 path 无效`);
const size = Number(entry.size ?? entry.fileSize ?? entry.file_size ?? 0);
if (!Number.isFinite(size) || size < 0) throw new Error(`第 ${index + 1} 项的 size 无效`);
const md5 = getRapidMd5(entry.md5 || entry.etag);
const gcid = getRapidGcid(entry.gcid);
if (!md5 && !gcid) throw new Error(`第 ${index + 1} 项缺少有效的 MD5 或 GCID`);
return { path: parts.join('/'), name: parts.at(-1), directories: parts.slice(0, -1), size, md5, gcid, cid: getRapidGcid(entry.cid || entry.wholeCid || entry.tripleCid) };
}
// 解析秒传 JSON
function parseRapidImportJson(rawJson) {
let value;
try { value = JSON.parse(String(rawJson || '')); } catch (error) { throw new Error('JSON 解析失败,请检查内容格式'); }
if (!value || typeof value !== 'object' || Array.isArray(value) || !Array.isArray(value.files)) throw new Error('JSON 顶层需包含 files 数组');
if (!value.files.length) throw new Error('files 数组为空,没有可导入的文件');
const files = [];
const invalid = [];
value.files.forEach((entry, index) => {
try { files.push({ ...normalizeRapidImportEntry(entry, index), sourceIndex: index }); } catch (error) { invalid.push(error.message); }
});
if (!files.length) throw new Error(invalid[0] || '没有可导入的有效文件');
return { files, invalid };
}
// 创建并缓存导入目录
async function ensureRapidDirectoryPath(rootId, directories, cache) {
let parentId = String(rootId || '');
let fullPath = '';
for (const directory of directories) {
fullPath = fullPath ? `${fullPath}/${directory}` : directory;
if (cache.has(fullPath)) {
parentId = cache.get(fullPath);
continue;
}
parentId = String(await ensureMovieDirectory(parentId, directory));
cache.set(fullPath, parentId);
}
return parentId;
}
// 等待秒传任务完成
async function waitRapidImportTask(taskId) {
return pollTask({
attempts: 60,
request: () => requestGuangyaRapidApi(`${API.guangya}/userres/v1/file/get_info_by_task_id`, { taskId: String(taskId) }),
inspect(response) {
const data = response?.data || {};
if (data.fileId || data.file_id || data.id) return { done: true };
const status = String(data.taskStatus ?? data.task_status ?? data.status ?? data.state ?? '').toUpperCase();
return [3, 4].includes(Number(status)) || /FAIL|ERROR|CANCEL|失败|错误|取消/.test(status) ? { error: response?.msg || '秒传任务失败' } : null;
},
timeoutMessage: '秒传任务超时',
});
}
// 执行单个秒传文件导入
async function importRapidFile(file, parentId) {
const tokenUrl = `${API.guangya}/userres/v1/get_res_center_token`;
const checkUrl = `${API.guangya}/userres/v1/check_can_flash_upload`;
const fileSize = Number(file.size);
const gcid = file.gcid;
const validCid = file.cid && file.cid !== gcid ? file.cid : '';
const candidates = [];
const addCandidate = (capacity, res) => {
const key = `${capacity}:${JSON.stringify(res)}`;
if (!candidates.some(candidate => candidate.key === key)) candidates.push({ key, capacity, res });
};
if (gcid) {
if (validCid) addCandidate(2, { gcid, cid: validCid, ...(file.md5 ? { md5: file.md5 } : {}), fileSize });
if (file.md5) addCandidate(2, { gcid, md5: file.md5, fileSize });
addCandidate(2, { gcid, fileSize });
} else {
addCandidate(1, { md5: file.md5, fileSize });
}
let tokenResponse = null;
let taskId = '';
let tokenError = null;
for (const candidate of candidates) {
try {
const response = await requestGuangyaRapidApi(tokenUrl, { capacity: candidate.capacity, res: candidate.res, name: file.name, parentId: String(parentId || '') });
if (Number(response?.code) === 156) return;
const currentTaskId = findTaskId(response?.data || response);
if (currentTaskId) {
tokenResponse = response;
taskId = currentTaskId;
break;
}
tokenResponse = response;
} catch (error) {
tokenError = error;
}
}
if (!gcid) throw new Error(tokenResponse?.msg || tokenError?.message || '资源未命中,无法秒传');
if (!taskId) throw new Error(tokenResponse?.msg || tokenError?.message || '未能创建秒传任务');
const checkCandidates = [];
if (validCid) checkCandidates.push({ taskId, gcid, cid: validCid, ...(file.md5 ? { md5: file.md5 } : {}), fileSize });
if (file.md5) checkCandidates.push({ taskId, gcid, md5: file.md5, fileSize });
checkCandidates.push({ taskId, gcid, fileSize });
let checkResponse = null;
let pollTaskId = '';
let checkError = null;
for (const body of checkCandidates) {
try {
const response = await requestGuangyaRapidApi(checkUrl, body);
checkResponse = response;
const data = response?.data || {};
if (data.canFlashUpload === true || data.fileId || data.file_id) {
pollTaskId = findTaskId(data) || taskId;
break;
}
const nextTaskId = findTaskId(data);
if (nextTaskId) {
pollTaskId = nextTaskId;
break;
}
} catch (error) {
checkError = error;
}
}
if (!pollTaskId) throw new Error(checkResponse?.msg || checkError?.message || '资源未命中,无法秒传');
await waitRapidImportTask(pollTaskId);
}
// 并发导入秒传文件
async function runRapidImport(rawJson, onProgress) {
const parsed = parseRapidImportJson(rawJson);
const rootId = getCurrentGuangyaDirectoryId();
const directoryCache = new Map([['', String(rootId || '')]]);
const summary = { total: parsed.files.length, success: 0, failed: 0, invalid: parsed.invalid, failures: [] };
for (let index = 0; index < parsed.files.length; index += 1) {
const file = parsed.files[index];
try {
if (onProgress) onProgress({ phase: 'preparing', index: index + 1, ...summary, file });
file.parentId = await ensureRapidDirectoryPath(rootId, file.directories, directoryCache);
if (onProgress) onProgress({ phase: 'prepared', index: index + 1, ...summary, file });
} catch (error) {
summary.failed += 1;
summary.failures.push({ path: file.path, reason: error?.message || '导入失败' });
if (onProgress) onProgress({ phase: 'failed', index: index + 1, ...summary, file, error });
}
}
const queue = parsed.files.filter(file => file.parentId !== undefined);
let nextIndex = 0;
const worker = async () => {
while (true) {
const file = queue[nextIndex++];
if (!file) return;
const index = file.sourceIndex + 1;
try {
if (onProgress) onProgress({ phase: 'processing', index, ...summary, file });
await importRapidFile(file, file.parentId);
summary.success += 1;
if (onProgress) onProgress({ phase: 'success', index, ...summary, file });
} catch (error) {
summary.failed += 1;
summary.failures.push({ path: file.path, reason: error?.message || '导入失败' });
if (onProgress) onProgress({ phase: 'failed', index, ...summary, file, error });
}
if (nextIndex < queue.length) await delay(100);
}
};
await Promise.all(Array.from({ length: Math.min(3, queue.length) }, worker));
return summary;
}
// 等待光鸭异步任务完成
async function waitGuangyaTask(response, label) {
const taskId = findTaskId(response);
if (!taskId) return;
return pollTask({
attempts: 40,
request: () => requestGuangyaApi(`${API.guangya}/nd.bizuserres.s/v1/get_task_status`, { taskId }),
inspect(taskResponse) {
const task = taskResponse?.data || taskResponse;
const status = String(task?.taskStatus ?? task?.task_status ?? task?.status ?? task?.state ?? '').toUpperCase();
if (Number(status) === 2 || /SUCCESS|SUCCEED|DONE|FINISH|COMPLET|成功|完成/.test(status)) return { done: true };
return [3, 4].includes(Number(status)) || /FAIL|ERROR|CANCEL|失败|错误|取消/.test(status) ? { error: label + '失败' } : null;
},
timeoutMessage: label + '超时',
});
}
// 获取目录下的全部文件
async function getGuangyaFiles(parentId) {
const files = [];
let page = 0;
while (true) {
const response = await requestGuangyaApi(
`${API.guangya}/nd.bizuserres.s/v1/file/get_file_list`,
{ page, parentId, pageSize: 1000, orderBy: 1, sortType: 1 }
);
const list = response?.data?.list || [];
files.push(...list.map(file => ({ ...file, id: String(file.fileId ?? file.id ?? ''), name: String(file.fileName ?? file.name ?? ''), resType: Number(file.resType), fileType: file.fileType, size: Number(file.fileSize ?? file.size ?? file.fileSizeByte ?? file.file_size ?? 0), parentId: String(file.parentId ?? parentId ?? '') })));
if (list.length === 0 || files.length >= (response?.data?.total || 0)) break;
page += 1;
}
return files;
}
// 提取可用于秒传的文件哈希
function getGuangyaRapidHash(file) {
const values = [file?.gcid, file?.md5, file?.etag, file?.hash, file?.fileHash, file?.fileMd5, file?.cid, file?.tripleCid, file?.wholeCid, file?.whole_cid, file?.sha1, file?.fileSha1].map(value => String(value || '').trim());
const gcid = values.find(value => /^[a-f\d]{40}$/i.test(value)) || '';
const md5 = values.find(value => /^[a-f\d]{32}$/i.test(value)) || '';
const cid = [file?.cid, file?.tripleCid, file?.triple_cid, file?.sampleCid, file?.sampleFileCid, file?.chunkCid, file?.chunkHash].map(value => String(value || '').trim()).find(value => /^[a-f\d]{40}$/i.test(value) && value.toLowerCase() !== gcid.toLowerCase()) || '';
const wholeCid = [file?.wholeCid, file?.whole_cid, file?.wholeSha1, file?.whole_sha1, file?.fileSha1, file?.file_sha1, file?.sha1].map(value => String(value || '').trim()).find(value => /^[a-f\d]{40}$/i.test(value) && value.toLowerCase() !== gcid.toLowerCase() && value.toLowerCase() !== cid.toLowerCase()) || '';
return { md5: md5.toLowerCase(), gcid: gcid.toUpperCase(), cid: cid.toUpperCase(), wholeCid: wholeCid.toUpperCase() };
}
// 递归收集所选资源中的全部文件
async function collectGuangyaExportFiles(resources, onProgress) {
return walkResources(resources, {
isFile: resource => resource.resType === 1,
getChildren: resource => getGuangyaFiles(resource.id),
onFile: (file, count) => onProgress?.({ phase: 'scanning', count }),
});
}
// 构建光鸭秒传导出数据
async function buildGuangyaRapidExportData(resources, onProgress) {
const sourceFiles = await collectGuangyaExportFiles(resources, onProgress);
return buildRapidExportData(sourceFiles, { source: 'guangya', phase: 'building', onProgress });
}
const guangyaSelection = {
selectedFiles: new Map(),
fileCache: new Map(),
filesByName: new Map(),
directoryId: null,
cachePromise: null,
isUpdating: false,
};
// 合并短时间内的重复调用
function debounce(callback, wait = 250) {
let timer = null;
return (...args) => {
window.clearTimeout(timer);
timer = window.setTimeout(() => callback(...args), wait);
};
}
// 确保当前目录文件已缓存
async function ensureGuangyaFileCache() {
const directoryId = getCurrentGuangyaDirectoryId();
if (guangyaSelection.directoryId === directoryId && guangyaSelection.fileCache.size > 0) return;
if (guangyaSelection.directoryId !== directoryId) {
guangyaSelection.directoryId = directoryId;
guangyaSelection.selectedFiles.clear();
guangyaSelection.fileCache.clear();
guangyaSelection.filesByName.clear();
}
if (guangyaSelection.cachePromise) return guangyaSelection.cachePromise;
guangyaSelection.cachePromise = getGuangyaFiles(directoryId)
.then((files) => {
guangyaSelection.fileCache = new Map(files.map(file => [file.id, file]));
guangyaSelection.filesByName.clear();
files.forEach((file) => {
const entries = guangyaSelection.filesByName.get(file.name) || [];
entries.push(file.id);
guangyaSelection.filesByName.set(file.name, entries);
});
})
.finally(() => {
guangyaSelection.cachePromise = null;
});
return guangyaSelection.cachePromise;
}
// 从缓存中按名称查找文件
function getCachedFileByName(fileName, preferUnselected = true) {
const ids = guangyaSelection.filesByName.get(fileName) || [];
const matchedId = preferUnselected
? ids.find(id => !guangyaSelection.selectedFiles.has(id)) || ids[0]
: ids[0];
return matchedId ? guangyaSelection.fileCache.get(matchedId) : null;
}
// 将文件加入选择集合
async function selectGuangyaFile(fileName) {
await ensureGuangyaFileCache();
const file = getCachedFileByName(fileName);
if (file) guangyaSelection.selectedFiles.set(file.id, file);
}
// 将文件移出选择集合
function unselectGuangyaFile(fileName) {
const ids = guangyaSelection.filesByName.get(fileName) || [];
const selectedId = ids.find(id => guangyaSelection.selectedFiles.has(id));
if (selectedId) guangyaSelection.selectedFiles.delete(selectedId);
}
// 同步页面可见的选中项
async function syncVisibleGuangyaSelection() {
const selectedNames = Array.from(document.querySelectorAll('.swangpan-file-list-table__row[data-state="selected"]'))
.map(row => row.querySelector('[title]')?.getAttribute('title'))
.filter(Boolean);
if (!selectedNames.length) return;
await ensureGuangyaFileCache();
selectedNames.forEach((fileName) => {
const file = getCachedFileByName(fileName);
if (file) guangyaSelection.selectedFiles.set(file.id, file);
});
}
// 绑定文件选择事件
function bindGuangyaSelectionEvents() {
const headerCheckbox = document.querySelector('.swangpan-file-list-table__header .swangpan-checkbox__input');
if (headerCheckbox && !headerCheckbox.dataset.gyhBound) {
headerCheckbox.dataset.gyhBound = '1';
headerCheckbox.addEventListener('change', async (event) => {
if (guangyaSelection.isUpdating) return;
guangyaSelection.isUpdating = true;
try {
if (event.target.checked) {
await ensureGuangyaFileCache();
guangyaSelection.selectedFiles = new Map(guangyaSelection.fileCache);
} else {
guangyaSelection.selectedFiles.clear();
}
} finally {
guangyaSelection.isUpdating = false;
}
});
}
const fileListBody = document.querySelector('.swangpan-file-list-table__body');
if (fileListBody && !fileListBody.dataset.gyhBound) {
fileListBody.dataset.gyhBound = '1';
fileListBody.addEventListener('click', (event) => {
if (guangyaSelection.isUpdating) return;
window.setTimeout(async () => {
const row = event.target.closest('.swangpan-file-list-table__row');
if (!row) return;
const fileName = row.querySelector('[title]')?.getAttribute('title');
if (!fileName) return;
guangyaSelection.isUpdating = true;
try {
if (row.getAttribute('data-state') === 'selected') {
await selectGuangyaFile(fileName);
} else {
unselectGuangyaFile(fileName);
}
} finally {
guangyaSelection.isUpdating = false;
}
}, 0);
});
}
}
// 初始化选择状态监听
function initializeGuangyaSelection() {
let lastDirectoryId = getCurrentGuangyaDirectoryId();
const rebind = debounce(() => {
const currentDirectoryId = getCurrentGuangyaDirectoryId();
if (currentDirectoryId !== lastDirectoryId) {
guangyaSelection.directoryId = null;
guangyaSelection.selectedFiles.clear();
lastDirectoryId = currentDirectoryId;
}
bindGuangyaSelectionEvents();
syncVisibleGuangyaSelection().catch(() => {});
ensureGuangyaFileCache().catch(() => {});
});
bindGuangyaSelectionEvents();
syncVisibleGuangyaSelection().catch(() => {});
ensureGuangyaFileCache().catch(() => {});
new MutationObserver(rebind).observe(document.body, { childList: true, subtree: true });
}
// 返回当前已选文件
async function getSelectedGuangyaFiles() {
await syncVisibleGuangyaSelection();
return [...guangyaSelection.selectedFiles.values()].filter(file => file.resType === 1);
}
// 返回当前已选资源
async function getSelectedGuangyaResources() {
await syncVisibleGuangyaSelection();
return [...guangyaSelection.selectedFiles.values()];
}
// 重命名单个光鸭文件
async function renameGuangyaFile(fileId, newName) {
const response = await requestGuangyaApi(
`${API.guangya}/nd.bizuserres.s/v1/file/rename`,
{ fileId, newName }
);
if (response?.msg !== 'success') throw new Error(response?.msg || '重命名失败');
await waitGuangyaTask(response, '重命名');
}
// 转义预览中的文本内容
function escapeHtml(value) {
return String(value).replace(/[&<>"]/g, character => ({
'&': '&', '<': '<', '>': '>', '"': '"',
})[character]);
}
// 判断文件是否为视频
function isVideoFile(file) {
return file.fileType === 2 || VIDEO_EXTENSION_RE.test(file.name);
}
const overlays = { rename: null, movie: null, tv: null, rapidImport: null, rapidExport: null, rapidExportProgress: null };
function removeOverlay(overlay) {
if (!overlay) return;
if (overlay._gyhOnKeydown) document.removeEventListener('keydown', overlay._gyhOnKeydown);
overlay.remove();
}
// 关闭弹窗并清理事件
function closeOverlay(overlay, clear, shouldRefresh = false) {
if (!overlay) return;
const refresh = shouldRefresh && overlay._gyhShouldRefresh === true;
removeOverlay(overlay);
clear();
if (refresh) window.location.reload();
}
function closeDesigner(name, shouldRefresh = false) {
closeOverlay(overlays[name], () => { overlays[name] = null; }, shouldRefresh);
}
// 渲染当前方式的规则输入项
function renderRenameFields(container, mode) {
const config = renameModes.find(item => item.key === mode);
container.innerHTML = config.fields.map(([label, placeholder]) => `
`).join('');
}
// 根据规则生成重命名预览
function buildRenamePlan(files, mode, inputs) {
const values = Array.from(inputs).map(input => input.value || '');
const config = renameModes.find(item => item.key === mode);
let prepared;
try { prepared = config.prepare?.(values); } catch (error) { return { error: '正则表达式格式不正确', items: [] }; }
const items = files.map((file, index) => {
const lastDot = file.name.lastIndexOf('.');
const baseName = lastDot > 0 ? file.name.slice(0, lastDot) : file.name;
const extension = lastDot > 0 ? file.name.slice(lastDot) : '';
return { ...file, newName: config.apply({ file, index, values, baseName, extension, prepared }) };
});
return { error: null, items };
}
// 渲染文件名称预览行
function renderRenamePreview(container, items) {
container.innerHTML = `
${items.map((file, index) => `
${String(index + 1).padStart(2, '0')} ${escapeHtml(file.name)}
→
${escapeHtml(file.newName)}
待改名
`).join('')}
`;
}
// 平滑展示正在处理的行
function revealRenameRow(previewBody, row) {
const rowRect = row.getBoundingClientRect();
const bodyRect = previewBody.getBoundingClientRect();
let offset = 0;
if (rowRect.top < bodyRect.top) {
offset = rowRect.top - bodyRect.top;
} else if (rowRect.bottom > bodyRect.bottom) {
offset = rowRect.bottom - bodyRect.bottom;
}
if (offset !== 0) {
previewBody.scrollTo({ top: previewBody.scrollTop + offset, behavior: 'smooth' });
}
}
// 更新预览行的状态样式
function setPreviewRowState(row, state, text) {
if (!row) return;
row.classList.remove('is-processing', 'is-success', 'is-failed');
if (state) row.classList.add('is-' + state);
const status = row.querySelector('.gyh-rename-row-status, .gyh-movie-preview-status');
if (status) status.textContent = text;
}
// 打开秒传提取进度弹窗
function showRapidExportProgress() {
closeDesigner('rapidExportProgress');
const overlay = document.createElement('div');
overlay.className = 'gyh-rename-overlay';
overlay.innerHTML = ``;
overlays.rapidExportProgress = overlay;
document.body.appendChild(overlay);
const text = overlay.querySelector('[data-rapid-export-progress-text]');
const count = overlay.querySelector('[data-rapid-export-progress-count]');
return {
update(progress) {
const phase = progress?.phase || '';
if (phase === 'scanning') {
text.textContent = '正在递归读取文件夹';
count.textContent = progress.count ? `已发现 ${progress.count} 个文件` : '';
} else if (phase === 'checking') {
text.textContent = '正在补全文件哈希';
count.textContent = progress.total ? `${progress.index}/${progress.total}` : '';
} else if (phase === 'building') {
text.textContent = '正在整理秒传信息';
count.textContent = progress.total ? `${progress.index}/${progress.total}` : '';
} else if (phase === 'refreshing') {
text.textContent = '正在刷新迅雷凭证';
count.textContent = '正在重新验证请求信息';
} else if (phase === 'retrying') {
text.textContent = '正在重新请求迅雷接口';
count.textContent = `第 ${progress.attempt} 次尝试`;
}
},
close: () => closeDesigner('rapidExportProgress'),
};
}
// 复制秒传 JSON
async function copyRapidExportJson(value) {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value);
return true;
}
} catch (error) {}
const textarea = document.createElement('textarea');
textarea.className = 'gyh-copy-buffer';
textarea.value = value;
document.body.appendChild(textarea);
textarea.select();
const copied = document.execCommand('copy');
textarea.remove();
return copied;
}
// 下载秒传 JSON
function downloadRapidExportJson(value) {
const now = new Date();
const timestamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}_${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`;
const fileName = `秒传导出_${timestamp}.json`;
const url = URL.createObjectURL(new Blob([value], { type: 'application/json;charset=utf-8' }));
const link = document.createElement('a');
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 0);
}
// 打开秒传文件导出弹窗
function showRapidExportDesigner(exportResult) {
if (overlays.rapidExport) return;
const exportData = exportResult.data;
const sourceName = exportData.source === 'xunlei' ? '迅雷云盘' : '光鸭云盘';
const exportJson = JSON.stringify(exportData, null, 2);
const fileCount = exportData.files.length;
const skipped = exportResult?.skipped?.length || 0;
const overlay = document.createElement('div');
overlay.className = 'gyh-rename-overlay';
overlay.innerHTML = `${modalHeader({ id: 'gyh-rapid-export-title', icon: ICONS.export, title: '导出秒传文件', subtitle: '生成可用于秒传导入的 JSON 文件', closeLabel: '关闭导出秒传文件' })}`;
overlays.rapidExport = overlay;
overlay.querySelector('[data-rapid-export-source]').textContent = sourceName;
overlay.querySelector('[data-rapid-export-count]').textContent = `${fileCount} 个`;
overlay.querySelector('[data-rapid-export-hint]').textContent = '已生成';
overlay.querySelector('[data-rapid-export-note]').textContent = skipped ? `已导出 ${fileCount} 个文件,跳过 ${skipped} 个缺少秒传信息的文件` : `已导出 ${fileCount} 个文件`;
const editorHost = overlay.querySelector('[data-rapid-export-editor]');
createJsonEditor(editorHost, { value: exportJson, readOnly: true, fallbackClass: 'gyh-rapid-export-fallback' });
const close = () => closeDesigner('rapidExport');
overlay.querySelector('.gyh-rename-close').addEventListener('click', close);
overlay.querySelector('[data-rapid-export-close]').addEventListener('click', close);
overlay.querySelector('[data-rapid-export-copy]').addEventListener('click', async () => {
const copied = await copyRapidExportJson(exportJson);
showToast(copied ? '秒传 JSON 已复制' : '复制失败,请手动复制', copied ? 'success' : 'error');
});
overlay.querySelector('[data-rapid-export-download]').addEventListener('click', () => {
downloadRapidExportJson(exportJson);
showToast('秒传 JSON 已下载', 'success');
});
bindOverlay(overlay, close, { modal: '.gyh-rapid-export-modal' });
}
// 打开秒传文件导入弹窗
function showRapidImportDesigner() {
if (overlays.rapidImport) return;
const overlay = document.createElement('div');
overlay.className = 'gyh-rename-overlay';
overlay.innerHTML = `${modalHeader({ id: 'gyh-rapid-import-title', icon: ICONS.import, title: '导入秒传文件', subtitle: '选择文件或直接填写秒传内容', closeLabel: '关闭导入秒传文件' })}可以选择导入秒传JSON文件,也可以直接填写JSON格式的内容
${modalFooter('支持 JSON 文件或直接填写内容', '')}`;
overlays.rapidImport = overlay;
const editorHost = overlay.querySelector('[data-rapid-import-editor]');
const modalBody = overlay.querySelector('.gyh-rename-body');
const editorSection = overlay.querySelector('[data-rapid-import-editor-section]');
const fileInput = overlay.querySelector('.gyh-rapid-import-file-input');
const fileName = overlay.querySelector('.gyh-rapid-import-file-name');
const footerNote = overlay.querySelector('[data-rapid-import-note]');
const fileButton = overlay.querySelector('.gyh-rapid-import-file-button');
const clearButton = overlay.querySelector('[data-rapid-import-clear]');
const cancelButton = overlay.querySelector('[data-rapid-import-cancel]');
const startButton = overlay.querySelector('[data-rapid-import-start]');
const closeButton = overlay.querySelector('.gyh-rename-close');
let importing = false;
let completed = false;
let progressBody = null;
let progressSection = null;
const progressRows = new Map();
const jsonEditor = createJsonEditor(editorHost, { autocomplete: true, fallbackClass: 'gyh-rapid-import-fallback', placeholder: '粘贴秒传 JSON 内容' });
const setEditorValue = value => jsonEditor.setValue(value);
const getEditorValue = () => jsonEditor.getValue();
const updateRapidFooter = progress => {
const finished = progress.success + progress.failed;
const prefix = ['preparing', 'prepared'].includes(progress.phase) ? `准备导入目录 ${progress.index}/${progress.total} 个` : `导入文件 ${finished}/${progress.total} 个`;
const skipped = progress.invalid?.length ? ` · 跳过 ${progress.invalid.length} 个` : '';
footerNote.innerHTML = `${prefix} · 已导入 ${progress.success} 个 · 失败 ${progress.failed} 个${skipped}`;
};
const updateRapidRow = (file, state) => {
const row = progressRows.get(file.sourceIndex);
if (!row) return;
const labels = { waiting: '待导入', processing: '导入中', success: '已导入', failed: '已失败' };
setPreviewRowState(row, state === 'waiting' ? '' : state, labels[state] || labels.waiting);
if (state !== 'waiting' && progressBody) revealRenameRow(progressBody, row);
};
const showRapidProgress = files => {
editorHost.hidden = true;
const section = document.createElement('section');
section.className = 'gyh-rename-section';
section.innerHTML = `导入进度
${files.length} 个文件`;
progressBody = section.querySelector('.gyh-rapid-import-progress-body');
files.forEach(file => {
const row = document.createElement('div');
const directory = file.directories.length ? file.directories.join('/') : '当前目录';
row.className = 'gyh-rapid-import-progress-row gyh-rename-row';
row.innerHTML = `${escapeHtml(directory)}${escapeHtml(file.name)}待导入`;
progressRows.set(file.sourceIndex, row);
progressBody.appendChild(row);
});
modalBody.appendChild(section);
progressSection = section;
};
const setImporting = value => {
importing = value;
const lockEditor = value || completed;
fileInput.disabled = lockEditor;
fileButton.classList.toggle('is-disabled', lockEditor);
clearButton.disabled = value;
cancelButton.disabled = value;
closeButton.disabled = value;
startButton.disabled = value;
clearButton.textContent = completed ? '重置' : '清空';
cancelButton.textContent = '取消';
startButton.textContent = value ? '导入中' : completed ? '关闭' : '开始导入';
};
const resetRapidImportState = () => {
completed = false;
overlay._gyhShouldRefresh = false;
if (progressSection) progressSection.remove();
progressSection = null;
progressBody = null;
progressRows.clear();
editorSection.hidden = false;
editorHost.hidden = false;
setImporting(false);
window.requestAnimationFrame(() => jsonEditor.resize());
};
clearButton.addEventListener('click', () => {
resetRapidImportState();
setEditorValue('');
fileInput.value = '';
fileName.textContent = '可以选择导入秒传JSON文件,也可以直接填写JSON格式的内容';
footerNote.textContent = '支持 JSON 文件或直接填写内容';
jsonEditor.focus();
});
fileInput.addEventListener('change', () => {
const file = fileInput.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
setEditorValue(String(reader.result || ''));
fileName.textContent = file.name + ' · ' + (file.size / 1024).toFixed(1) + ' KB';
footerNote.textContent = '已读取 JSON 文件,可继续编辑内容';
};
reader.onerror = () => {
fileName.textContent = '文件读取失败,请重新选择';
footerNote.textContent = '请选择有效的 JSON 文件';
};
reader.readAsText(file, 'UTF-8');
});
const close = () => { if (!importing) closeDesigner('rapidImport', true); };
closeButton.addEventListener('click', close);
cancelButton.addEventListener('click', close);
startButton.addEventListener('click', async () => {
if (importing) return;
if (completed) {
closeDesigner('rapidImport', true);
return;
}
const rawJson = getEditorValue();
try {
const preview = parseRapidImportJson(rawJson);
showRapidProgress(preview.files);
setImporting(true);
updateRapidFooter({ phase: 'preparing', index: 0, total: preview.files.length, success: 0, failed: 0, invalid: preview.invalid });
const result = await runRapidImport(rawJson, progress => {
if (progress.phase === 'processing') updateRapidRow(progress.file, 'processing');
if (progress.phase === 'success') updateRapidRow(progress.file, 'success');
if (progress.phase === 'failed') updateRapidRow(progress.file, 'failed');
updateRapidFooter(progress);
});
completed = true;
overlay._gyhShouldRefresh = result.success > 0;
updateRapidFooter({ phase: 'completed', ...result });
showToast(result.failed ? `导入完成,成功 ${result.success} 个,失败 ${result.failed} 个` : `已成功导入 ${result.success} 个文件`, result.failed ? 'info' : 'success');
} catch (error) {
footerNote.textContent = error?.message || '导入失败,请检查秒传 JSON';
showToast(error?.message || '导入失败,请检查秒传 JSON', 'error');
} finally {
setImporting(false);
}
});
bindOverlay(overlay, close, { modal: '.gyh-rapid-import-modal', focus: jsonEditor });
}
// 打开 TMDB 配置弹窗
async function showTmdbConfig(onSaved, onClosed) {
if (overlays.movie) return;
const currentKey = await getTmdbKey();
const overlay = document.createElement('div');
overlay.className = 'gyh-rename-overlay';
overlay.innerHTML = `${modalHeader({ id: 'gyh-tmdb-title', icon: ICONS.tmdb, title: 'TMDB 配置', subtitle: '配置后才能开始影视识别', closeLabel: '关闭 TMDB 配置' })}密钥设置
仅保存在本机请输入 TMDB 的 API Key 或 Read Access Token,验证通过后才会保存
${modalFooter('不会在未验证前保存密钥', '')}`;
overlays.movie = overlay;
const input = overlay.querySelector('.gyh-movie-key-input');
const status = overlay.querySelector('[data-tmdb-key-status]');
const saveButton = overlay.querySelector('[data-tmdb-save]');
const closeButton = overlay.querySelector('.gyh-rename-close');
const cancelButton = overlay.querySelector('[data-tmdb-cancel]');
const clearButton = overlay.querySelector('[data-tmdb-clear]');
let closed = false;
const close = () => { if (closed) return; closed = true; closeDesigner('movie', true); if (onClosed) onClosed(); };
closeButton.addEventListener('click', close);
cancelButton.addEventListener('click', close);
clearButton.addEventListener('click', async () => { await saveTmdbKey(''); input.value = ''; status.className = 'gyh-movie-key-status is-success'; status.textContent = '密钥已清除'; });
saveButton.addEventListener('click', async () => {
const key = normalizeTmdbKey(input.value);
if (!key) { status.className = 'gyh-movie-key-status is-error'; status.textContent = '请输入 TMDB 密钥'; return; }
saveButton.disabled = true;
saveButton.textContent = '验证中...';
status.className = 'gyh-movie-key-status';
status.textContent = '正在验证密钥';
try {
await validateTmdbKey(key);
await saveTmdbKey(key);
status.className = 'gyh-movie-key-status is-success';
status.textContent = '验证成功,密钥已保存';
await delay(260);
close();
if (onSaved) onSaved();
} catch (error) {
status.className = 'gyh-movie-key-status is-error';
status.textContent = error.message || '密钥验证失败';
} finally {
saveButton.disabled = false;
saveButton.textContent = '验证并保存';
}
});
bindOverlay(overlay, close, { modal: '.gyh-movie-modal', focus: input });
}
function showTmdbCorrection({ type, info: initialInfo, searchCandidates, onApply }) {
const isMovie = type === 'movie';
const mediaName = isMovie ? '影片' : '剧集';
const overlay = document.createElement('div');
overlay.className = 'gyh-rename-overlay gyh-overlay-elevated';
overlay.innerHTML = `${modalHeader({ id: 'gyh-media-correct-title', icon: ICONS.correct, title: '纠错识别', subtitle: `查找后选择正确的${mediaName}再确认`, closeLabel: '关闭纠错识别' })}${modalFooter('只更新匹配预览,不会修改文件', ``)}`;
const titleInput = overlay.querySelector('[data-media-correct-title]');
const yearInput = overlay.querySelector('[data-media-correct-year]');
const results = overlay.querySelector('[data-media-correct-results]');
const status = overlay.querySelector('[data-media-correct-status]');
const saveButton = overlay.querySelector('[data-media-correct-save]');
let candidates = [];
let selectedIndex = -1;
const renderCandidates = () => {
results.innerHTML = candidates.map((candidate, index) => ``).join('');
};
const resetCandidates = () => {
candidates = [];
selectedIndex = -1;
results.innerHTML = '';
status.className = 'gyh-movie-key-status';
status.textContent = '';
if (!saveButton.disabled) saveButton.textContent = `查找${mediaName}`;
};
const close = () => removeOverlay(overlay);
overlay.querySelector('.gyh-rename-close').addEventListener('click', close);
overlay.querySelector('[data-media-correct-cancel]').addEventListener('click', close);
titleInput.addEventListener('input', resetCandidates);
yearInput.addEventListener('input', resetCandidates);
results.addEventListener('click', event => {
const option = event.target.closest('[data-media-candidate]');
if (!option) return;
selectedIndex = Number(option.dataset.mediaCandidate);
renderCandidates();
});
saveButton.addEventListener('click', async () => {
const title = String(titleInput.value || '').trim();
const year = String(yearInput.value || '').trim();
if (!title) {
status.className = 'gyh-movie-key-status is-error';
status.textContent = `请输入${mediaName}名称`;
return;
}
if (year && !/^(?:19|20)\d{2}$/.test(year)) {
status.className = 'gyh-movie-key-status is-error';
status.textContent = '上映时间应为四位年份';
return;
}
const info = { ...initialInfo, title, year };
if (candidates.length) {
const match = candidates[selectedIndex];
if (!match) {
status.className = 'gyh-movie-key-status is-error';
status.textContent = `请选择一个${mediaName}结果`;
return;
}
saveButton.disabled = true;
try {
await onApply(info, match);
close();
} catch (error) {
status.className = 'gyh-movie-key-status is-error';
status.textContent = error?.message || `${mediaName}匹配失败`;
saveButton.disabled = false;
}
return;
}
saveButton.disabled = true;
titleInput.disabled = true;
yearInput.disabled = true;
saveButton.textContent = '匹配中...';
status.className = 'gyh-movie-key-status';
status.textContent = `正在匹配${mediaName}信息`;
try {
candidates = await searchCandidates(info) || [];
selectedIndex = candidates.length ? 0 : -1;
if (!candidates.length) {
status.className = 'gyh-movie-key-status is-error';
status.textContent = `没有找到匹配的${mediaName}`;
return;
}
renderCandidates();
status.className = 'gyh-movie-key-status is-success';
status.innerHTML = `找到 ${candidates.length} 个结果,请选择后确认`;
saveButton.textContent = '确认匹配';
} catch (error) {
status.className = 'gyh-movie-key-status is-error';
status.textContent = error?.message || `${mediaName}匹配失败`;
} finally {
saveButton.disabled = false;
titleInput.disabled = false;
yearInput.disabled = false;
}
});
bindOverlay(overlay, close, { modal: '.gyh-movie-correct-modal', focus: titleInput });
}
// 创建命名规则设置
function createMovieNamingRuleSection({ fields = movieNamingFields, defaultFields = defaultMovieNamingFields, examples: customExamples = {}, storageKey = '', requiredFields = [], showFolderMode = false } = {}) {
const section = document.createElement('section');
const savedFields = storageKey ? getStoredValue(storageKey, []) : [];
const restoredFields = Array.isArray(savedFields) ? savedFields.filter(field => fields.includes(field)) : [];
const state = { fields: restoredFields.length ? restoredFields : [...defaultFields] };
requiredFields.slice().reverse().forEach(field => { if (!state.fields.includes(field)) state.fields.unshift(field); });
const persist = () => { if (storageKey) setStoredValue(storageKey, [...state.fields]); };
section.className = 'gyh-rename-section';
section.innerHTML = '' + (showFolderMode ? '' : '');
const list = section.querySelector('[data-movie-rule-list]');
const options = section.querySelector('[data-movie-rule-options]');
const preview = section.querySelector('[data-movie-rule-preview]');
const examples = { '影片名': '肖申克的救赎', '上映时间': '1994', '来源': 'BluRay', '分辨率': '2160p', '视频编码': 'H265.SDR', '音频编码': 'AAC', 'TMDB': '{tmdb-278}', ...customExamples };
const render = () => {
list.innerHTML = state.fields.map((field, index) => '' + ICONS.grip + '' + field + '' + (requiredFields.includes(field) ? '' : '') + '').join('');
options.innerHTML = fields.map(field => '').join('');
preview.textContent = '示例:' + state.fields.map(field => examples[field] || field).join('.') + '.mkv';
};
section.addEventListener('click', event => {
const button = event.target.closest('[data-movie-rule-action]');
if (!button || button.disabled) return;
const field = button.dataset.movieRuleField;
const index = state.fields.indexOf(field);
if (button.dataset.movieRuleAction === 'add' && !state.fields.includes(field)) state.fields.push(field);
if (button.dataset.movieRuleAction === 'remove' && requiredFields.includes(field)) return;
if (button.dataset.movieRuleAction === 'remove' && index >= 0) state.fields.splice(index, 1);
render();
persist();
});
section.addEventListener('dragstart', event => { const token = event.target.closest('[data-movie-rule-index]'); if (!token) return; state.dragIndex = Number(token.dataset.movieRuleIndex); token.classList.add('is-dragging'); event.dataTransfer.effectAllowed = 'move'; });
section.addEventListener('dragover', event => { const token = event.target.closest('[data-movie-rule-index]'); if (!token) return; event.preventDefault(); list.querySelectorAll('.is-drag-over').forEach(item => item.classList.remove('is-drag-over')); token.classList.add('is-drag-over'); });
section.addEventListener('drop', event => { const token = event.target.closest('[data-movie-rule-index]'); if (!token || state.dragIndex === undefined) return; event.preventDefault(); const targetIndex = Number(token.dataset.movieRuleIndex); if (state.dragIndex !== targetIndex) { const [field] = state.fields.splice(state.dragIndex, 1); state.fields.splice(targetIndex, 0, field); } state.dragIndex = undefined; render(); persist(); });
section.addEventListener('dragend', event => { const token = event.target.closest('[data-movie-rule-index]'); if (token) token.classList.remove('is-dragging'); state.dragIndex = undefined; list.querySelectorAll('.is-drag-over').forEach(item => item.classList.remove('is-drag-over')); });
if (showFolderMode) {
const folderOptions = [...section.querySelectorAll('.gyh-movie-folder-option')];
section.addEventListener('change', event => {
if (!event.target.matches('input[name="gyh-movie-folder-mode"]')) return;
folderOptions.forEach(option => option.classList.toggle('is-selected', option.querySelector('input').checked));
});
}
section.getMovieNamingRule = () => ({
fields: [...state.fields],
folderMode: showFolderMode ? section.querySelector('input[name="gyh-movie-folder-mode"]:checked')?.value || 'shared' : 'shared',
});
render();
return section;
}
function buildMediaName(type, info, match, rule) {
const isMovie = type === 'movie';
const title = String(match?.title || info.title || '').replace(/[\\/:*?"<>|]/g, '').trim();
const fields = rule?.fields?.length ? rule.fields : isMovie ? defaultMovieNamingFields : defaultTvNamingFields;
const videoFormat = info.videoCodec ? [info.videoCodec, info.hq, info.dynamicRange, info.frameRate].filter(Boolean).join('.') : '';
const values = { [isMovie ? '影片名' : '剧集名']: title, '季集信息': info.seasonEpisode || '', '上映时间': info.seasonYear || match?.year || info.year || '', '来源': info.source || '', '分辨率': info.resolution || '', '视频编码': videoFormat, '音频编码': info.audioCodec || '', 'TMDB': match?.id ? '{tmdb-' + match.id + '}' : '' };
return fields.map(field => values[field] || '').filter(Boolean).join('.') + info.extension;
}
function buildMovieName(info, match, rule) {
return buildMediaName('movie', info, match, rule);
}
function buildMediaFolderName(info, match) {
const title = String(match?.title || info.title || '').replace(/[\\/:*?"<>|]/g, '').trim();
const tmdb = match?.id ? '{tmdb-' + match.id + '}' : '';
return [title, match?.year || info.year || '', tmdb].filter(Boolean).join('.');
}
// 获取当前季的首播年份
function getTvSeasonYear(seasons, season, fallback = '') {
return seasons.find(item => Number(item.season) === Number(season))?.year || fallback;
}
// 生成剧集单集目标名称
function buildTvEpisodeName(info, match, rule) {
return buildMediaName('tv', info, match, rule);
}
// 递归收集剧集视频文件
async function collectTvEpisodes(folder, onProgress) {
return walkResources([folder], {
isFile: resource => resource.resType === 1,
getChildren: resource => getGuangyaFiles(resource.id),
acceptFile: resource => isVideoFile(resource) && getMovieFileSize(resource) > MIN_MEDIA_SIZE,
mapFile: (resource, path) => ({ file: { ...resource, path }, sourceFolder: folder }),
onFolder: (resource, count) => onProgress?.(count),
});
}
// 汇总剧集各季集数
function getTvSeasonSummary(group) {
const local = new Map();
(group.entries || []).forEach(item => {
const season = Number(item.info.season || 1);
const value = local.get(season) || { season, entries: [], ready: 0 };
value.entries.push(item);
if (item.newName) value.ready += 1;
local.set(season, value);
});
const totals = new Map((group.seasons || []).map(item => [Number(item.season), Number(item.total) || 0]));
return [...local.keys()].sort((a, b) => a - b).map(season => {
const value = local.get(season) || { season, entries: [], ready: 0 };
return { ...value, total: totals.get(season) || value.entries.length };
});
}
// 生成剧集季集概览文字
function formatTvSeasonSummary(group) {
return getTvSeasonSummary(group).map(item => (item.season === 0 ? '特别篇' : item.season + '季') + ' ' + item.ready + '/' + item.total + '集').join('|');
}
// 渲染剧集整理预览
function renderTvPlan(previewBody, groups) {
previewBody.innerHTML = groups.map(group => {
const ready = (group.entries || []).filter(item => item.newName).length;
const summary = formatTvSeasonSummary(group);
const target = group.match ? group.folderName + (summary ? '|' + summary : '') : '未匹配';
const status = group.match && ready ? (group.status || '待整理') : '已跳过';
return '' + escapeHtml(group.folder.name) + '' + escapeHtml(status) + '
';
}).join('');
}
// 刷新剧集文件夹处理状态
function refreshTvGroupRow(previewBody, group) {
const row = previewBody.querySelector('[data-tv-row="' + group.id + '"]');
const runnable = (group.entries || []).filter(item => item.newName);
const pending = runnable.some(item => item.status === '待整理' || item.status === '整理中');
const failed = runnable.some(item => item.status === '已失败');
group.status = !runnable.length ? '已跳过' : pending ? '整理中' : failed ? '已失败' : '已整理';
if (!row) return;
setPreviewRowState(row, { '整理中': 'processing', '已整理': 'success', '已失败': 'failed' }[group.status] || '', group.status);
}
// 渲染电影匹配预览
function renderMoviePlan(previewBody, plan) {
previewBody.innerHTML = plan.map((item, index) => {
const target = item.newName || (item.match ? item.match.title : '未匹配');
const matchCell = '' + escapeHtml(target) + '';
const correctButton = '';
const statusCell = '' + escapeHtml(item.status) + '';
return '' + escapeHtml(item.file.name) + '' + matchCell + correctButton + statusCell + '
';
}).join('');
}
// 执行电影整理任务
async function executeMoviePlan(overlay, previewBody, footerNote, startButton, plan, resources, folderMode = 'shared') {
const runnable = plan.filter(item => item.match && item.newName);
if (!runnable.length) {
showToast('没有可整理的匹配结果', 'error');
return false;
}
startButton.disabled = true;
startButton.textContent = '整理中...';
previewBody.classList.add('is-organizing');
overlay.querySelector('[data-tmdb-settings]').disabled = true;
overlay.querySelector('.gyh-rename-close').disabled = true;
overlay.querySelector('[data-movie-cancel]').disabled = true;
const parentId = getCurrentGuangyaDirectoryId();
const useSeparateFolders = folderMode === 'separate';
const targetId = useSeparateFolders ? '' : await ensureMovieDirectory(parentId, '整理完成');
const movieDirectoryIds = new Map();
let success = 0;
let failed = 0;
for (let index = 0; index < runnable.length; index += 1) {
const item = runnable[index];
const row = previewBody.querySelector('[data-movie-row="' + plan.indexOf(item) + '"]');
if (row) revealRenameRow(previewBody, row);
setPreviewRowState(row, 'processing', '整理中');
try {
let itemTargetId = targetId;
if (useSeparateFolders) {
const directoryName = buildMediaFolderName(item.info, item.match);
if (!directoryName) throw new Error('无法生成影片目录名称');
itemTargetId = movieDirectoryIds.get(directoryName);
if (!itemTargetId) {
itemTargetId = await ensureMovieDirectory(parentId, directoryName);
movieDirectoryIds.set(directoryName, itemTargetId);
}
}
if (item.file.name !== item.newName) await renameGuangyaFile(item.file.id, item.newName);
await moveGuangyaFile(item.file.id, itemTargetId);
item.status = '已整理';
setPreviewRowState(row, 'success', '已整理');
success += 1;
} catch (error) {
item.status = '已失败';
setPreviewRowState(row, 'failed', '已失败');
failed += 1;
}
if (index < runnable.length - 1) await delay(0);
}
const sourceFolders = resources.filter(resource => resource.resType !== 1 && resource.name !== '整理完成' && resource.name !== '残留文件');
let residualSuccess = 0;
let residualFailed = 0;
if (sourceFolders.length) {
footerNote.textContent = '正在移动残留文件';
try {
const residualId = await ensureMovieDirectory(parentId, '残留文件');
try {
await moveGuangyaFiles(sourceFolders.map(folder => folder.id), residualId);
residualSuccess = sourceFolders.length;
} catch (error) {
for (const folder of sourceFolders) {
try { await moveGuangyaFile(folder.id, residualId); residualSuccess += 1; } catch (moveError) { residualFailed += 1; }
}
}
} catch (error) {
residualFailed = sourceFolders.length;
}
}
startButton.disabled = false;
startButton.textContent = '关闭';
overlay.querySelector('.gyh-rename-close').disabled = false;
overlay.querySelector('[data-movie-cancel]').disabled = false;
overlay._gyhShouldRefresh = true;
footerNote.textContent = '整理完成 ' + success + '/' + runnable.length + ' · 失败 ' + failed + (sourceFolders.length ? ' · 残留文件夹 ' + residualSuccess + '/' + sourceFolders.length : '') + ' · 目标:' + (useSeparateFolders ? '独立影片文件夹' : '整理完成');
showToast(failed || residualFailed ? '电影整理完成,失败 ' + (failed + residualFailed) + ' 项' : '电影整理完成', failed || residualFailed ? 'error' : 'success');
return true;
}
// 打开电影整理预览
function showMovieDesigner(resources) {
if (overlays.movie) return;
const folderCount = resources.filter(resource => resource.resType !== 1).length;
const fileCount = resources.length - folderCount;
const rows = resources.map(resource => `${escapeHtml(resource.name)}${resource.resType === 1 ? '待匹配 TMDB' : '递归读取视频'}待整理
`).join('');
const overlay = document.createElement('div');
overlay.className = 'gyh-rename-overlay';
overlay.innerHTML = `${modalHeader({ id: 'gyh-movie-title', icon: ICONS.movie, title: '整理电影', subtitle: '匹配 TMDB 后预览,确认后再整理', closeLabel: '关闭整理电影' })}扫描范围
已选资源${resources.length} 项
文件与文件夹${fileCount} 文件 · ${folderCount} 文件夹
视频筛选大于 10MB
`;
overlays.movie = overlay;
const movieBody = overlay.querySelector('.gyh-rename-body');
overlay.querySelector('[data-movie-cancel]').textContent = '取消';
const previewSection = movieBody.querySelectorAll('.gyh-rename-section')[1];
const namingRuleSection = createMovieNamingRuleSection({ storageKey: MOVIE_NAMING_RULE_STORAGE, requiredFields: ['影片名'], showFolderMode: true });
movieBody.insertBefore(namingRuleSection, previewSection);
const previewBody = overlay.querySelector('.gyh-movie-preview-body');
const previewHead = overlay.querySelector('.gyh-movie-preview-head span:nth-child(2)');
const footerNote = overlay.querySelector('.gyh-movie-footer-note');
const startButton = overlay.querySelector('.gyh-rename-btn-primary');
footerNote.textContent = '扫描后预览,确认才会修改文件';
let moviePlan = [];
let activeNamingRule = { fields: [...defaultMovieNamingFields] };
let hasScanned = false;
let hasCompleted = false;
startButton.disabled = false;
startButton.textContent = '扫描并匹配';
const close = () => closeDesigner('movie', true);
overlay.querySelector('.gyh-rename-close').addEventListener('click', close);
overlay.querySelector('[data-movie-cancel]').addEventListener('click', close);
overlay.querySelector('[data-tmdb-settings]').addEventListener('click', () => {
const parentOverlay = overlays.movie;
parentOverlay.classList.add('gyh-is-hidden');
overlays.movie = null;
const restore = () => { if (!document.body.contains(parentOverlay)) return; parentOverlay.classList.remove('gyh-is-hidden'); overlays.movie = parentOverlay; };
showTmdbConfig(() => restore(), restore);
});
// 打开纠错匹配弹窗
const showMovieCorrection = index => {
const item = moviePlan[index];
if (!item) return;
showTmdbCorrection({
type: 'movie',
info: item.info,
searchCandidates: searchTmdbMovieCandidates,
onApply(info, match) {
item.info = info;
item.match = match;
item.newName = buildMovieName(info, match, activeNamingRule);
item.status = '待确认';
renderMoviePlan(previewBody, moviePlan);
const matched = moviePlan.filter(entry => entry.match).length;
footerNote.textContent = '匹配完成 ' + matched + ' 个 · 跳过 ' + (moviePlan.length - matched) + ' 个 · 确认后才会修改文件';
startButton.disabled = matched === 0;
},
});
};
previewBody.addEventListener('click', event => {
const button = event.target.closest('[data-movie-correct]');
if (!button || !hasScanned || hasCompleted || overlay.querySelector('.gyh-rename-close').disabled) return;
showMovieCorrection(Number(button.dataset.movieCorrect));
});
startButton.addEventListener('click', async () => {
if (hasCompleted) { close(); return; }
if (hasScanned) {
try { hasCompleted = await executeMoviePlan(overlay, previewBody, footerNote, startButton, moviePlan, resources, namingRuleSection.getMovieNamingRule().folderMode); } catch (error) { previewBody.classList.remove('is-organizing'); startButton.disabled = false; startButton.textContent = '确认整理'; overlay.querySelector('[data-tmdb-settings]').disabled = false; overlay.querySelector('.gyh-rename-close').disabled = false; overlay.querySelector('[data-movie-cancel]').disabled = false; showToast(error.message || '电影整理失败', 'error'); }
return;
}
startButton.disabled = true;
startButton.textContent = '扫描中...';
footerNote.textContent = '正在递归读取视频文件';
try {
activeNamingRule = namingRuleSection.getMovieNamingRule();
const videos = await collectMovieVideos(resources, count => { footerNote.textContent = '已发现 ' + count + ' 个视频'; });
moviePlan = [];
for (const file of videos) {
const info = parseMovieName(file.name, file.path);
footerNote.textContent = '正在匹配:' + info.title;
const match = await searchTmdbMovie(info);
moviePlan.push({ file, info, match, newName: match ? buildMovieName(info, match, activeNamingRule) : '', status: match ? '待确认' : '已跳过' });
}
hasScanned = true;
previewHead.textContent = '目标名称';
renderMoviePlan(previewBody, moviePlan);
const matched = moviePlan.filter(item => item.match).length;
const skipped = moviePlan.length - matched;
startButton.disabled = matched === 0;
startButton.textContent = '确认整理';
footerNote.textContent = '匹配完成 ' + matched + ' 个 · 跳过 ' + skipped + ' 个 · 确认后才会修改文件';
} catch (error) {
startButton.disabled = false;
startButton.textContent = '重新扫描';
footerNote.textContent = error.message || '扫描失败';
showToast(error.message || '电影扫描失败', 'error');
}
});
bindOverlay(overlay, close, { modal: '.gyh-movie-modal' });
}
// 打开剧集整理预览
function showTvDesigner(folders) {
if (overlays.tv) return;
const rows = folders.map(folder => '' + escapeHtml(folder.name) + '待扫描剧集待整理
').join('');
const overlay = document.createElement('div');
overlay.className = 'gyh-rename-overlay';
overlay.innerHTML = `${modalHeader({ id: 'gyh-tv-title', icon: ICONS.tv, title: '整理剧集', subtitle: '匹配 TMDB 后预览,确认后再整理', closeLabel: '关闭整理剧集' })}扫描范围
已选文件夹${folders.length} 个
识别方式文件夹优先
视频筛选大于 10MB
`;
overlays.tv = overlay;
const body = overlay.querySelector('.gyh-rename-body');
const previewSection = body.querySelectorAll('.gyh-rename-section')[1];
const namingRuleSection = createMovieNamingRuleSection({ fields: tvNamingFields, defaultFields: defaultTvNamingFields, examples: { '剧集名': '赦罪', '季集信息': 'S01E01', '上映时间': '2026', '来源': 'WEB-DL', '分辨率': '2160p', '视频编码': 'H265.HDR', '音频编码': 'AAC', 'TMDB': '{tmdb-111}' }, storageKey: TV_NAMING_RULE_STORAGE, requiredFields: ['剧集名', '季集信息'] });
body.insertBefore(namingRuleSection, previewSection);
const previewBody = overlay.querySelector('.gyh-movie-preview-body');
const footerNote = overlay.querySelector('.gyh-movie-footer-note');
const startButton = overlay.querySelector('.gyh-rename-btn-primary');
let tvPlan = [];
let tvGroups = [];
let activeNamingRule = { fields: [...defaultTvNamingFields] };
let hasScanned = false;
let hasCompleted = false;
const close = () => closeDesigner('tv', true);
const updateSummary = () => {
const matched = tvGroups.filter(group => group.match).length;
const ready = tvPlan.filter(item => item.newName).length;
footerNote.textContent = '匹配剧集 ' + matched + '/' + tvGroups.length + ' 部 · 可整理 ' + ready + ' 集 · 确认后才会修改文件';
startButton.disabled = ready === 0;
};
overlay.querySelector('.gyh-rename-close').addEventListener('click', close);
overlay.querySelector('[data-tv-cancel]').addEventListener('click', close);
overlay.querySelector('[data-tmdb-settings]').addEventListener('click', () => {
const parentOverlay = overlays.tv;
parentOverlay.classList.add('gyh-is-hidden');
overlays.tv = null;
const restore = () => { if (!document.body.contains(parentOverlay)) return; parentOverlay.classList.remove('gyh-is-hidden'); overlays.tv = parentOverlay; };
showTmdbConfig(restore, restore);
});
// 打开剧集纠错弹窗
const showTvCorrection = groupId => {
const group = tvGroups.find(item => item.id === groupId);
if (!group) return;
showTmdbCorrection({
type: 'tv',
info: group.info,
searchCandidates: searchTmdbTvCandidates,
async onApply(info, match) {
group.info = info;
group.match = match;
group.seasons = await getTmdbTvSeasons(match.id).catch(() => []);
group.folderName = buildMediaFolderName(info, match);
tvPlan.filter(item => item.group.id === group.id).forEach(item => {
item.info = { ...item.info, title: info.title, year: info.year, seasonYear: getTvSeasonYear(group.seasons, item.info.season, match?.year || info.year) };
item.newName = item.info.seasonEpisode ? buildTvEpisodeName(item.info, match, activeNamingRule) : '';
item.status = item.newName ? '待整理' : '已跳过';
});
renderTvPlan(previewBody, tvGroups);
updateSummary();
},
});
};
// 打开剧集整理详情
const showTvDetails = groupId => {
const group = tvGroups.find(item => item.id === groupId);
if (!group) return;
const detailsOverlay = document.createElement('div');
const seasonSections = getTvSeasonSummary(group).map(season => {
const rows = season.entries.sort((a, b) => Number(a.info.episode || 0) - Number(b.info.episode || 0)).map(item => '' + escapeHtml(item.file.name) + '' + escapeHtml(item.newName || '缺少季集信息') + '
').join('');
const seasonName = season.season === 0 ? '特别篇' : '第 ' + season.season + ' 季';
return '' + seasonName + '
' + season.ready + '/' + season.total + ' 集可整理原文件目标名称
' + (rows || '
暂无本季文件
') + '
';
}).join('') || '';
detailsOverlay.className = 'gyh-rename-overlay gyh-overlay-elevated';
detailsOverlay.innerHTML = `${modalHeader({ id: 'gyh-tv-details-title', icon: ICONS.details, title: '剧集整理详情', subtitle: escapeHtml(group.folderName || group.folder.name), closeLabel: '关闭剧集整理详情' })}${seasonSections}
${modalFooter('仅展示预览,不会修改文件', '')}`;
const closeDetails = () => removeOverlay(detailsOverlay);
detailsOverlay.querySelector('.gyh-rename-close').addEventListener('click', closeDetails);
detailsOverlay.querySelector('[data-tv-details-close]').addEventListener('click', closeDetails);
bindOverlay(detailsOverlay, closeDetails, { modal: '.gyh-movie-modal' });
};
previewBody.addEventListener('click', event => {
const target = event.target.closest('[data-tv-details]');
if (target && hasScanned && !overlay.querySelector('.gyh-rename-close').disabled) {
showTvDetails(target.dataset.tvDetails);
return;
}
const button = event.target.closest('[data-tv-correct]');
if (!button || !hasScanned || hasCompleted || overlay.querySelector('.gyh-rename-close').disabled) return;
showTvCorrection(button.dataset.tvCorrect);
});
startButton.addEventListener('click', async () => {
if (hasCompleted) { close(); return; }
if (hasScanned) {
try { hasCompleted = await executeTvPlan(overlay, previewBody, footerNote, startButton, tvPlan, folders); } catch (error) { previewBody.classList.remove('is-organizing'); startButton.disabled = false; startButton.textContent = '确认整理'; overlay.querySelector('[data-tmdb-settings]').disabled = false; overlay.querySelector('.gyh-rename-close').disabled = false; overlay.querySelector('[data-tv-cancel]').disabled = false; showToast(error.message || '剧集整理失败', 'error'); }
return;
}
startButton.disabled = true;
startButton.textContent = '扫描中...';
footerNote.textContent = '正在读取剧集文件夹';
try {
activeNamingRule = namingRuleSection.getMovieNamingRule();
tvPlan = [];
tvGroups = [];
for (const folder of folders) {
footerNote.textContent = '正在读取:' + folder.name;
const episodes = await collectTvEpisodes(folder, count => { footerNote.textContent = folder.name + ' · 已发现 ' + count + ' 集'; });
if (!episodes.length) continue;
const folderInfo = parseMovieName(folder.name);
const first = episodes[0];
const firstInfo = parseTvEpisode(first.file, folder);
const info = { ...firstInfo, title: extractTvTitle(folder.name) || firstInfo.title, year: folderInfo.year || firstInfo.year };
footerNote.textContent = '正在匹配:' + info.title;
const match = await searchTmdbTv(info);
const seasons = match ? await getTmdbTvSeasons(match.id).catch(() => []) : [];
const group = { id: String(folder.id), folder, info, match, seasons, folderName: match ? buildMediaFolderName(info, match) : '', entries: [] };
tvGroups.push(group);
episodes.forEach((episode, index) => {
const parsedInfo = parseTvEpisode(episode.file, folder);
const episodeInfo = { ...parsedInfo, title: info.title, year: info.year, seasonYear: getTvSeasonYear(seasons, parsedInfo.season, match?.year || info.year) };
const newName = match && episodeInfo.seasonEpisode ? buildTvEpisodeName(episodeInfo, match, activeNamingRule) : '';
const item = { file: episode.file, sourceFolder: folder, group, info: episodeInfo, newName, status: newName ? '待整理' : '已跳过' };
group.entries.push(item);
tvPlan.push(item);
});
}
hasScanned = true;
renderTvPlan(previewBody, tvGroups);
startButton.textContent = '确认整理';
updateSummary();
} catch (error) {
startButton.disabled = false;
startButton.textContent = '重新扫描';
footerNote.textContent = error.message || '扫描失败';
showToast(error.message || '剧集扫描失败', 'error');
}
});
bindOverlay(overlay, close, { modal: '.gyh-movie-modal' });
}
// 执行剧集整理任务
async function executeTvPlan(overlay, previewBody, footerNote, startButton, plan, folders) {
const runnable = plan.filter(item => item.group.match && item.newName);
if (!runnable.length) {
showToast('没有可整理的剧集', 'error');
return false;
}
startButton.disabled = true;
startButton.textContent = '整理中...';
previewBody.classList.add('is-organizing');
overlay.querySelector('[data-tmdb-settings]').disabled = true;
overlay.querySelector('.gyh-rename-close').disabled = true;
overlay.querySelector('[data-tv-cancel]').disabled = true;
const parentId = getCurrentGuangyaDirectoryId();
let success = 0;
let failed = 0;
const groups = [...new Map(plan.map(item => [item.group.id, item.group])).values()];
const matched = groups.filter(group => group.match).length;
const selectedFolderIds = new Set(folders.map(folder => String(folder.id)));
const updateFooterProgress = () => { footerNote.innerHTML = '匹配剧集 ' + matched + '/' + groups.length + ' 部 · 可整理 ' + runnable.length + ' 集 · 已整理 ' + success + ' 集 · 失败 ' + failed + ' 集'; };
updateFooterProgress();
let residualId = '';
let residualFailed = 0;
for (const group of groups) {
const groupItems = runnable.filter(item => item.group.id === group.id);
if (!groupItems.length) continue;
const row = previewBody.querySelector('[data-tv-row="' + group.id + '"]');
groupItems.forEach(item => { item.status = '整理中'; });
refreshTvGroupRow(previewBody, group);
if (row) revealRenameRow(previewBody, row);
updateFooterProgress();
let showId = '';
try {
showId = await ensureMovieDirectory(parentId, group.folderName);
} catch (error) {
groupItems.forEach(item => { item.status = '已失败'; });
failed += groupItems.length;
refreshTvGroupRow(previewBody, group);
updateFooterProgress();
continue;
}
let shouldMoveResidual = true;
const seasonBuckets = new Map();
groupItems.forEach(item => {
const seasonName = 'S' + (item.info.season || '01');
const bucket = seasonBuckets.get(seasonName) || { seasonName, items: [] };
bucket.items.push(item);
seasonBuckets.set(seasonName, bucket);
});
for (const bucket of seasonBuckets.values()) {
try {
bucket.targetId = await ensureMovieDirectory(showId, bucket.seasonName);
} catch (error) {
bucket.items.forEach(item => { item.status = '已失败'; });
failed += bucket.items.length;
shouldMoveResidual = false;
refreshTvGroupRow(previewBody, group);
}
updateFooterProgress();
}
for (const bucket of seasonBuckets.values()) {
if (!bucket.targetId) continue;
try {
await moveGuangyaFiles(bucket.items.map(item => item.file.id), bucket.targetId);
bucket.items.forEach(item => { item._gyhMoved = true; });
} catch (error) {
let movedIds = new Set();
try { movedIds = new Set((await getGuangyaFiles(bucket.targetId)).map(file => file.id)); } catch (listError) {}
for (const item of bucket.items) {
try {
if (!movedIds.has(item.file.id)) await moveGuangyaFile(item.file.id, bucket.targetId);
item._gyhMoved = true;
} catch (moveError) {
item.status = '已失败';
failed += 1;
}
}
}
refreshTvGroupRow(previewBody, group);
updateFooterProgress();
}
const movedItems = groupItems.filter(item => item._gyhMoved);
for (let index = 0; index < movedItems.length; index += 1) {
const item = movedItems[index];
item.status = '整理中';
refreshTvGroupRow(previewBody, group);
if (row) revealRenameRow(previewBody, row);
try {
if (item.file.name !== item.newName) await renameGuangyaFile(item.file.id, item.newName);
item.status = '已整理';
success += 1;
} catch (error) {
item.status = '已失败';
failed += 1;
}
refreshTvGroupRow(previewBody, group);
updateFooterProgress();
if (index < movedItems.length - 1) await delay(0);
}
if (shouldMoveResidual && selectedFolderIds.has(String(group.folder.id))) {
try {
if (!residualId) residualId = await ensureMovieDirectory(parentId, '残留文件');
await moveGuangyaFile(group.folder.id, residualId);
} catch (error) {
residualFailed += 1;
}
}
refreshTvGroupRow(previewBody, group);
updateFooterProgress();
}
startButton.disabled = false;
startButton.textContent = '关闭';
overlay.querySelector('.gyh-rename-close').disabled = false;
overlay.querySelector('[data-tv-cancel]').disabled = false;
overlay._gyhShouldRefresh = true;
updateFooterProgress();
showToast(failed || residualFailed ? '剧集整理完成,失败 ' + (failed + residualFailed) + ' 项' : '剧集整理完成', failed || residualFailed ? 'error' : 'success');
return true;
}
// 打开批量重命名弹窗
function showRenameDesigner(files) {
if (overlays.rename) return;
const overlay = document.createElement('div');
overlay.className = 'gyh-rename-overlay';
overlay.innerHTML = `
${modalHeader({ id: 'gyh-rename-title', icon: ICONS.rename, title: '批量重命名', subtitle: '设置规则并预览文件的新名称', closeLabel: '关闭批量重命名' })}
重命名方式
选择一种处理规则
${renameModes.map((mode, index) => ``).join('')}
`;
overlays.rename = overlay;
const closeButton = overlay.querySelector('.gyh-rename-close');
const cancelButton = overlay.querySelector('[data-rename-cancel]');
const fields = overlay.querySelector('[data-rename-fields]');
const modeLabel = overlay.querySelector('[data-rename-mode-label]');
const preview = overlay.querySelector('[data-rename-preview-content]');
const footerNote = overlay.querySelector('[data-rename-footer-note]');
const startButton = overlay.querySelector('.gyh-rename-btn-primary');
const videoFilter = overlay.querySelector('[data-rename-video-filter]');
const modeLabels = Object.fromEntries(renameModes.map(mode => [mode.key, `${mode.label}重命名`]));
let activeMode = 'replace';
let hasExecuted = false;
// 返回当前筛选后的文件
const getActiveFiles = () => videoFilter.checked ? files.filter(isVideoFile) : files;
// 获取当前规则的处理计划
const getPlan = () => buildRenamePlan(getActiveFiles(), activeMode, fields.querySelectorAll('.gyh-rename-input'));
// 刷新名称预览与底部提示
const refreshPreview = () => {
const plan = getPlan();
if (plan.error) {
footerNote.textContent = `${plan.error} · 请检查规则设置`;
startButton.disabled = true;
return plan;
}
renderRenamePreview(preview, plan.items);
if (plan.items.length === 0) {
footerNote.textContent = '仅视频已开启,但当前选择中没有视频文件';
startButton.disabled = true;
return plan;
}
footerNote.textContent = `当前处理 ${plan.items.length} 个文件${videoFilter.checked ? ' · 仅视频' : ''} · 已生成预览`;
startButton.disabled = false;
return plan;
};
// 锁定或恢复可编辑控件
const setRunning = (running) => {
overlay.querySelectorAll('[data-rename-mode]').forEach((modeButton) => {
modeButton.disabled = running && !modeButton.classList.contains('is-active');
});
overlay.querySelectorAll('.gyh-rename-input, [data-rename-video-filter]')
.forEach(element => { element.disabled = running; });
};
// 按顺序执行重命名任务
const executeRename = async () => {
if (hasExecuted) {
closeDesigner('rename', true);
return;
}
const plan = refreshPreview();
if (plan.error || plan.items.length === 0) {
showToast(plan.error || '没有可处理的视频文件', 'error');
return;
}
hasExecuted = true;
setRunning(true);
startButton.disabled = true;
startButton.textContent = '重命名中...';
let successCount = 0;
let failCount = 0;
const rows = preview.querySelectorAll('.gyh-rename-row');
for (let index = 0; index < plan.items.length; index += 1) {
const item = plan.items[index];
const row = rows[index];
revealRenameRow(preview, row);
setPreviewRowState(row, 'processing', '处理中');
try {
if (item.name !== item.newName) await renameGuangyaFile(item.id, item.newName);
setPreviewRowState(row, 'success', '已改名');
successCount += 1;
} catch (error) {
setPreviewRowState(row, 'failed', '已失败');
failCount += 1;
}
if (index < plan.items.length - 1) await delay(0);
}
startButton.disabled = false;
startButton.textContent = '关闭';
overlay._gyhShouldRefresh = true;
footerNote.textContent = `处理完成 ${successCount + failCount}/${plan.items.length} · 成功 ${successCount} · 失败 ${failCount}`;
showToast(failCount ? `重命名完成,${failCount} 个失败` : '重命名完成', failCount ? 'error' : 'success');
};
// 切换重命名方式
const updateMode = (modeKey) => {
activeMode = modeKey;
overlay.querySelectorAll('[data-rename-mode]').forEach((button) => {
const isActive = button.dataset.renameMode === modeKey;
button.classList.toggle('is-active', isActive);
button.setAttribute('aria-selected', String(isActive));
});
modeLabel.textContent = modeLabels[modeKey];
renderRenameFields(fields, modeKey);
fields.querySelectorAll('.gyh-rename-input').forEach(input => input.addEventListener('input', refreshPreview));
refreshPreview();
};
overlay.querySelectorAll('[data-rename-mode]').forEach((button) => {
button.addEventListener('click', () => {
if (!hasExecuted) updateMode(button.dataset.renameMode);
});
});
videoFilter.addEventListener('change', refreshPreview);
closeButton.addEventListener('click', () => closeDesigner('rename', true));
cancelButton.addEventListener('click', () => closeDesigner('rename', true));
startButton.addEventListener('click', executeRename);
bindOverlay(overlay, () => closeDesigner('rename', true), { focus: closeButton });
updateMode('replace');
}
// 创建悬浮助手入口
function createAssistant() {
if (document.getElementById(ROOT_ID)) return;
const root = document.createElement('section');
root.id = ROOT_ID;
root.setAttribute('aria-label', '光鸭助手');
const mainActions = [
{ action: 'movie', icon: ICONS.movie, name: '整理电影', note: '识别电影信息与整理' },
{ action: 'tv', icon: ICONS.tv, name: '整理剧集', note: '识别剧集信息与整理' },
{ action: 'rename', icon: ICONS.renameAction, name: '批量重命名', note: '预览后统一修改名称' },
{ action: 'transfer', icon: ICONS.transfer, name: '秒传导入/导出', note: '快捷迁移资源与配置', label: '秒传导入或导出' },
].map(assistantAction).join('');
const transferActions = [
{ action: 'import', attribute: 'data-transfer-action', icon: ICONS.import, name: '导入秒传文件', note: '读取文件并创建秒传任务' },
{ action: 'export', attribute: 'data-transfer-action', icon: ICONS.export, name: '导出秒传文件', note: '将文件/夹生成秒传文件' },
].map(assistantAction).join('');
root.innerHTML = `
`;
if (isXunleiPan) {
root.querySelector('.gyh-trigger').setAttribute('aria-label', '打开迅雷助手');
root.querySelector('.gyh-trigger').setAttribute('title', '迅雷助手');
root.querySelector('.gyh-title').textContent = '迅雷助手';
root.querySelector('.gyh-subtitle span').textContent = '好用、全能的迅雷云盘助手';
root.querySelector('.gyh-actions').innerHTML = assistantAction({ action: null, attribute: 'data-xunlei-export', icon: ICONS.export, name: '导出秒传文件', note: '将文件/夹生成秒传文件' });
root.querySelector('.gyh-transfer-panel').remove();
}
const trigger = root.querySelector('.gyh-trigger');
const panel = root.querySelector('.gyh-panel');
const transferPanel = root.querySelector('.gyh-transfer-panel');
const movieAction = root.querySelector('[data-action="movie"]');
const renameAction = root.querySelector('[data-action="rename"]');
const tvAction = root.querySelector('[data-action="tv"]');
const transferAction = root.querySelector('[data-action="transfer"]');
const transferBack = root.querySelector('.gyh-submenu-back');
// 切换助手菜单状态
const setOpen = (isOpen) => {
trigger.setAttribute('aria-expanded', String(isOpen));
panel.setAttribute('aria-hidden', String(!isOpen));
panel.classList.toggle('is-open', isOpen);
if (!isOpen && transferPanel) {
transferPanel.setAttribute('aria-hidden', 'true');
transferPanel.classList.remove('is-open');
}
};
// 切换秒传二级面板
const setTransferOpen = (isOpen) => {
if (!transferPanel) return;
trigger.setAttribute('aria-expanded', 'true');
panel.setAttribute('aria-hidden', String(isOpen));
panel.classList.toggle('is-open', !isOpen);
transferPanel.setAttribute('aria-hidden', String(!isOpen));
transferPanel.classList.toggle('is-open', isOpen);
};
trigger.addEventListener('click', (event) => {
event.stopPropagation();
setOpen(trigger.getAttribute('aria-expanded') !== 'true');
});
if (renameAction) renameAction.addEventListener('click', async () => {
setOpen(false);
renameAction.disabled = true;
try {
const selectedFiles = await getSelectedGuangyaFiles();
if (selectedFiles.length === 0) {
showToast('请先选择文件', 'error');
return;
}
showRenameDesigner(selectedFiles);
} catch (error) {
showToast(error.message || '读取选中文件失败', 'error');
} finally {
renameAction.disabled = false;
}
});
const openMovieOrganizer = async () => {
const key = await getTmdbKey();
if (!key) {
showTmdbConfig(openMovieOrganizer);
return;
}
try {
const resources = await getSelectedGuangyaResources();
if (resources.length === 0) {
showToast('请先选择文件或文件夹', 'error');
return;
}
showMovieDesigner(resources);
} catch (error) {
showToast(error.message || '读取选中资源失败', 'error');
}
};
if (movieAction) movieAction.addEventListener('click', async () => {
setOpen(false);
movieAction.disabled = true;
try {
await openMovieOrganizer();
} finally {
movieAction.disabled = false;
}
});
const openTvOrganizer = async () => {
const key = await getTmdbKey();
if (!key) {
showTmdbConfig(openTvOrganizer);
return;
}
const resources = await getSelectedGuangyaResources();
if (!resources.length) {
showToast('请先选择剧集文件夹', 'error');
return;
}
if (resources.some(resource => resource.resType === 1)) {
showToast('剧集整理仅支持选择文件夹', 'error');
return;
}
showTvDesigner(resources);
};
if (tvAction) tvAction.addEventListener('click', async () => {
setOpen(false);
tvAction.disabled = true;
try {
await openTvOrganizer();
} catch (error) {
showToast(error.message || '读取选中文件夹失败', 'error');
} finally {
tvAction.disabled = false;
}
});
if (transferAction) transferAction.addEventListener('click', () => {
setTransferOpen(true);
});
if (transferBack) transferBack.addEventListener('click', () => setTransferOpen(false));
const rapidImportAction = root.querySelector('[data-transfer-action="import"]');
if (rapidImportAction) rapidImportAction.addEventListener('click', async () => {
setOpen(false);
await loadAceEditor();
showRapidImportDesigner();
});
const rapidExportAction = root.querySelector('[data-transfer-action="export"], [data-xunlei-export]');
if (rapidExportAction) rapidExportAction.addEventListener('click', async () => {
setOpen(false);
rapidExportAction.disabled = true;
let progress = null;
try {
await loadAceEditor();
progress = showRapidExportProgress();
if (isXunleiPan) xunleiRetryNotifier = detail => progress.update(detail);
const resources = isXunleiPan ? await getSelectedXunleiResources() : await getSelectedGuangyaResources();
if (!resources.length) {
progress.close();
showToast('请先选择文件或文件夹', 'error');
return;
}
const onProgress = detail => progress.update(detail);
const exportResult = isXunleiPan ? await buildXunleiRapidExportData(resources, onProgress) : await buildGuangyaRapidExportData(resources, onProgress);
progress.close();
showRapidExportDesigner(exportResult);
} catch (error) {
if (progress) progress.close();
showToast(error?.message || '生成秒传文件失败', 'error');
} finally {
xunleiRetryNotifier = null;
rapidExportAction.disabled = false;
}
});
document.addEventListener('click', (event) => {
if (!root.contains(event.target)) setOpen(false);
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') setOpen(false);
});
document.body.appendChild(root);
}
// 启动助手组件
function init() {
addStyles();
createAssistant();
if (isXunleiPan) installXunleiRequestCapture();
else initializeGuangyaSelection();
}
if (document.body) {
init();
} else {
document.addEventListener('DOMContentLoaded', init, { once: true });
}
})();