MT DOCK

面向 M-Team 的功能增强与连接应用工作台的油猴脚本。

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

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

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         MT DOCK
// @namespace    https://github.com/kinaxng/mt-dock
// @description  面向 M-Team 的功能增强与连接应用工作台的油猴脚本。
// @version      2.0.0
// @homepageURL  https://github.com/kinaxng/mt-dock
// @supportURL   https://github.com/kinaxng/mt-dock/issues
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/vue.min.js
// @require      https://raw.githubusercontent.com/kinaxng/mt-dock/v1.0.0/vendor/coco-message.js#sha256=HSkD+edeGlgMv+/NCAarYPU219Y8EGQl7Y/TaWYOjBg=
// @match        https://*/details.php*
// @match        https://*/*/details.php*
// @match        https://test2.m-team.cc/*
// @match        https://*.m-team.cc/*
// @match        https://*.m-team.io/*
// @match        https://totheglory.im/t/*
// @grant        GM_xmlhttpRequest
// @connect      *
// @grant        GM_log
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @grant        GM_listValues
// @grant        GM_registerMenuCommand
// @grant        unsafeWindow
// @grant        window.close
// @grant        window.focus
// @grant        window.onurlchange
// @run-at       document-start
// @connect      *
// @license      GPL-2.0
// @author       KINAX
// ==/UserScript==

// MT DOCK: independently maintained by KINAX, 2026.
// License: GPL-2.0. See THIRD_PARTY_NOTICES.md and LICENSES/.
// Generated by scripts/build.cjs; edit src/ instead.
(function () {
    'use strict';
    const dockVersion = "2.0.0";

    // Profile storage is kept here; legacy keys remain untouched for rollback.
    const profileKeys = ['id', 'name', 'client', 'address', 'username', 'password', 'separator', 'saveLocations', 'authMode', 'apiKey', 'locationMode', 'remoteCategories'];
    const globalDefaults = { autoStartDownload: true, autoCloseWindow: false,
        sequentialDownload: false, firstLastPiecePrio: false, autoTMM: false, pinButton: false, theme: 'dark',
        largeImageMode: false };
    const clone = value => JSON.parse(JSON.stringify(value));

    function createProfile() {
        const names = getProfiles().map(p => p.name);
        let number = 1;
        while (names.includes(`qBittorrent ${number}`)) number++;
        const id = typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() :
            'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
                const r = typeof crypto !== 'undefined' && crypto.getRandomValues ?
                    crypto.getRandomValues(new Uint8Array(1))[0] & 15 : Math.floor(Math.random() * 16);
                return (c === 'x' ? r : (r & 3) | 8).toString(16);
            });
        return { id, name: `qBittorrent ${number}`, client: 'qbittorrent', address: '',
            username: '', password: '', authMode: 'apikey', apiKey: '', locationMode: 'remote', remoteCategories: [], separator: '/', saveLocations: [{ label: '默认', value: '' }] };
    }
    function getProfiles() {
        return clone(GM_getValue('clientProfilesV2', [])).map(profile => ({
            authMode: 'password', apiKey: '', locationMode: 'manual', remoteCategories: [], ...profile
        }));
    }
    function profileLocations(profile) {
        return profile.client === 'qbittorrent' && profile.locationMode === 'remote' ? profile.remoteCategories : profile.saveLocations;
    }
    function connectionIdentity(profile) {
        return JSON.stringify([profile.id, profile.client, profile.address, profile.authMode, profile.apiKey, profile.username, profile.password]);
    }
    function saveProfiles(profiles) {
        if (!profiles.length) throw new Error('至少保留一个下载器');
        GM_setValue('clientProfilesV2', clone(profiles.map(p =>
            Object.fromEntries(profileKeys.map(key => [key, p[key]])))));
        if (!profiles.some(p => p.id === GM_getValue('activeProfileId'))) {
            GM_setValue('activeProfileId', profiles[0].id);
        }
    }
    function getActiveProfile() {
        const profiles = getProfiles();
        return profiles.find(p => p.id === GM_getValue('activeProfileId')) || profiles[0];
    }
    function setActiveProfile(id) {
        if (!getProfiles().some(p => p.id === id)) throw new Error('下载器不存在');
        GM_setValue('activeProfileId', id);
    }
    function updateProfile(id, patch) {
        saveProfiles(getProfiles().map(p => p.id === id ? { ...p, ...patch, id } : p));
    }
    function deleteProfile(id) {
        saveProfiles(getProfiles().filter(p => p.id !== id));
        const selected = GM_getValue('selectedLocationByProfile', {});
        delete selected[id];
        GM_setValue('selectedLocationByProfile', selected);
    }
    function getSelectedLocation(profileId) {
        const profile = getProfiles().find(p => p.id === profileId);
        const index = GM_getValue('selectedLocationByProfile', {})[profileId];
        return Number.isInteger(index) && index >= 0 && index < (profile ? profileLocations(profile).length : 0) ? index : 0;
    }
    function setSelectedLocation(profileId, index) {
        GM_setValue('selectedLocationByProfile', { ...GM_getValue('selectedLocationByProfile', {}), [profileId]: index });
    }
    function migrateLegacyConfig() {
        if (GM_getValue('clientProfilesV2') === undefined) {
            const profile = createProfile();
            if (GM_getValue('configMigrationVersion', 0) < 2) {
                for (const key of profileKeys.filter(key => !['id', 'name'].includes(key))) {
                    profile[key] = GM_getValue(key, profile[key]);
                }
                profile.name = profile.client === 'transmission' ? '默认 Transmission' : '默认 qBittorrent';
                profile.separator = profile.separator || '/';
                if (GM_getValue('address') !== undefined) { profile.authMode = 'password'; profile.locationMode = 'manual'; }
            }
            saveProfiles([profile]);
            setSelectedLocation(profile.id, GM_getValue('selectedLabel', 0));
        } else if (!getProfiles().length) {
            saveProfiles([createProfile()]);
        } else {
            setActiveProfile(getActiveProfile().id);
        }
        GM_setValue('configMigrationVersion', 2);
    }
    function buildEffectiveConfig(profile) {
        const globals = Object.fromEntries(Object.entries(globalDefaults).map(([key, fallback]) => [key, GM_getValue(key, fallback)]));
        return clone({ ...globals, ...profile });
    }
    function saveGlobalSettings(settings) {
        Object.keys(globalDefaults).forEach(key => GM_setValue(key, settings[key]));
    }
    function profileReady(profile) {
        try {
            const url = new URL(profile.address);
            return ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password &&
                ['qbittorrent', 'transmission'].includes(profile.client) &&
                (profile.client === 'qbittorrent' && profile.authMode === 'apikey' ? !!profile.apiKey?.trim() : !!profile.username && !!profile.password);
        } catch (_) { return false; }
    }

    // MT DOCK media-library detection. Indexes contain identifiers only and stay in this tab.
    const mediaServerKey = 'mediaServersV1';
    const mediaIndexes = new Map();
    const mediaStatuses = new Map();
    const mediaStatusListeners = new Set();
    const mediaTorrentData = new Map();
    const mediaTtl = 10 * 60 * 1000;
    let mediaGeneration = 0;
    let mediaRequestCount = 0;
    const mediaRequestQueue = [];
    function newMediaServer() {
        return {id:createProfile().id, name:'媒体库', type:'emby', address:'', apiKey:'', userId:'', enabled:true};
    }
    function getMediaServers() { return clone(GM_getValue(mediaServerKey, [])); }
    function validateMediaServers(value) {
        if (!Array.isArray(value) || value.length > 20) throw new Error('最多配置 20 个媒体服务器');
        const ids = new Set();
        return value.map(server => {
            if (!server || !['emby','jellyfin'].includes(server.type) || typeof server.enabled !== 'boolean') throw new Error('媒体服务器格式无效');
            for (const key of ['id','name','address','apiKey','userId']) {
                if (typeof server[key] !== 'string' || server[key].length > 4096) throw new Error('媒体服务器字段无效');
            }
            if (!server.id || ids.has(server.id) || !server.name.trim()) throw new Error('媒体服务器名称或 ID 无效');
            ids.add(server.id);
            const address = server.address.trim().replace(/\/+$/, '');
            if (address) {
                let url;
                try { url = new URL(address); } catch (_) { throw new Error('媒体服务器地址无效'); }
                if (!['http:','https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new Error('请填写不含凭据、查询参数的服务器地址');
            }
            return {id:server.id, name:server.name.trim(), type:server.type, address,
                apiKey:server.apiKey.trim(), userId:server.userId.trim(), enabled:server.enabled};
        });
    }
    function saveMediaServers(value) {
        const servers = validateMediaServers(value);
        GM_setValue(mediaServerKey, servers);
        mediaGeneration++;
        mediaIndexes.clear(); mediaStatuses.clear();
        notifyMediaStatus(); scheduleMediaScan();
    }
    function mediaSignature(server) { return JSON.stringify(server); }
    function mediaReady(server) { return !!server.address && !!server.apiKey && server.enabled; }
    function notifyMediaStatus() { for (const fn of mediaStatusListeners) fn(); }
    function setMediaStatus(server, message, generation) {
        if (generation !== mediaGeneration) return;
        mediaStatuses.set(server.id, message); notifyMediaStatus();
    }
    async function mediaRequest(server, endpoint, query = {}) {
        if (mediaRequestCount >= 2) await new Promise(resolve => mediaRequestQueue.push(resolve));
        else mediaRequestCount++;
        try {
            const url = server.address + '/' + endpoint + '?' + new URLSearchParams(query);
            const headers = {Accept:'application/json'};
            if (server.type === 'jellyfin') headers.Authorization = `MediaBrowser Client="MT%20DOCK", Device="Browser", DeviceId="${encodeURIComponent('mt-dock-' + server.id)}", Version="${dockVersion}", Token="${encodeURIComponent(server.apiKey)}"`;
            else headers['X-Emby-Token'] = server.apiKey;
            const response = await GM_fetch({method:'GET',url,anonymous:true,redirect:'error',headers});
            // Do not follow a redirected response as if it belonged to the configured endpoint.
            if (response.finalUrl && new URL(response.finalUrl).origin !== new URL(server.address).origin) throw new Error('媒体服务器发生跨站重定向');
            if ([401,403].includes(response.status)) throw new Error('API Key 无效或无权访问媒体库');
            if (response.status !== 200) throw new Error('媒体服务器接口不可用,请检查地址');
            try { return JSON.parse(response.responseText); } catch (_) { throw new Error('媒体服务器返回了无效数据'); }
        } catch (error) {
            if (/API Key 无效|媒体服务器|无效数据/.test(error.message || '')) throw error;
            throw new Error('媒体服务器连接超时或网络异常');
        } finally {
            const next = mediaRequestQueue.shift();
            if (next) next(); else mediaRequestCount--;
        }
    }
    function normalizedAv(value) {
        const text = String(value || '').normalize('NFKC').toUpperCase().trim();
        let match = /^(?:FC2[\s_-]*(?:PPV[\s_-]*)?)(\d{4,9})$/.exec(text);
        if (match) return 'FC2-PPV-' + String(Number(match[1]));
        match = /^([A-Z]{2,12})[\s_-]*0*(\d{2,7})$/.exec(text);
        if (!match || /^(?:H|X|HEVC|AVC|AAC|DTS|MP|WEB|S|EP|TMDB|IMDB|THE|PART|VOL|SEASON|SERIES|EPISODE|DISC|CD|BD|DVD|HD|UHD)$/.test(match[1])) return '';
        return match[1] + '-' + String(Number(match[2]));
    }
    function avNumbers(value) {
        const text = String(value || '').normalize('NFKC').toUpperCase();
        const result = new Set();
        for (const m of text.matchAll(/(?:^|[^A-Z0-9])((?:FC2[\s_-]*(?:PPV[\s_-]*)?\d{4,9})|(?:[A-Z]{2,12}[-_]?\d{3,7}))(?=$|[^A-Z0-9])/g)) {
            const code = normalizedAv(m[1]); if (code) result.add(code);
        }
        return [...result];
    }
    function mediaIdentity(data = {}, root = null) {
        const keys = new Set();
        const links = root ? [...root.querySelectorAll('a[href]')].filter(a => !a.closest('.mt-dock-library-badges')).map(a => a.getAttribute('href') || '') : [];
        const sources = [...links];
        for (const key of ['imdb','imdbId','imdbUrl','tmdb','tmdbId','tmdbUrl','dmm','dmmUrl','dmmCode','productNumber','product_number']) {
            const value = data[key]; if (typeof value === 'string' || typeof value === 'number') sources.push(String(value));
        }
        for (const text of sources) {
            for (const m of text.matchAll(/\btt\d{5,12}\b/gi)) keys.add('imdb:' + m[0].toLowerCase());
            const tmdb = /(?:themoviedb\.org|tmdb\.org)\/(movie|tv)\/(\d+)/i.exec(text);
            if (tmdb) keys.add('tmdb:' + (tmdb[1].toLowerCase() === 'tv' ? 'Series:' : 'Movie:') + String(Number(tmdb[2])));
            const product = /[?&]product_number=([^&#]+)/i.exec(text);
            if (product) { try { const code = normalizedAv(decodeURIComponent(product[1])); if (code) keys.add('av:' + code); } catch (_) {} }
        }
        for (const value of [data.dmmCode,data.productNumber,data.product_number]) {
            const code = normalizedAv(value); if (code) keys.add('av:' + code);
        }
        const title = data.name || data.originFileName || '';
        // Names are only a fallback for AV codes. Ordinary films never match by title alone.
        if (![...keys].some(key => /^(?:imdb|tmdb):/.test(key))) for (const code of avNumbers(title)) keys.add('av:' + code);
        return [...keys].sort();
    }
    function addMediaItem(index, item) {
        if (!item || !item.Id || item.IsPlaceHolder || item.IsVirtualItem || item.LocationType === 'Virtual') return;
        if (!['Movie','Series','Video'].includes(item.Type)) return;
        const keys = [];
        for (const [provider, raw] of Object.entries(item.ProviderIds || {})) {
            const id = String(raw || '');
            if (provider.toLowerCase() === 'imdb' && /^tt\d+$/i.test(id)) keys.push('imdb:' + id.toLowerCase());
            if (provider.toLowerCase() === 'tmdb' && /^\d+$/.test(id) && ['Movie','Series'].includes(item.Type)) keys.push('tmdb:' + item.Type + ':' + String(Number(id)));
            if (/^(?:dmm|jav|javdb|javbus|productnumber)$/i.test(provider)) {
                const code = normalizedAv(id); if (code) keys.push('av:' + code);
            }
        }
        const pathParts = String(item.Path || '').split(/[\\/]/).slice(-2).join(' ');
        for (const code of avNumbers([item.Name,item.OriginalTitle,pathParts].filter(Boolean).join(' '))) keys.push('av:' + code);
        const info = {id:String(item.Id), type:item.Type, serverId:String(item.ServerId || '')};
        for (const key of keys) if (!index.has(key)) index.set(key, info);
    }
    async function getMediaIndex(server) {
        const signature = mediaSignature(server), cached = mediaIndexes.get(server.id);
        if (cached && cached.signature === signature && (cached.pending || cached.expires > Date.now())) return cached.promise;
        const generation = mediaGeneration;
        const entry = {signature, pending:true, expires:0, promise:null};
        mediaIndexes.set(server.id, entry);
        entry.promise = (async () => {
            const index = new Map(), seen = new Set();
            let offset = 0;
            try {
                while (offset < 100000) {
                    if (generation !== mediaGeneration) throw new Error('媒体库配置已变更');
                    setMediaStatus(server, `正在读取媒体索引(${offset} 项)…`, generation);
                    const query = {Recursive:'true',IncludeItemTypes:'Movie,Series,Video',Fields:'ProviderIds,Path,OriginalTitle',
                        EnableImages:'false',EnableUserData:'false',EnableTotalRecordCount:'true',IsVirtualItem:'false',
                        StartIndex:String(offset),Limit:'500',SortBy:'SortName',SortOrder:'Ascending'};
                    if (server.userId) query.UserId = server.userId;
                    const data = await mediaRequest(server, 'Items', query);
                    if (!data || !Array.isArray(data.Items)) throw new Error('媒体服务器返回了无效数据');
                    let fresh = 0;
                    for (const item of data.Items) {
                        if (item?.Id && !seen.has(String(item.Id))) { seen.add(String(item.Id)); fresh++; addMediaItem(index,item); }
                    }
                    offset += data.Items.length;
                    if (!data.Items.length || (Number.isFinite(data.TotalRecordCount) && offset >= data.TotalRecordCount) || (!Number.isFinite(data.TotalRecordCount) && data.Items.length < 500)) {
                        if (generation !== mediaGeneration) throw new Error('媒体库配置已变更');
                        entry.expires = Date.now() + mediaTtl;
                        setMediaStatus(server, `已就绪 · ${seen.size} 项 · 缓存 10 分钟`, generation);
                        return index;
                    }
                    if (!fresh) throw new Error('媒体服务器分页异常,请刷新后重试');
                }
                throw new Error('媒体库超过 10 万项,请填写用户 ID 缩小可见范围');
            } catch (error) {
                entry.expires = Date.now() + 30000;
                setMediaStatus(server, error.message, generation);
                throw error;
            } finally { entry.pending = false; }
        })();
        return entry.promise;
    }
    async function testMediaServer(server) {
        const validated = validateMediaServers([server])[0];
        if (!validated.address || !validated.apiKey) throw new Error('请填写服务器地址与 API Key');
        const query = {Recursive:'true',IncludeItemTypes:'Movie,Series,Video',Limit:'1',EnableImages:'false'};
        if (validated.userId) query.UserId = validated.userId;
        const data = await mediaRequest(validated,'Items',query);
        if (!Array.isArray(data?.Items)) throw new Error('媒体服务器返回了无效数据');
        return '连接成功,可以读取媒体库';
    }
    let mediaScanTimer = null;
    let mediaObserver = null;
    function scheduleMediaScan() {
        if (!mediaObserver) return;
        clearTimeout(mediaScanTimer);
        mediaScanTimer = setTimeout(scanMediaPage, 200);
    }
    function acceptMediaMetadata(data) {
        const list = Array.isArray(data?.data) ? data.data : Array.isArray(data) ? data : [data];
        for (const item of list) {
            if (!item || !/^\d+$/.test(String(item.id))) continue;
            mediaTorrentData.set(String(item.id), {keys:mediaIdentity(item), name:String(item.name || item.originFileName || '')});
        }
        while (mediaTorrentData.size > 1000) mediaTorrentData.delete(mediaTorrentData.keys().next().value);
        scheduleMediaScan();
    }
    function mediaDiscountAnchor(root) {
        const candidates = [...root.querySelectorAll('.ant-tag, [class*="discount"], [class*="promotion"], span, strong')].filter(el =>
            !el.closest('#plugin-download-div, .mt-dock-library-badges') &&
            /^(?:(?:2|3|4|5|10)[x×]|(?:(?:2|3|4|5|10)[x×]\s*)?(?:free|免费|免費|(?:-?\d+(?:\.\d+)?\s*%)|(?:\d+(?:\.\d+)?折))(?:\s*(?:2|3|4|5|10)[x×])?)$/i.test(el.textContent.trim())
        );
        const last = candidates[candidates.length - 1];
        return last ? (last.closest('.ant-tag') || last) : null;
    }
    function mediaCategoryAnchor(root) {
        const candidates = [...root.querySelectorAll('a[href], [data-category], [class*="category"], [class*="tag"]')].filter(el => {
            if (el.closest('#plugin-download-div, .mt-dock-library-badges')) return false;
            if (el.matches('[class*="discount"], [class*="promotion"]')) return false;
            const href = el.getAttribute?.('href') || '';
            const text = el.textContent.trim();
            if (el.classList?.contains('ant-tag') && /^(?:(?:2|3|4|5|10)[x×]|(?:(?:2|3|4|5|10)[x×]\s*)?(?:free|免费|免費|(?:-?\d+(?:\.\d+)?\s*%)|(?:\d+(?:\.\d+)?折))(?:\s*(?:2|3|4|5|10)[x×])?)$/i.test(text)) return false;
            return !!text && (el.hasAttribute('data-category') || /(?:\/browse(?:\/|\?|$)|[?&](?:category|cat)=|\/category(?:\/|$))/i.test(href) || /分类|電影|电影|电视剧|剧集|成人|音乐|Movie|TV|Adult|Anime|Music/i.test(text));
        });
        return candidates[candidates.length - 1] || null;
    }
    function mediaDmmSection(root) {
        const link = root.querySelector('a[href*="/dmmlist"], a[href*="product_number"]');
        return link?.closest('[id*="dmm" i], [class*="dmm" i], section, article, table, .ant-card, .ant-descriptions, .ant-collapse-item') || link?.parentElement || null;
    }
    function mediaDetailTitleAnchor(root) {
        const dmm = mediaDmmSection(root);
        const outside = element => !dmm || !dmm.contains(element);
        const preferred = [...root.querySelectorAll('#top, h1.torrent-title, .torrent-title, [class*="torrent-title"], [class*="detail-title"], [class*="post-title"], [class*="page-header-heading-title"]')].find(outside);
        if (preferred) return preferred;
        return [...root.querySelectorAll('h1, h2')].find(outside) || null;
    }
    const mediaTranslationStates = new WeakMap();
    function hasJapaneseText(value) {
        return /[\u3040-\u30ff\u3400-\u4dbf]/.test(String(value || ''));
    }
    function mediaDmmTitleAnchor(root) {
        const section = mediaDmmSection(root);
        if (!section) return null;
        return [...section.querySelectorAll('h1, h2, h3, strong, [class*="title"], p, span')].find(element => {
            if (element.closest('.mt-dock-translation-controls')) return false;
            const text = element.textContent.trim();
            return text && hasJapaneseText(text) && !element.querySelector('h1, h2, h3, strong, [class*="title"], p, span');
        }) || null;
    }
    function renderDmmTranslationFailure(state) {
        state.controls?.remove();
        const controls = document.createElement('span');
        controls.className = 'mt-dock-translation-controls';
        const button = document.createElement('button');
        button.type = 'button';
        button.className = 'mt-dock-translate-button';
        button.textContent = '翻译';
        button.title = '使用 Edge 内置 Translator API 翻译 DMM 标题';
        button.addEventListener('click', () => translateDmmTitle(state));
        controls.append(button);
        state.target.after(controls);
        state.controls = controls;
        state.phase = 'failed';
    }
    async function translateDmmTitle(state) {
        if (state.phase === 'pending') return;
        state.controls?.remove();
        state.controls = null;
        state.phase = 'pending';
        try {
            const translator = globalThis.Translator || (typeof unsafeWindow !== 'undefined' ? unsafeWindow.Translator : null);
            if (!translator?.availability || !translator?.create) throw new Error('Edge Translator API 不可用');
            const availability = await translator.availability({sourceLanguage:'ja', targetLanguage:'zh'});
            if (availability === 'unavailable') throw new Error('日语到中文模型不可用');
            const session = await translator.create({sourceLanguage:'ja', targetLanguage:'zh'});
            let translated = '';
            try { translated = String(await session.translate(state.original) || '').trim(); }
            finally { session.destroy?.(); }
            if (!translated || translated === state.original.trim()) throw new Error('翻译结果为空');
            state.target.textContent = translated;
            state.target.title = state.original;
            const controls = document.createElement('span');
            controls.className = 'mt-dock-translation-controls';
            const badge = document.createElement('span');
            badge.className = 'mt-dock-translation-badge';
            badge.textContent = '已翻译';
            badge.title = '原文:' + state.original;
            controls.append(badge);
            state.target.after(controls);
            state.controls = controls;
            state.phase = 'success';
        } catch (_) {
            renderDmmTranslationFailure(state);
        }
    }
    function scanDmmTranslation(root) {
        const target = mediaDmmTitleAnchor(root);
        if (!target) return;
        const original = target.dataset.mtDockOriginal || target.textContent.trim();
        if (!hasJapaneseText(original)) return;
        const previous = mediaTranslationStates.get(root);
        if (previous?.target === target && previous.original === original && previous.phase) return;
        previous?.controls?.remove();
        target.dataset.mtDockOriginal = original;
        const state = {target, original, controls:null, phase:''};
        mediaTranslationStates.set(root, state);
        translateDmmTitle(state);
    }
    function mountMediaBadges(target, container) {
        if (target.isDetail) {
            const parent = target.anchor.parentElement;
            if (parent && parent !== target.root && typeof getComputedStyle === 'function' &&
                getComputedStyle(parent).display === 'flex' && /row/i.test(getComputedStyle(parent).flexDirection)) {
                parent.after(container);
                return;
            }
        }
        target.anchor.after(container);
    }
    function applyLargeImageMode() {
        const enabled = !!GM_getValue('largeImageMode', false) && !currentDetailId();
        document.documentElement.classList.toggle('mt-dock-large-image-mode', enabled);
        document.querySelectorAll('.mt-dock-large-image-cell').forEach(cell => {
            if (!enabled) cell.classList.remove('mt-dock-large-image-cell');
        });
        if (!enabled) return;
        document.querySelectorAll('img.torrent-list__thumbnail').forEach(image => {
            image.classList.add('mt-dock-large-image');
            image.closest('td')?.classList.add('mt-dock-large-image-cell');
            image.closest('.ant-image')?.classList.add('mt-dock-large-image-box');
        });
    }
    function mediaExternalLinks(keys) {
        const code = keys.find(key => key.startsWith('av:'))?.slice(3);
        if (!code) return [];
        const encoded = encodeURIComponent(code);
        return [
            ['Jable', `https://jable.tv/videos/${encoded}/`],
            ['Missav', `https://missav.ai/${encoded}`],
            ['Javbus', `https://www.javbus.com/${encoded}`],
            ['Javlibrary', `https://www.javlibrary.com/cn/vl_searchbyid.php?keyword=${encoded}`],
            ['站内搜索', `https://kp.m-team.cc/browse/adult?keyword=${encoded}`]
        ];
    }
    function mediaServerLink(server, match) {
        const id = encodeURIComponent(String(match.id));
        let base = server.address.replace(/\/+$/, '');
        if (/\/web\/index\.html$/i.test(base)) {
            // Keep a user-supplied Emby/Jellyfin web entrypoint unchanged.
        } else if (/\/web$/i.test(base)) base += '/index.html';
        else base += '/web/index.html';
        if (server.type === 'emby') {
            const serverId = match.serverId ? '&serverId=' + encodeURIComponent(match.serverId) : '';
            return base + '#!/item?id=' + id + serverId;
        }
        return base + '#!/details?id=' + id;
    }
    function mediaPageTargets() {
        const targets = [], roots = new Set();
        for (const link of document.querySelectorAll('a[href*="/detail/"]')) {
            if (link.closest('#plugin-download-div, .mt-dock-library-badges')) continue;
            const id = /\/detail\/(\d+)/.exec(link.getAttribute('href') || '')?.[1];
            const root = link.closest('tr, .ant-list-item, .ant-card, [data-torrent-id]');
            if (!id || !root || roots.has(root)) continue;
            roots.add(root);
            const meta = mediaTorrentData.get(id);
            const title = [...root.querySelectorAll('a[href*="/detail/"]')].find(a => a.textContent.trim()) || link;
            const keys = meta?.keys.length ? meta.keys : mediaIdentity({name:meta?.name || title.textContent}, root);
            targets.push({root,id,keys,anchor:mediaCategoryAnchor(root) || title,isDetail:currentDetailId() === id});
        }
        const id = currentDetailId();
        if (id && !targets.some(item => item.id === id)) {
            const root = document.querySelector('.app-content__inner') || document.querySelector('.mt-4') || document.body;
            if (root) {
                const meta = mediaTorrentData.get(id);
                const anchor = mediaDetailTitleAnchor(root) || mediaDiscountAnchor(root);
                const keys = meta?.keys.length ? meta.keys : mediaIdentity({name:meta?.name || ''},root);
                if (anchor) targets.push({root,id,keys,anchor,isDetail:true});
            }
        }
        return targets;
    }
    const mediaRootStates = new WeakMap();
    function scanMediaPage() {
        applyLargeImageMode();
        const servers = getMediaServers().filter(mediaReady), generation = mediaGeneration;
        const signature = JSON.stringify(servers);
        for (const target of mediaPageTargets()) {
            if (target.isDetail) scanDmmTranslation(target.root);
            const key = JSON.stringify([target.id,target.keys,signature,generation]);
            const previous = mediaRootStates.get(target.root);
            if (previous?.key === key && previous.expires > Date.now() && previous.anchor === target.anchor && previous.container?.isConnected) continue;
            previous?.container?.remove();
            const container = document.createElement('span');
            container.className = 'mt-dock-library-badges' + (target.isDetail ? ' mt-dock-detail-badges' : '');
            container.setAttribute('aria-label','媒体库入库状态');
            const state = {key,anchor:target.anchor,container,expires:Date.now()+mediaTtl};
            mediaRootStates.set(target.root,state);
            mountMediaBadges(target, container);
            if (!target.keys.length || !servers.length) {
                if (target.isDetail) for (const [label, href] of mediaExternalLinks(target.keys)) {
                    const link = document.createElement('a');
                    link.className = 'mt-dock-external-badge'; link.textContent = label; link.href = href;
                    link.target = '_blank'; link.rel = 'noopener noreferrer';
                    link.title = '在 ' + label + ' 打开 ' + (target.keys.find(key => key.startsWith('av:'))?.slice(3) || '');
                    container.append(link);
                }
                continue;
            }
            Promise.all(servers.map(async server => {
                try {
                    const index = await getMediaIndex(server);
                    const match = target.keys.map(id => index.get(id)).find(Boolean);
                    return match ? {server,match} : null;
                } catch (_) { return null; } // A failed server never becomes a positive badge.
            })).then(results => {
                state.expires = Math.min(...servers.map(server => mediaIndexes.get(server.id)?.expires || Date.now()+30000));
                if (generation !== mediaGeneration || !container.isConnected || mediaRootStates.get(target.root) !== state) return;
                // A virtualized row may be reused before MutationObserver's next scan.
                const fresh = mediaPageTargets().find(item => item.root === target.root);
                if (!fresh || fresh.id !== target.id || JSON.stringify(fresh.keys) !== JSON.stringify(target.keys)) return;
                for (const result of results.filter(Boolean)) {
                    const badge = document.createElement('a');
                    badge.className = 'mt-dock-library-badge ' + result.server.type;
                    badge.textContent = `${result.server.name}已入库`;
                    badge.href = mediaServerLink(result.server, result.match);
                    badge.target = '_blank';
                    badge.rel = 'noopener noreferrer';
                    badge.title = `${result.server.type === 'emby' ? 'Emby' : 'Jellyfin'} · 已入库${result.match.type === 'Series' ? '(剧集条目存在,不代表整季齐全)' : ''}`;
                    container.append(badge);
                }
                if (target.isDetail) for (const [label, href] of mediaExternalLinks(target.keys)) {
                    const link = document.createElement('a');
                    link.className = 'mt-dock-external-badge'; link.textContent = label; link.href = href;
                    link.target = '_blank'; link.rel = 'noopener noreferrer'; link.title = `在 ${label} 打开 ${target.keys.find(key => key.startsWith('av:'))?.slice(3) || ''}`;
                    container.append(link);
                }
            });
        }
    }
    function startMediaDetection() {
        if (mediaObserver || typeof MutationObserver === 'undefined' || !document.body) return;
        GM_addStyle('.mt-dock-library-badges{display:inline-flex;flex-wrap:wrap;gap:8px;margin-left:5px;vertical-align:middle}.mt-dock-library-badges:not(.mt-dock-detail-badges){margin-left:10px!important;margin-right:8px!important}.mt-dock-detail-badges{display:flex!important;clear:both;flex-basis:100%;width:100%;margin:2px 0 14px!important}.mt-dock-library-badge,.mt-dock-external-badge{display:inline-block!important;padding:0 6px!important;border-radius:4px!important;font:600 11px/20px system-ui,sans-serif!important;white-space:nowrap!important;color:#fff!important;text-decoration:none!important;cursor:pointer!important}.mt-dock-library-badge.emby{background:#237b35!important;border:1px solid #329448!important}.mt-dock-library-badge.jellyfin{background:#5750b9!important;border:1px solid #7067da!important}.mt-dock-external-badge{background:#4b5563!important;border:1px solid #697586!important}.mt-dock-translation-controls{display:inline-flex;align-items:center;gap:4px;margin-left:5px;vertical-align:middle}.mt-dock-translation-badge,.mt-dock-translate-button{padding:0 6px!important;border-radius:4px!important;font:600 11px/20px system-ui,sans-serif!important;white-space:nowrap!important}.mt-dock-translation-badge{color:#246b39!important;background:#e8f6ec!important;border:1px solid #9bd5aa!important}.mt-dock-translate-button{color:#365b9a!important;background:#eef4ff!important;border:1px solid #a8c0ed!important;cursor:pointer!important}.mt-dock-large-image-mode .app-content__inner{max-width:2160px!important}.mt-dock-large-image-mode .app-content__inner div.mx-auto{max-width:100%!important}.mt-dock-large-image-mode .ant-spin-container table{table-layout:auto!important}.mt-dock-large-image-mode .mt-dock-large-image-cell{width:600px!important;min-width:320px!important}.mt-dock-large-image-mode img.mt-dock-large-image{height:auto!important;max-height:600px!important;width:100%!important;max-width:600px!important;object-fit:contain!important}.mt-dock-large-image-mode .mt-dock-large-image-box{height:auto!important;width:100%!important;max-width:600px!important;display:block!important}.mt-dock-large-image-mode .mt-dock-large-image-box img{max-height:600px!important;max-width:600px!important;object-fit:contain!important}');
        mediaObserver = new MutationObserver(records => {
            if (records.some(record => {
                const element = record.target.nodeType === 1 ? record.target : record.target.parentElement;
                if (element?.closest('#plugin-download-div, .mt-dock-library-badges, .mt-dock-translation-controls')) return false;
                const changed = [...record.addedNodes,...record.removedNodes];
                return !changed.length || changed.some(node => node.nodeType !== 1 || !node.matches('.mt-dock-library-badges, .mt-dock-translation-controls'));
            })) scheduleMediaScan();
        });
        mediaObserver.observe(document.body,{subtree:true,childList:true,characterData:true,attributes:true,attributeFilter:['href']});
        scheduleMediaScan();
    }

    const backupFormat = 'mt-dock-backup';
    const backupLimit = 2 * 1024 * 1024;
    function backupData(includeSecrets = false) {
        return {
            profiles: getProfiles().map(profile => ({ ...profile, remoteCategories: [],
                password: includeSecrets ? profile.password : '', apiKey: includeSecrets ? profile.apiKey : '' })),
            mediaServers: getMediaServers().map(server => ({...server,apiKey:includeSecrets ? server.apiKey : ''})),
            globals: Object.fromEntries(Object.entries(globalDefaults).map(([key, value]) => [key, GM_getValue(key, value)])),
            activeProfileId: getActiveProfile().id,
            selected: GM_getValue('selectedLocationByProfile', {})
        };
    }
    function toBase64(bytes) {
        let text = '';
        for (const byte of bytes) text += String.fromCharCode(byte);
        return btoa(text);
    }
    function fromBase64(value) {
        if (typeof value !== 'string' || value.length > backupLimit) throw new Error('备份格式无效');
        return Uint8Array.from(atob(value), ch => ch.charCodeAt(0));
    }
    async function backupKey(password, salt) {
        if (!globalThis.crypto?.subtle) throw new Error('当前页面不支持加密备份,请在 HTTPS 页面操作');
        const material = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveKey']);
        return crypto.subtle.deriveKey({ name:'PBKDF2', hash:'SHA-256', salt, iterations:250000 }, material,
            { name:'AES-GCM', length:256 }, false, ['encrypt','decrypt']);
    }
    async function exportBackup(includeSecrets = false, password = '') {
        const data = backupData(includeSecrets);
        if (!includeSecrets) return JSON.stringify({ format:backupFormat, version:1, encrypted:false, data }, null, 2);
        if (password.length < 12) throw new Error('完整备份需要至少 12 个字符的加密口令');
        const salt = crypto.getRandomValues(new Uint8Array(16)), iv = crypto.getRandomValues(new Uint8Array(12));
        const key = await backupKey(password, salt);
        const ciphertext = await crypto.subtle.encrypt({name:'AES-GCM',iv}, key, new TextEncoder().encode(JSON.stringify(data)));
        return JSON.stringify({format:backupFormat, version:1, encrypted:true, salt:toBase64(salt), iv:toBase64(iv), ciphertext:toBase64(new Uint8Array(ciphertext))}, null, 2);
    }
    function validateBackup(data) {
        if (!data || !Array.isArray(data.profiles) || !data.profiles.length || data.profiles.length > 100) throw new Error('备份应包含 1–100 个下载器');
        const string = value => typeof value === 'string' && value.length <= 8192;
        const ids = new Set();
        const profiles = data.profiles.map(p => {
            if (!p || !string(p.id) || !p.id || ids.has(p.id) || !string(p.name) || !p.name.trim() ||
                !['qbittorrent','transmission'].includes(p.client) || !string(p.address) ||
                !string(p.username) || !string(p.password) || !string(p.apiKey ?? '') ||
                !['apikey','password'].includes(p.authMode ?? 'password') || !['remote','manual'].includes(p.locationMode ?? 'manual') ||
                !['/','\\'].includes(p.separator) || !Array.isArray(p.saveLocations) || p.saveLocations.length > 500) throw new Error('下载器配置格式无效');
            if (p.address) {
                let url;
                try { url = new URL(p.address); } catch (_) { throw new Error('下载器地址无效'); }
                if (!['http:','https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new Error('下载器地址无效');
            }
            ids.add(p.id);
            const saveLocations = p.saveLocations.map(item => {
                if (!item || !string(item.label) || !string(item.value)) throw new Error('目录配置无效');
                return {label:item.label, value:item.value};
            });
            return {id:p.id, name:p.name, client:p.client, address:p.address, username:p.username, password:p.password,
                apiKey:p.apiKey || '', authMode:p.authMode || 'password', locationMode:p.locationMode || 'manual',
                separator:p.separator, saveLocations, remoteCategories:[]};
        });
        const globals = Object.fromEntries(Object.entries(globalDefaults).map(([key, fallback]) => {
            const value = data.globals?.[key];
            return [key, key === 'theme' ? (['dark','light'].includes(value) ? value : fallback) : (typeof value === 'boolean' ? value : fallback)];
        }));
        const selected = Object.fromEntries(profiles.map(p => {
            const value = data.selected?.[p.id];
            return [p.id, Number.isInteger(value) && value >= 0 && value < p.saveLocations.length && p.locationMode === 'manual' ? value : 0];
        }));
        const mediaServers = validateMediaServers(data.mediaServers === undefined ? [] : data.mediaServers);
        return {profiles, mediaServers, globals, selected, activeProfileId:ids.has(data.activeProfileId) ? data.activeProfileId : profiles[0].id};
    }
    async function readBackup(text, password = '') {
        if (typeof text !== 'string' || text.length > backupLimit) throw new Error('备份文件过大(上限 2 MB)');
        let envelope;
        try { envelope = JSON.parse(text); } catch (_) { throw new Error('无法读取备份 JSON'); }
        if (!envelope || envelope.format !== backupFormat || envelope.version !== 1 || typeof envelope.encrypted !== 'boolean') throw new Error('不支持的备份格式或版本');
        if (!envelope.encrypted) return validateBackup(envelope.data);
        let decrypted;
        try {
            const salt = fromBase64(envelope.salt), iv = fromBase64(envelope.iv), bytes = fromBase64(envelope.ciphertext);
            if (salt.length !== 16 || iv.length !== 12 || bytes.length < 16) throw new Error();
            const key = await backupKey(password, salt);
            decrypted = JSON.parse(new TextDecoder().decode(await crypto.subtle.decrypt({name:'AES-GCM',iv},key,bytes)));
        } catch (_) { throw new Error('无法解密:口令错误或备份已损坏'); }
        return validateBackup(decrypted);
    }
    function importBackup(data, includeGlobals = false) {
        const validated = validateBackup(data); // Validate completely before any storage mutation.
        const additions = validated.profiles.map(p => ({...p, id:createProfile().id}));
        const mediaAdditions = validated.mediaServers.map(server => ({...server,id:createProfile().id}));
        const mediaCombined = validateMediaServers([...getMediaServers(),...mediaAdditions]);
        saveProfiles([...getProfiles(), ...additions]);
        additions.forEach((p,index) => setSelectedLocation(p.id, validated.selected[validated.profiles[index].id]));
        if (includeGlobals) saveGlobalSettings(validated.globals);
        if (mediaAdditions.length) saveMediaServers(mediaCombined);
        // Append-only import never replaces an existing credential or changes the active target.
        return additions.length;
    }

    let torrentInfo = {}

    /**
     * 在这里面的网站走策略特殊处理 {域名标识: 标签(对应siteStrategies对象中key)}
     * sites 中没配置的走 NexusPHP 默认逻辑, NexusPHP 站点一般不用配置, 理论上 NexusPHP 站点都支持.
     *
     */
    let sites = {
        // "m-team.cc": "new_mteam",
        "m-team": "new_mteam",
        // "m-team": "mteam",
        "www.ptlsp.com": "ptlsp",
        "www.tjupt.org": "tjupt",
        "springsunday": "springsunday",
        "hhanclub": "hhanclub",
        "hdsky": "hdsky",
        "hdhome.org": "hdhome",
        "audiences.me": "audiences",
        "keepfrds.com": "keepfrds",
        "zmpt.cc": "zmpt",
        "hdarea.club": "hdarea",
        "totheglory": "totheglory",
        "hddolby.com": "hddolby"
    }

    // 异步加载种子信息的网站, 如新版馒头
    const asyncArr = ["new_mteam"]

    let host = window.location.host;

    function getSite() {
        const entries = Object.entries(sites);
        for (const [key, value] of entries) {
            if (host.includes(key)) {
                return value
            }
        }
        return null;
    }


    /**
     * 不同站点的策略对象
     * 没有配置的站点走默认逻辑:defaultStrategy.xxx()
     */
    const siteStrategies = {
        new_mteam: {
            getTorrentUrl: () => torrentInfo.url,
            getTorrentHash: () => "",
            getTorrentTitle: () => torrentInfo.name,
            getTorrentName: () => torrentInfo.originFileName,
            getTorrentSubTitle: () => torrentInfo.smallDescr,
            getDownloadButtonMountPoint: () => {
                if (document.querySelector(".mt-4.app-content__inner")) {
                    return document.querySelector('button.ant-btn.ant-btn-link.ant-btn-sm.ant-dropdown-trigger')?.closest("td")
                }
                return document.querySelector(".mt-4>div");
            }
        },
        mteam: {
            getTorrentUrl: () => {
                return document.evaluate("//a[text()='[IPv4+https]']", document).iterateNext().href;
            }
        },
        ptlsp: {
            getTorrentUrl: () => document.querySelector(`#download_pkey`).getAttribute(`href`)
        },
        tjupt: {
            getTorrentUrl: () => document.querySelector(`#direct_link`).getAttribute(`href`)
        },
        springsunday: {
            getTorrentTitle: () => document.querySelector(`#torrent-name`).innerText,
            getDownloadButtonMountPoint: () => document.querySelector('a[title="下载种子"]').closest('td')
        },
        hhanclub: {
            getTorrentTitle: () => {
                return document.evaluate("//div[text()='标题']", document).iterateNext()?.nextElementSibling.innerText.trim()
            },
            getTorrentName: () => {
                let str = document.querySelector("a.index").innerText;
                console.log("原始种子名:", str);
                return /\.(.+)\./.exec(str)[1];
            },
            getTorrentSubTitle: () => document.evaluate("//div[text()='副标题']", document).iterateNext()?.nextElementSibling.innerText,
            getDownloadButtonMountPoint: () => document.querySelector(".flex.gap-x-5")
        },
        hdsky: {
            getTorrentName: () => {
                let str = document.evaluate("//td[text()='下载']", document).iterateNext()?.nextElementSibling.querySelector("input").value
                console.log("原始种子名:", str);
                return /\.(.+)\./.exec(str)[1];
            },
            getDownloadButtonMountPoint: () => document.querySelector("#outer .dt_download")?.closest("td")
        },
        hdhome: {
            getTorrentUrl: () => {
                const linkElement = document.querySelector('td.rowfollow a[href*="/download.php?id="]');
                return linkElement ? linkElement.getAttribute("href") : null;
            }
        },
        audiences: {
            getTorrentUrl: () => {
                const linkElement = document.getElementById('torrent_dl_url').querySelector('a');
                return linkElement ? linkElement.getAttribute('href') : null;
            }
        },
        zmpt: {
            getTorrentUrl: () => document.getElementById('content').textContent.trim()
        },
        keepfrds: {
            getTorrentUrl: () => document.getElementById('download_link').value
        },
        hdarea: {
            getTorrentTitle: () => {
                const titleElement = document.querySelector("h1#top");
                let titleText = titleElement.textContent.trim(); // 获取标题文本并去除首尾空白
                titleText = titleText.replace(/(<.*?>|\[.*?\])/g, ''); // 使用正则表达式替换 HTML 标签和括号内容
                return titleText ? titleText : null;
            },
            getTorrentUrl: () => {
                const regex = /https?:\/\/\S+download\.php\?id=\d+&passkey=\w+/;
                const match = document.body.textContent.match(regex);
                return match ? match[0] : null;
            }
        },
        totheglory: {
            getTorrentName: () => document.querySelector("td.rowhead").nextElementSibling.querySelector("a").textContent.replace("[TTG]", ""),
            getTorrentTitle: () => document.querySelector("h1").textContent,
            getTorrentSubTitle: () => "",
            getTorrentUrl: () => document.querySelector("td[valign='top'] a").getAttribute("href"),
            getDownloadButtonMountPoint: () => document.querySelector('a[href^="https://totheglory.im/dl/"]').closest('td')

        },
        hddolby: {
            getTorrentUrl: () => {
                const currentDomain = window.location.origin;
                const relativeUrl = [...document.querySelectorAll('a.faqlink')].find(element => element.textContent.includes('右键复制种子链接')).getAttribute('href');
                return currentDomain + '/' + relativeUrl;
            }
        },
        // 默认策略
        defaultStrategy: {
            getTorrentUrl: () => {
                let allLinks = document.querySelectorAll('body a');
                // 查找第一个下载链接 link.href 会自动将相对路径转换为绝对路径,提供完整的 URL。
                let firstMatchingLink = Array.from(allLinks).find(function (link) {
                    return /download.php\?id=[0-9]+&passkey=.+$/.test(link.href)
                        || /download.php\?downhash=[0-9]+\|.+$/.test(link.href);
                });
                return firstMatchingLink ? firstMatchingLink.href : "";
            },
            getTorrentHash: () => {
                let text = document.querySelector("body").innerText;
                // let match = text.match(/hash.?:\s([a-fA-F0-9]{40})/i);
                let match = text.match(/([a-fA-F0-9]{40})/);
                return match ? match[1] : "";
            },
            getTorrentTitle: () => {
                return document.querySelector("#top").firstChild.nodeValue;
            },
            getTorrentName: () => {
                let str = document.querySelector("#outer td.rowfollow > a.index").innerText.trim()
                console.log("原始种子名:", str);
                return /\.(.+)\./.exec(str)[1];
            },
            getTorrentSubTitle: () => {
                let subTitleTd = document.evaluate("//td[text()='副標題']", document).iterateNext()
                    || document.evaluate("//td[text()='副标题']", document).iterateNext()
                    || document.evaluate("//td[text()='Small Description']", document).iterateNext()
                return subTitleTd.nextElementSibling.innerText;
            },
            getDownloadButtonMountPoint: () => document.querySelector("#outer img.dt_download")?.closest("td")
        }
    };

    function execMethodName(methodName) {
        try {
            let strategy = getSite() && siteStrategies[getSite()] || siteStrategies.defaultStrategy;
            let execMethodName = strategy[methodName] || siteStrategies.defaultStrategy[methodName];
            let flag = getSite() && siteStrategies[getSite()] && siteStrategies[getSite()][methodName] ? getSite() : "defaultStrategy"
            console.log(`执行: ${flag}.${methodName}(${execMethodName})`)
            return execMethodName() ?? "";
        } catch (e) {
            console.error(`执行 ${methodName}() 失败!`, e)
        }
        return ""
    }

    const PT = {
        getTorrentUrl: () => {
            let v = execMethodName("getTorrentUrl");

            return v;
        },
        getTorrentHash: () => {
            let v = execMethodName("getTorrentHash").trim();
            console.log("Hash值: ", v);
            return v;
        },
        getTorrentTitle: () => {
            let v = replaceUnsupportedCharacters(execMethodName("getTorrentTitle")).trim();
            if (v.endsWith(".torrent")) {
                v = v.replace(".torrent", "");
            }
            console.log("标题: ", v);
            return v;
        },
        getTorrentName: () => {
            let v = replaceUnsupportedCharacters(execMethodName("getTorrentName")).trim();
            if (v.endsWith(".torrent")) {
                v = v.replace(".torrent", "");
            }
            console.log("种子名: ", v);
            return v;
        },
        getTorrentSubTitle: () => {
            let v = replaceUnsupportedCharacters(execMethodName("getTorrentSubTitle")).trim();
            console.log("副标题: ", v);
            return v;
        },
        getDownloadButtonMountPoint: () => {
            return execMethodName("getDownloadButtonMountPoint")
        }
    }

    const deliveryStages = { queued:'等待发送', auth:'连接下载器', checking:'检查已有任务', fetching:'获取种子文件',
        uploading:'上传到下载器', querying:'查询已添加任务', renaming:'重命名文件', done:'投递完成' };
    const deliveryListeners = new Set();
    const activeDeliveries = new Map();
    const deliveryOwner = (() => {
        try {
            const existing = sessionStorage.getItem('mtDockDeliveryOwner');
            if (existing) return existing;
            const id = createProfile().id;
            sessionStorage.setItem('mtDockDeliveryOwner', id);
            return id;
        } catch (_) { return createProfile().id; }
    })();

    function getDeliveryHistory() { return clone(GM_getValue('deliveryHistoryV1', [])).slice(0,50); }
    function safeDeliveryText(text, secrets = []) {
        let value = String(text || '');
        for (const secret of secrets.filter(Boolean)) value = value.split(secret).join('[已隐藏]');
        return value.replace(/https?:\/\/\S+/gi, '[链接已隐藏]')
            .replace(/(?:qbt_[a-z0-9]+|(?:passkey|token|sid|password|cookie|authorization|api[_-]?key)\s*[:=]\s*\S+)/gi, '[已隐藏]').slice(0,300);
    }
    function notifyDeliveries() { for (const listener of deliveryListeners) listener(); }
    function deliveryUpdate(config, stage, outcome, reason) {
        if (!config.deliveryId) return;
        const history = getDeliveryHistory(), row = history.find(item => item.id === config.deliveryId);
        if (!row) return;
        if (stage) row.stage = stage;
        if (outcome) row.outcome = outcome;
        if (reason !== undefined) row.reason = reason;
        row.updatedAt = Date.now();
        GM_setValue('deliveryHistoryV1', history);
        notifyDeliveries();
    }
    function beginDelivery(config, name, source) {
        const secrets = [config.password, config.apiKey];
        const key = JSON.stringify([config.id, safeDeliveryText(source.mteamId || source.identity || name, secrets)]);
        if (activeDeliveries.has(key)) return null;
        const history = getDeliveryHistory();
        if (history.filter(row => row.outcome === 'running').length >= 50) return null;
        // An interrupted upload could already exist on the server. Never automatically submit it again.
        if (history.some(row => row.key === key && ['unknown','interrupted','added'].includes(row.outcome))) return null;
        const id = createProfile().id;
        const row = {id, key, owner:deliveryOwner, target:safeDeliveryText(config.name,secrets), name:safeDeliveryText(name,secrets),
            stage:'queued', outcome:'running', startedAt:Date.now(), updatedAt:Date.now()};
        GM_setValue('deliveryHistoryV1', [row,...history].slice(0,50));
        activeDeliveries.set(key, id);
        notifyDeliveries();
        return {id,key};
    }
    function settleDelivery(config, ok, error) {
        const row = getDeliveryHistory().find(item => item.id === config.deliveryId);
        if (!row) return;
        const outcome = ok ? 'success' : row.stage === 'uploading' ? 'unknown' :
            ['querying','renaming'].includes(row.stage) ? 'added' : 'failed';
        const text = String(error?.message || error || '');
        let reason = '';
        if (!ok) {
            if (/已经存在|已存在/.test(text)) reason = '目标下载器已有该种子';
            else if (/用户名|密码|API Key|配置是否正确|会话认证/.test(text)) reason = '认证失败,请检查连接配置';
            else if (/超时|网络|无响应/.test(text)) reason = '请求超时或网络异常';
            else reason = ({auth:'连接下载器失败', checking:'检查已有任务失败', fetching:'获取种子文件失败,请检查站点登录状态',
                uploading:'请在下载器确认是否已经添加', querying:'已上传,暂未查询到任务', renaming:'任务已添加,文件重命名失败'})[row.stage] || '操作未完成';
        }
        deliveryUpdate(config, ok ? 'done' : null, outcome, reason);
    }
    function recoverInterruptedDeliveries() {
        const history = getDeliveryHistory();
        let changed = false;
        history.forEach(row => {
            if (row.outcome === 'running' && row.owner === deliveryOwner && ![...activeDeliveries.values()].includes(row.id)) {
                row.outcome = ['uploading','querying','renaming'].includes(row.stage) ? 'interrupted' : 'failed';
                changed = true;
            }
        });
        if (changed) GM_setValue('deliveryHistoryV1', history);
    }
    function deliveryOutcome(row) {
        return ({running:'进行中', success:'已完成', failed:'发送前失败,可重试', unknown:'上传结果未知,请先核对下载器',
            interrupted:'上次操作中断,请先核对下载器', added:'已上传,后续处理失败;请检查现有任务'})[row.outcome] || '未知状态';
    }
    function clearDeliveryHistory() {
        GM_setValue('deliveryHistoryV1', getDeliveryHistory().filter(row => row.outcome === 'running'));
        notifyDeliveries();
    }
    async function fetchTorrentFile(source) {
        const descriptor = typeof source === 'string' ? {url:source} : source;
        let lastError;
        for (let attempt = 0; attempt < (descriptor.mteamId ? 2 : 1); attempt++) {
            try {
                // Refresh only the captured torrent's token, never the mutable page's current torrent.
                const url = descriptor.mteamId ? await result(descriptor.mteamId) : descriptor.url;
                const response = await GM_fetch({method:'GET', url, responseType:'arraybuffer'});
                if (response.status !== 200 || !response.response?.byteLength) throw new Error('无法获取种子文件');
                const prefix = new TextDecoder().decode(new Uint8Array(response.response).slice(0,100)).trimStart();
                if (/^(?:<|\{)/.test(prefix)) throw new Error('站点返回登录页或错误信息');
                return response.response;
            } catch (_) { lastError = new Error('获取种子文件失败,请检查站点登录状态或网络后重试'); }
        }
        throw lastError;
    }

    // 封装 GM_xmlhttpRequest 为 Promise
    function GM_fetch(options) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                timeout: 15000,
                ...options,
                ontimeout: () => reject(new Error("请求超时 / 网络异常")),
                onload: (response) => resolve(response),
                onerror: () => reject(new Error("请求超时 / 网络异常")),
            });
        });
    }


    // Malformed API responses must reject the operation instead of leaving it pending.
    function clientRequest(options) {
        GM_xmlhttpRequest({
            ...options,
            onload(response) {
                try {
                    Promise.resolve(options.onload(response)).catch(() => options.onerror?.());
                } catch (_) { options.onerror?.(); }
            }
        });
    }

    function createQbittorrentClient(config) {
        config = clone(config);
        function authOptions(options) {
            return config.authMode === 'apikey' ? { ...options, anonymous: true,
                headers: { ...options.headers, Authorization: `Bearer ${config.apiKey.trim()}` } } : options;
        }
        function request(options) { return clientRequest(authOptions(options)); }
        async function readApi(endpoint) {
            const response = await GM_fetch(authOptions({ method: 'GET', url: `${config.address}/api/v2/${endpoint}` }));
            if ([401, 403].includes(response.status)) throw new Error(config.authMode === 'apikey' ? 'API Key 无效或权限不足;请确认 qB ≥ 5.2' : '登录已失效,请重新连接');
            if (response.status !== 200) throw new Error('登录成功但 API 不可用');
            return response.responseText;
        }
        let login = () => {
            if (config.authMode === 'apikey') return Promise.resolve('API Key 认证');
            return new Promise((resolve, reject) => {
                request({
                    timeout: 15000,
                    ontimeout: () => reject("请求超时 / 网络异常"),
                    method: 'POST',
                    url: `${config.address}/api/v2/auth/login`,
                    data: getQueryString({
                        'username': config.username, 'password': config.password
                    }),
                    headers: {
                        "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"
                    },
                    onload: function (response) { // 请求成功
                        if (response.status === 404) {
                            reject("地址无法访问,请检查 WebUI 地址");
                            return;
                        }
                        // 兼容 qBittorrent 登录成功的两种响应:旧版返回 200 "Ok.";5.2.x 可能返回 204 空响应
                        const loginOk = response.status === 204 || (response.status === 200 && response.responseText === "Ok.");
                        if (!loginOk) {
                            reject(response.status === 401 || response.status === 403 || response.responseText === "Fails." ? "用户名或密码错误" : "地址无法访问或登录接口不可用");
                            return;
                        }

                        resolve("请求成功!")
                    },
                    onerror: function (error) { // 请求失败

                        reject("请求超时 / 网络异常");
                    }
                });
            })
        }
        /**
         * 获取种子信息
         * @param {String} hash 种子hash
         */
        let getTorrentInfo = (hash, newTorrentName) => {
            return new Promise((resolve, reject) => {
                if (hash) {
                    request({
                        timeout: 15000,
                        ontimeout: () => reject("请求超时 / 网络异常"),
                        method: 'GET',
                        url: `${config.address}/api/v2/torrents/info?hashes=${hash}`,
                        onload: function (response) {

                            let data = JSON.parse(response.responseText);
                            console.log("查询到种子数:", data.length)

                            if (data && data.length === 1) {

                                let info = data[0];

                                // 下载目录下面第一级
                                let oldFilePath = info.content_path.replace(info.save_path, '');

                                if (!oldFilePath.startsWith(config.separator)) oldFilePath = config.separator + oldFilePath;

                                // 原文件名 -- 根据原文件名重命名 "Richard Walters - Murmurate (2023) [24B-48kHz]"
                                let oldFileName = oldFilePath.split(config.separator)[1];

                                resolve({
                                    "oldFileName": oldFileName, "message": "获取种子信息成功."
                                })
                                return;
                            }
                            reject("获取种子信息失败,种子列表未找到种子.")
                        },
                        onerror: function (error) {

                            reject("获取种子信息失败!")
                        }
                    });
                } else {
                    request({
                        timeout: 15000,
                        ontimeout: () => reject("请求超时 / 网络异常"),
                        method: 'GET',
                        url: `${config.address}/api/v2/torrents/info?${getQueryString({
                            "limit": 5,
                            "sort": "added_on",
                            "reverse": "true"
                        })}`,
                        onload: function (response) {

                            let dataArr = JSON.parse(response.responseText);

                            if (!dataArr) {
                                reject("获取种子信息失败,种子列表未找到种子.");
                                return;
                            }

                            dataArr.forEach((info) => {
                                if (info.name === newTorrentName) {


                                    // content_path 这个路径不同版本不固定,有时候是相对路径,有时候是绝对路径
                                    // let oldFileName = info.content_path.replace(info.save_path, '').match(/([^\/]+)/)[0];
                                    // content_path: "D:\\Adobe\\Richard Walters - Murmurate (2023) [24B-48kHz]"
                                    // save_pat: "D:\Adobe"

                                    // 下载目录下面第一级
                                    let oldFilePath = info.content_path.replace(info.save_path, '');

                                    if (!oldFilePath.startsWith(config.separator)) oldFilePath = config.separator + oldFilePath;

                                    // 原文件名 -- 根据原文件名重命名 "Richard Walters - Murmurate (2023) [24B-48kHz]"
                                    let oldFileName = oldFilePath.split(config.separator)[1];

                                    console.log(`原文件名: ${oldFileName}`);

                                    console.log(`新文件名: ${newTorrentName}`);

                                    return resolve({
                                        "hash": info.hash,
                                        "oldFileName": oldFileName,
                                        "torrentName": newTorrentName,
                                        "message": "获取种子信息成功."
                                    })
                                }
                            })

                            reject("获取种子信息失败,种子列表未找到种子.")
                        },
                        onerror: function (error) {

                            reject("获取种子信息失败!")
                        }
                    })
                }
            })

        }

        /**
         * 判断种子是否存在了
         * @returns
         * @param hash
         */
        let checkExist = (hash) => {
            return new Promise((resolve, reject) => {
                if (hash) {
                    request({
                        timeout: 15000,
                        ontimeout: () => reject("请求超时 / 网络异常"),
                        method: 'GET',
                        url: `${config.address}/api/v2/torrents/info?hashes=${hash}`,
                        onload: function (response) {
                            let data = JSON.parse(response.responseText);
                            if (data && data.length === 1) {
                                reject("种子已经存在啦!")
                                return;
                            }
                            resolve()
                        },
                        onerror: function (error) {

                            reject("获取种子信息失败!")
                        }
                    });
                } else {
                    resolve("没有 Hash 值不判断是否存在...")
                }
            })
        }

        /**
         * 重命名
         *
         * hash: hash
         * oldPath: 111
         * newPath: 222
         *
         * @param {*} hash
         * @param {*} oldPath
         * @param {*} newPath
         */
        function renameFileOrFolder(hash, oldPath, newPath) {
            return new Promise((resolve, reject) => {

                console.log(`原文件名: ${oldPath}`);
                console.log(`新文件名: ${newPath}`);

                const endpoint = isFolder(oldPath) ? '/api/v2/torrents/renameFolder' : '/api/v2/torrents/renameFile';

                request({
                    timeout: 15000,
                    ontimeout: () => reject("请求超时 / 网络异常"),
                    method: 'POST',
                    headers: {
                        "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"
                    },
                    url: `${config.address}${endpoint}`,
                    data: getQueryString({
                        'hash': hash, 'oldPath': oldPath, 'newPath': newPath
                    }),
                    onload: function (response) {
                        if (response.status !== 200) return reject('重命名失败,请检查客户端 API');
                        console.log('重命名成功.');
                        resolve("重命名成功.")
                    },
                    onerror: function (error) {
                        // 请求失败

                        reject('重命名失败!');
                    }
                });
            })
        }

        /**
         * 将种子添加到qBittorrent
         * @param {String} rename 选中种子名
         * @param {String} savePath
         * @param {String} torrentUrl
         * @returns
         */
        function addTorrent(rename, savePath, torrentUrl) {
            deliveryUpdate(config, 'fetching');
            return new Promise((resolve, reject) => {
                let downloadMsg = cocoMessage.loading("下载中!", 10000, true);
                fetchTorrentFile(torrentUrl).then(binaryData => {

                    let formData = new FormData();
                    // 设置mime
                    let bl = new Blob([binaryData], { type: "application/x-bittorrent" })
                    // 将下载的种子文件内容添加到表单
                    formData.append('torrents', bl);

                    // 设置其他参数
                    formData.append('savepath', savePath); // 下载文件夹 不传就保存到默认文件夹
                    formData.append('rename', rename); // 重命名种子
                    formData.append('sequentialDownload', config.sequentialDownload); // 启用顺序下载。可能的值为true, false(默认)
                    formData.append('firstLastPiecePrio', config.firstLastPiecePrio); // 优先下载最后一块。可能的值为true, false(默认)
                    formData.append('autoTMM', config.autoTMM); // 优先下载最后一块。可能的值为true, false(默认)

                    // 通过 savePath 获得 category
                    const item = config.saveLocations.find(item => item.value === savePath);
                    const category = config.selectedCategory !== undefined ? config.selectedCategory : item?.label;
                    if (category !== undefined) formData.append('category', category); // 分类

                    formData.append('paused', !config.autoStartDownload); // 暂停? 默认 false
                    // qBittorrent >= 5.0 https://github.com/qbittorrent/qBittorrent/issues/22766
                    formData.append('stopped', !config.autoStartDownload); // 暂停? 默认 false

                    // let downloadMsg = cocoMessage.loading("下载中!", 10000, true);

                    deliveryUpdate(config, 'uploading');
                    request({
                        timeout: 15000,
                        ontimeout: () => { downloadMsg(); reject('上传结果未知,请核对下载器'); },
                        method: 'POST',
                        url: `${config.address}/api/v2/torrents/add`,
                        data: formData,
                        onload: function (response) {
                            const responseData = response.responseText;
                            // 兼容 qBittorrent 添加成功的两种响应:旧版返回 "Ok.";5.2.x 返回 JSON {"success_count":..,"failure_count":0}
                            let addOk = responseData === "Ok.";
                            try {
                                const r = JSON.parse(responseData);
                                if (r && r.failure_count === 0) addOk = true;
                            } catch (e) { /* 非 JSON,保持旧版判断 */ }
                            if (response.status !== 200 || !addOk) {
                                downloadMsg();
                                reject("添加种子失败,请检查客户端状态");
                            } else {
                                sleep(1000);
                                downloadMsg();
                                resolve("添加种子成功.");
                            }
                        },
                        onerror: function (error) {
                            downloadMsg();

                            reject("添加种子失败: 请求发生错误...");
                        }
                    });
                }).catch(error => {

                    reject("下载种子时出错:请求超时 / 网络异常");
                });
            })
        }

        return { // qBittorrent
            testConnection: async () => {
                await login();
                const version = await readApi('app/version');
                const path = await readApi('app/defaultSavePath');
                if (!/^v?\d+\.\d+/.test(version.trim()) || !path.trim() || /<html|<!doctype/i.test(path)) throw new Error('登录成功但 API 不可用');
                return `连接成功\nqBittorrent ${version.trim()}\n默认路径:${path}`;
            },
            getCategories: async () => {
                await login();
                const raw = await readApi('torrents/categories');
                const defaultPath = await readApi('app/defaultSavePath');
                let categories;
                try { categories = JSON.parse(raw); } catch (_) { throw new Error('分类 API 返回的数据无效'); }
                if (!categories || Array.isArray(categories) || typeof categories !== 'object' || /<html|<!doctype/i.test(defaultPath)) throw new Error('分类 API 返回的数据无效');
                const locations = [{ label: '未分类', category: '', value: defaultPath.trim() }];
                Object.entries(categories).sort(([a], [b]) => a.localeCompare(b)).forEach(([name, category]) => {
                    if (!category || typeof category.savePath !== 'string') throw new Error('分类 API 返回的数据无效');
                    locations.push({ label: name, category: name, value: category.savePath || defaultPath.trim() });
                });
                return locations;
            },
            download: (rename, savePath, hash, torrentUrl, autoCloseWindow) => {
                deliveryUpdate(config, 'auth');
                let readyRenameMsg = null;
                return login().then(m => { // 检查是否添加过了
                    console.log(m)
                    deliveryUpdate(config, 'checking');
                    return checkExist(hash);
                }).then(m => { // 添加种子
                    if (m) console.log(m)
                    return addTorrent(rename, savePath, torrentUrl);
                }).then(m => {
                    // 添加种子之后不是第一时间就在 qBittorrent 中能查询到,所以得循环等待,查询到后才能重命名。
                    deliveryUpdate(config, 'querying');
                    readyRenameMsg = cocoMessage.loading("重命名中...", true);
                    return Promise.retry(() => getTorrentInfo(hash, rename), 60, 1500);
                }).then((data) => { // 文件重命名
                    console.log(data.message);
                    if (data.oldFileName === rename) {
                        console.log("文件名相同无需修改");
                        return;
                    }
                    if (!hash && data.hash) hash = data.hash
                    deliveryUpdate(config, 'renaming');
                    return renameFileOrFolder(hash, data.oldFileName, rename);
                }).then(() => {
                    readyRenameMsg()
                    settleDelivery(config, true);
                    downloadSucceed(autoCloseWindow);
                    return {ok:true};
                }).catch((e) => {
                    if (readyRenameMsg) readyRenameMsg();

                    settleDelivery(config, false, e);
                    cocoMessage.error('投递未完成,请查看发送记录中的失败阶段', 0);
                    return {ok:false};
                })
            },
            setFileSystemSeparatorAndDefaultSavePath: () => {
                // 设置文件分隔符和默认目录  打开设置时触发
                return new Promise((resolve, reject) => {
                    login().then(m => {
                        return new Promise((resolve, reject) => {
                            request({
                                timeout: 15000,
                                ontimeout: () => reject("请求超时 / 网络异常"),
                                method: 'GET',
                                url: `${config.address}/api/v2/app/defaultSavePath`, onload: function (response) {

                                    if (response.status !== 200) {
                                        reject("获取默认保存路径失败!");
                                        return;
                                    }

                                    let save_path = response.responseText;
                                    if (!save_path.trim() || /<html|<!doctype/i.test(save_path)) {
                                        reject('登录成功但 API 不可用');
                                        return;
                                    }

                                    console.log("默认保存路径:", save_path)

                                    setConfig(config, save_path, resolve);
                                }, onerror: function (error) {

                                    reject("获取系统信息失败!")
                                }
                            });
                        })
                    }).then(m => {
                        resolve(m)
                    }).catch((e) => {

                        // alert(e);
                        reject(e)
                    })
                })
            }
        }
    }

    function createTransmissionClient(config) {
        config = clone(config);

        let sessionId = ""; // Session belongs to this operation and endpoint only.

        function request(data, onload, onerror) {
            clientRequest({
                timeout: 15000,
                ontimeout: () => onerror && onerror(),
                method: 'POST',
                url: `${config.address}/transmission/rpc`,
                data: JSON.stringify(data),
                headers: {
                    'Content-Type': 'application/json',
                    'X-Transmission-Session-Id': sessionId,
                },
                user: config.username,
                password: config.password,
                onload,
                onerror
            });
        }

        function getBasicInfo(retried = false) {
            return new Promise((resolve, reject) => {
                request({ "method": "session-get" }, async function (response) { // 请求成功

                    if (response.status === 404) {
                        reject("请检查 Transmission 访问地址是否正确");
                        return;
                    }
                    if (response.status === 409) { // X-Transmission-Session-Id 失效
                        const match = response.responseHeaders.match(/X-Transmission-Session-Id:\s*(\S+)/i);
                        if (retried || !match) return reject('Transmission 会话认证失败');
                        sessionId = match[1];

                        // 加 await 更直观
                        await getBasicInfo(true).then(resolve).catch(reject);
                        return;
                    }
                    if (response.status !== 200) {
                        reject("请检查 Transmission 配置是否正确!");
                        return;
                    }

                    let data = JSON.parse(response.responseText);

                    if (data.result !== "success") {
                        reject(`请求失败:${data.result}`);
                        return;
                    }

                    resolve({
                        message: "登录成功!",
                        savePath: data.arguments["download-dir"]
                    })
                }, function (error) { // 请求失败

                    reject("Transmission 无响应!");
                });
            })
        }

        function arrayBufferToBase64(buffer) {
            return new Promise((resolve, reject) => {
                const blob = new Blob([buffer], { type: "application/x-bittorrent" });
                const reader = new FileReader();
                reader.onloadend = () => resolve(reader.result.split(',')[1]);
                reader.onerror = reject;
                reader.readAsDataURL(blob);
            });
        }

        function addTorrent(rename, savePath, torrentUrl) {
            deliveryUpdate(config, 'fetching');
            return new Promise((resolve, reject) => {
                fetchTorrentFile(torrentUrl).then(binaryData => {
                    return arrayBufferToBase64(binaryData);
                }).then(base64Torrent => {
                    deliveryUpdate(config, 'uploading');
                    request({
                        "arguments": {
                            "download-dir": savePath,
                            // "filename": torrentUrl,
                            "metainfo": base64Torrent, // base64 编码的 .torrent 内容
                            "paused": !config.autoStartDownload
                        },
                        "method": "torrent-add"
                    }, function (response) {
                        const responseData = response.responseText;
                        if (response.status !== 200) {
                            return reject("添加种子失败,请检查客户端状态");
                        }

                        let data = JSON.parse(response.responseText);

                        if (data.result !== "success") {
                            return reject(`添加种子失败:${data.result}`);
                        }
                        let duplicate = data.arguments["torrent-duplicate"];
                        let torrent = duplicate ? data.arguments["torrent-duplicate"] : data.arguments["torrent-added"];
                        resolve({
                            message: "添加种子成功.",
                            id: torrent.id,
                            hash: torrent.hashString,
                            name: torrent.name,
                            duplicate: duplicate
                        });
                    }, () => reject("请求超时 / 网络异常"))
                }).catch(error => {

                    reject("下载种子时出错:请求超时 / 网络异常");
                });
            })
        }

        function renameFile(id, oldPath, newPath) {
            return new Promise((resolve, reject) => {
                request({
                    "arguments": {
                        "ids": [id],
                        "name": newPath,
                        "path": oldPath
                    },
                    "method": "torrent-rename-path"
                }, function (response) {
                    const responseData = response.responseText;
                    if (response.status !== 200) {
                        return reject("添加种子失败,请检查客户端状态");
                    }
                    let data = JSON.parse(response.responseText);

                    if (data.result !== "success") {
                        return reject(`重命名文件失败: ${data.result}`);
                    }
                    resolve({
                        message: "添加种子成功.",
                        id: data.arguments.id,
                        hash: data.arguments.hashString,
                        name: data.arguments.name,
                    });
                }, () => reject("请求超时 / 网络异常"))
            })
        }

        return {
            testConnection: async () => {
                const info = await getBasicInfo();
                return `连接成功\nTransmission\n默认路径:${info.savePath}`;
            },
            download: (rename, savePath, hash, torrentUrl, autoCloseWindow) => {
                deliveryUpdate(config, 'auth');
                let readyRenameMsg = cocoMessage.loading("下载中...", true);
                let duplicate = false;
                return getBasicInfo().then((data) => {
                    console.log(data.message)
                    return addTorrent(rename, savePath, torrentUrl);
                }).then((data) => {
                    console.log(data.message)
                    deliveryUpdate(config, 'querying');
                    duplicate = data.duplicate;
                    if (data.name === rename) {
                        console.log("文件名相同无需修改");
                        return;
                    }
                    deliveryUpdate(config, 'renaming');
                    return renameFile(data.id, data.name, rename);
                }).then(() => {
                    readyRenameMsg()
                    settleDelivery(config, true);
                    downloadSucceed(autoCloseWindow, duplicate);
                    return {ok:true};
                }).catch((e) => {
                    readyRenameMsg();

                    settleDelivery(config, false, e);
                    cocoMessage.error('投递未完成,请查看发送记录中的失败阶段', 0);
                    return {ok:false};
                });
            },
            setFileSystemSeparatorAndDefaultSavePath: () => { // 设置文件分隔符和默认目录  打开设置时触发
                return new Promise((resolve, reject) => {
                    getBasicInfo().then(data => {
                        let save_path = data.savePath;
                        console.log("默认保存路径:", save_path)
                        setConfig(config, save_path, resolve);
                    }).catch((e) => {

                        // alert(e);
                        reject(e)
                    })
                })
            }
        }
    }

    // Cookies ignore ports. Serialize operations sharing a host so each operation
    // logs in immediately before using its endpoint, while other hosts run in parallel.
    const clientQueues = new Map();
    function isolatedClient(factory, config) {
        const snapshot = clone(config);
        const key = new URL(snapshot.address).hostname;
        const run = (method, args, operationConfig = snapshot) => {
            const pending = (clientQueues.get(key) || Promise.resolve())
                .catch(() => {}).then(() => factory(operationConfig)[method](...args));
            const settled = pending.then(() => {}, () => {});
            clientQueues.set(key, settled);
            settled.then(() => { if (clientQueues.get(key) === settled) clientQueues.delete(key); });
            return pending;
        };
        return {
            download: (...args) => {
                const source = typeof args[3] === 'string' ? {url:args[3]} : args[3];
                const task = beginDelivery(snapshot, args[0], source);
                if (!task) {
                    cocoMessage.error('该种子已有进行中或结果待核对的投递,请先查看发送记录', 5000);
                    return Promise.resolve({ok:false, blocked:true});
                }
                const operation = {...snapshot, deliveryId:task.id};
                return run('download', args, operation).catch(() => {
                    settleDelivery(operation, false);
                    return {ok:false};
                }).finally(() => { activeDeliveries.delete(task.key); });
            },
            testConnection: () => run('testConnection', []),
            getCategories: () => run('getCategories', []),
            setFileSystemSeparatorAndDefaultSavePath: () => run('setFileSystemSeparatorAndDefaultSavePath', [])
        };
    }
    const Client = {
        qbittorrent: config => isolatedClient(createQbittorrentClient, config),
        transmission: config => isolatedClient(createTransmissionClient, config)
    }

    function downloadSucceed(autoCloseWindow, duplicate = false) {
        console.log("下载并重命名成功!")
        let message = duplicate ? "种子已存在,重命名成功!" : "下载并重命名成功!";
        if (autoCloseWindow && !(window.history && window.history.length > 1)) {
            cocoMessage.success(`${message} 窗口 3 秒后关闭!`, 0);
            setTimeout(function () {
                window.close();
            }, 3000);
        } else {
            cocoMessage.success(message, 0);
        }
    }

    function setConfig(config, save_path, resolve) {
        const current = getProfiles().find(p => p.id === config.id);
        // An older request must not overwrite edits or resurrect a deleted profile.
        if (current && ['client', 'address', 'username', 'password', 'separator', 'saveLocations']
            .every(key => JSON.stringify(current[key]) === JSON.stringify(config[key]))) {
            const patch = { separator: /^(?:[A-Za-z]:|\\\\)/.test(save_path) ? "\\" : "/" };
            if (!current.saveLocations.length || (current.saveLocations.length === 1 &&
                current.saveLocations[0].label === '默认' && !current.saveLocations[0].value)) {
                patch.saveLocations = [{ label: '默认', value: save_path }];
            }
            updateProfile(config.id, patch);
        }
        resolve("保存配置成功!");
    }

    let sleep = (time) => {
        return new Promise((resolve) => {
            setTimeout(function () {
                console.log(`经过 ${time} 毫秒`);
                resolve()
            }, time);
        })
    }

    function getQueryString(params) {
        return new URLSearchParams(params).toString();
    }

    // 种子以这些文件结尾时,单文件储存,非目录
    const fileSuffix = [".zip", ".rar", ".7z", ".tar.gz", ".tgz", ".tar.bz2", ".tbz2", ".tar", ".gz", ".bz2", ".xz", ".lzma", ".md", ".txt", ".pdf", ".epub", ".mp4", ".avi", ".mkv", ".mov", ".wmv", ".flv", ".mpg", ".mpeg", ".3gp", ".webm", ".rmvb", ".mp3", ".wav", ".flac", ".aac", ".ogg", ".wma", ".m4a", ".mpc", ".iso"]

    /**
     * 判断 torrentName 是否是以数组fileSuffix中的字符串结尾的,是的话返回false
     *
     * @param {String} torrentName
     *
     * @returns {Boolean}
     */
    function isFolder(torrentName) {
        for (const suffix of fileSuffix) {
            if (torrentName.endsWith(suffix)) {
                return false;
            }
        }
        return true;
    }

    function getSuffix(torrentName) {
        for (const suffix of fileSuffix) {
            if (torrentName.endsWith(suffix)) {
                return suffix;
            }
        }
        return "";
    }

    /**
     * @description: 加入失败后使用失败重试功能,如果 n 次中有任意一次成功了,就停止尝试并返回
     * @param fn
     * @param times
     * @param delay
     * @returns {Promise<unknown>}
     */
    Promise.retry = function (fn, times, delay) {
        let tryTimes = 0
        return new Promise((resolve, reject) => {
            function attempt() {
                console.log(`重试第 ${tryTimes} 次`)
                Promise.resolve(fn()).then(res => {
                    resolve(res)
                }).catch(err => {
                    if (++tryTimes < times) {
                        setTimeout(attempt, delay)
                    } else {
                        reject(err)
                    }
                })
            }

            attempt()
        })
    }

    /**
     *
     * 取代 Linux 和 Windows 非法字符为空格
     *
     * @param {*} filename
     */
    function replaceUnsupportedCharacters(filename) {
        // 使用正则表达式匹配Linux和Windows不支持的字符
        let unsupportedCharsRegex = /[\/\\:*?"<>|]/g;

        // 将不支持的字符替换为空格
        filename = filename.replace(unsupportedCharsRegex, ' ');

        // 去除结尾的点号
        filename = filename.replace(/\.+$/, '');

        // 替换连续多个空格为一个空格 (空格,制表符,换行,回车等)
        return filename.replace(/\s+/g, ' ');
    }

    let dockApp = null;
    let dockMenuRegistered = false;
    const applicationCapabilities = {
        qbittorrent: {locations:true, download:true, qbOptions:true},
        transmission: {locations:true, download:true},
        emby: {media:true},
        jellyfin: {media:true}
    };
    const settingsPages = [
        {id:'connection', label:'连接'},
        {id:'locations', label:'分类与目录', capability:'locations'},
        {id:'download', label:'下载行为', capability:'download'},
        {id:'general', label:'通用'},
        {id:'backup', label:'备份'},
        {id:'history', label:'记录'},
        {id:'about', label:'关于'}
    ];
    function init() {
        migrateLegacyConfig();
        recoverInterruptedDeliveries();
        let torrentName = PT.getTorrentName();
        let app = new Vue({
            el: '#plugin-download-div',
            data: {
                dockVersion,
                mediaServers: getMediaServers(),
                mediaStatus: {},
                mediaResult: '',
                mediaTesting: {},
                activeMediaServerId: null,
                applicationType: getActiveProfile().client,
                newApplicationProfileId: null,
                isVisible: false, //
                isPopupVisible: false,
                profiles: getProfiles(),
                activeProfileId: getActiveProfile().id,
                selectedLabel: getSelectedLocation(getActiveProfile().id),
                config: buildEffectiveConfig(getActiveProfile()),
                connectionResult: '',
                testing: false,
                saving: false,
                syncing: false,
                settingsTab: 'connection',
                showSecret: false,
                categoryResult: '',
                advanced: false,
                history: getDeliveryHistory(),
                deliveryStages,
                backupSecrets: false,
                backupPassword: '',
                backupBusy: false,
                backupResult: '',
                pendingBackup: null,
                importGlobals: false,
                originalTorrentName: torrentName,
                nameChoice: 'torrentName',
                torrentName: torrentName,
                title: PT.getTorrentTitle(),
                subTitle: PT.getTorrentSubTitle(),
                // 拖动div
                isDragging: false,
                initialX: 0,
                initialY: 0,
                position: { x: 0, y: 0 },
            },
            methods: {
                scheduleSettingsMeasure() {
                    if (this._settingsFrame) cancelAnimationFrame(this._settingsFrame);
                    this._settingsFrame = requestAnimationFrame(() => {
                        this._settingsFrame = null;
                        const body = this.$el.querySelector('#configPopup .dock-layout > .dock-body');
                        if (!this.isVisible || !body?.getBoundingClientRect().width) return;
                        const probe = body.cloneNode(true);
                        probe.setAttribute('aria-hidden', 'true');
                        probe.inert = true;
                        probe.querySelectorAll('[id]').forEach(el => el.removeAttribute('id'));
                        Object.assign(probe.style, {position:'absolute', visibility:'hidden', pointerEvents:'none',
                            width:body.getBoundingClientRect().width + 'px', boxSizing:'border-box',
                            minHeight:'0', height:'auto', top:'0', left:'0'});
                        const pages = [...probe.children].filter(el => el.classList.contains('dock-options'));
                        [...probe.children].filter(el => el.classList.contains('dock-status')).forEach(el => { el.style.display = 'block'; });
                        body.parentElement.appendChild(probe);
                        let height = 0;
                        for (const page of pages) {
                            pages.forEach(el => { el.style.display = el === page ? 'grid' : 'none'; });
                            height = Math.max(height, probe.getBoundingClientRect().height);
                        }
                        probe.remove();
                        // Keep one shared baseline across application types. Recalculate
                        // at a new viewport size so narrow-screen measurements do not
                        // leave an unnecessarily tall window after resizing.
                        const sizeKey = [Math.round(body.getBoundingClientRect().width), window.innerHeight].join(':');
                        if (this._settingsSizeKey !== sizeKey) {
                            this._settingsSizeKey = sizeKey;
                            this._settingsHeight = 0;
                        }
                        this._settingsHeight = Math.max(this._settingsHeight || 0, Math.ceil(height));
                        body.style.minHeight = this._settingsHeight + 'px';
                    });
                },
                addMediaServer(type = 'emby') {
                    if (this.mediaServers.length >= 20) return null;
                    const server = newMediaServer();
                    server.type = type;
                    server.name = type === 'jellyfin' ? 'Jellyfin 媒体库' : 'Emby 媒体库';
                    this.mediaServers.push(server);
                    this.activeMediaServerId = server.id;
                    return server;
                },
                addApplication() {
                    this.settingsTab = 'connection';
                    this.activeMediaServerId = null;
                    this.addProfile();
                    this.newApplicationProfileId = this.config.id;
                    this.applicationType = this.config.client;
                },
                focusMediaServer(id) {
                    this.settingsTab = 'connection';
                    this.activeMediaServerId = id;
                    const server = this.mediaServers.find(item => item.id === id);
                    if (server) this.applicationType = server.type;
                    this.connectionResult = '';
                },
                removeMediaServer(id) {
                    if (!window.confirm('删除这个媒体服务器配置?不会修改服务器中的影片。')) return;
                    this.mediaServers = this.mediaServers.filter(server => server.id !== id);
                    this.activeMediaServerId = this.mediaServers[0]?.id || null;
                    if (!this.activeMediaServerId) this.applicationType = this.config.client;
                    this.saveMediaConfig();
                },
                saveMediaConfig() {
                    try {
                        saveMediaServers(this.mediaServers);
                        this.mediaServers = getMediaServers();
                        this.mediaResult = '媒体库配置已保存,正在重新检测当前页';
                        this.connectionResult = '媒体应用配置已保存,正在重新检测当前页';
                    } catch (error) { this.mediaResult = error.message; this.connectionResult = error.message; }
                },
                changeApplicationType() {
                    this.connectionResult = '';
                    if (this.isMediaApplication) {
                        this.settingsTab = 'connection';
                        if (this.newApplicationProfileId && this.profiles.length > 1 && !this.config.address && !this.config.apiKey && !this.config.username && !this.config.password) {
                            deleteProfile(this.newApplicationProfileId);
                            this.profiles = getProfiles();
                            this.activeProfileId = getActiveProfile().id;
                            this.config = buildEffectiveConfig(getActiveProfile());
                        }
                        if (this.currentMediaServer) this.currentMediaServer.type = this.applicationType;
                        else this.addMediaServer(this.applicationType);
                    } else if (this.activeMediaServerId) {
                        const target = this.applicationType;
                        this.activeMediaServerId = null;
                        this.addProfile();
                        this.config.client = target;
                        this.changeClient();
                        this.persistCurrent();
                    } else {
                        this.config.client = this.applicationType;
                        this.changeClient();
                        this.persistCurrent();
                    }
                    this.newApplicationProfileId = null;
                },
                removeApplication() {
                    if (this.isMediaApplication) { this.removeMediaServer(this.activeMediaServerId); return; }
                    this.removeProfile();
                },
                saveLargeImagePreference() {
                    saveGlobalSettings(this.config);
                    scheduleMediaScan();
                },
                async checkMediaServer(server) {
                    const signature = mediaSignature(server);
                    this.$set(this.mediaTesting,server.id,true);
                    try {
                        const message = await testMediaServer(clone(server));
                        if (this.mediaServers.some(item => mediaSignature(item) === signature)) this.$set(this.mediaStatus,server.id,message);
                    } catch (error) {
                        if (this.mediaServers.some(item => mediaSignature(item) === signature)) this.$set(this.mediaStatus,server.id,error.message);
                    } finally { this.$set(this.mediaTesting,server.id,false); }
                },
                refreshTorrent() {
                    const root = document.querySelector('#plugin-download-div');
                    const ready = getSite() !== 'new_mteam' || !!torrentInfo.id;
                    if (root) { root.style.display = 'inline-flex'; const launch = root.querySelector('.dock-launch'); if (launch) launch.style.display = ready ? '' : 'none'; }
                    if (!ready) return;
                    this.torrentName = PT.getTorrentName();
                    this.originalTorrentName = this.torrentName;
                    this.title = PT.getTorrentTitle();
                    this.subTitle = PT.getTorrentSubTitle();
                },
                deliveryOutcome,
                showHistory() { this.settingsTab = 'history'; this.isVisible = true; },
                clearHistory() {
                    if (window.confirm('清除已结束的本地记录?不会删除连接应用中的任务;再次发送前请确认目标中是否已有该种子。')) clearDeliveryHistory();
                },
                async exportSettings() {
                    this.backupBusy = true;
                    this.backupResult = '';
                    try {
                        this.persistCurrent();
                        const text = await exportBackup(this.backupSecrets, this.backupPassword);
                        const url = URL.createObjectURL(new Blob([text], {type:'application/json'}));
                        const anchor = document.createElement('a');
                        anchor.href = url; anchor.download = this.backupSecrets ? 'mt-dock-encrypted.json' : 'mt-dock-config.json';
                        anchor.click();
                        setTimeout(() => URL.revokeObjectURL(url), 1000);
                        this.backupResult = this.backupSecrets ? '已导出加密备份,请妥善保管口令' : '已导出配置,不含密码或 API Key';
                    } catch (error) { this.backupResult = String(error.message || '导出失败'); }
                    finally { this.backupBusy = false; this.backupPassword = ''; }
                },
                async previewImport(event) {
                    const file = event.target.files?.[0];
                    event.target.value = '';
                    this.pendingBackup = null;
                    if (!file) return;
                    if (file.size > backupLimit) { this.backupResult = '备份文件过大(上限 2 MB)'; return; }
                    this.backupBusy = true;
                    try {
                        this.pendingBackup = await readBackup(await file.text(), this.backupPassword);
                        this.backupResult = `已读取 ${this.pendingBackup.profiles.length} 个下载应用、${this.pendingBackup.mediaServers.length} 个媒体服务,确认后追加导入`;
                    } catch (error) { this.backupResult = String(error.message || '读取失败'); }
                    finally { this.backupBusy = false; this.backupPassword = ''; }
                },
                confirmImport() {
                    if (!this.pendingBackup) return;
                    try {
                        this.persistCurrent();
                        const count = importBackup(this.pendingBackup, this.importGlobals);
                        this.profiles = getProfiles();
                        this.mediaServers = getMediaServers();
                        if (this.importGlobals) this.config = buildEffectiveConfig(getActiveProfile());
                        this.backupResult = `已新增 ${count} 个下载应用、${this.pendingBackup.mediaServers.length} 个媒体服务,现有配置保持不变`;
                        this.pendingBackup = null;
                        this.focusDialog();
                    } catch (error) { this.backupResult = String(error.message || '导入失败'); }
                },
                focusDialog() {
                    this.$nextTick(() => {
                        const selector = this.isVisible ? '#configPopup' : this.isPopupVisible ? '#popup' : null;
                        if (selector) {
                            if (!this._returnFocus) this._returnFocus = document.activeElement;
                            this.$el.querySelector(selector)?.focus();
                        } else {
                            this._returnFocus?.focus();
                            this._returnFocus = null;
                        }
                    });
                },
                trapFocus(event) {
                    const panel = event.currentTarget.querySelector('[role="dialog"]');
                    const items = Array.from(panel.querySelectorAll('button:not(:disabled), input:not(:disabled), select:not(:disabled)'))
                        .filter(el => el.getClientRects().length);
                    if (!items.length) return;
                    const first = items[0], last = items[items.length - 1];
                    if (!items.includes(document.activeElement) || (!event.shiftKey && document.activeElement === last)) {
                        event.preventDefault(); first.focus();
                    } else if (event.shiftKey && document.activeElement === first) {
                        event.preventDefault(); last.focus();
                    }
                },
                toggleConfigPopup() {
                    this.isVisible = !this.isVisible;
                },
                togglePopup() {
                    // 切换元素的显示与隐藏
                    if (!this.isPopupVisible) {
                        cocoMessage.destroyAll();
                    }
                    this.isPopupVisible = !this.isPopupVisible;
                    if (this.isPopupVisible) this.ensureCategories();
                },
                persistCurrent() {
                    this.config.address = this.config.address.trim().replace(/\/+$/, '');
                    this.config.name = this.config.name.trim() || '未命名应用';
                    const previous = getProfiles().find(profile => profile.id === this.config.id);
                    if (previous && connectionIdentity(previous) !== connectionIdentity(this.config)) {
                        this.config.remoteCategories = [];
                        if (this.config.locationMode === 'remote') this.selectedLabel = 0;
                    }
                    updateProfile(this.config.id, this.config);
                    saveGlobalSettings(this.config);
                    setSelectedLocation(this.config.id, this.selectedLabel);
                    this.profiles = getProfiles();
                },
                switchProfile(id) {
                    this.persistCurrent();
                    setActiveProfile(id);
                    this.activeProfileId = id;
                    this.applicationType = getProfiles().find(profile => profile.id === id)?.client || 'qbittorrent';
                    this.newApplicationProfileId = null;
                    this.activeMediaServerId = null;
                    this.config = buildEffectiveConfig(getActiveProfile());
                    this.selectedLabel = getSelectedLocation(id);
                    this.connectionResult = '';
                    this.categoryResult = '';
                    this.showSecret = false;
                    this.ensureCategories();
                },
                addProfile() {
                    this.persistCurrent();
                    const profile = createProfile();
                    saveProfiles([...getProfiles(), profile]);
                    this.switchProfile(profile.id);
                },
                renameProfile() {
                    const name = window.prompt('应用名称', this.config.name);
                    if (name && name.trim()) { this.config.name = name.trim(); this.persistCurrent(); }
                },
                removeProfile() {
                    if (this.profiles.length < 2) return;
                    if (!window.confirm(`删除应用「${this.config.name}」?`)) return;
                    deleteProfile(this.config.id);
                    this.profiles = getProfiles();
                    this.activeProfileId = getActiveProfile().id;
                    this.config = buildEffectiveConfig(getActiveProfile());
                    this.selectedLabel = getSelectedLocation(this.activeProfileId);
                    this.connectionResult = '';
                },
                changeClient() {
                    if (this.config.client === 'transmission') this.config.locationMode = 'manual';
                    this.connectionResult = ''; this.selectedLabel = 0;
                },
                changeLocationMode() {
                    this.selectedLabel = 0;
                    this.persistCurrent();
                    this.ensureCategories();
                },
                ensureCategories() {
                    if (this.config.client === 'qbittorrent' && this.config.locationMode === 'remote' &&
                        !this.config.remoteCategories.length && profileReady(this.config) && !this.syncing) this.syncCategories();
                },
                async syncCategories() {
                    if (!profileReady(this.config)) { this.categoryResult = '请先填写连接信息'; return; }
                    this.persistCurrent();
                    const snapshot = clone(this.config);
                    const selected = this.locations[this.selectedLabel]?.category;
                    this.syncing = true;
                    this.categoryResult = '';
                    try {
                        const locations = await Client[snapshot.client](snapshot).getCategories();
                        const current = getProfiles().find(p => p.id === snapshot.id);
                        if (!current || connectionIdentity(current) !== connectionIdentity(snapshot)) return;
                        const separator = /^(?:[A-Za-z]:|\\\\)/.test(locations[0]?.value || '') ? '\\' : '/';
                        updateProfile(snapshot.id, { remoteCategories: locations, separator });
                        const next = Math.max(0, locations.findIndex(item => item.category === selected));
                        setSelectedLocation(snapshot.id, next);
                        if (connectionIdentity(this.config) === connectionIdentity(snapshot)) {
                            this.config.remoteCategories = locations;
                            this.config.separator = separator;
                            this.selectedLabel = next;
                            this.categoryResult = `已同步 ${locations.length - 1} 个分类`;
                        }
                        this.profiles = getProfiles();
                    } catch (error) {
                        if (connectionIdentity(this.config) === connectionIdentity(snapshot)) this.categoryResult = String(error.message || error);
                    } finally { this.syncing = false; }
                },
                rememberLocation() {
                    setSelectedLocation(this.config.id, this.selectedLabel);
                },
                async testConnection() {
                    if (this.isMediaApplication) {
                        const server = this.currentMediaServer;
                        if (!server) { this.connectionResult = '请先添加媒体应用'; return; }
                        this.testing = true;
                        try { this.connectionResult = await testMediaServer(server); }
                        catch (error) { this.connectionResult = String(error.message || error); }
                        finally { this.testing = false; }
                        return;
                    }
                    if (!profileReady(this.config)) { this.connectionResult = '该应用尚未完成配置'; return; }
                    this.testing = true;
                    const signature = JSON.stringify(this.config);
                    const snapshot = clone(this.config);
                    snapshot.address = snapshot.address.trim().replace(/\/+$/, '');
                    try {
                        const message = await Client[snapshot.client](snapshot).testConnection();
                        if (JSON.stringify(this.config) === signature) { this.connectionResult = message; this.ensureCategories(); }
                    } catch (error) {
                        if (JSON.stringify(this.config) === signature) this.connectionResult = String(error.message || error);
                    } finally { this.testing = false; }
                },
                async configSave() {
                    if (['general','download'].includes(this.settingsTab)) {
                        saveGlobalSettings(this.config);
                        scheduleMediaScan();
                        this.connectionResult = '偏好设置已保存';
                        return;
                    }
                    if (this.isMediaApplication) { this.saveMediaConfig(); return; }
                    this.persistCurrent();
                    const snapshot = buildEffectiveConfig(this.config);
                    if (!profileReady(snapshot)) { this.connectionResult = '配置已保存;该应用尚未完成配置'; return; }
                    this.saving = true;
                    try {
                        await Client[snapshot.client](snapshot).setFileSystemSeparatorAndDefaultSavePath();
                        this.profiles = getProfiles();
                        const saved = this.profiles.find(profile => profile.id === snapshot.id);
                        if (saved && this.config.id === snapshot.id && JSON.stringify(this.config) === JSON.stringify(snapshot)) {
                            this.config = buildEffectiveConfig(saved);
                            this.connectionResult = '保存配置成功';
                            this.ensureCategories();
                        }
                    } catch (_) {
                        if (this.config.id === snapshot.id) this.connectionResult = '配置已保存,默认目录获取失败,可手动填写目录或测试连接';
                    } finally { this.saving = false; }
                },
                download(inputValue) {
                    if (!inputValue.trim()) { cocoMessage.error('名称不能为空', 3000); return; }

                    console.log("InputValue: ", inputValue)

                    const isFolderFlag = isFolder(this.originalTorrentName);

                    // 原来文件是单文件 当前文件名未加后缀
                    if (!isFolderFlag && isFolder(inputValue)) inputValue += getSuffix(this.originalTorrentName);

                    console.log("InputValue 增加后缀: ", inputValue)

                    if (!this.canDownload) {
                        cocoMessage.error("请点击脚本图标进行下载配置", 10000, true);
                        return;
                    }

                    let hash = PT.getTorrentHash();
                    // 馒头新架构获取不到 hash
                    // if (!hash) {
                    //     cocoMessage.error("未在页面找到 Hash 值!", 10000, true);
                    //     return;
                    // }

                    let byteCount = new TextEncoder().encode(inputValue).length;
                    if (byteCount > 255) {
                        console.log(`字节数超过255,有 ${byteCount} 个字节。`);
                        cocoMessage.error(`字节数超过255,一个中文占用3字节,当前字节数:${byteCount}`, 10000, true);
                        return;
                    }

                    this.persistCurrent();
                    const config = buildEffectiveConfig(this.config);


                    if (this.locations.length === 0 || this.selectedLabel >= this.locations.length) {
                        cocoMessage.error("必须选择下载位置,如果没有下载位置请点击脚本图标进行配置", 10000, true);
                        return;
                    }

                    let savePath = this.locations[this.selectedLabel].value;
                    config.selectedCategory = this.locations[this.selectedLabel].category ?? this.locations[this.selectedLabel].label;
                    if (!savePath) {
                        cocoMessage.error("下载路径为空!如果没有下载位置请点击脚本图标进行配置", 10000, true);
                        return;
                    }

                    let torrentUrl = PT.getTorrentUrl();
                    const source = getSite() === 'new_mteam' && torrentInfo.id ? {url:torrentUrl, mteamId:String(torrentInfo.id)} : {url:torrentUrl, identity:hash || undefined};
                    if (!torrentUrl && !source.mteamId) {
                        cocoMessage.error("获取下载地址为空!", 10000, true);
                        return;
                    }

                    console.log("下载路径:", savePath)

                    // 记住上次下载位置
                    setActiveProfile(config.id);
                    setSelectedLocation(config.id, this.selectedLabel);
                    Client[config.client](config).download(inputValue, savePath, hash, source, this.config.autoCloseWindow);
                },
                addLine() {
                    this.config.saveLocations.push({ label: "", value: "" })
                },
                saveLine() {
                    this.persistCurrent();
                },
                delLine(index) {
                    console.log("删除元素:", this.config.saveLocations[index])
                    this.config.saveLocations.splice(index, 1)
                    if (this.selectedLabel > index) this.selectedLabel--;
                    else if (this.selectedLabel === index || this.selectedLabel >= this.config.saveLocations.length) {
                        this.selectedLabel = 0;
                    }
                    this.rememberLocation();
                }, // 拖动 div
            },
            computed: {
                selectedCapabilities() { return applicationCapabilities[this.applicationType] || {}; },
                availableSettingsPages() { return settingsPages.filter(page => !page.capability || this.selectedCapabilities[page.capability]); },
                isMediaApplication() { return !!this.selectedCapabilities.media; },
                applicationSupportsLocations() { return !!this.selectedCapabilities.locations; },
                applicationSupportsDownload() { return !!this.selectedCapabilities.download; },
                currentMediaServer() { return this.mediaServers.find(server => server.id === this.activeMediaServerId) || null; },
                locations() { return profileLocations(this.config); },
                selectedPath() { return this.locations[this.selectedLabel]?.value || ''; },
                canDownload() {
                    const saved = getProfiles().find(profile => profile.id === this.config.id);
                    const cacheMatches = this.config.locationMode !== 'remote' || this.config.client !== 'qbittorrent' ||
                        (saved && connectionIdentity(saved) === connectionIdentity(this.config));
                    const torrentReady = getSite() !== 'new_mteam' || !!torrentInfo.id;
                    const sending = this.history.some(row => row.outcome === 'running' && row.target === safeDeliveryText(this.config.name, [this.config.password, this.config.apiKey]));
                    return torrentReady && !sending && cacheMatches && profileReady(this.config) && !!this.locations[this.selectedLabel]?.value && !this.syncing;
                },
            },
            mounted() {
                this._settingsResize = () => this.scheduleSettingsMeasure();
                window.addEventListener('resize', this._settingsResize);
                this.scheduleSettingsMeasure();
                this._onDeliveries = () => { this.history = getDeliveryHistory(); };
                deliveryListeners.add(this._onDeliveries);
                this._onMediaStatus = () => { this.mediaStatus = Object.fromEntries(mediaStatuses); };
                mediaStatusListeners.add(this._onMediaStatus);
                this._onMediaStatus();
            },
            updated() { this.scheduleSettingsMeasure(); },
            beforeDestroy() {
                window.removeEventListener('resize', this._settingsResize);
                if (this._settingsFrame) cancelAnimationFrame(this._settingsFrame);
                if (this._onDeliveries) deliveryListeners.delete(this._onDeliveries);
                if (this._onMediaStatus) mediaStatusListeners.delete(this._onMediaStatus);
            },
            watch: {
                availableSettingsPages() {
                    if (!this.availableSettingsPages.some(page => page.id === this.settingsTab)) this.settingsTab = 'connection';
                },
                isVisible() { this.focusDialog(); },
                isPopupVisible() { this.focusDialog(); }
            },
        })

        dockApp = app;
        if (!dockMenuRegistered) {
            GM_registerMenuCommand("点击这里进行配置", function () {
                if (dockApp) dockApp.isVisible = true;
                cocoMessage.destroyAll();
            });
            dockMenuRegistered = true;
        }
        return app;
    }

    // Scoped styles prevent M-Team's table, input and button rules from leaking into the panels.
    function setStyle() {
        GM_addStyle(`
            #plugin-download-div { display:inline-flex; align-items:center; margin-left:8px; font:14px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif; text-align:left; }
            #plugin-download-div .dock { --bg:#16181d; --surface:#202329; --field:#111318; --line:#353942; --text:#f1f2f4; --muted:#a3a9b4; --accent:#d9b651; --accent-text:#191509; color:var(--text); font:14px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif; text-align:left; }
            #plugin-download-div .dock[data-theme="light"] { --bg:#fff; --surface:#f5f6f8; --field:#fff; --line:#dce0e5; --text:#1b2028; --muted:#626a77; --accent:#d9b651; }
            #plugin-download-div .dock :where(div,section,header,footer,nav,aside,label,span,small,p,h2,h3) { margin:0; padding:0; border:0; float:none; position:static; width:auto; height:auto; min-width:0; max-width:none; background:none; color:inherit; font:inherit; text-align:inherit; letter-spacing:normal; }
            #plugin-download-div .dock *, #plugin-download-div > button * { box-sizing:border-box; }
            #plugin-download-div button { all:unset; box-sizing:border-box; cursor:pointer; font:inherit; text-align:center; }
            #plugin-download-div > .dock-launch { display:inline-flex; align-items:center; gap:9px; padding:10px 18px; height:42px; border-radius:9px; background:#d9b651; color:#15130b; font:600 15px/1 system-ui,sans-serif; box-shadow:0 2px 0 #a68a36; }
            #plugin-download-div > .dock-launch:hover { background:#e4c56c; }
            #plugin-download-div svg { width:19px; height:19px; flex:none; fill:none; stroke:currentColor; stroke-width:1.8; stroke-linecap:round; stroke-linejoin:round; }
            #plugin-download-div .dock-overlay { position:fixed; inset:0; z-index:2147483000; display:flex; align-items:center; justify-content:center; background:rgba(0,0,0,.62); padding:20px; backdrop-filter:blur(5px); }
            #plugin-download-div .dock-panel { box-sizing:border-box; width:510px; max-width:100%; max-height:calc(100dvh - 40px); overflow:auto; border:1px solid var(--line); border-radius:18px; background:var(--bg); box-shadow:0 24px 90px #0007; }
            #plugin-download-div .dock-settings { width:860px; }
            #plugin-download-div .dock-header { display:flex; align-items:center; justify-content:space-between; padding:22px 24px 18px; gap:16px; border-bottom:1px solid var(--line); }
            #plugin-download-div h2,#plugin-download-div h3,#plugin-download-div p { all:unset; display:block; color:inherit; }
            #plugin-download-div h2 { font-size:20px; line-height:1.4; font-weight:650; }
            #plugin-download-div h3 { font-size:15px; font-weight:650; margin-bottom:14px; }
            #plugin-download-div .dock-eyebrow { color:var(--accent); font-size:11px; font-weight:700; letter-spacing:1.8px; margin-bottom:5px; }
            #plugin-download-div .dock-muted { color:var(--muted); font-size:12px; overflow-wrap:anywhere; }
            #plugin-download-div .dock-body { padding:22px 24px; display:grid; gap:20px; align-content:start; min-width:0; }
            #plugin-download-div .dock-field { display:grid; gap:8px; min-width:0; }
            #plugin-download-div label { font-size:13px; color:var(--text); }
            #plugin-download-div .dock-label { display:flex; align-items:center; justify-content:space-between; font-size:13px; gap:12px; }
            #plugin-download-div .dock input[type="text"],#plugin-download-div .dock input[type="password"],#plugin-download-div .dock select { box-sizing:border-box; font:14px/1.5 system-ui,sans-serif; color:var(--text); background:var(--field); border:1px solid var(--line); border-radius:9px; height:42px; min-width:0; width:100%; padding:9px 12px; margin:0; outline:none; box-shadow:none; text-align:left; }
            #plugin-download-div .dock input:focus,#plugin-download-div .dock select:focus { border-color:var(--accent); box-shadow:0 0 0 3px #d9b65120; }
            #plugin-download-div .dock input[type="checkbox"],#plugin-download-div .dock input[type="radio"] { all:revert; accent-color:var(--accent); width:16px; height:16px; margin:0; flex:none; }
            #plugin-download-div .dock-segments { display:flex; padding:4px; gap:4px; background:var(--field); border-radius:10px; border:1px solid var(--line); }
            #plugin-download-div .dock-segments label { display:flex; justify-content:center; align-items:center; gap:7px; flex:1; cursor:pointer; padding:6px; border-radius:6px; color:var(--muted); }
            #plugin-download-div .dock-segments label:has(input:checked) { color:var(--text); background:var(--surface); }
            #plugin-download-div .dock-btn { display:inline-flex; align-items:center; justify-content:center; gap:7px; padding:9px 14px; border:1px solid var(--line); border-radius:9px; background:var(--surface); color:var(--text); line-height:1.4; white-space:nowrap; }
            #plugin-download-div .dock-btn:hover { border-color:var(--muted); }
            #plugin-download-div .dock-btn.primary { background:var(--accent); color:var(--accent-text); border-color:var(--accent); font-weight:650; }
            #plugin-download-div .dock-icon { width:32px; height:32px; display:inline-flex; justify-content:center; align-items:center; color:var(--muted); border-radius:7px; font-size:22px; }
            #plugin-download-div .dock-icon:hover { background:var(--surface); color:var(--text); }
            #plugin-download-div button:disabled { opacity:.45; cursor:not-allowed; }
            #plugin-download-div button:focus-visible { outline:2px solid var(--accent,#d9b651); outline-offset:3px; }
            #plugin-download-div .dock-footer { display:flex; justify-content:space-between; align-items:center; position:sticky; bottom:0; background:var(--bg); padding:16px 24px; gap:12px; border-top:1px solid var(--line); }
            #plugin-download-div .dock-actions { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
            #plugin-download-div .dock-link { color:var(--accent); font-size:12px; cursor:pointer; }
            #plugin-download-div .dock-path { background:var(--surface); border-radius:8px; padding:10px 12px; font:12px/1.5 ui-monospace,monospace; color:var(--muted); overflow-wrap:anywhere; }
            #plugin-download-div .dock-options { display:grid; gap:14px; align-content:start; }
            #plugin-download-div .dock-option { display:flex; align-items:center; justify-content:space-between; gap:16px; }
            #plugin-download-div .dock-option span { display:grid; gap:3px; }
            #plugin-download-div .dock-status { white-space:pre-wrap; overflow-wrap:anywhere; font-size:12px; color:var(--accent); }
            #plugin-download-div .dock-layout { display:grid; grid-template-columns:205px minmax(0,1fr); }
            #plugin-download-div .dock-sidebar { padding:20px 14px; background:var(--field); border-right:1px solid var(--line); display:flex; flex-direction:column; gap:8px; }
            #plugin-download-div .dock-sidebar-title { margin:0 4px 5px; color:var(--text); font-size:14px; }
            #plugin-download-div .dock-sidebar-caption { margin:0 4px 8px; color:var(--muted); font-size:11px; line-height:1.4; }
            #plugin-download-div .dock-sidebar-heading { margin:8px 4px 0; color:var(--muted); font-size:10px; letter-spacing:.12em; text-transform:uppercase; }
            #plugin-download-div .dock-profile { display:flex; flex-direction:column; min-height:58px; padding:11px 12px; text-align:left; border-radius:9px; border:1px solid transparent; color:var(--muted); overflow-wrap:anywhere; }
            #plugin-download-div .dock-profile.active { border-color:#d9b65166; background:#d9b6510d; color:var(--text); }
            #plugin-download-div .dock-record.active { border-color:#d9b65166; box-shadow:0 0 0 1px #d9b65122 inset; }
            #plugin-download-div .dock-profile small { color:var(--muted); font-size:11px; margin-top:3px; }
            #plugin-download-div .dock-add-application { width:100%; margin-top:5px; }
            #plugin-download-div .dock-add-media { align-self:flex-start; padding:1px 4px; }
            #plugin-download-div .dock-tabs { display:flex; flex-wrap:wrap; align-items:flex-start; align-content:flex-start; gap:12px 18px; border-bottom:1px solid var(--line); margin-bottom:2px; }
            #plugin-download-div .dock-tabs button { padding:0 0 12px; color:var(--muted); border-bottom:2px solid transparent; font-size:13px; }
            #plugin-download-div .dock-tabs button.active { color:var(--accent); border-color:var(--accent); }

            #plugin-download-div .dock-two { display:grid; grid-template-columns:1fr 1fr; gap:16px; }
            #plugin-download-div .dock-secret { display:flex; gap:8px; }
            #plugin-download-div .dock-location { display:grid; grid-template-columns:minmax(80px,1fr) minmax(120px,2fr) 32px; gap:8px; align-items:center; }
            #plugin-download-div .dock-categories { display:grid; gap:8px; max-height:245px; overflow:auto; }
            #plugin-download-div .dock-category { display:grid; gap:4px; padding:10px 12px; background:var(--surface); border-radius:8px; }
            #plugin-download-div .dock-record { display:grid; gap:5px; padding:12px; border:1px solid var(--line); border-radius:9px; overflow-wrap:anywhere; }
            #plugin-download-div .dock-history { display:grid; gap:10px; max-height:330px; overflow:auto; }
            #plugin-download-div .dock-danger { color:#e89a9a; }
            @media(max-width:640px) {
                #plugin-download-div .dock-overlay { padding:10px; }
                #plugin-download-div .dock-panel { max-height:calc(100dvh - 20px); border-radius:13px; }
                #plugin-download-div .dock-layout { grid-template-columns:1fr; }
                #plugin-download-div .dock-sidebar { border-right:0; border-bottom:1px solid var(--line); flex-direction:row; overflow:auto; }
                #plugin-download-div .dock-profile { min-width:130px; }
                #plugin-download-div .dock-body,#plugin-download-div .dock-header { padding:18px; }
                #plugin-download-div .dock-two { grid-template-columns:1fr; }
                #plugin-download-div .dock-footer { padding:14px 18px; }
            }
        `);
    }

    function setHtml() {
        const downloadIcon = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v12m-5-5 5 5 5-5M4 16v4h16v-4"/></svg>';
        const html = `<div id="plugin-download-div">
            <button class="dock-launch" @click="togglePopup()" title="选择连接应用">${downloadIcon}<span>发送到应用</span></button>
            <div class="dock" :data-theme="config.theme" :data-application-type="applicationType" :data-supports-locations="applicationSupportsLocations">
                <div class="dock-overlay" v-show="isPopupVisible && !isVisible" @click.self="isPopupVisible = false" @keydown.esc="isPopupVisible = false" @keydown.tab="trapFocus">
                    <section id="popup" class="dock-panel" role="dialog" aria-modal="true" aria-labelledby="download-title" tabindex="-1">
                        <header class="dock-header"><div><p class="dock-eyebrow">MT DOCK</p><h2 id="download-title">发送种子</h2></div><button class="dock-icon" @click="isPopupVisible = false" aria-label="关闭下载窗口">×</button></header>
                        <div class="dock-body">
                            <div class="dock-field"><span class="dock-label">任务名称</span>
                                <div class="dock-segments"><label><input type="radio" v-model="nameChoice" value="title">标题</label><label><input type="radio" v-model="nameChoice" value="torrentName">种子名</label><label><input type="radio" v-model="nameChoice" value="subTitle">副标题</label></div>
                                <input v-if="nameChoice === 'title'" type="text" v-model="title" aria-label="标题">
                                <input v-if="nameChoice === 'torrentName'" type="text" v-model="torrentName" aria-label="种子名">
                                <input v-if="nameChoice === 'subTitle'" type="text" v-model="subTitle" aria-label="副标题">
                            </div>
                            <div class="dock-field"><label for="dock-target">下载到</label><select id="dock-target" :value="activeProfileId" @change="switchProfile($event.target.value)"><option v-for="profile in profiles" :key="profile.id" :value="profile.id">{{profile.name}}</option></select></div>
                            <div class="dock-field"><div class="dock-label"><label for="dock-location">分类 / 保存位置</label><button v-if="config.client === 'qbittorrent' && config.locationMode === 'remote'" class="dock-link" :disabled="syncing" @click="syncCategories">{{syncing ? '同步中…' : '刷新分类'}}</button></div>
                                <select id="dock-location" v-model.number="selectedLabel" @change="rememberLocation" :disabled="syncing || !locations.length"><option v-for="(item,index) in locations" :key="index" :value="index">{{item.label}}</option></select>
                                <p class="dock-path">{{selectedPath || '连接应用后同步分类,或在设置中添加目录'}}</p>
                                <p v-if="categoryResult" class="dock-status" role="status">{{categoryResult}}</p>
                            </div>
                            <label class="dock-option"><span>添加后立即开始<small class="dock-muted">关闭后以暂停状态添加</small></span><input type="checkbox" v-model="config.autoStartDownload"></label>
                            <button class="dock-link" @click="advanced = !advanced">{{advanced ? '收起高级选项' : '高级选项'}}</button>
                            <div v-show="advanced" class="dock-options"><label v-if="config.client === 'qbittorrent'" class="dock-option">顺序下载<input type="checkbox" v-model="config.sequentialDownload"></label><label v-if="config.client === 'qbittorrent'" class="dock-option">首尾优先<input type="checkbox" v-model="config.firstLastPiecePrio"></label></div>
                            <div v-if="history.length" class="dock-record" aria-live="polite"><span>{{history[0].target}} · {{deliveryStages[history[0].stage]}}</span><small class="dock-muted">{{deliveryOutcome(history[0])}}</small><button class="dock-link" @click="showHistory">查看发送记录</button></div><p v-if="!canDownload && !history.some(item => item.outcome === 'running')" class="dock-muted">{{syncing ? '正在读取分类…' : '请先完成应用连接和保存位置设置'}}</p>
                        </div>
                        <footer class="dock-footer"><button class="dock-link" @click="toggleConfigPopup">管理连接应用</button><button class="dock-btn primary" :disabled="!canDownload" @click="download(nameChoice === 'title' ? title : nameChoice === 'subTitle' ? subTitle : torrentName)">${downloadIcon}下载</button></footer>
                    </section>
                </div>
                <div class="dock-overlay" v-show="isVisible" @click.self="toggleConfigPopup" @keydown.esc="toggleConfigPopup" @keydown.tab="trapFocus">
                    <section id="configPopup" class="dock-panel dock-settings" role="dialog" aria-modal="true" aria-labelledby="dock-settings-title" tabindex="-1">
                        <header class="dock-header"><div><p class="dock-eyebrow">MT DOCK / SETTINGS</p><h2 id="dock-settings-title">连接应用设置</h2></div><button class="dock-icon" @click="toggleConfigPopup" aria-label="关闭设置">×</button></header>
                        <div class="dock-layout"><aside class="dock-sidebar" aria-label="连接应用列表"><h3 class="dock-sidebar-title">已连接应用</h3><p class="dock-sidebar-caption">下载器 · 媒体库 · 其他连接</p><button v-for="profile in profiles" :key="profile.id" class="dock-profile dock-application" :aria-label="profile.name + ' ' + (profile.client === 'qbittorrent' ? 'qBittorrent' : 'Transmission')" :class="{active: profile.id === activeProfileId && !isMediaApplication}" @click="switchProfile(profile.id)"><span>{{profile.name}}</span><small>下载应用 · {{profile.client === 'qbittorrent' ? 'qBittorrent' : 'Transmission'}}</small></button><button v-for="server in mediaServers" :key="server.id" class="dock-profile dock-application" :class="{active: server.id === activeMediaServerId && isMediaApplication}" @click="focusMediaServer(server.id)"><span>{{server.name}}</span><small>媒体库 · {{server.type === 'emby' ? 'Emby' : 'Jellyfin'}}{{server.enabled ? '' : ' · 已停用'}}</small></button><button class="dock-btn dock-add-application" aria-label="+ 添加" @click="addApplication">+ 添加应用</button></aside>
                        <div class="dock-body"><nav class="dock-tabs" aria-label="设置分区"><button v-for="page in availableSettingsPages" :key="page.id" :class="{active: settingsTab === page.id}" @click="settingsTab = page.id">{{page.label}}</button></nav>
                            <div v-show="settingsTab === 'connection'" class="dock-options">
                                <div class="dock-two"><div class="dock-field"><label for="dock-name">应用名称</label><input v-if="!isMediaApplication" id="dock-name" type="text" v-model="config.name" placeholder="例如:家庭 NAS"><input v-else id="dock-name" type="text" v-model="currentMediaServer.name" placeholder="例如:家庭媒体库"></div><div class="dock-field"><label for="dock-client">应用类型</label><select id="dock-client" v-model="applicationType" @change="changeApplicationType"><option value="qbittorrent">qBittorrent</option><option value="transmission">Transmission</option><option value="emby">Emby</option><option value="jellyfin">Jellyfin</option></select></div></div>
                                <template v-if="isMediaApplication">
                                    <div class="dock-field"><label for="dock-address">应用地址</label><input id="dock-address" type="text" v-model="currentMediaServer.address" placeholder="http://media.example.com" autocomplete="off"></div>
                                    <div class="dock-field"><label for="dock-key">API Key</label><div class="dock-secret"><input id="dock-key" :type="showSecret ? 'text' : 'password'" v-model="currentMediaServer.apiKey" placeholder="媒体服务 API Key" autocomplete="off"><button class="dock-btn" @click="showSecret = !showSecret">{{showSecret ? '隐藏' : '显示'}}</button></div></div>
                                    <div class="dock-field"><label for="dock-user-id">用户 ID(可选)</label><input id="dock-user-id" type="text" v-model="currentMediaServer.userId" placeholder="留空读取 API Key 可见的全部媒体库"></div>
                                    <label class="dock-option">启用入库检测<input type="checkbox" v-model="currentMediaServer.enabled"></label>
                                </template>
                                <template v-else>
                                    <div class="dock-field"><label for="dock-address">应用地址</label><input id="dock-address" aria-label="WebUI 地址" type="text" v-model="config.address" placeholder="https://qb.example.com" autocomplete="off"></div>
                                    <div v-if="config.client === 'qbittorrent'" class="dock-field"><label for="dock-auth">认证方式</label><select id="dock-auth" v-model="config.authMode"><option value="apikey">API Key(推荐 · qB ≥ 5.2)</option><option value="password">用户名与密码(兼容旧版)</option></select></div>
                                    <div v-if="config.client === 'qbittorrent' && config.authMode === 'apikey'" class="dock-field"><label for="dock-key">API Key</label><div class="dock-secret"><input id="dock-key" :type="showSecret ? 'text' : 'password'" v-model="config.apiKey" placeholder="qbt_…" autocomplete="off"><button class="dock-btn" @click="showSecret = !showSecret">{{showSecret ? '隐藏' : '显示'}}</button></div><p class="dock-muted">在 qBittorrent「设置 → WebUI → API Key」中获取。旧版客户端请选择用户名与密码。</p></div>
                                    <div v-else class="dock-two"><div class="dock-field"><label for="dock-user">用户名</label><input id="dock-user" type="text" v-model="config.username" autocomplete="off"></div><div class="dock-field"><label for="dock-password">密码</label><input id="dock-password" type="password" v-model="config.password" autocomplete="off"></div></div>
                                </template>
                                <div class="dock-actions"><button class="dock-btn" :disabled="testing" @click="testConnection">{{testing ? '连接中…' : '测试连接'}}</button><button class="dock-link dock-danger" @click="removeApplication">删除应用</button></div>
                            </div>
                            <div v-if="applicationSupportsLocations" v-show="settingsTab === 'locations'" class="dock-options">
                                <div v-if="config.client === 'qbittorrent'" class="dock-field"><label for="dock-location-mode">分类来源</label><select id="dock-location-mode" v-model="config.locationMode" @change="changeLocationMode"><option value="remote">从 qBittorrent 同步</option><option value="manual">手动配置目录</option></select></div>
                                <template v-if="config.client === 'qbittorrent' && config.locationMode === 'remote'"><div class="dock-label"><span>远程分类</span><button class="dock-btn" :disabled="syncing" @click="syncCategories">{{syncing ? '同步中…' : '同步分类'}}</button></div><p class="dock-muted">读取已有分类和保存路径,不会修改连接应用中的分类。</p><div class="dock-categories"><div v-for="item in config.remoteCategories" :key="item.category" class="dock-category"><span>{{item.label}}</span><small class="dock-muted">{{item.value}}</small></div></div><p class="dock-status">{{categoryResult}}</p></template>
                                <template v-else><p class="dock-muted">每个下载应用使用独立目录。qBittorrent 使用标签作为分类。</p><div v-for="(item,index) in config.saveLocations" :key="index" class="dock-location"><input type="text" v-model="item.label" aria-label="目录标签" placeholder="分类"><input type="text" v-model="item.value" aria-label="目录路径" placeholder="/downloads"><button class="dock-icon" @click="delLine(index)" aria-label="删除目录">×</button></div><button class="dock-btn" @click="addLine">+ 添加目录</button></template>
                                <div class="dock-field"><label for="dock-separator">路径格式</label><select id="dock-separator" v-model="config.separator"><option value="/">Linux / NAS</option><option :value="String.fromCharCode(92)">Windows</option></select></div>
                            </div>
                            <div v-show="settingsTab === 'general'" class="dock-options">
                                <h3>界面与浏览</h3>
                                <div class="dock-field"><label for="dock-theme">外观主题</label><select id="dock-theme" aria-label="外观" v-model="config.theme"><option value="dark">深色</option><option value="light">浅色</option></select></div>
                                <label class="dock-option"><span>右侧浮动按钮<small class="dock-muted">刷新页面后生效</small></span><input type="checkbox" v-model="config.pinButton"></label>
                                <label class="dock-option"><span>列表大图模式<small class="dock-muted">放大 M-Team 列表缩略图,适合成人区瀑布流浏览</small></span><input type="checkbox" v-model="config.largeImageMode" @change="saveLargeImagePreference"></label>
                            </div>
                            <div v-if="applicationSupportsDownload" v-show="settingsTab === 'download'" class="dock-options">
                                <h3>下载行为</h3><p class="dock-muted">以下为下载任务的默认偏好,适用于支持相应功能的下载应用。</p>
                                <label class="dock-option"><span>自动开始<small class="dock-muted">添加任务后立即下载</small></span><input type="checkbox" v-model="config.autoStartDownload"></label>
                                <label v-if="selectedCapabilities.qbOptions" class="dock-option">顺序下载<input type="checkbox" v-model="config.sequentialDownload"></label><label v-if="selectedCapabilities.qbOptions" class="dock-option">首尾优先<input type="checkbox" v-model="config.firstLastPiecePrio"></label>
                                <label v-if="selectedCapabilities.qbOptions" class="dock-option"><span>自动 Torrent 管理<small class="dock-muted">由 qBittorrent 按分类规则管理保存位置</small></span><input type="checkbox" v-model="config.autoTMM"></label>
                                <h3>发送后行为</h3>
                                <label class="dock-option"><span>智能关闭页面<small class="dock-muted">成功后关闭单独打开的详情页</small></span><input type="checkbox" v-model="config.autoCloseWindow"></label>
                            </div>
                            <div v-show="settingsTab === 'backup'" class="dock-options">
                                <h3>配置备份与迁移</h3><p class="dock-muted">默认导出不含密码和 API Key,但包含连接应用地址和目录。完整备份使用口令加密。</p>
                                <label class="dock-option">完整加密备份(含密码 / Key)<input type="checkbox" v-model="backupSecrets"></label>
                                <div class="dock-field"><label for="dock-backup-password">加密 / 解密口令</label><input id="dock-backup-password" type="password" v-model="backupPassword" autocomplete="new-password" placeholder="完整导出至少 12 个字符;加密导入使用原口令"></div>
                                <div class="dock-actions"><button class="dock-btn" :disabled="backupBusy" @click="exportSettings">导出备份</button><label class="dock-btn">选择备份<input type="file" accept=".json,application/json" aria-label="选择备份文件" :disabled="backupBusy" @change="previewImport" style="max-width:180px;"></label></div>
                                <div v-if="pendingBackup" class="dock-record"><p>将追加以下连接应用,不覆盖现有配置:</p><span v-for="profile in pendingBackup.profiles" :key="profile.id">{{profile.name}}</span><span v-for="server in pendingBackup.mediaServers" :key="server.id">媒体库:{{server.name}}</span><label class="dock-option">同时应用通用偏好<input type="checkbox" v-model="importGlobals"></label><div class="dock-actions"><button class="dock-btn primary" @click="confirmImport">确认导入</button><button class="dock-btn" @click="pendingBackup = null">取消</button></div></div>
                                <p class="dock-status" role="status">{{backupResult}}</p>
                            </div>
                            <div v-show="settingsTab === 'history'" class="dock-options"><div class="dock-label"><h3>最近 50 次投递</h3><button class="dock-link" @click="clearHistory">清除已结束记录</button></div><p class="dock-muted">仅保存在脚本管理器本地,不保存密钥、Cookie 或下载链接。上传结果未知时,请先在目标应用核对。</p><div class="dock-history"><article v-for="row in history" :key="row.id" class="dock-record"><span>{{row.name}}</span><small class="dock-muted">{{row.target}} · {{deliveryStages[row.stage]}} · {{new Date(row.startedAt).toLocaleString()}}</small><p class="dock-status">{{deliveryOutcome(row)}}</p><small v-if="row.reason" class="dock-muted">{{row.reason}}</small></article><p v-if="!history.length" class="dock-muted">还没有投递记录</p></div></div>
                            <div v-show="settingsTab === 'about'" class="dock-options">
                                <h3>MT DOCK</h3><p class="dock-status">版本 {{dockVersion}}</p>
                                <p class="dock-muted">面向 M-Team 的功能增强与连接应用工作台的油猴脚本。</p>
                                <p>统一管理下载器、媒体服务、分类目录、入库检测与站外快捷入口。</p>
                                <h3>数据与同步</h3><p class="dock-muted">本脚本通过 GM Storage 保存配置,不主动上传配置到云端。若你启用脚本管理器的同步或备份,数据范围由管理器及你的选项决定。脚本同步不等于连接应用实时同步。</p>
                                <p class="dock-muted">跨设备迁移配置可使用「备份」页。含密码和 API Key 的完整备份需要加密口令。</p>
                                <a class="dock-link" href="https://www.tampermonkey.net/faq.php?q=Q105" target="_blank" rel="noopener noreferrer">Tampermonkey 官方同步说明 ↗</a>
                                <a class="dock-link" href="https://github.com/kinaxng/mt-dock" target="_blank" rel="noopener noreferrer">GitHub 项目主页 ↗</a>
                                <a class="dock-link" href="https://github.com/kinaxng/mt-dock/issues" target="_blank" rel="noopener noreferrer">反馈问题 ↗</a>
                                <a class="dock-link" href="https://github.com/kinaxng/mt-dock/blob/main/THIRD_PARTY_NOTICES.md" target="_blank" rel="noopener noreferrer">开源许可与致谢 ↗</a>
                            </div>
                            <p id="dock-connection-status" v-show="['connection','locations','general','download'].includes(settingsTab)" class="dock-status" role="status">{{connectionResult}}</p>
                        </div></div>
                        <footer class="dock-footer"><p class="dock-muted">切换应用会保存当前配置</p><div class="dock-actions"><button class="dock-btn" @click="toggleConfigPopup">关闭</button><button class="dock-btn primary" :disabled="saving" @click="configSave">{{saving ? '保存中…' : '保存'}}</button></div></footer>
                    </section>
                </div>
            </div>
        </div>`;
        const mount = PT.getDownloadButtonMountPoint();
        const wrapper = document.createElement('div');
        wrapper.innerHTML = html;
        wrapper.style.display = 'inline-block';
        if (!GM_getValue('pinButton', false) && mount) mount.append(wrapper);
        else {
            GM_addStyle('#plugin-download-div { position:fixed; top:20%; right:16px; z-index:999; }');
            document.body.append(wrapper);
        }
    }

    function isNexusPHP() {
        const meta = document.querySelector('meta[name="generator"]');
        return meta && meta.getAttribute('content') === 'NexusPHP';
    }

    let styleInstalled = false;
    async function main() {
        try {
            if (!(getSite() || isNexusPHP())) return;
            if (getSite() === 'new_mteam' && !currentDetailId()) return;
            if (!styleInstalled) { setStyle(); styleInstalled = true; }
            if (dockApp && document.querySelector('#plugin-download-div')) {
                dockApp.refreshTorrent();
                return;
            }
            if (dockApp) {
                dockApp.persistCurrent();
                dockApp.$destroy?.();
            }
            setHtml();
            init();
        } catch (_) { console.error('脚本初始化失败,请刷新页面重试'); }
    }

    async function result(id = torrentInfo.id) {
        const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
        const timer = controller ? setTimeout(() => controller.abort(), 15000) : null;
        try {
            const response = await fetch(`${localStorage.getItem('apiHost')}/torrent/genDlToken`, {
                method:'POST',
                headers:{'Content-Type':'application/x-www-form-urlencoded; charset=UTF-8',
                    TS:Math.floor(Date.now()/1000), Authorization:localStorage.getItem('auth') || ''},
                body:getQueryString({id}),
                ...(controller ? {signal:controller.signal} : {})
            });
            if (!response.ok) throw new Error();
            const data = await response.json();
            if (data.code !== '0' || typeof data.data !== 'string' || !/^https?:\/\//i.test(data.data)) throw new Error();
            return data.data;
        } catch (_) { throw new Error('获取种子下载地址失败,请检查站点登录状态或网络'); }
        finally { if (timer !== null) clearTimeout(timer); }
    }

    function currentDetailId() {
        return /\/detail\/(\d+)(?:\/|$)/.exec(window.location.pathname || '')?.[1] || '';
    }
    let detailEpoch = 0;
    let loadedDetailId = '';
    let lastRouteId = currentDetailId();
    let detailLoading = false;
    const mteamDetailCache = new Map();
    async function acceptMteamDetail(data) {
        if (!data || !/^\d+$/.test(String(data.id))) return;
        const id = String(data.id), routeId = currentDetailId();
        if (window.location.pathname && routeId !== id) return; // Ignore a late response from another route.
        acceptMediaMetadata(data);
        if (loadedDetailId === id && (detailLoading || document.querySelector('#plugin-download-div'))) return;
        mteamDetailCache.set(id, {id:data.id, name:data.name || '', originFileName:data.originFileName || '', smallDescr:data.smallDescr || ''});
        if (mteamDetailCache.size > 20) mteamDetailCache.delete(mteamDetailCache.keys().next().value);
        loadedDetailId = id;
        const epoch = ++detailEpoch;
        detailLoading = true;
        Object.assign(torrentInfo, {id:data.id, name:data.name || '', originFileName:data.originFileName || '', smallDescr:data.smallDescr || '', url:''});
        if (dockApp) { dockApp.isPopupVisible = false; dockApp.refreshTorrent(); }
        try {
            const url = await result(id);
            if (epoch !== detailEpoch) return;
            torrentInfo.url = url;
        } catch (_) {
            if (epoch !== detailEpoch) return;
            // Sending obtains a fresh token, so a transient startup failure does not disable configuration.
            cocoMessage.error('种子链接暂未取得,发送时将重新获取', 4000);
        } finally {
            if (epoch === detailEpoch) detailLoading = false;
        }
        if (epoch === detailEpoch) main();
    }
    function handleMteamRoute() {
        const id = currentDetailId();
        if (id === lastRouteId) return;
        lastRouteId = id;
        detailEpoch++;
        loadedDetailId = '';
        detailLoading = false;
        Object.assign(torrentInfo, {id:'', name:'', originFileName:'', smallDescr:'', url:''});
        if (dockApp) {
            dockApp.isPopupVisible = false;
            dockApp.isVisible = false;
            dockApp.refreshTorrent();
            const root = document.querySelector('#plugin-download-div');
            if (root) root.style.display = 'none';
        }
        // Client-side routers may restore a cached page without another detail XHR.
        if (id && mteamDetailCache.has(id)) acceptMteamDetail(mteamDetailCache.get(id));
        scheduleMediaScan();
    }

    // Bootstrap marker used by tests to expose the actual closure, not a copied implementation.
    let executed = false;
    if (asyncArr.includes(getSite())) {
        if (!dockMenuRegistered) {
            GM_registerMenuCommand('MT DOCK 设置 / 媒体库', function () {
                if (!document.querySelector('#plugin-download-div')) {
                    if (dockApp) dockApp.$destroy?.();
                    if (!styleInstalled) { setStyle(); styleInstalled = true; }
                    setHtml(); init();
                }
                dockApp.refreshTorrent(); dockApp.settingsTab = 'media'; dockApp.isVisible = true;
            });
            dockMenuRegistered = true;
        }
        if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded',startMediaDetection);
        else startMediaDetection();
        const originOpen = XMLHttpRequest.prototype.open;
        XMLHttpRequest.prototype.open = function (_, url) {
            if (/\/api\/torrent\/search(?:\?|$)/.test(String(url))) {
                this.addEventListener('readystatechange', function () {
                    if (this.readyState !== 4 || this.status !== 200) return;
                    try { const res = JSON.parse(this.responseText); if (res.message === 'SUCCESS' || res.code === '0') acceptMediaMetadata(res.data); } catch (_) {}
                });
            }
            if (String(url).includes('/api/torrent/detail')) {
                this.addEventListener('readystatechange', function () {
                    if (this.readyState !== 4 || this.status !== 200) return;
                    try {
                        const res = JSON.parse(this.responseText);
                        if (res.message === 'SUCCESS') acceptMteamDetail(res.data);
                    } catch (_) { /* Ignore non-JSON application responses. */ }
                });
            }
            return originOpen.apply(this, arguments);
        };
        for (const method of ['pushState','replaceState']) {
            const original = window.history?.[method];
            if (typeof original !== 'function') continue;
            window.history[method] = function () {
                const value = original.apply(this, arguments);
                handleMteamRoute();
                return value;
            };
        }
        window.addEventListener?.('popstate', handleMteamRoute);
    } else if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', main);
    } else { main(); }

})();