MT DOCK

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

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

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

})();