// ==UserScript== // @name 光鸭云盘手动整理助手 // @namespace guanya.custom.organizer // @version 0.2.0 // @license AGPL // @description 在“云添加”后增加“整理”按钮,支持 TMDB 选择并按剧集规则整理 // @author You // @match https://www.guangyapan.com/* // @run-at document-end // @grant GM_xmlhttpRequest // @grant GM_registerMenuCommand // @grant unsafeWindow // @connect api.guangyapan.com // @connect www.guangyapan.com // @connect api.tmdb.org // @downloadURL https://update.greasyfork.icu/scripts/575867/%E5%85%89%E9%B8%AD%E4%BA%91%E7%9B%98%E6%89%8B%E5%8A%A8%E6%95%B4%E7%90%86%E5%8A%A9%E6%89%8B.user.js // @updateURL https://update.greasyfork.icu/scripts/575867/%E5%85%89%E9%B8%AD%E4%BA%91%E7%9B%98%E6%89%8B%E5%8A%A8%E6%95%B4%E7%90%86%E5%8A%A9%E6%89%8B.meta.js // ==/UserScript== (function () { "use strict"; const LOG_PREFIX = "[光鸭整理]"; const ORGANIZE_BTN_ATTR = "data-guanya-organize-btn"; const MODAL_ID = "guangya-organize-modal"; const STYLE_ID = "guangya-organize-style"; const TMDB_KEY_STORAGE = "__GUANGYA_TMDB_API_KEY__"; const REQUEST_EVENT = "__guangya_organize_request__"; const RESPONSE_EVENT = "__guangya_organize_response__"; const TRANSIENT_KEYS = new Set([ "cursor", "nextCursor", "nextKey", "nextToken", "pageToken", "continueToken", "marker", "offset", "start", "startId", "startKey", "lastId", "lastKey", "lastFileId", "lastSortValue", "pageNo", "pageNum", "pageIndex", "scrollId", ]); const LIST_FILTER_KEYS = new Set([ "isDir", "isFolder", "folderOnly", "onlyFolder", "onlyDir", "dirOnly", "showDir", "showFolder", "type", "fileType", "resType", "resourceType", "mediaType", "category", "fileCategory", "sourceType", "kind", ]); const VIDEO_EXTS = new Set([ "mp4", "mkv", "avi", "mov", "m4v", "flv", "wmv", "rmvb", "rm", "ts", "webm", "mpeg", "mpg", "strm", ]); const COMMON_FILE_EXTS = new Set([ "srt", "ass", "ssa", "vtt", "sub", "idx", "nfo", "txt", "md", "json", "xml", "yaml", "yml", "jpg", "jpeg", "png", "gif", "webp", "bmp", "heic", "mp3", "flac", "aac", "wav", "m4a", "ogg", "zip", "rar", "7z", "tar", "gz", "bz2", "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", ]); const API_PATH_HINT_RE = /\/nd\.bizuserres\.s\/v1\//i; const CONFIG = { apiHost: "https://api.guangyapan.com", listPath: "/nd.bizuserres.s/v1/file/get_file_list", renamePath: "/nd.bizuserres.s/v1/file/rename", createDirPath: "/nd.bizuserres.s/v1/file/create_dir", movePath: "/nd.bizuserres.s/v1/file/move_file", deletePath: "/nd.bizuserres.s/v1/file/delete_file", taskStatusPath: "/nd.bizuserres.s/v1/get_task_status", tmdbHost: "https://api.tmdb.org/3", tmdbLanguage: "zh-CN", listPageSize: 1000, taskPollMs: 1400, taskPollMaxTries: 80, }; const STATE = { headers: { authorization: "", did: "", dt: "", }, lastApiHeaders: {}, lastListUrl: "", lastListBody: null, listByParent: {}, lastListItems: [], lastRenameRequest: null, lastCreateDirRequest: null, lastMoveRequest: null, lastDeleteRequest: null, ui: null, selectedItems: [], selectedTmdb: null, busy: false, }; function log(...args) { console.log(LOG_PREFIX, ...args); } function warn(...args) { console.warn(LOG_PREFIX, ...args); } function fail(...args) { console.error(LOG_PREFIX, ...args); } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function safeJsonParse(value) { if (typeof value !== "string") { return value; } try { return JSON.parse(value); } catch { return null; } } function deepClone(value) { return value && typeof value === "object" ? JSON.parse(JSON.stringify(value)) : {}; } function sanitizeHeaders(headersLike) { const out = {}; if (!headersLike) { return out; } if (headersLike instanceof Headers) { for (const [key, value] of headersLike.entries()) { out[String(key).toLowerCase()] = value; } return out; } if (Array.isArray(headersLike)) { for (const [key, value] of headersLike) { out[String(key).toLowerCase()] = value; } return out; } if (typeof headersLike === "object") { for (const [key, value] of Object.entries(headersLike)) { out[String(key).toLowerCase()] = value; } } return out; } function mergeCoreHeaders(headersLike) { const headers = sanitizeHeaders(headersLike); STATE.lastApiHeaders = { ...STATE.lastApiHeaders, ...headers, }; for (const key of ["authorization", "did", "dt"]) { if (headers[key]) { STATE.headers[key] = headers[key]; } } } function pickExistingKey(obj, candidates, fallback) { if (!obj || typeof obj !== "object") { return fallback; } for (const key of candidates) { if (Object.prototype.hasOwnProperty.call(obj, key)) { return key; } } return fallback; } function sanitizeListBody(body) { const source = body && typeof body === "object" ? body : {}; const out = {}; for (const [key, value] of Object.entries(source)) { if ( value == null || value === "" || TRANSIENT_KEYS.has(String(key)) ) { continue; } out[key] = value; } return out; } function getRequestHeaders() { const forwarded = {}; for (const key of [ "x-device-id", "x-requested-with", "appid", "timestamp", "signature", "nonce", ]) { if (STATE.lastApiHeaders[key]) { forwarded[key] = STATE.lastApiHeaders[key]; } } const headers = { accept: "application/json, text/plain, */*", "content-type": "application/json", ...forwarded, }; if (STATE.headers.authorization) { headers.authorization = STATE.headers.authorization; } if (STATE.headers.did) { headers.did = STATE.headers.did; } if (STATE.headers.dt) { headers.dt = STATE.headers.dt; } return headers; } function getListUrl() { const fallback = `${CONFIG.apiHost}${CONFIG.listPath}`; const captured = String(STATE.lastListUrl || "").trim(); if (!captured) { return fallback; } if (!isLikelyFileListUrl(captured)) { return fallback; } return captured; } function getRenameUrl() { return ( STATE.lastRenameRequest?.url || `${CONFIG.apiHost}${CONFIG.renamePath}` ); } function getCreateDirUrl() { return ( STATE.lastCreateDirRequest?.url || `${CONFIG.apiHost}${CONFIG.createDirPath}` ); } function getMoveUrl() { return ( STATE.lastMoveRequest?.url || `${CONFIG.apiHost}${CONFIG.movePath}` ); } function getDeleteUrl() { return `${CONFIG.apiHost}${CONFIG.deletePath}`; } function getTaskStatusUrl() { return `${CONFIG.apiHost}${CONFIG.taskStatusPath}`; } function isLikelyFileListUrl(url) { const text = String(url || "").toLowerCase(); if (!text) { return false; } if (text.includes(String(CONFIG.listPath || "").toLowerCase())) { return true; } return ( /\/file\/(?:get_)?file[_-]?list(?:\?|$)/i.test(text) || /\/list(?:\?|$)/i.test(text) ); } function isPlatformApiUrl(url) { const text = String(url || ""); if (!text) { return false; } return text.includes(CONFIG.apiHost) || API_PATH_HINT_RE.test(text); } function getGmXhr() { if (typeof GM_xmlhttpRequest === "function") { return GM_xmlhttpRequest; } if ( typeof GM === "object" && GM && typeof GM.xmlHttpRequest === "function" ) { return GM.xmlHttpRequest.bind(GM); } return null; } function requestViaGM(url, optionsLike = {}) { const gmXhr = getGmXhr(); if (!gmXhr) { return Promise.reject( new Error("当前脚本环境不支持 GM_xmlhttpRequest"), ); } const options = buildBridgeRequestOptions(optionsLike); const resolvedUrl = (() => { try { return new URL( String(url || ""), window.location.origin, ).toString(); } catch { return String(url || ""); } })(); return new Promise((resolve, reject) => { gmXhr({ method: String(options.method || "GET").toUpperCase(), url: resolvedUrl, headers: sanitizeHeaders(options.headers), data: typeof options.body === "string" ? options.body : undefined, timeout: 30000, responseType: "text", anonymous: false, withCredentials: true, onload: (resp) => { resolve({ ok: Number(resp?.status || 0) >= 200 && Number(resp?.status || 0) < 300, status: Number(resp?.status || 0), text: String(resp?.responseText || ""), via: "gm_xhr", }); }, onerror: (err) => { reject( new Error( `GM_xmlhttpRequest error: ${getErrorText(err) || "unknown error"}`, ), ); }, ontimeout: () => { reject( new Error(`GM_xmlhttpRequest timeout: ${resolvedUrl}`), ); }, }); }); } function buildBridgeRequestOptions(optionsLike) { const source = optionsLike && typeof optionsLike === "object" ? optionsLike : {}; const out = { method: String(source.method || "GET").toUpperCase(), }; const headers = sanitizeHeaders(source.headers); if (Object.keys(headers).length) { out.headers = headers; } if (typeof source.body === "string") { out.body = source.body; } const credentials = String(source.credentials || "").toLowerCase(); if (["include", "same-origin", "omit"].includes(credentials)) { out.credentials = credentials; } const mode = String(source.mode || "").toLowerCase(); if (mode) { out.mode = mode; } return out; } function injectRequestBridge() { if (window.__guangyaOrganizeRequestBridgeReady) { return; } const code = ` (() => { if (window.__guangyaOrganizeRequestBridgeReady) { return; } window.__guangyaOrganizeRequestBridgeReady = true; const REQUEST_EVENT = ${JSON.stringify(REQUEST_EVENT)}; const RESPONSE_EVENT = ${JSON.stringify(RESPONSE_EVENT)}; const originalFetch = window.fetch.bind(window); const normalizeHeaders = (headersLike) => { const out = {}; if (!headersLike) { return out; } if (headersLike instanceof Headers) { for (const [k, v] of headersLike.entries()) { out[String(k)] = String(v); } return out; } if (Array.isArray(headersLike)) { for (const [k, v] of headersLike) { out[String(k)] = String(v); } return out; } if (typeof headersLike === 'object') { for (const [k, v] of Object.entries(headersLike)) { out[String(k)] = String(v); } } return out; }; const emitResponse = (detail) => { window.dispatchEvent(new CustomEvent(RESPONSE_EVENT, { detail })); }; const requestViaXhr = (url, optionsLike) => new Promise((resolve, reject) => { const options = optionsLike && typeof optionsLike === 'object' ? optionsLike : {}; const xhr = new XMLHttpRequest(); const method = String(options.method || 'GET').toUpperCase(); xhr.open(method, String(url), true); xhr.withCredentials = String(options.credentials || '').toLowerCase() === 'include'; xhr.timeout = 30000; const headers = normalizeHeaders(options.headers); for (const [key, value] of Object.entries(headers)) { try { xhr.setRequestHeader(key, value); } catch (err) { reject(new Error('XHR setRequestHeader failed for ' + key + ': ' + (err && err.message ? err.message : String(err)))); return; } } xhr.onload = () => { resolve({ ok: xhr.status >= 200 && xhr.status < 300, status: xhr.status, text: typeof xhr.responseText === 'string' ? xhr.responseText : '', via: 'xhr', }); }; xhr.onerror = () => reject(new Error('XHR network error')); xhr.ontimeout = () => reject(new Error('XHR timeout')); const body = typeof options.body === 'string' ? options.body : null; xhr.send(body); }); window.addEventListener(REQUEST_EVENT, async (event) => { const detail = event && event.detail ? event.detail : {}; if (!detail.requestId || !detail.url) { return; } const requestUrl = String(detail.url); const sourceOptions = detail.options && typeof detail.options === 'object' ? detail.options : {}; const requestOptions = { method: String(sourceOptions.method || 'GET').toUpperCase(), headers: normalizeHeaders(sourceOptions.headers), body: typeof sourceOptions.body === 'string' ? sourceOptions.body : undefined, credentials: sourceOptions.credentials || 'same-origin', mode: sourceOptions.mode || 'cors', }; try { const response = await originalFetch(requestUrl, requestOptions); const text = await response.text(); emitResponse({ requestId: detail.requestId, ok: response.ok, status: response.status, text, via: 'fetch', }); } catch (fetchErr) { try { const fallback = await requestViaXhr(requestUrl, requestOptions); emitResponse({ requestId: detail.requestId, ok: fallback.ok, status: fallback.status, text: fallback.text, via: fallback.via, fallbackFrom: String(fetchErr && fetchErr.message ? fetchErr.message : fetchErr), }); } catch (xhrErr) { emitResponse({ requestId: detail.requestId, error: 'fetch failed: ' + String(fetchErr && fetchErr.message ? fetchErr.message : fetchErr) + ' | xhr failed: ' + String(xhrErr && xhrErr.message ? xhrErr.message : xhrErr), }); } } }); })(); `; const script = document.createElement("script"); script.textContent = code; ( document.documentElement || document.head || document.body ).appendChild(script); script.remove(); } function requestViaPage(url, optionsLike = {}) { injectRequestBridge(); if (!window.__guangyaOrganizeRequestBridgeReady) { throw new Error("页面请求桥接器未就绪"); } const requestId = `gyreq_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; const options = buildBridgeRequestOptions(optionsLike); return new Promise((resolve, reject) => { const timeoutMs = 35000; let done = false; let timer = null; const cleanup = () => { if (timer) { window.clearTimeout(timer); timer = null; } window.removeEventListener(RESPONSE_EVENT, onResponse); }; const finalize = (fn) => { if (done) { return; } done = true; cleanup(); fn(); }; const onResponse = (event) => { const detail = event && event.detail ? event.detail : {}; if (detail.requestId !== requestId) { return; } if (detail.error) { finalize(() => reject(new Error(String(detail.error)))); return; } finalize(() => { resolve({ ok: Boolean(detail.ok), status: Number(detail.status || 0), text: typeof detail.text === "string" ? detail.text : "", via: String(detail.via || ""), }); }); }; timer = window.setTimeout(() => { finalize(() => reject(new Error(`页面请求超时:${url}`))); }, timeoutMs); window.addEventListener(RESPONSE_EVENT, onResponse); window.dispatchEvent( new CustomEvent(REQUEST_EVENT, { detail: { requestId, url: String(url), options, }, }), ); }); } async function postJson(url, body) { const urlCandidates = Array.isArray(url) ? url : [url]; const requestOptions = { method: "POST", headers: getRequestHeaders(), mode: "cors", credentials: "include", body: JSON.stringify(body), }; const errors = []; for (const endpoint of urlCandidates) { if (!endpoint) { continue; } try { const gmRes = await requestViaGM(endpoint, requestOptions); return { ok: gmRes.ok, status: gmRes.status, text: gmRes.text, payload: safeJsonParse(gmRes.text), }; } catch (gmErr) { warn( `GM_xmlhttpRequest 失败:${getErrorText(gmErr)} | ${endpoint}`, ); } try { const response = await window.fetch(endpoint, requestOptions); const text = await response.text(); const payload = safeJsonParse(text); return { ok: response.ok, status: response.status, text, payload, }; } catch (fetchErr) { warn( `window.fetch 失败,改用页面上下文请求:${getErrorText(fetchErr)} | ${endpoint}`, ); try { const bridged = await requestViaPage( endpoint, requestOptions, ); const payload = safeJsonParse(bridged.text); return { ok: bridged.ok, status: bridged.status, text: bridged.text, payload, }; } catch (bridgeErr) { errors.push(`${endpoint}: ${getErrorText(bridgeErr)}`); } } } throw new Error(errors.join(" || ") || "请求失败"); } function findFirstValueByKeys(node, keys) { if (!node || typeof node !== "object") { return null; } if (Array.isArray(node)) { for (const item of node) { const found = findFirstValueByKeys(item, keys); if (found != null) { return found; } } return null; } for (const key of keys) { if ( Object.prototype.hasOwnProperty.call(node, key) && node[key] != null ) { return node[key]; } } for (const value of Object.values(node)) { const found = findFirstValueByKeys(value, keys); if (found != null) { return found; } } return null; } function extractTaskId(payload) { const taskId = findFirstValueByKeys(payload, [ "taskId", "task_id", "id", ]); return taskId == null ? "" : String(taskId); } function extractTaskStatus(payload) { const raw = findFirstValueByKeys(payload, [ "taskStatus", "task_status", "status", "state", "taskState", "task_state", ]); return raw == null ? "" : String(raw).toUpperCase(); } function getNumericTaskStatus(status) { const value = Number(status); return Number.isFinite(value) ? value : null; } function isTaskFinished(payload) { const status = extractTaskStatus(payload); if (status) { const numeric = getNumericTaskStatus(status); if (numeric != null) { if ([2, 3, 4].includes(numeric)) return true; if ([0, 1].includes(numeric)) return false; } if ( [ "SUCCESS", "SUCCEEDED", "DONE", "FINISH", "FINISHED", "COMPLETED", "FAILED", "ERROR", "CANCEL", "成功", "完成", "失败", ].some((w) => status.includes(w)) ) { return true; } if ( [ "RUN", "PROCESS", "PENDING", "QUEUE", "WAIT", "进行", "处理中", "等待", ].some((w) => status.includes(w)) ) { return false; } } const finished = findFirstValueByKeys(payload, [ "finished", "done", "completed", "isFinished", ]); if (typeof finished === "boolean") { return finished; } return false; } function isTaskSuccessful(payload) { const status = extractTaskStatus(payload); if (status) { const numeric = getNumericTaskStatus(status); if (numeric != null) { if (numeric === 2) return true; if ([3, 4].includes(numeric)) return false; } if ( ["FAILED", "ERROR", "CANCEL", "CANCELLED", "失败"].some((w) => status.includes(w), ) ) { return false; } if ( [ "SUCCESS", "SUCCEEDED", "DONE", "FINISH", "FINISHED", "COMPLETED", "成功", "完成", ].some((w) => status.includes(w)) ) { return true; } } const success = findFirstValueByKeys(payload, ["success", "ok"]); if (typeof success === "boolean") { return success; } return true; } function isProbablySuccess(payload, response) { if (!response.ok) { return false; } if (!payload || typeof payload !== "object") { return true; } if (payload.success === false || payload.status === "error") { return false; } if ("code" in payload) { const code = String(payload.code); if (!["0", "200", "2000"].includes(code) && !code.startsWith("2")) { return false; } } return true; } function getErrorText(detail) { if (!detail) return ""; if (typeof detail === "string") return detail; if (detail instanceof Error) return detail.message || String(detail); if (typeof detail === "object") { return ( detail.message || detail.error || detail.msg || detail.code || JSON.stringify(detail) ); } return String(detail); } async function ensureTaskDoneIfNeeded(response, progressText = "") { if (!isProbablySuccess(response.payload, response)) { throw new Error( getErrorText( response.payload || response.text || `HTTP ${response.status}`, ), ); } const taskId = extractTaskId(response.payload); if (!taskId) { return; } for (let i = 1; i <= CONFIG.taskPollMaxTries; i += 1) { const taskRes = await postJson(getTaskStatusUrl(), { taskId }); if (!taskRes.ok) { throw new Error(`任务查询失败 HTTP ${taskRes.status}`); } if (isTaskFinished(taskRes.payload)) { if (!isTaskSuccessful(taskRes.payload)) { throw new Error( `${progressText || "任务"}失败:${getErrorText(taskRes.payload) || "未知原因"}`, ); } return; } await sleep(CONFIG.taskPollMs); } throw new Error(`${progressText || "任务"}超时:taskId=${taskId}`); } function extractFileExt(name) { const text = String(name || ""); const idx = text.lastIndexOf("."); if (idx <= 0) { return ""; } return text.slice(idx + 1).toLowerCase(); } function getItemVideoExt(item) { const nameExt = extractFileExt(item?.name || ""); if (nameExt) { return nameExt; } const raw = item?.raw || {}; const rawExt = String( raw.ext || raw.suffix || raw.fileSuffix || raw.fileExt || raw.extension || raw.file_extension || "", ) .replace(/^\./, "") .toLowerCase(); return rawExt; } function parseResTypeValue(value) { if (value == null || value === "") { return null; } if (typeof value === "number" && Number.isFinite(value)) { const n = Math.trunc(value); return n === 1 || n === 2 ? n : null; } const text = String(value).trim(); if (!text) { return null; } if (/^\d+$/.test(text)) { const n = Number(text); return n === 1 || n === 2 ? n : null; } const m = text.match(/\b([12])\b/); if (m) { return Number(m[1]); } return null; } function getResTypeFromRaw(raw) { const obj = raw && typeof raw === "object" ? raw : {}; const candidates = [ obj.resType, obj.res_type, obj.resourceType, obj.resource_type, obj.fileType, obj.file_type, obj.type, ]; for (const val of candidates) { const parsed = parseResTypeValue(val); if (parsed != null) { return parsed; } } return null; } function isFolderByResType(raw) { return getResTypeFromRaw(raw) === 2; } function isFileByResType(raw) { return getResTypeFromRaw(raw) === 1; } function isVideoItem(item) { if (!item) return false; if (isFolderByResType(item.raw || {})) return false; const ext = getItemVideoExt(item); if (ext && VIDEO_EXTS.has(ext)) { return true; } if (item.isFolder) return false; const typeText = String( item.raw?.mediaType || item.raw?.fileType || item.raw?.type || item.raw?.mimeType || item.raw?.mime || "", ).toLowerCase(); return /(video|视频|影片)/.test(typeText); } function normalizeDomText(text) { return String(text || "") .replace(/\s+/g, " ") .trim(); } function isProbablyMetadataText(text) { const value = normalizeDomText(text); if (!value) return true; return ( /^\d+(\.\d+)?\s*(B|KB|MB|GB|TB|PB)$/i.test(value) || /^\d{4}[-/.年]\d{1,2}[-/.月]\d{1,2}/.test(value) || /^(今天|昨天|刚刚|\d{1,2}:\d{2})$/.test(value) ); } function collectTextCandidates(row) { const out = new Set(); const push = (value) => { const text = normalizeDomText(value); if (!text || isProbablyMetadataText(text)) { return; } out.add(text); }; if (!row) return []; push(row.getAttribute && row.getAttribute("title")); push(row.getAttribute && row.getAttribute("aria-label")); const attrNodes = Array.from( row.querySelectorAll( "[title], [aria-label], [data-name], [data-filename]", ), ); for (const node of attrNodes) { push(node.getAttribute && node.getAttribute("title")); push(node.getAttribute && node.getAttribute("aria-label")); push(node.getAttribute && node.getAttribute("data-name")); push(node.getAttribute && node.getAttribute("data-filename")); } const leafNodes = Array.from( row.querySelectorAll("span, div, p, a, strong, td"), ) .filter((el) => el && el.childElementCount === 0) .map((el) => el.textContent); for (const value of leafNodes) { push(value); } const rowText = String(row.innerText || row.textContent || ""); for (const line of rowText.split(/\n+/)) { push(line); } return Array.from(out); } function chooseBestName(candidates) { const items = Array.from( new Set( (candidates || []) .map((x) => normalizeDomText(x)) .filter(Boolean), ), ); if (!items.length) { return ""; } items.sort((a, b) => b.length - a.length); return items[0]; } function extractNameFromRow(row) { return chooseBestName(collectTextCandidates(row)); } function extractFileIdFromNode(node) { if (!node || typeof node !== "object") { return ""; } const candidates = [ node.getAttribute && node.getAttribute("data-id"), node.getAttribute && node.getAttribute("data-file-id"), node.getAttribute && node.getAttribute("data-fileid"), node.getAttribute && node.getAttribute("data-resource-id"), node.getAttribute && node.getAttribute("data-res-id"), node.getAttribute && node.getAttribute("data-key"), node.getAttribute && node.getAttribute("data-row-key"), node.getAttribute && node.getAttribute("id"), node.getAttribute && node.getAttribute("value"), node.dataset && node.dataset.id, node.dataset && node.dataset.fileId, node.dataset && node.dataset.fileid, node.dataset && node.dataset.resourceId, node.dataset && node.dataset.resId, node.dataset && node.dataset.key, node.dataset && node.dataset.rowKey, ]; for (const raw of candidates) { const id = String(raw || "").trim(); if (!id) { continue; } const num = id.match(/\d{8,}/); if (num) { return num[0]; } if (/^[A-Za-z0-9_-]{8,}$/.test(id)) { return id; } } return ""; } function extractResTypeFromNode(node) { if (!node || typeof node !== "object") { return null; } const candidates = [ node.getAttribute && node.getAttribute("data-res-type"), node.getAttribute && node.getAttribute("data-restype"), node.getAttribute && node.getAttribute("data-resource-type"), node.getAttribute && node.getAttribute("data-resourceType"), node.getAttribute && node.getAttribute("data-file-type"), node.getAttribute && node.getAttribute("data-fileType"), node.getAttribute && node.getAttribute("data-type"), node.dataset && node.dataset.resType, node.dataset && node.dataset.restype, node.dataset && node.dataset.resourceType, node.dataset && node.dataset.fileType, node.dataset && node.dataset.type, ]; for (const raw of candidates) { const parsed = parseResTypeValue(raw); if (parsed != null) { return parsed; } } return null; } function getRowSelector() { return [ '[role="row"]', "tr", "li", '[class*="row"]', '[class*="item"]', '[class*="file"]', ].join(", "); } function resolveListParentId() { const body = STATE.lastListBody && typeof STATE.lastListBody === "object" ? STATE.lastListBody : {}; return String(body.parentId || "").trim(); } function getCapturedItemsForCurrentParent() { const parentId = resolveListParentId(); if (parentId && STATE.listByParent[parentId]) { return STATE.listByParent[parentId]; } return STATE.lastListItems || []; } function collectSelectedRows() { const checkedNodes = new Set(); const isNodeInsideModal = (node) => Boolean(node && node.closest && node.closest(`#${MODAL_ID}`)); const canUseCheckedNode = (node) => { if (!node || isNodeInsideModal(node)) { return false; } if ( node instanceof HTMLInputElement && String(node.type || "").toLowerCase() === "radio" ) { return false; } return true; }; document .querySelectorAll('input[type="checkbox"]:checked') .forEach((node) => { if (canUseCheckedNode(node)) { checkedNodes.add(node); } }); document .querySelectorAll('[role="checkbox"][aria-checked="true"]') .forEach((node) => { if (canUseCheckedNode(node)) { checkedNodes.add(node); } }); document .querySelectorAll( '[class*="checkbox"][class*="checked"], [class*="check"][class*="checked"]', ) .forEach((node) => { if (canUseCheckedNode(node)) { checkedNodes.add(node); } }); const rows = []; const seen = new Set(); for (const node of checkedNodes) { const row = node.closest ? node.closest(getRowSelector()) : null; if (!row || isNodeInsideModal(row) || seen.has(row)) { continue; } seen.add(row); rows.push(row); } return rows; } function collectSelectedItemsFromPage() { const rows = collectSelectedRows(); const capturedItems = getCapturedItemsForCurrentParent(); const byId = new Map(); const byName = new Map(); for (const item of capturedItems) { const id = String(item.fileId || ""); const name = normalizeDomText(item.name); if (id && !byId.has(id)) { byId.set(id, item); } if (name && !byName.has(name)) { byName.set(name, item); } } const result = []; const seenKey = new Set(); for (const row of rows) { let fileId = extractFileIdFromNode(row); let rowResType = extractResTypeFromNode(row); if (!fileId) { const desc = Array.from( row.querySelectorAll( "[data-id],[data-file-id],[data-row-key],[data-res-type],[data-restype],[data-type]", ), ); for (const node of desc) { fileId = extractFileIdFromNode(node); if (rowResType == null) { rowResType = extractResTypeFromNode(node); } if (fileId) break; } } const name = extractNameFromRow(row); const matchedById = fileId ? byId.get(fileId) : null; const matchedByName = !matchedById && name ? byName.get(normalizeDomText(name)) : null; let matchedByLoose = null; if (!matchedById && !matchedByName && name) { const rowKey = normalizeNameForMatch(name); if (rowKey) { matchedByLoose = capturedItems.find((item) => { const itemKey = normalizeNameForMatch(item.name); return ( itemKey && (rowKey.includes(itemKey) || itemKey.includes(rowKey)) ); }) || null; } } const matched = matchedById || matchedByName || matchedByLoose || null; const resolvedId = fileId || String(matched?.fileId || ""); const resolvedName = String(matched?.name || name || ""); const key = `${resolvedId}|${resolvedName}`; if (!resolvedName || seenKey.has(key)) { continue; } seenKey.add(key); const item = { fileId: resolvedId, name: resolvedName, parentId: String( matched?.parentId || matched?.raw?.parentId || matched?.raw?.pid || "", ), raw: matched?.raw || (rowResType != null ? { resType: rowResType } : null), isFolder: (() => { const matchedResType = getResTypeFromRaw( matched?.raw || {}, ); const effectiveResType = matchedResType != null ? matchedResType : rowResType; if (effectiveResType === 2) { return true; } if (effectiveResType === 1) { return false; } return ( Boolean(matched?.isFolder) || (!extractFileExt(resolvedName) && !isVideoItem({ name: resolvedName, isFolder: false, raw: matched?.raw || {}, })) ); })(), }; result.push(item); } return result; } function refreshSelectedItems(options = {}) { const keepPreviousIfEmpty = options.keepPreviousIfEmpty !== false; const latest = collectSelectedItemsFromPage(); if (latest.length || !keepPreviousIfEmpty) { STATE.selectedItems = latest; } renderSelectedItems(STATE.selectedItems || []); return STATE.selectedItems; } function normalizeItem(obj) { if (!obj || typeof obj !== "object" || Array.isArray(obj)) { return null; } const fileId = obj.fileId ?? obj.id ?? obj.resourceId ?? obj.resId ?? obj.bizId ?? obj.objId ?? obj.file_id ?? obj.resource_id ?? obj.res_id ?? obj.obj_id ?? obj.fid ?? obj.fsId ?? obj.fileID; const name = chooseBestName([ obj.name, obj.fileName, obj.file_name, obj.filename, obj.fname, obj.fileTitle, obj.file_title, obj.resName, obj.res_name, obj.resourceName, obj.resource_name, obj.title, obj.objName, obj.obj_name, obj.displayName, obj.display_name, obj.originalName, obj.original_name, obj.fileFullName, obj.fullName, obj.pathName, obj.path_name, ]); if (!(typeof fileId === "string" || typeof fileId === "number")) { return null; } const hasKnownNameField = [ "name", "fileName", "file_name", "filename", "fname", "resName", "res_name", "resourceName", "resource_name", "title", "objName", "obj_name", "displayName", "display_name", "originalName", "original_name", "fileFullName", "fullName", "pathName", "path_name", ].some((k) => Object.prototype.hasOwnProperty.call(obj, k)); if (!name || !hasKnownNameField) { return null; } const raw = obj; const resType = getResTypeFromRaw(raw); const folderFlagValue = raw.isDir ?? raw.isFolder ?? raw.folder ?? raw.isDirectory ?? raw.dir ?? raw.is_dir ?? raw.is_folder; const folderFlag = folderFlagValue === true || folderFlagValue === "true" || Number(folderFlagValue) === 1; const typeText = String( raw.type || raw.fileType || raw.resType || raw.resourceType || raw.kind || raw.category || "", ).toLowerCase(); const maybeFileExt = getItemVideoExt({ name, raw }); const isFolder = resType === 2 || (resType === 1 ? false : (folderFlag && !maybeFileExt) || ["folder", "dir", "directory"].includes(typeText) || raw.childrenCount != null || raw.childCount != null || raw.dirCount != null); return { fileId: String(fileId), name, parentId: String( raw.parentId || raw.parent_id || raw.pid || raw.pId || raw.dirId || raw.folderId || "", ), isFolder, raw, }; } function scanItems(node, out = []) { if (!node || typeof node !== "object") { return out; } if (Array.isArray(node)) { for (const item of node) { scanItems(item, out); } return out; } const normalized = normalizeItem(node); if (normalized) { out.push(normalized); } for (const value of Object.values(node)) { scanItems(value, out); } return out; } function scoreItemQuality(item) { if (!item) { return -1; } let score = 0; if (item.fileId) score += 2; if (item.parentId) score += 1; if (item.name && item.name.length > 1) score += 2; if (extractFileExt(item.name)) score += 2; if ( item.raw && (item.raw.fileName || item.raw.file_name || item.raw.resName || item.raw.resourceName) ) score += 1; if ( item.raw && (item.raw.parentId || item.raw.pid || item.raw.parent_id) ) score += 1; if ( item.raw && (item.raw.size != null || item.raw.fileSize != null || item.raw.resourceSize != null) ) score += 1; return score; } function dedupeItems(items) { const byId = new Map(); for (const item of items || []) { if (!item || !item.fileId) { continue; } const key = String(item.fileId); const prev = byId.get(key); if (!prev || scoreItemQuality(item) > scoreItemQuality(prev)) { byId.set(key, item); } } return Array.from(byId.values()); } function extractItemsFromPayload(payload) { const nodes = []; const pushArrayNode = (arr) => { if (Array.isArray(arr) && arr.length) { nodes.push(arr); } }; const data = payload && typeof payload === "object" ? payload.data || payload.result || payload.payload || payload : {}; pushArrayNode(payload && payload.list); pushArrayNode(payload && payload.records); pushArrayNode(payload && payload.rows); pushArrayNode(data && data.list); pushArrayNode(data && data.records); pushArrayNode(data && data.rows); pushArrayNode(data && data.fileList); pushArrayNode(data && data.items); pushArrayNode(data && data.children); let items = []; for (const node of nodes) { items = items.concat(scanItems(node, [])); } if (!items.length) { items = scanItems(payload, []); } return dedupeItems(items); } function looksLikeListRequest(url, requestBody) { if (typeof url === "string" && /get_file_list|list|share/i.test(url)) { return true; } if (!requestBody || typeof requestBody !== "object") { return false; } return [ "parentId", "pageSize", "pageNum", "pageNo", "sortType", "orderBy", ].some((key) => Object.prototype.hasOwnProperty.call(requestBody, key)); } function looksLikeListResponse(payload) { if (!payload || typeof payload !== "object") { return false; } return extractItemsFromPayload(payload).length > 0; } function mergeCapturedList(parentId, items) { const key = String(parentId || "").trim(); const merged = dedupeItems([ ...(STATE.listByParent[key] || []), ...(items || []), ]); STATE.listByParent[key] = merged; STATE.lastListItems = merged; } function handleCapture(detail) { if (!detail || typeof detail !== "object") { return; } const url = String(detail.url || ""); if (!isPlatformApiUrl(url)) { return; } mergeCoreHeaders(detail.headers); const requestBody = safeJsonParse(detail.requestBody); const responseBody = safeJsonParse(detail.responseText); if ( looksLikeListRequest(url, requestBody) || looksLikeListResponse(responseBody) ) { if (requestBody && typeof requestBody === "object") { STATE.lastListBody = sanitizeListBody(requestBody); } STATE.lastListUrl = url; const items = extractItemsFromPayload(responseBody); if (items.length) { const parentId = String( requestBody?.parentId || STATE.lastListBody?.parentId || "", ); mergeCapturedList(parentId, items); } } if (url.includes(CONFIG.renamePath) || /\/rename(?:\?|$)/i.test(url)) { STATE.lastRenameRequest = { url, headers: sanitizeHeaders(detail.headers), requestBody, }; } if ( url.includes(CONFIG.createDirPath) || /\/create[_-]?(dir|folder)(?:\?|$)/i.test(url) ) { STATE.lastCreateDirRequest = { url, headers: sanitizeHeaders(detail.headers), requestBody, }; } if ( url.includes(CONFIG.movePath) || /\/move(?:[_-]?(file|dir|folder))?(?:\?|$)/i.test(url) ) { STATE.lastMoveRequest = { url, headers: sanitizeHeaders(detail.headers), requestBody, }; } if ( url.includes(CONFIG.deletePath) || /\/(?:delete|remove|recycle)(?:[_-]?(file|dir|folder|batch))?(?:\?|$)/i.test( url, ) ) { STATE.lastDeleteRequest = { url, headers: sanitizeHeaders(detail.headers), requestBody, }; } } function installCaptureHooks() { if (window.__guangyaOrganizeCaptureInstalled) { return; } window.__guangyaOrganizeCaptureInstalled = true; const rawFetch = window.fetch.bind(window); window.fetch = async function patchedFetch(input, init) { const url = typeof input === "string" ? input : (input && input.url) || ""; const headers = sanitizeHeaders( (init && init.headers) || (input && input.headers), ); const requestBody = init && typeof init.body === "string" ? init.body : ""; const response = await rawFetch(input, init); if (isPlatformApiUrl(url)) { try { const cloned = response.clone(); const text = await cloned.text(); handleCapture({ type: "fetch", url, headers, requestBody, responseText: text, status: response.status, }); } catch (err) { warn("捕获 fetch 失败:", err); } } return response; }; const rawOpen = XMLHttpRequest.prototype.open; const rawSetHeader = XMLHttpRequest.prototype.setRequestHeader; const rawSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.open = function patchedOpen(method, url) { this.__guangyaCapture = { method, url, headers: {}, requestBody: "", }; return rawOpen.apply(this, arguments); }; XMLHttpRequest.prototype.setRequestHeader = function patchedSetHeader( name, value, ) { if (this.__guangyaCapture) { this.__guangyaCapture.headers[String(name).toLowerCase()] = value; } return rawSetHeader.apply(this, arguments); }; XMLHttpRequest.prototype.send = function patchedSend(body) { if (this.__guangyaCapture && typeof body === "string") { this.__guangyaCapture.requestBody = body; } this.addEventListener("load", function onLoad() { const detail = this.__guangyaCapture || {}; const url = this.responseURL || detail.url || ""; if (!isPlatformApiUrl(url)) { return; } handleCapture({ type: "xhr", url, headers: detail.headers || {}, requestBody: detail.requestBody || "", responseText: this.responseText || "", status: this.status, }); }); return rawSend.apply(this, arguments); }; } function resolveListBody(parentId) { const base = sanitizeListBody(STATE.lastListBody || {}); const body = { ...base, parentId: String(parentId || base.parentId || ""), pageSize: Number(base.pageSize || CONFIG.listPageSize), }; if (!body.pageSize || body.pageSize < 1) { body.pageSize = CONFIG.listPageSize; } if (Number(body.pageSize) > CONFIG.listPageSize) { body.pageSize = CONFIG.listPageSize; } if (body.orderBy == null || body.orderBy === "") { body.orderBy = 3; } if (body.sortType == null || body.sortType === "") { body.sortType = 1; } return sanitizeListBody(body); } function stripObjectKeys(source, keysSet) { const out = {}; for (const [key, value] of Object.entries(source || {})) { if (keysSet && keysSet.has(String(key))) { continue; } out[key] = value; } return out; } function pickObjectKeys(source, allowList) { const out = {}; for (const key of allowList || []) { if ( source && Object.prototype.hasOwnProperty.call(source, key) && source[key] != null && source[key] !== "" ) { out[key] = source[key]; } } return out; } function buildListBodyVariants(parentId) { const normalizedParentId = String(parentId || "").trim(); const primary = resolveListBody(normalizedParentId); const variants = []; const push = (body) => { const sanitized = sanitizeListBody(body); if (!sanitized.parentId) { sanitized.parentId = normalizedParentId; } if (!sanitized.pageSize || Number(sanitized.pageSize) < 1) { sanitized.pageSize = CONFIG.listPageSize; } if (Number(sanitized.pageSize) > CONFIG.listPageSize) { sanitized.pageSize = CONFIG.listPageSize; } if (sanitized.orderBy == null || sanitized.orderBy === "") { sanitized.orderBy = 3; } if (sanitized.sortType == null || sanitized.sortType === "") { sanitized.sortType = 1; } const key = JSON.stringify(sanitized); if (!variants.some((x) => x.__key === key)) { variants.push({ ...sanitized, __key: key }); } }; push({ parentId: normalizedParentId, pageSize: CONFIG.listPageSize, orderBy: 3, sortType: 1, }); push(primary); push({ ...stripObjectKeys(primary, LIST_FILTER_KEYS), parentId: normalizedParentId, pageSize: Number(primary.pageSize || CONFIG.listPageSize), orderBy: primary.orderBy ?? 3, sortType: primary.sortType ?? 1, }); push({ ...pickObjectKeys(primary, [ "shareId", "shareFileId", "spaceId", "driveId", "bizType", "scene", "sceneType", ]), parentId: normalizedParentId, pageSize: Number(primary.pageSize || CONFIG.listPageSize), orderBy: primary.orderBy ?? 3, sortType: primary.sortType ?? 1, }); return variants.map((x) => { const copy = { ...x }; delete copy.__key; return copy; }); } async function fetchListByParent(parentId) { const normalizedParentId = String(parentId || "").trim(); if (!normalizedParentId) { throw new Error("缺少 parentId,先打开目标目录让脚本捕获列表请求"); } const bodies = buildListBodyVariants(normalizedParentId); let bestItems = []; const errors = []; for (const body of bodies) { try { const res = await postJson(getListUrl(), body); if (!res.ok) { errors.push(`HTTP ${res.status}`); continue; } const items = extractItemsFromPayload(res.payload); if (items.length) { mergeCapturedList(normalizedParentId, items); } if (items.length > bestItems.length) { bestItems = items; } if (items.some((item) => isVideoItem(item))) { return items; } } catch (err) { errors.push(getErrorText(err)); } } if (bestItems.length) { return bestItems; } const captured = (STATE.listByParent[normalizedParentId] || []).filter( Boolean, ); if (captured.length) { return captured; } if (errors.length) { throw new Error(`获取列表失败:${errors[0]}`); } return []; } function buildRenamePayload(fileId, newName) { const base = deepClone(STATE.lastRenameRequest?.requestBody); const payload = {}; const idKey = pickExistingKey( base, ["fileId", "id", "resourceId", "resId", "bizId", "objId"], "fileId", ); const nameKey = pickExistingKey( base, ["newName", "name", "fileName", "file_name", "filename", "title"], "newName", ); for (const key of [ "parentId", "shareId", "shareFileId", "spaceId", "driveId", "folderId", "resourceType", "resType", "fileType", "bizType", ]) { if (base[key] != null && base[key] !== "") { payload[key] = base[key]; } } payload[idKey] = String(fileId); payload[nameKey] = String(newName); return payload; } function buildCreateDirPayload(parentId, dirName) { const base = deepClone(STATE.lastCreateDirRequest?.requestBody); const payload = {}; const parentKey = pickExistingKey( base, ["parentId", "pid", "folderId", "targetParentId", "toParentId"], "parentId", ); const nameKey = pickExistingKey( base, [ "dirName", "name", "folderName", "newFolderName", "fileName", "title", ], "dirName", ); for (const key of [ "shareId", "shareFileId", "spaceId", "driveId", "resourceType", "resType", "bizType", ]) { if (base[key] != null && base[key] !== "") { payload[key] = base[key]; } } payload[parentKey] = String(parentId); payload[nameKey] = String(dirName); return payload; } function normalizeFileIds(fileIdsLike) { const list = Array.isArray(fileIdsLike) ? fileIdsLike : [fileIdsLike]; const out = []; const seen = new Set(); for (const raw of list) { const id = String(raw || "").trim(); if (!id || seen.has(id)) { continue; } seen.add(id); out.push(id); } return out; } function buildMovePayloadVariants( fileIdsLike, targetParentId, sourceParentId = "", ) { const target = String(targetParentId || "").trim(); const ids = normalizeFileIds(fileIdsLike); const firstId = ids[0] || ""; const buildCanonicalPayload = (idKey, targetKey) => { const payload = {}; if ( idKey === "fileIds" || idKey === "ids" || idKey === "resourceIds" || idKey === "resIds" || idKey === "objIds" ) { payload[idKey] = ids; } else { payload[idKey] = firstId; } payload[targetKey] = target; return payload; }; const rawVariants = []; // 先命中你提供的真实格式:{"fileIds":[...], "parentId":"目标目录ID"} rawVariants.push(buildCanonicalPayload("fileIds", "parentId")); rawVariants.push(buildCanonicalPayload("ids", "parentId")); rawVariants.push(buildCanonicalPayload("resourceIds", "parentId")); rawVariants.push(buildCanonicalPayload("objIds", "parentId")); // 兜底:其它常见目标字段 rawVariants.push(buildCanonicalPayload("fileIds", "toParentId")); rawVariants.push(buildCanonicalPayload("fileIds", "targetParentId")); rawVariants.push(buildCanonicalPayload("fileIds", "destParentId")); rawVariants.push(buildCanonicalPayload("fileIds", "folderId")); rawVariants.push(buildCanonicalPayload("fileId", "parentId")); const keysSeen = new Set(); const out = []; for (const payload of rawVariants) { const key = JSON.stringify(payload); if (keysSeen.has(key)) { continue; } keysSeen.add(key); out.push(payload); } return out; } function buildDeletePayload(fileIdsLike) { const ids = normalizeFileIds(fileIdsLike); return { fileIds: ids }; } async function renameFile(fileId, newName) { const response = await postJson( getRenameUrl(), buildRenamePayload(fileId, newName), ); await ensureTaskDoneIfNeeded(response, "重命名"); return response; } async function createDir(parentId, dirName) { const response = await postJson( getCreateDirUrl(), buildCreateDirPayload(parentId, dirName), ); await ensureTaskDoneIfNeeded(response, "创建目录"); return response; } async function moveFiles(fileIdsLike, targetParentId, sourceParentId = "") { const ids = normalizeFileIds(fileIdsLike); if (!ids.length) { return null; } const variants = buildMovePayloadVariants( ids, targetParentId, sourceParentId, ); let lastError = null; let lastResponse = null; let lastPayloadKeys = ""; const expectedFileSet = new Set(ids); const expectedParentId = String(targetParentId || ""); const verifyMoved = async () => { for (let i = 0; i < 3; i += 1) { let items = []; try { items = await fetchListByParent(expectedParentId); } catch (err) { warn(`移动后校验读取目录失败:${getErrorText(err)}`); } if (Array.isArray(items)) { const present = new Set( items.map((item) => String(item.fileId || "")), ); const allFound = Array.from(expectedFileSet).every((id) => present.has(id), ); if (allFound) { return true; } } const captured = ( STATE.listByParent[expectedParentId] || [] ).filter(Boolean); if (captured.length) { const present = new Set( captured.map((item) => String(item.fileId || "")), ); const allFound = Array.from(expectedFileSet).every((id) => present.has(id), ); if (allFound) { return true; } } await sleep(450); } return false; }; for (const payload of variants) { try { const response = await postJson(getMoveUrl(), payload); lastResponse = response; await ensureTaskDoneIfNeeded(response, "移动文件"); const movedOk = await verifyMoved(); if (movedOk) { return response; } warn( `移动返回成功但目标目录未找到文件,继续尝试其它参数:count=${ids.length} target=${expectedParentId}`, ); } catch (err) { lastError = err; lastPayloadKeys = Object.keys(payload).sort().join(","); warn( `移动参数尝试失败:${getErrorText(err)} | payload=${JSON.stringify(payload)}`, ); } } if (lastError) { const msg = getErrorText(lastError); throw new Error( lastPayloadKeys ? `${msg} (move字段=${lastPayloadKeys})` : msg, ); } throw new Error(getErrorText(lastResponse?.payload) || "移动文件失败"); } async function moveFile(fileId, targetParentId, sourceParentId = "") { return moveFiles([fileId], targetParentId, sourceParentId); } async function deleteFiles(fileIdsLike, parentId = "") { const ids = normalizeFileIds(fileIdsLike); if (!ids.length) { return null; } const payload = buildDeletePayload(ids); const verifyDeleted = async () => { if (!parentId) { return true; } const expected = new Set(ids); for (let i = 0; i < 3; i += 1) { let items = []; try { items = await fetchListByParent(parentId); } catch (err) { warn(`删除后校验读取目录失败:${getErrorText(err)}`); } if (Array.isArray(items)) { const present = new Set( items.map((item) => String(item.fileId || "")), ); const allRemoved = Array.from(expected).every( (id) => !present.has(id), ); if (allRemoved) { return true; } } await sleep(450); } return false; }; try { const response = await postJson(getDeleteUrl(), payload); await ensureTaskDoneIfNeeded(response, "删除文件"); const removedOk = await verifyDeleted(); if (removedOk) { return response; } throw new Error("删除返回成功但目录仍可见"); } catch (err) { const msg = getErrorText(err); throw new Error( `${msg}。删除接口目前固定为 /file/delete_file,参数固定为 {"fileIds":[...]}`, ); } } function extractCreatedId(payload) { const val = findFirstValueByKeys(payload, [ "folderId", "newFolderId", "fileId", "resourceId", "resId", "objId", "id", ]); return val == null ? "" : String(val); } function pad2(value) { return String(Number(value || 0)).padStart(2, "0"); } function extractResolution(name) { const match = String(name || "").match( /(4320p|2160p|2060p|1440p|1080p|720p|480p|8k|4k)/i, ); return match ? match[1].toLowerCase() : ""; } function extractQualityTags(name) { const text = String(name || ""); if (!text) { return []; } const tagRules = [ { tag: "DV", re: /\b(?:dolby[\s.\-]*vision|dovi|dv)\b/i }, { tag: "HDR10+", re: /\bhdr10\+\b/i }, { tag: "HDR10", re: /\bhdr10\b/i }, { tag: "HDR", re: /\bhdr\b/i }, { tag: "BluRay", re: /\bblu[\s.\-]*ray\b/i }, { tag: "BDRemux", re: /\bbd[\s.\-]*remux\b/i }, { tag: "BDRip", re: /\bbd[\s.\-]*rip\b/i }, { tag: "WEB-DL", re: /\bweb[\s.\-]*dl\b/i }, { tag: "WEBRip", re: /\bweb[\s.\-]*rip\b/i }, { tag: "UHD", re: /\buhd\b/i }, ]; const out = []; const seen = new Set(); for (const rule of tagRules) { if (rule.re.test(text) && !seen.has(rule.tag)) { seen.add(rule.tag); out.push(rule.tag); } } return out; } function buildVideoExtraPart(resolution, qualityTags) { const res = String(resolution || "").trim(); const tags = Array.isArray(qualityTags) ? Array.from( new Set( qualityTags .map((x) => String(x || "").trim()) .filter(Boolean), ), ) : []; if (res && tags.length) { return `${res}.${tags.join(".")}`; } if (res) { return res; } if (tags.length) { return tags.join("."); } return ""; } function toHalfWidthDigits(text) { return String(text || "").replace(/[0-9]/g, (ch) => String(ch.charCodeAt(0) - 65248), ); } function parseChineseNumber(raw) { const text = String(raw || "").trim(); if (!text) { return NaN; } const normalized = toHalfWidthDigits(text).replace(/\s+/g, ""); if (/^\d+$/.test(normalized)) { return Number(normalized); } const map = { 零: 0, 〇: 0, 一: 1, 二: 2, 两: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9, }; const unitMap = { 十: 10, 百: 100, 千: 1000 }; const chars = Array.from(normalized); let total = 0; let section = 0; let number = 0; for (const ch of chars) { if (Object.prototype.hasOwnProperty.call(map, ch)) { number = map[ch]; continue; } if (Object.prototype.hasOwnProperty.call(unitMap, ch)) { const unit = unitMap[ch]; section += (number || 1) * unit; number = 0; continue; } return NaN; } total += section + number; return total; } function parseSeasonEpisode(name) { const text = String(name || ""); const sxe = text.match(/S\s*([0-9]{1,2})\s*E\s*([0-9]{1,})/i); if (sxe) { return { season: Number(sxe[1]), episode: Number(sxe[2]), }; } const seasonMatch = text.match( /(?:第\s*([0-90-9一二三四五六七八九十两零〇]{1,3})\s*季|Season\s*([0-9]{1,2}))/iu, ); const episodeMatch = text.match( /(?:第\s*([0-90-9]{1,})\s*[集话]|EP?\s*([0-9]{1,}))/iu, ); let season = null; let episode = null; if (seasonMatch) { season = parseChineseNumber( (seasonMatch[1] || seasonMatch[2] || "").replace(/\s+/g, ""), ); if (!Number.isFinite(season) || season <= 0) { season = 1; } } if (episodeMatch) { episode = Number( (episodeMatch[1] || episodeMatch[2] || "").replace( /[^0-9]/g, "", ), ); if (!Number.isFinite(episode) || episode <= 0) { episode = null; } } // 兼容纯数字文件名:01.mp4 / 1.mkv -> E01 / E01 if (!episode) { const pureEpisode = text.match( /^\s*0*([1-9][0-9]{0,2})\s*(?:\.[^./\\]+)?\s*$/, ); if (pureEpisode) { const value = Number(pureEpisode[1]); if (Number.isFinite(value) && value > 0) { episode = value; } } } return { season: Number.isFinite(season) && season > 0 ? season : null, episode, }; } function inferSeasonFromFolderName(name) { const text = String(name || ""); const parsed = parseSeasonEpisode(text); if (Number.isFinite(parsed.season) && parsed.season > 0) { return parsed.season; } const sMatch = text.match(/\bS\s*([0-9]{1,2})\b/i); if (sMatch) { const value = Number(sMatch[1]); if (Number.isFinite(value) && value > 0) { return value; } } const seasonMatch = text.match(/season\s*([0-9]{1,2})/i); if (seasonMatch) { const value = Number(seasonMatch[1]); if (Number.isFinite(value) && value > 0) { return value; } } const zhSeason = text.match( /第\s*([0-90-9一二三四五六七八九十百千两零〇]{1,4})\s*季/u, ); if (zhSeason) { const value = parseChineseNumber(zhSeason[1]); if (Number.isFinite(value) && value > 0) { return value; } } // 兼容纯数字目录:01 / 1 / 002 -> S01 / S01 / S02 const pureSeason = normalizeDomText(text).match( /^0*([1-9][0-9]{0,2})$/, ); if (pureSeason) { const value = Number(pureSeason[1]); if (Number.isFinite(value) && value > 0) { return value; } } // 兼容目录前缀季号:01 xxxx / 02-字幕组 / 03_SeasonName const prefixSeason = normalizeDomText(text).match( /^0*([1-9][0-9]{0,2})(?=\s|[._\-])/, ); if (prefixSeason) { const value = Number(prefixSeason[1]); if (Number.isFinite(value) && value > 0) { return value; } } return 1; } function parseSeasonFolderNumber(label) { const text = normalizeDomText(label).toUpperCase().replace(/\s+/g, ""); if (!text) { return null; } let match = text.match(/^S0*([0-9]{1,3})$/); if (match) { const value = Number(match[1]); return Number.isFinite(value) && value > 0 ? value : null; } match = text.match(/^SEASON0*([0-9]{1,3})$/); if (match) { const value = Number(match[1]); return Number.isFinite(value) && value > 0 ? value : null; } match = normalizeDomText(label).match( /^第\s*([0-90-9一二三四五六七八九十百千两零〇]{1,4})\s*季$/u, ); if (match) { const value = parseChineseNumber(match[1]); return Number.isFinite(value) && value > 0 ? value : null; } match = normalizeDomText(label).match(/^0*([1-9][0-9]{0,2})$/); if (match) { const value = Number(match[1]); return Number.isFinite(value) && value > 0 ? value : null; } match = normalizeDomText(label).match( /^0*([1-9][0-9]{0,2})(?=\s|[._\-])/, ); if (match) { const value = Number(match[1]); return Number.isFinite(value) && value > 0 ? value : null; } return null; } function inferSeasonFromItemMeta(item) { const raw = item?.raw && typeof item.raw === "object" ? item.raw : {}; const numericSeasonKeys = [ "season", "seasonNo", "season_num", "seasonNumber", "season_index", ]; for (const key of numericSeasonKeys) { const value = Number(raw[key]); if (Number.isFinite(value) && value > 0) { return Math.trunc(value); } } const textSeasonFields = [ raw.parentName, raw.parent_name, raw.dirName, raw.dir_name, raw.folderName, raw.folder_name, ]; for (const value of textSeasonFields) { const season = parseSeasonFolderNumber(value); if (Number.isFinite(season) && season > 0) { return season; } } const pathCandidates = [ raw.pathName, raw.path_name, raw.path, raw.fullPath, raw.full_path, raw.filePath, raw.file_path, raw.parentPath, raw.parent_path, raw.dirPath, raw.dir_path, raw.folderPath, raw.folder_path, raw.objectPath, raw.object_path, item?.name, ]; for (const value of pathCandidates) { const text = String(value || "").trim(); if (!text) { continue; } const normalizedPath = text.replace(/\\/g, "/"); const parts = normalizedPath .split("/") .map((x) => normalizeDomText(x)) .filter(Boolean); if (!parts.length) { continue; } let end = parts.length; if (extractFileExt(parts[end - 1])) { end -= 1; } for (let i = end - 1; i >= 0; i -= 1) { const season = parseSeasonFolderNumber(parts[i]); if (Number.isFinite(season) && season > 0) { return season; } } } return 0; } function scoreSeasonFolderName(name, seasonNo) { const normalized = normalizeDomText(name) .toUpperCase() .replace(/\s+/g, ""); const target = String(Number(seasonNo || 0)); let score = 100; if ( normalized === `S${target}` || normalized === `S${target.padStart(2, "0")}` ) { score = 0; } else if ( normalized === `SEASON${target}` || normalized === `SEASON${target.padStart(2, "0")}` ) { score = 1; } else if ( normalizeDomText(name) === `第 ${target} 季` || normalizeDomText(name) === `第${target}季` ) { score = 2; } else { score = 10; } if (/\(\d+\)\s*$/i.test(String(name || ""))) { score += 50; } score += String(name || "").length * 0.01; return score; } function findExistingSeasonFolder(children, seasonFolderName) { const folders = (children || []).filter( (item) => item && item.isFolder && item.fileId, ); if (!folders.length) { return null; } const wantedName = normalizeDomText(seasonFolderName).toUpperCase(); const exact = folders.find( (item) => normalizeDomText(item.name).toUpperCase() === wantedName, ); if (exact) { return exact; } const wantedSeasonNo = parseSeasonFolderNumber(seasonFolderName); if (!Number.isFinite(wantedSeasonNo) || wantedSeasonNo <= 0) { return null; } const sameSeason = folders.filter( (item) => parseSeasonFolderNumber(item.name) === wantedSeasonNo, ); if (!sameSeason.length) { return null; } sameSeason.sort( (a, b) => scoreSeasonFolderName(a.name, wantedSeasonNo) - scoreSeasonFolderName(b.name, wantedSeasonNo), ); return sameSeason[0] || null; } function getCachedChildrenByParent(parentFolderId) { const parentId = String(parentFolderId || "").trim(); if (!parentId) { return []; } const merged = [ ...(STATE.listByParent[parentId] || []).filter(Boolean), ...(STATE.lastListItems || []).filter( (item) => String(item?.parentId || "") === parentId, ), ]; return dedupeItems(merged); } function findCachedItemByFileId(fileId) { const targetId = String(fileId || "").trim(); if (!targetId) { return null; } const pools = []; if (Array.isArray(STATE.lastListItems) && STATE.lastListItems.length) { pools.push(STATE.lastListItems); } for (const list of Object.values(STATE.listByParent || {})) { if (Array.isArray(list) && list.length) { pools.push(list); } } for (const pool of pools) { for (const item of pool) { if (item && String(item.fileId || "") === targetId) { return item; } } } return null; } function applyListPage(body, pageNo) { const size = Math.max( 1, Number(body?.pageSize || CONFIG.listPageSize || 50), ); const page = Math.max(1, Number(pageNo || 1)); return { ...body, pageNum: page, pageNo: page, start: (page - 1) * size, offset: (page - 1) * size, }; } async function findExistingSeasonFolderByPaging( parentFolderId, seasonFolderName, maxPages = 8, ) { const variants = buildListBodyVariants(parentFolderId); const merged = []; for (const baseBody of variants) { const pageSize = Math.max( 1, Number(baseBody.pageSize || CONFIG.listPageSize || 50), ); for (let page = 1; page <= maxPages; page += 1) { const body = applyListPage(baseBody, page); let response = null; try { response = await postJson(getListUrl(), body); } catch (err) { warn( `分页查找季目录失败:${getErrorText(err)} | page=${page}`, ); break; } if (!response || !response.ok) { break; } const items = extractItemsFromPayload(response.payload); if (items.length) { mergeCapturedList(String(parentFolderId || ""), items); merged.push(...items); } const found = findExistingSeasonFolder( dedupeItems(merged), seasonFolderName, ); if (found) { return found; } if (items.length < pageSize) { break; } } } const cached = getCachedChildrenByParent(parentFolderId); return findExistingSeasonFolder(cached, seasonFolderName); } function buildEpisodeName( seriesName, year, season, episode, resolution, qualityTags, ext, ) { const seasonStr = `S${pad2(season)}`; const episodeStr = pad2(episode); const title = `${seriesName} (${year}) - ${seasonStr}E${episodeStr} - 第 ${episodeStr} 集`; const extra = buildVideoExtraPart(resolution, qualityTags); const resPart = extra ? ` - ${extra}` : ""; const extPart = ext ? `.${ext}` : ""; return `${title}${resPart}${extPart}`; } function buildMovieName(seriesName, year, resolution, qualityTags, ext) { const extra = buildVideoExtraPart(resolution, qualityTags); const resPart = extra ? ` - ${extra}` : ""; const extPart = ext ? `.${ext}` : ""; return `${seriesName} (${year})${resPart}${extPart}`; } function getMovieVariantKey(plan) { const extra = buildVideoExtraPart(plan?.resolution, plan?.qualityTags); return normalizeDomText(extra).toUpperCase(); } function getSortedSeasonEntries(bySeason) { return Array.from(bySeason.entries()).sort((a, b) => a[0] - b[0]); } function buildTaskList(context) { const seasonEntries = getSortedSeasonEntries(context.bySeason); const tasks = []; const skippedMovieDuplicates = []; const seenMovieVariantKeys = new Set(); for (const [season, plans] of seasonEntries) { const seasonFolder = `S${pad2(season)}`; for (const plan of plans) { if (context?.isMovie) { const variantKey = getMovieVariantKey(plan); if (seenMovieVariantKeys.has(variantKey)) { skippedMovieDuplicates.push({ season, seasonFolder, plan, }); continue; } seenMovieVariantKeys.add(variantKey); } tasks.push({ season, seasonFolder, plan, }); } } return { seasonEntries, tasks, skippedMovieDuplicates }; } function buildTargetVideoName(context, task, index = 1, total = 1) { if (context.isMovie) { return buildMovieName( context.seriesName, context.year, task.plan.resolution, task.plan.qualityTags, task.plan.ext, ); } return buildEpisodeName( context.seriesName, context.year, task.season, task.plan.episode, task.plan.resolution, task.plan.qualityTags, task.plan.ext, ); } function cleanNameForTitle(text) { return String(text || "") .replace(/\.[a-z0-9]{1,6}$/i, "") .replace(/\{tmdbid-\d+\}/gi, "") .replace(/^[\s\[【((].*?[\]】))]\s*/g, "") .replace(/\bS\d{1,2}E\d{1,}\b/gi, " ") .replace(/\bS\d{1,2}\b/gi, " ") .replace(/\bE\d{1,}\b/gi, " ") .replace( /第\s*[0-90-9一二三四五六七八九十百零〇两]+\s*季/giu, " ", ) .replace(/第\s*[0-90-9]+\s*[集话]/gu, " ") .replace(/\bSeason\s*\d{1,2}\b/gi, " ") .replace(/\bEP?\s*\d{1,}\b/gi, " ") .replace( /\b(?:2160p|1080p|720p|4k|8k|web[- ]?dl|bluray|x264|x265|h264|h265|aac|dts)\b/gi, " ", ) .replace(/\b(?:19|20)\d{2}\b/g, " ") .replace(/[._-]+/g, " ") .replace(/\s+/g, " ") .replace(/^[\s\-_.:|/]+|[\s\-_.:|/]+$/g, "") .trim(); } function normalizeTitleKey(text) { return String(text || "") .toLowerCase() .replace(/\s+/g, "") .replace(/[【】[\]()()._\-:|/]/g, ""); } function extractTitleFromVideoName(name) { const original = String(name || ""); const direct = original.match( /^(.*?)(?:\s*[-_. ]*\bS\d{1,2}E\d{1,}\b|\s*[-_. ]*第\s*[0-90-9]+\s*[集话]|\s*[-_. ]*EP?\s*\d{1,})/i, ); if (direct && direct[1]) { return cleanNameForTitle(direct[1]); } return cleanNameForTitle(original); } function scoreTitleCandidate(name, sourceType, count) { const text = String(name || "").trim(); if (!text) { return -9999; } let score = 0; score += text.length * 2; score += Number(count || 1) * 22; if (sourceType === "folder") { score += 45; } if (/[\u4e00-\u9fff]/.test(text)) { score += 20; } if (/[A-Za-z]/.test(text)) { score += 8; } if (/\d/.test(text)) { score -= 10; } if (text.length < 2) { score -= 60; } if (/^(sample|trailer|预告|花絮|片头|片尾)$/i.test(text)) { score -= 80; } return score; } function guessSeriesTitle(selectedItems) { const candidates = []; for (const item of selectedItems || []) { if (!item || !item.name) { continue; } if (item.isFolder) { const folderTitle = cleanNameForTitle(item.name); if (folderTitle) { candidates.push({ sourceType: "folder", text: folderTitle, }); } } else { const fromVideo = extractTitleFromVideoName(item.name); if (fromVideo) { candidates.push({ sourceType: "video", text: fromVideo, }); } } } if (!candidates.length) { return ""; } const grouped = new Map(); for (const item of candidates) { const key = normalizeTitleKey(item.text); if (!key) { continue; } if (!grouped.has(key)) { grouped.set(key, { text: item.text, sourceType: item.sourceType, count: 0, }); } const slot = grouped.get(key); slot.count += 1; if (item.sourceType === "folder") { slot.sourceType = "folder"; } if (String(item.text).length > String(slot.text).length) { slot.text = item.text; } } const merged = Array.from(grouped.values()); merged.sort((a, b) => { const diff = scoreTitleCandidate(b.text, b.sourceType, b.count) - scoreTitleCandidate(a.text, a.sourceType, a.count); if (diff !== 0) { return diff; } return String(b.text).length - String(a.text).length; }); return merged[0]?.text || ""; } function normalizeNameForMatch(text) { return String(text || "") .toLowerCase() .replace(/\s+/g, "") .replace(/[【】[\]()()._\-:|/]/g, ""); } function looksLikeFolderByName(name) { const ext = extractFileExt(name); if (!ext) { return true; } if (VIDEO_EXTS.has(ext)) { return false; } if (COMMON_FILE_EXTS.has(ext)) { return false; } return true; } function pickTargetFolderFromSelected(selectedItems) { const list = Array.isArray(selectedItems) ? selectedItems.filter(Boolean) : []; if (!list.length) { return null; } const withId = list.filter((item) => String(item.fileId || "").trim()); const directFolder = withId.find((item) => item.isFolder); if (directFolder) { return directFolder; } const byNameLooksFolder = withId.find((item) => looksLikeFolderByName(item.name), ); if (byNameLooksFolder) { return { ...byNameLooksFolder, isFolder: true, }; } const allCaptured = dedupeItems([ ...(getCapturedItemsForCurrentParent() || []), ...(STATE.lastListItems || []), ...Object.values(STATE.listByParent || {}) .flat() .filter(Boolean), ]); const captured = allCaptured; if (captured.length) { for (const item of withId) { const matchedById = captured.find( (x) => String(x.fileId) === String(item.fileId) && x.isFolder, ); if (matchedById) { return { ...item, isFolder: true, fileId: String(matchedById.fileId), name: matchedById.name || item.name, raw: matchedById.raw || item.raw, }; } } for (const item of list) { const key = normalizeNameForMatch(item.name); if (!key) continue; const matchedByName = captured.find( (x) => normalizeNameForMatch(x.name) === key && (x.isFolder || looksLikeFolderByName(x.name)), ); if (matchedByName) { return { fileId: String(matchedByName.fileId), name: matchedByName.name, isFolder: true, raw: matchedByName.raw || null, }; } } for (const item of list) { const key = normalizeNameForMatch(item.name); if (!key || !looksLikeFolderByName(item.name)) { continue; } const matchedLoose = captured.find((x) => { const xKey = normalizeNameForMatch(x.name); return xKey && (xKey.includes(key) || key.includes(xKey)); }); if (matchedLoose && matchedLoose.fileId) { return { fileId: String(matchedLoose.fileId), name: String(matchedLoose.name || item.name), isFolder: true, raw: matchedLoose.raw || null, }; } } } return null; } function pickSelectedVideoFiles(selectedItems) { const list = Array.isArray(selectedItems) ? selectedItems.filter(Boolean) : []; const withId = list.filter((item) => String(item.fileId || "").trim()); const videos = withId.filter((item) => isVideoItem({ name: item.name, isFolder: Boolean(item.isFolder), raw: item.raw || {}, }), ); return videos.map((item) => ({ ...item, isFolder: false, parentId: String( item.parentId || item.raw?.parentId || item.raw?.pid || "", ), })); } function pickMainParentIdFromFiles(files) { const counts = new Map(); for (const file of files || []) { const parentId = String( file?.parentId || file?.raw?.parentId || file?.raw?.pid || "", ).trim(); if (!parentId) { continue; } counts.set(parentId, (counts.get(parentId) || 0) + 1); } let best = ""; let bestCount = -1; for (const [pid, count] of counts.entries()) { if (count > bestCount) { best = pid; bestCount = count; } } return best || String(resolveListParentId() || ""); } async function resolveSelectedFolderFallback(selectedItems) { const list = Array.isArray(selectedItems) ? selectedItems.filter(Boolean) : []; if (!list.length) { return { folder: null, children: null }; } const withId = list.filter((item) => String(item?.fileId || "").trim()); const folderLikeWithId = withId.filter( (item) => !isVideoItem({ name: item?.name, isFolder: Boolean(item?.isFolder), raw: item?.raw || {}, }), ); // 兜底1:逐个用 fileId 探测是否可作为目录读取 for (const item of folderLikeWithId) { const fileId = String(item.fileId || "").trim(); if (!fileId) { continue; } try { const children = await fetchListByParent(fileId); return { folder: { fileId, name: String(item.name || ""), isFolder: true, raw: item.raw || null, }, children, }; } catch { // ignore } } // 兜底2:按名称在当前目录列表中反查 const parentId = String(resolveListParentId() || "").trim(); if (!parentId) { return { folder: null, children: null }; } let siblings = []; try { siblings = await fetchListByParent(parentId); } catch { siblings = []; } const folders = (siblings || []).filter( (item) => item && item.fileId && (item.isFolder || looksLikeFolderByName(item.name)), ); if (!folders.length) { return { folder: null, children: null }; } const nameCandidates = Array.from( new Set( list .map((item) => normalizeNameForMatch(item?.name || "")) .filter(Boolean), ), ); for (const key of nameCandidates) { const exact = folders.find( (x) => normalizeNameForMatch(x.name) === key, ); if (exact) { return { folder: { fileId: String(exact.fileId), name: String(exact.name || ""), isFolder: true, raw: exact.raw || null, }, children: null, }; } } for (const key of nameCandidates) { const loose = folders.find((x) => { const xKey = normalizeNameForMatch(x.name); return xKey && (xKey.includes(key) || key.includes(xKey)); }); if (loose) { return { folder: { fileId: String(loose.fileId), name: String(loose.name || ""), isFolder: true, raw: loose.raw || null, }, children: null, }; } } return { folder: null, children: null }; } function resolveTmdbCredential(raw) { const value = String(raw || "").trim(); if (!value) { return { kind: "", token: "", apiKey: "" }; } if (/^Bearer\s+/i.test(value)) { return { kind: "token", token: value.replace(/^Bearer\s+/i, "").trim(), apiKey: "", }; } const jwtLike = /^[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+$/.test(value); if (jwtLike) { return { kind: "token", token: value, apiKey: "" }; } return { kind: "apiKey", token: "", apiKey: value }; } function normalizeTmdbResults(results) { const list = Array.isArray(results) ? results : []; const mapped = list .filter((item) => item && item.media_type !== "person") .map((item) => { const mediaType = String( item.media_type || (item.first_air_date ? "tv" : item.release_date ? "movie" : "tv"), ).toLowerCase(); const name = item.name || item.title || item.original_name || item.original_title || ""; const originalName = item.original_name || item.original_title || name; const firstAirDate = item.first_air_date || item.release_date || ""; return { ...item, media_type: mediaType, name, original_name: originalName, first_air_date: firstAirDate, }; }); mapped.sort((a, b) => { const aw = a.media_type === "tv" ? 0 : 1; const bw = b.media_type === "tv" ? 0 : 1; if (aw !== bw) { return aw - bw; } const ap = Number(a.popularity || 0); const bp = Number(b.popularity || 0); return bp - ap; }); return mapped; } async function searchTmdbTv(query, tmdbCredential) { const credential = resolveTmdbCredential(tmdbCredential); const params = new URLSearchParams({ query, language: CONFIG.tmdbLanguage, page: "1", include_adult: "false", }); if (credential.kind === "apiKey" && credential.apiKey) { params.set("api_key", credential.apiKey); } const url = `${CONFIG.tmdbHost}/search/multi?${params.toString()}`; let text = ""; let ok = false; let status = 0; const requestOptions = { method: "GET", headers: { accept: "application/json", }, mode: "cors", credentials: "omit", }; if (credential.kind === "token" && credential.token) { requestOptions.headers.authorization = `Bearer ${credential.token}`; } try { const gmRes = await requestViaGM(url, requestOptions); text = gmRes.text; ok = gmRes.ok; status = gmRes.status; } catch (gmErr) { warn( `TMDB GM_xmlhttpRequest 失败,改用 fetch:${getErrorText(gmErr)}`, ); try { const response = await window.fetch(url, requestOptions); text = await response.text(); ok = response.ok; status = response.status; } catch (fetchErr) { warn( `TMDB window.fetch 失败,改用页面上下文请求:${getErrorText(fetchErr)}`, ); const bridged = await requestViaPage(url, requestOptions); text = bridged.text; ok = bridged.ok; status = bridged.status; } } const payload = safeJsonParse(text); if (!ok) { throw new Error(`TMDB 搜索失败 HTTP ${status}`); } return normalizeTmdbResults(payload?.results); } function getButtonLabel(button) { const spans = button.querySelectorAll("span"); if (!spans.length) { return button.textContent ? button.textContent.trim() : ""; } return spans[spans.length - 1].textContent ? spans[spans.length - 1].textContent.trim() : ""; } function isCloudAddButton(button) { return ( button instanceof HTMLButtonElement && getButtonLabel(button) === "云添加" ); } function injectStyle() { if (document.getElementById(STYLE_ID)) { return; } const style = document.createElement("style"); style.id = STYLE_ID; style.textContent = ` #${MODAL_ID} { position: fixed; inset: 0; z-index: 2147483646; display: none; } #${MODAL_ID}.open { display: block; } #${MODAL_ID} .gy-mask { position: absolute; inset: 0; background: rgba(8, 16, 32, 0.52); } #${MODAL_ID} .gy-dialog { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); width: min(760px, calc(100vw - 28px)); max-height: min(86vh, 920px); overflow: auto; box-sizing: border-box; background: #fff; border-radius: 14px; padding: 16px; color: #1d2742; box-shadow: 0 16px 48px rgba(8, 24, 48, 0.34); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; -webkit-user-select: text !important; user-select: text !important; } #${MODAL_ID} .gy-dialog * { -webkit-user-select: text !important; user-select: text !important; } #${MODAL_ID} input[type="text"], #${MODAL_ID} input[type="password"], #${MODAL_ID} textarea { -webkit-user-select: text !important; user-select: text !important; } #${MODAL_ID} .gy-title { font-size: 16px; font-weight: 700; margin-bottom: 10px; } #${MODAL_ID} .gy-row { margin-bottom: 10px; } #${MODAL_ID} .gy-row label { display: block; font-size: 12px; color: #51628f; margin-bottom: 6px; } #${MODAL_ID} input[type="text"], #${MODAL_ID} input[type="password"] { width: 100%; box-sizing: border-box; border: 1px solid #c8d4ea; border-radius: 8px; padding: 8px 10px; font-size: 13px; } #${MODAL_ID} .gy-inline { display: grid; grid-template-columns: 1fr auto auto; gap: 8px; } #${MODAL_ID} button { border: 0; border-radius: 8px; padding: 8px 12px; font-size: 12px; cursor: pointer; } #${MODAL_ID} .gy-primary { background: #0f62fe; color: #fff; } #${MODAL_ID} .gy-secondary { background: #eef2ff; color: #2b3d66; } #${MODAL_ID} .gy-danger { background: #d92d20; color: #fff; } #${MODAL_ID} .gy-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; } #${MODAL_ID} .gy-selected, #${MODAL_ID} .gy-status { background: #f6f9ff; border-radius: 8px; padding: 8px 10px; font-size: 12px; white-space: pre-wrap; line-height: 1.5; } #${MODAL_ID} .gy-preview { background: #f8fafc; border: 1px solid #d7e0f5; border-radius: 8px; padding: 8px 10px; font-size: 12px; white-space: pre; line-height: 1.5; max-height: 220px; overflow: auto; overflow-x: auto; overflow-y: auto; } #${MODAL_ID} .gy-results { border: 1px solid #d7e0f5; border-radius: 8px; padding: 8px; max-height: 260px; overflow: auto; } #${MODAL_ID} .gy-item { display: block; border-radius: 8px; padding: 8px; cursor: pointer; } #${MODAL_ID} .gy-item-main { margin-top: 6px; display: grid; grid-template-columns: 56px 1fr; gap: 10px; align-items: start; } #${MODAL_ID} .gy-item-poster, #${MODAL_ID} .gy-item-no-poster { width: 56px; height: 84px; border-radius: 6px; border: 1px solid #d7e0f5; box-sizing: border-box; } #${MODAL_ID} .gy-item-poster { display: block; object-fit: cover; background: #e8efff; } #${MODAL_ID} .gy-item-no-poster { display: flex; align-items: center; justify-content: center; background: #f3f6ff; color: #6b7fa8; font-size: 10px; line-height: 1.2; text-align: center; padding: 4px; } #${MODAL_ID} .gy-item-meta { min-width: 0; } #${MODAL_ID} .gy-item:hover { background: #f7faff; } #${MODAL_ID} .gy-item-title { font-weight: 600; font-size: 13px; } #${MODAL_ID} .gy-item-sub { margin-top: 3px; color: #5b6d99; font-size: 12px; } #${MODAL_ID} button, #${MODAL_ID} input[type="radio"] { -webkit-user-select: none !important; user-select: none !important; } #${MODAL_ID} .gy-selected, #${MODAL_ID} .gy-status, #${MODAL_ID} .gy-preview, #${MODAL_ID} .gy-results, #${MODAL_ID} .gy-item-title, #${MODAL_ID} .gy-item-sub { cursor: text; } #${MODAL_ID} .gy-selected::selection, #${MODAL_ID} .gy-status::selection, #${MODAL_ID} .gy-preview::selection, #${MODAL_ID} .gy-item-title::selection, #${MODAL_ID} .gy-item-sub::selection { background: #0f62fe; color: #fff; } #${MODAL_ID} input[type="text"]::selection, #${MODAL_ID} input[type="password"]::selection, #${MODAL_ID} textarea::selection { background: #0f62fe; color: #fff; } #${MODAL_ID} input[type="text"]::-moz-selection, #${MODAL_ID} input[type="password"]::-moz-selection, #${MODAL_ID} textarea::-moz-selection { background: #0f62fe; color: #fff; } `; document.head.appendChild(style); } function ensureModal() { injectStyle(); let root = document.getElementById(MODAL_ID); if (root) { return root; } root = document.createElement("div"); root.id = MODAL_ID; root.innerHTML = `
`; document.body.appendChild(root); const ui = { root, selected: root.querySelector('[data-role="selected"]'), status: root.querySelector('[data-role="status"]'), preview: root.querySelector('[data-role="preview"]'), results: root.querySelector('[data-role="results"]'), tmdbKey: root.querySelector('[data-field="tmdbKey"]'), title: root.querySelector('[data-field="title"]'), }; ui.tmdbKey.value = window.localStorage.getItem(TMDB_KEY_STORAGE) || ""; installInputCopyGuard(ui.title); installInputCopyGuard(ui.tmdbKey); const stopOnly = (event) => { if (!root.contains(event.target)) { return; } event.stopPropagation(); }; ["copy", "cut", "paste", "selectstart", "contextmenu"].forEach( (type) => { root.addEventListener(type, stopOnly, true); }, ); root.addEventListener( "keydown", (event) => { if (!root.contains(event.target)) { return; } const isHotkey = (event.metaKey || event.ctrlKey) && ["a", "c", "v", "x"].includes( String(event.key || "").toLowerCase(), ); if (isHotkey) { event.stopPropagation(); } }, true, ); document.addEventListener( "keydown", (event) => { const key = String(event.key || "").toLowerCase(); const isEsc = key === "escape" || key === "esc"; if (!isEsc) { return; } if ( !STATE.ui?.root || !STATE.ui.root.classList.contains("open") ) { return; } event.preventDefault(); event.stopPropagation(); closeModal(); }, true, ); root.addEventListener("click", async (event) => { const btn = event.target.closest("[data-action]"); if (!btn) return; const action = btn.getAttribute("data-action"); if (action === "close") { closeModal(); return; } if (action === "search") { await onTmdbSearch(); return; } if (action === "copy-title") { const title = String(ui.title?.value || "").trim(); if (!title) { setStatus("剧名为空,无法复制"); return; } const ok = await copyTextToClipboard(title); setStatus(ok ? "已复制剧名" : "复制失败,请手动复制"); return; } if (action === "preview") { await onPreviewOrganize(); return; } if (action === "start") { await onStartOrganize(); } }); STATE.ui = ui; return root; } function setStatus(text) { if (STATE.ui?.status) { STATE.ui.status.textContent = String(text || ""); } } function setPreview(text) { if (STATE.ui?.preview) { STATE.ui.preview.textContent = String(text || ""); } } function closeModal(options = {}) { if (!STATE.ui?.root) return; const shouldReload = options.reload !== false; const wasOpen = STATE.ui.root.classList.contains("open"); STATE.ui.root.classList.remove("open"); if (shouldReload && wasOpen) { window.setTimeout(() => { window.location.reload(); }, 0); } } function renderSelectedItems(items) { if (!STATE.ui?.selected) return; if (!items.length) { STATE.ui.selected.textContent = "未检测到勾选项"; return; } const lines = items.map((item, idx) => { const idText = item.fileId ? ` | id=${item.fileId}` : ""; return `${idx + 1}. ${item.name}${item.isFolder ? " [文件夹]" : ""}${idText}`; }); STATE.ui.selected.textContent = lines.join("\n"); } function renderTmdbResults(results) { if (!STATE.ui?.results) return; if (!results.length) { STATE.ui.results.textContent = "没有找到匹配结果"; STATE.selectedTmdb = null; return; } STATE.ui.results.innerHTML = results .map((item, idx) => { const year = String(item.first_air_date || "").slice(0, 4); const title = item.name || item.original_name || "(无标题)"; const overview = (item.overview || "").trim(); const mediaType = String(item.media_type || "").toLowerCase(); const mediaTypeLabel = mediaType === "tv" ? "剧集" : mediaType === "movie" ? "电影" : mediaType ? mediaType.toUpperCase() : "未知类型"; const sub = `${mediaTypeLabel}${year ? ` | ${year}` : ""} | TMDB ID: ${item.id}`; const checked = idx === 0 ? "checked" : ""; const overviewHtml = overview ? `
${escapeHtml(overview.slice(0, 140))}
` : ""; const posterPath = String( item.poster_path || item.backdrop_path || "", ).trim(); const posterUrl = posterPath ? `https://image.tmdb.org/t/p/w154${posterPath.startsWith("/") ? posterPath : `/${posterPath}`}` : ""; const posterHtml = posterUrl ? `${escapeHtml(title)}` : `
暂无海报
`; return ` `; }) .join(""); STATE.selectedTmdb = results[0]; STATE.ui.results .querySelectorAll('input[type="radio"][name="gy_tmdb_pick"]') .forEach((input) => { input.addEventListener("change", () => { const id = String(input.value || ""); const picked = results.find((x) => String(x.id) === id) || null; STATE.selectedTmdb = picked; }); }); } function escapeHtml(text) { return String(text || "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } async function copyTextToClipboard(text) { const value = String(text || ""); try { if ( navigator.clipboard && typeof navigator.clipboard.writeText === "function" ) { await navigator.clipboard.writeText(value); return true; } } catch {} try { const ta = document.createElement("textarea"); ta.value = value; ta.setAttribute("readonly", "readonly"); ta.style.position = "fixed"; ta.style.left = "-9999px"; ta.style.top = "0"; document.body.appendChild(ta); ta.select(); const ok = document.execCommand("copy"); ta.remove(); return Boolean(ok); } catch { return false; } } function installInputCopyGuard(inputEl) { if (!inputEl || inputEl.__gyCopyGuardInstalled) { return; } inputEl.__gyCopyGuardInstalled = true; inputEl.addEventListener( "keydown", async (event) => { const key = String(event.key || "").toLowerCase(); const hotkey = (event.metaKey || event.ctrlKey) && key === "c"; if (!hotkey) { return; } event.preventDefault(); event.stopPropagation(); const start = Number(inputEl.selectionStart); const end = Number(inputEl.selectionEnd); const full = String(inputEl.value || ""); const selected = Number.isFinite(start) && Number.isFinite(end) && end > start ? full.slice(start, end) : full; const ok = await copyTextToClipboard(selected); setStatus(ok ? "已复制输入框文本" : "复制失败,请手动复制"); }, true, ); } async function onTmdbSearch() { if (STATE.busy) return; const query = normalizeDomText(STATE.ui?.title?.value || ""); const apiKey = normalizeDomText(STATE.ui?.tmdbKey?.value || ""); if (!query) { setStatus("请先输入或确认剧名"); return; } if (!apiKey) { setStatus("请先填写 TMDB API Key 或 Bearer Token"); return; } window.localStorage.setItem(TMDB_KEY_STORAGE, apiKey); setStatus(`TMDB 搜索中:${query}`); try { const results = await searchTmdbTv(query, apiKey); renderTmdbResults(results); setStatus(`TMDB 搜索完成:${results.length} 条`); } catch (err) { setStatus(`TMDB 搜索失败:${getErrorText(err)}`); fail("TMDB 搜索失败", err); } } async function ensureSeasonFolder(parentFolderId, seasonFolderName) { const children = await fetchListByParent(parentFolderId); const existing = findExistingSeasonFolder( dedupeItems([ ...(children || []), ...getCachedChildrenByParent(parentFolderId), ]), seasonFolderName, ); if (existing) { return existing.fileId; } const pagedExisting = await findExistingSeasonFolderByPaging( parentFolderId, seasonFolderName, 8, ); if (pagedExisting) { return pagedExisting.fileId; } const res = await createDir(parentFolderId, seasonFolderName); let folderId = extractCreatedId(res.payload); if (folderId) { return folderId; } const refreshed = await fetchListByParent(parentFolderId); const matched = findExistingSeasonFolder( dedupeItems([ ...(refreshed || []), ...getCachedChildrenByParent(parentFolderId), ]), seasonFolderName, ); folderId = matched ? String(matched.fileId) : ""; if (!folderId) { throw new Error(`创建季目录后未找到目录 ID:${seasonFolderName}`); } return folderId; } function findExistingFolderByName(children, folderName) { const normalized = normalizeDomText(folderName).toUpperCase(); const folders = (children || []).filter( (item) => item && item.fileId && (item.isFolder || isFolderByResType(item.raw || {})), ); if (!folders.length) { return null; } return ( folders.find( (item) => normalizeDomText(item.name).toUpperCase() === normalized, ) || null ); } async function findExistingFolderByNameByPaging( parentFolderId, folderName, maxPages = 8, ) { const variants = buildListBodyVariants(parentFolderId); const merged = []; for (const baseBody of variants) { const pageSize = Math.max( 1, Number(baseBody.pageSize || CONFIG.listPageSize || 50), ); for (let page = 1; page <= maxPages; page += 1) { const body = applyListPage(baseBody, page); let response = null; try { response = await postJson(getListUrl(), body); } catch (err) { warn( `分页查找剧名目录失败:${getErrorText(err)} | page=${page}`, ); break; } if (!response || !response.ok) { break; } const items = extractItemsFromPayload(response.payload); if (items.length) { mergeCapturedList(String(parentFolderId || ""), items); merged.push(...items); } const found = findExistingFolderByName( dedupeItems([ ...merged, ...getCachedChildrenByParent(parentFolderId), ]), folderName, ); if (found) { return found; } if (items.length < pageSize) { break; } } } return findExistingFolderByName( getCachedChildrenByParent(parentFolderId), folderName, ); } async function ensureSeriesFolder(parentFolderId, folderName) { const children = await fetchListByParent(parentFolderId); const existing = findExistingFolderByName( dedupeItems([ ...(children || []), ...getCachedChildrenByParent(parentFolderId), ]), folderName, ); if (existing && existing.fileId) { return { fileId: String(existing.fileId), name: String(existing.name || folderName), }; } const pagedExisting = await findExistingFolderByNameByPaging( parentFolderId, folderName, 8, ); if (pagedExisting && pagedExisting.fileId) { return { fileId: String(pagedExisting.fileId), name: String(pagedExisting.name || folderName), }; } const res = await createDir(parentFolderId, folderName); let folderId = extractCreatedId(res.payload); if (folderId) { return { fileId: String(folderId), name: folderName, }; } const refreshed = await fetchListByParent(parentFolderId); const matched = findExistingFolderByName( dedupeItems([ ...(refreshed || []), ...getCachedChildrenByParent(parentFolderId), ]), folderName, ); folderId = matched ? String(matched.fileId || "") : ""; if (!folderId) { throw new Error(`创建剧名目录后未找到目录 ID:${folderName}`); } return { fileId: folderId, name: String(matched?.name || folderName), }; } function buildEpisodePlan(children, options = {}) { const defaultSeason = Number(options.defaultSeason || 1) || 1; const sourceParentId = String(options.sourceParentId || ""); const resolveSeasonByParentId = typeof options.resolveSeasonByParentId === "function" ? options.resolveSeasonByParentId : null; const videos = children.filter((item) => isVideoItem(item)); const plans = videos.map((item) => { const parsed = parseSeasonEpisode(item.name); const ext = getItemVideoExt(item); const resolution = extractResolution(item.name); const qualityTags = extractQualityTags(item.name); const itemParentId = String(item.parentId || ""); const resolvedSourceParentId = itemParentId || sourceParentId || ""; let resolvedSeason = parsed.season; if (!Number.isFinite(resolvedSeason) || resolvedSeason <= 0) { let hinted = 0; if (resolveSeasonByParentId && resolvedSourceParentId) { hinted = Number( resolveSeasonByParentId(resolvedSourceParentId, item) || 0, ); } if (!Number.isFinite(hinted) || hinted <= 0) { hinted = Number(inferSeasonFromItemMeta(item) || 0); } if (Number.isFinite(hinted) && hinted > 0) { resolvedSeason = hinted; } } return { item, season: resolvedSeason || defaultSeason, episode: parsed.episode, ext, resolution, qualityTags, sourceParentId: resolvedSourceParentId, }; }); const bySeason = new Map(); for (const plan of plans) { if (!bySeason.has(plan.season)) { bySeason.set(plan.season, []); } bySeason.get(plan.season).push(plan); } normalizeEpisodePlanMap(bySeason); return bySeason; } function normalizeEpisodePlanMap(bySeason) { for (const [season, list] of bySeason.entries()) { list.sort((a, b) => String(a.item.name || "").localeCompare( String(b.item.name || ""), "zh-Hans-CN", ), ); const used = new Set( list .filter((x) => Number.isFinite(x.episode)) .map((x) => x.episode), ); let cursor = 1; for (const row of list) { if (Number.isFinite(row.episode) && row.episode > 0) { continue; } while (used.has(cursor)) { cursor += 1; } row.episode = cursor; used.add(cursor); cursor += 1; } bySeason.set(season, list); } } function mergeSeasonPlans(baseMap, extraMap) { for (const [season, list] of extraMap.entries()) { if (!baseMap.has(season)) { baseMap.set(season, []); } baseMap.get(season).push(...list); } return baseMap; } function shouldPreferNestedSeasonPlan(directMap, targetFolderId) { if (!(directMap instanceof Map) || !directMap.size) { return false; } const plans = []; for (const list of directMap.values()) { if (Array.isArray(list)) { plans.push(...list); } } if (!plans.length) { return false; } const targetId = String(targetFolderId || "").trim(); const sourceIds = new Set( plans .map((x) => String(x?.sourceParentId || "").trim()) .filter(Boolean), ); const unresolvedSource = sourceIds.size === 0 || (sourceIds.size === 1 && sourceIds.has(targetId)); const singleSeasonOnly = directMap.size === 1 && directMap.has(1); const nameCount = new Map(); let hasDuplicateFileNames = false; for (const plan of plans) { const key = String(plan?.item?.name || "") .trim() .toLowerCase(); if (!key) { continue; } const next = (nameCount.get(key) || 0) + 1; nameCount.set(key, next); if (next >= 2) { hasDuplicateFileNames = true; } } return singleSeasonOnly && (unresolvedSource || hasDuplicateFileNames); } async function buildEpisodePlanWithFallback(targetFolderId, children) { const seasonByFolderId = new Map(); const folderCandidates = dedupeItems([ ...(children || []).filter( (item) => item && item.isFolder && item.fileId, ), ...getCachedChildrenByParent(targetFolderId).filter( (item) => item && item.isFolder && item.fileId, ), ]); for (const folder of folderCandidates) { const folderId = String(folder.fileId || "").trim(); if (!folderId) { continue; } const hinted = inferSeasonFromFolderName(folder.name); if (Number.isFinite(hinted) && hinted > 0) { seasonByFolderId.set(folderId, hinted); } } const resolveSeasonByParentId = (parentId) => { const pid = String(parentId || "").trim(); if (!pid) { return 0; } if (seasonByFolderId.has(pid)) { return seasonByFolderId.get(pid); } const parentItem = findCachedItemByFileId(pid); if (parentItem && parentItem.name) { const hinted = inferSeasonFromFolderName(parentItem.name); if (Number.isFinite(hinted) && hinted > 0) { seasonByFolderId.set(pid, hinted); return hinted; } } return 0; }; const direct = buildEpisodePlan(children, { defaultSeason: 1, sourceParentId: targetFolderId, resolveSeasonByParentId, }); const seasonFolders = (children || []).filter( (item) => item && item.isFolder && item.fileId, ); const forceNested = seasonFolders.length > 0 && shouldPreferNestedSeasonPlan(direct, targetFolderId); if (direct.size && !forceNested) { return direct; } if (!seasonFolders.length) { return direct; } const merged = new Map(); for (const folder of seasonFolders) { let nested = []; try { nested = await fetchListByParent(folder.fileId); } catch (err) { warn( `读取子目录失败,已跳过:${folder.name} | ${getErrorText(err)}`, ); continue; } const seasonHint = inferSeasonFromFolderName(folder.name); const nestedPlan = buildEpisodePlan(nested, { defaultSeason: seasonHint || 1, sourceParentId: folder.fileId, resolveSeasonByParentId, }); mergeSeasonPlans(merged, nestedPlan); } normalizeEpisodePlanMap(merged); if (merged.size) { return merged; } return direct; } async function prepareOrganizeContext(options = {}) { const allowCreate = options && options.allowCreate === true; refreshSelectedItems({ keepPreviousIfEmpty: true }); if (!STATE.selectedItems.length) { throw new Error("请至少勾选 1 个剧集文件夹或视频文件再执行整理。"); } const tmdb = STATE.selectedTmdb; if (!tmdb || !tmdb.id) { throw new Error("请先 TMDB 搜索并选择结果"); } if ( !STATE.headers.authorization || !STATE.headers.did || !STATE.headers.dt ) { throw new Error( "尚未捕获到授权头(authorization/did/dt)。先刷新页面后重试。", ); } const selectedVideos = pickSelectedVideoFiles(STATE.selectedItems); let pickedFolder = pickTargetFolderFromSelected(STATE.selectedItems); const organizeMode = selectedVideos.length > 0 ? "files" : "folder"; const seriesName = normalizeDomText( tmdb.name || tmdb.title || tmdb.original_name || tmdb.original_title || "", ); const year = String(tmdb.first_air_date || tmdb.release_date || "").slice( 0, 4, ) || "0000"; if (!seriesName || !/^\d{4}$/.test(year)) { throw new Error("TMDB 信息不完整,无法继续"); } const mediaType = String(tmdb.media_type || "").toLowerCase(); const isMovie = mediaType === "movie" || (!!tmdb.release_date && !tmdb.first_air_date); const folderTargetName = `${seriesName} (${year}) {tmdbid-${tmdb.id}}`; let targetFolder = pickedFolder || null; let children = []; let bySeason = new Map(); let shouldMove = false; if ( organizeMode === "folder" && (!pickedFolder || !pickedFolder.fileId) ) { const fallback = await resolveSelectedFolderFallback( STATE.selectedItems, ); if (fallback.folder && fallback.folder.fileId) { pickedFolder = fallback.folder; targetFolder = pickedFolder; if (Array.isArray(fallback.children)) { children = fallback.children; } } } if ( organizeMode === "folder" && (!pickedFolder || !pickedFolder.fileId) ) { const selectedHint = (STATE.selectedItems || []) .map((x) => normalizeDomText(x?.name || "")) .filter(Boolean) .slice(0, 3) .join(" | "); throw new Error( `未识别到可整理的目标文件夹。请只勾选 1 个剧集/电影文件夹后重试。${selectedHint ? ` 已勾选:${selectedHint}` : ""}`, ); } if (organizeMode === "files") { const parentId = pickMainParentIdFromFiles(selectedVideos); if (!parentId) { throw new Error( "未识别到所选视频的父目录,先进入目标目录后重试。", ); } const parentFolderItem = findCachedItemByFileId(parentId); const parentFolderName = String( parentFolderItem?.name || "", ).trim(); const sameAsTargetFolder = Boolean(parentFolderName) && normalizeDomText(parentFolderName).toUpperCase() === normalizeDomText(folderTargetName).toUpperCase(); if (sameAsTargetFolder) { targetFolder = { fileId: String(parentId), name: parentFolderName || folderTargetName, isFolder: true, }; shouldMove = false; } else { if (allowCreate) { const ensured = await ensureSeriesFolder( parentId, folderTargetName, ); targetFolder = { fileId: String(ensured.fileId), name: String(ensured.name || folderTargetName), isFolder: true, }; } else { targetFolder = { fileId: "", parentId: String(parentId), name: folderTargetName, isFolder: true, }; } shouldMove = true; } children = selectedVideos; bySeason = buildEpisodePlan(selectedVideos, { defaultSeason: 1 }); } else { if (!children.length) { children = await fetchListByParent(targetFolder.fileId); } bySeason = await buildEpisodePlanWithFallback( targetFolder.fileId, children, ); shouldMove = !isMovie; } const total = Array.from(bySeason.values()).reduce( (sum, arr) => sum + arr.length, 0, ); return { tmdb, organizeMode, shouldMove, targetFolder, seriesName, year, folderTargetName, children, bySeason, total, isMovie, }; } function buildPreviewText(context) { const getSourceFolderName = (plan) => { const sourceId = String(plan?.sourceParentId || "").trim(); if (!sourceId) { return ""; } const targetId = String(context?.targetFolder?.fileId || "").trim(); if (targetId && sourceId === targetId) { return String(context?.targetFolder?.name || ""); } const fromChildren = (context?.children || []).find( (item) => item && item.isFolder && String(item.fileId || "").trim() === sourceId, ); if (fromChildren?.name) { return String(fromChildren.name); } const cached = findCachedItemByFileId(sourceId); if (cached?.name) { return String(cached.name); } const raw = plan?.item?.raw || {}; const nameByRaw = raw.parentName || raw.parent_name || raw.dirName || raw.dir_name || raw.folderName || raw.folder_name; if (nameByRaw) { return String(nameByRaw); } return sourceId; }; const buildSourcePath = (task) => { const fileName = String(task?.plan?.item?.name || ""); const folderName = getSourceFolderName(task?.plan); return folderName ? `${folderName}/${fileName}` : fileName; }; const lines = []; lines.push(`文件夹:${context.targetFolder.name}`); lines.push(`=> ${context.folderTargetName}`); if (context.organizeMode === "files") { lines.push( context.shouldMove ? "模式:文件模式(会移动到剧名目录)" : "模式:文件模式(已在剧名目录,仅重命名)", ); } else { lines.push( context.shouldMove ? "模式:文件夹模式(剧集会分季移动)" : "模式:文件夹模式(电影仅重命名,不移动)", ); } lines.push(""); if (!context.bySeason.size || context.total === 0) { lines.push("没有识别到可整理的视频文件"); return lines.join("\n"); } lines.push(`共 ${context.total} 个视频文件,按以下规则整理:`); lines.push(""); const { tasks, skippedMovieDuplicates } = buildTaskList(context); if (context.isMovie) { lines.push("[电影模式:不创建季目录]"); if (skippedMovieDuplicates.length) { lines.push( `[已忽略重复版本 ${skippedMovieDuplicates.length} 个:版本标识相同]`, ); } tasks.forEach((task, idx) => { const newFileName = buildTargetVideoName( context, task, idx + 1, tasks.length, ); lines.push( `- ${buildSourcePath(task)} -> ${context.folderTargetName}/${newFileName}`, ); }); return lines.join("\n").trim(); } const bySeasonTask = new Map(); for (const task of tasks) { if (!bySeasonTask.has(task.season)) { bySeasonTask.set(task.season, []); } bySeasonTask.get(task.season).push(task); } lines.push( context.shouldMove ? "[剧集模式:会创建/复用季目录]" : "[剧集模式:仅重命名,不创建季目录]", ); lines.push(""); for (const [season, seasonTasks] of Array.from( bySeasonTask.entries(), ).sort((a, b) => a[0] - b[0])) { const seasonFolder = `S${pad2(season)}`; lines.push(`[${seasonFolder}]`); for (const task of seasonTasks) { const newFileName = buildTargetVideoName(context, task); const destination = context.shouldMove ? `${context.folderTargetName}/${seasonFolder}/${newFileName}` : `${context.folderTargetName}/${newFileName}`; lines.push(`- ${buildSourcePath(task)} -> ${destination}`); } lines.push(""); } return lines.join("\n").trim(); } async function onPreviewOrganize() { if (STATE.busy) { return; } try { setStatus("正在生成预览..."); const context = await prepareOrganizeContext({ allowCreate: false, }); const previewText = buildPreviewText(context); setPreview(previewText); setStatus(`预览完成:共 ${context.total} 个视频`); } catch (err) { const msg = getErrorText(err); setStatus(`预览失败:${msg}`); setPreview(`预览失败:${msg}`); } } async function onStartOrganize() { if (STATE.busy) { return; } STATE.busy = true; try { const context = await prepareOrganizeContext({ allowCreate: true }); setPreview(buildPreviewText(context)); setStatus( `1/4 重命名剧集目录:${context.targetFolder.name} -> ${context.folderTargetName}`, ); if (context.targetFolder.name !== context.folderTargetName) { await renameFile( context.targetFolder.fileId, context.folderTargetName, ); } context.targetFolder.name = context.folderTargetName; if (!context.bySeason.size) { setStatus( `没有识别到可整理的视频文件(已扫描 ${context.children.length} 项,可能接口返回被筛选)`, ); return; } const { seasonEntries, tasks, skippedMovieDuplicates } = buildTaskList(context); let renamed = 0; for (let i = 0; i < tasks.length; i += 1) { const task = tasks[i]; const { plan } = task; const newFileName = buildTargetVideoName( context, task, i + 1, tasks.length, ); task.newFileName = newFileName; renamed += 1; setStatus( `2/4 重命名视频 ${renamed}/${context.total}\n${plan.item.name}\n-> ${newFileName}`, ); if (plan.item.name !== newFileName) { await renameFile(plan.item.fileId, newFileName); } } let moved = 0; let deletedEmptyFolders = 0; if (!context.shouldMove) { const reason = context.organizeMode === "folder" ? "文件夹模式(电影):仅重命名,不移动文件" : "视频已在剧集目录,跳过移动"; setStatus(`3/4 ${reason}`); } else if (context.isMovie) { const bySource = new Map(); const targetParentId = String( context.targetFolder.fileId || "", ).trim(); for (const task of tasks) { const sourceParentId = String( task?.plan?.sourceParentId || "", ).trim(); if (sourceParentId && sourceParentId === targetParentId) { continue; } const key = sourceParentId || "__unknown__"; if (!bySource.has(key)) { bySource.set(key, []); } bySource.get(key).push(task); } if (!bySource.size) { setStatus("3/4 所有视频已在目标目录,跳过移动"); } else { for (const [sourceKey, batchTasks] of bySource.entries()) { const sourceParentId = sourceKey === "__unknown__" ? "" : sourceKey; const fileIds = batchTasks .map((x) => String(x?.plan?.item?.fileId || "").trim(), ) .filter(Boolean); if (!fileIds.length) { continue; } const nextMoved = Math.min( context.total, moved + fileIds.length, ); setStatus( `3/4 批量移动视频 ${nextMoved}/${context.total}\n-> ${context.folderTargetName}(${fileIds.length} 个)`, ); await moveFiles( fileIds, context.targetFolder.fileId, sourceParentId, ); moved = nextMoved; } } } else { const seasonFolderIds = new Map(); const targetFolderId = String( context.targetFolder.fileId || "", ).trim(); // 文件夹模式下,如果某个子目录仅对应单一季,直接重命名为 Sxx,避免“新建季目录+搬运”后留下空目录。 if (context.organizeMode === "folder") { const childFolders = (context.children || []).filter( (item) => item && item.fileId && item.isFolder, ); const folderById = new Map( childFolders.map((item) => [String(item.fileId), item]), ); const sourceToSeasons = new Map(); for (const [season, plans] of seasonEntries) { for (const plan of plans) { const sourceId = String( plan?.sourceParentId || "", ).trim(); if (!sourceId || sourceId === targetFolderId) { continue; } if (!sourceToSeasons.has(sourceId)) { sourceToSeasons.set(sourceId, new Set()); } sourceToSeasons.get(sourceId).add(season); } } for (const [season, plans] of seasonEntries) { const seasonFolder = `S${pad2(season)}`; const sourceIds = Array.from( new Set( plans .map((plan) => String( plan?.sourceParentId || "", ).trim(), ) .filter( (id) => id && id !== targetFolderId, ), ), ); if (sourceIds.length !== 1) { continue; } const sourceId = sourceIds[0]; const sourceFolder = folderById.get(sourceId); const seasonSet = sourceToSeasons.get(sourceId); if ( !sourceFolder || !seasonSet || seasonSet.size !== 1 || !seasonSet.has(season) ) { continue; } const existing = findExistingSeasonFolder( dedupeItems([ ...(context.children || []), ...getCachedChildrenByParent(targetFolderId), ]), seasonFolder, ); if ( existing && String(existing.fileId || "") !== sourceId ) { continue; } const oldName = String(sourceFolder.name || ""); if ( normalizeDomText(oldName).toUpperCase() !== normalizeDomText(seasonFolder).toUpperCase() ) { setStatus( `3/4 季目录重命名:${oldName} -> ${seasonFolder}`, ); await renameFile(sourceId, seasonFolder); sourceFolder.name = seasonFolder; } seasonFolderIds.set(season, sourceId); } } for (const [season] of seasonEntries) { if (seasonFolderIds.has(season)) { continue; } const seasonFolder = `S${pad2(season)}`; setStatus(`3/4 准备季目录:${seasonFolder}`); const seasonFolderId = await ensureSeasonFolder( context.targetFolder.fileId, seasonFolder, ); seasonFolderIds.set(season, seasonFolderId); } for (const [season, plans] of seasonEntries) { const seasonFolder = `S${pad2(season)}`; const seasonFolderId = seasonFolderIds.get(season); if (!seasonFolderId) { throw new Error(`未找到季目录 ID:${seasonFolder}`); } const bySource = new Map(); for (const plan of plans) { const sourceParentId = String( plan?.sourceParentId || "", ).trim(); if ( sourceParentId && sourceParentId === seasonFolderId ) { continue; } const key = sourceParentId || "__unknown__"; if (!bySource.has(key)) { bySource.set(key, []); } bySource.get(key).push(plan); } for (const [sourceKey, batchPlans] of bySource.entries()) { const sourceParentId = sourceKey === "__unknown__" ? "" : sourceKey; const fileIds = batchPlans .map((x) => String(x?.item?.fileId || "").trim()) .filter(Boolean); if (!fileIds.length) { continue; } const nextMoved = Math.min( context.total, moved + fileIds.length, ); setStatus( `3/4 批量移动视频 ${nextMoved}/${context.total}\n-> ${seasonFolder}(${fileIds.length} 个)`, ); await moveFiles( fileIds, seasonFolderId, sourceParentId, ); moved = nextMoved; } } if (context.organizeMode === "folder") { const seasonFolderSet = new Set( Array.from(seasonFolderIds.values()) .map((x) => String(x || "").trim()) .filter(Boolean), ); const cleanupCandidates = new Set(); for (const [, plans] of seasonEntries) { for (const plan of plans) { const sourceId = String( plan?.sourceParentId || "", ).trim(); if ( !sourceId || sourceId === targetFolderId || seasonFolderSet.has(sourceId) ) { continue; } cleanupCandidates.add(sourceId); } } const candidateList = Array.from(cleanupCandidates); for (let i = 0; i < candidateList.length; i += 1) { const folderId = candidateList[i]; let nested = []; try { nested = await fetchListByParent(folderId); } catch (err) { warn( `清理空目录前读取失败,跳过:${folderId} | ${getErrorText(err)}`, ); continue; } if (Array.isArray(nested) && nested.length > 0) { continue; } try { setStatus( `3/4 清理空目录 ${i + 1}/${candidateList.length}`, ); await deleteFiles([folderId], targetFolderId); deletedEmptyFolders += 1; } catch (err) { warn( `删除空目录失败,已跳过:${folderId} | ${getErrorText(err)}`, ); } } } } setStatus( `4/4 完成\n${context.folderTargetName}\n共处理 ${context.total} 个视频文件${renamed !== context.total ? `,实际整理 ${renamed} 个` : ""}${skippedMovieDuplicates.length ? `,忽略重复版本 ${skippedMovieDuplicates.length} 个` : ""}${moved ? `,移动 ${moved} 个` : ""}${deletedEmptyFolders ? `,删除空目录 ${deletedEmptyFolders} 个` : ""}`, ); log("整理完成", { folderId: context.targetFolder.fileId, targetName: context.folderTargetName, mode: context.organizeMode, total: context.total, moved, deletedEmptyFolders, }); } catch (err) { setStatus(`整理失败:${getErrorText(err)}`); fail("整理失败", err); } finally { STATE.busy = false; } } function openModal() { const root = ensureModal(); refreshSelectedItems({ keepPreviousIfEmpty: false }); STATE.selectedTmdb = null; const guessed = guessSeriesTitle(STATE.selectedItems); if (STATE.ui?.title) { STATE.ui.title.value = guessed; } if (STATE.ui?.results) { STATE.ui.results.textContent = "暂无搜索结果"; } setPreview("点击“预览”生成计划..."); setStatus( STATE.selectedItems.length ? `已读取勾选项 ${STATE.selectedItems.length} 条。请确认剧名后点击 TMDB 搜索。` : "未读取到勾选项,请先在列表里勾选目标文件夹/文件。", ); root.classList.add("open"); } function createOrganizeButton(cloudBtn) { const btn = cloudBtn.cloneNode(true); btn.setAttribute(ORGANIZE_BTN_ATTR, "1"); btn.classList.remove("ant-dropdown-trigger"); btn.removeAttribute("aria-expanded"); btn.removeAttribute("aria-haspopup"); btn.title = "整理"; const spans = btn.querySelectorAll("span"); if (spans.length) { const textSpan = spans[spans.length - 1]; textSpan.textContent = "整理"; } else { btn.textContent = "整理"; } const iconWrap = btn.querySelector(".ant-btn-icon"); if (iconWrap) { iconWrap.remove(); } btn.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); openModal(); }); return btn; } function mountOrganizeButtons() { const allButtons = Array.from( document.querySelectorAll("button.ant-btn"), ); for (const button of allButtons) { if (!isCloudAddButton(button)) { continue; } const next = button.nextElementSibling; if ( next && next.getAttribute && next.getAttribute(ORGANIZE_BTN_ATTR) === "1" ) { continue; } const organizeBtn = createOrganizeButton(button); button.insertAdjacentElement("afterend", organizeBtn); } } function bootstrap() { installCaptureHooks(); ensureModal(); let timer = null; const observer = new MutationObserver(() => { if (timer) { window.clearTimeout(timer); } timer = window.setTimeout(() => { mountOrganizeButtons(); }, 90); }); observer.observe(document.documentElement, { childList: true, subtree: true, }); mountOrganizeButtons(); log("脚本已启动:会在“云添加”后插入“整理”按钮。"); } bootstrap(); })();