面向 M-Team 的功能增强与连接应用工作台的油猴脚本。
// ==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(); } })();