qBittorrent Tracker 替换工具

批量替换 qBittorrent 中的 tracker

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         qBittorrent Tracker 替换工具
// @namespace    http://tampermonkey.net/
// @version      1.2
// @description  批量替换 qBittorrent 中的 tracker
// @author       江畔
// @match        *://*/*
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @connect      *
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    // ==================== 配置区域 ====================
    // 请在这里配置你的 qBittorrent 信息
    const QB_CONFIG = {
        host: 'http://localhost:8080',  // qBittorrent Web UI 地址
        username: 'admin',               // 用户名
        password: 'adminpass'            // 密码
    };
    // ==================================================

    let qbCookie = null;

    // 创建对话框
    function showDialog() {
        // 如果对话框已存在,先移除
        const existingDialog = document.getElementById('qb-tracker-replacer-dialog');
        if (existingDialog) {
            existingDialog.remove();
        }

        // 创建遮罩层
        const overlay = document.createElement('div');
        overlay.id = 'qb-tracker-replacer-dialog';
        overlay.style.cssText = `
            position: fixed;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            background: rgba(0, 0, 0, 0.6);
            display: flex;
            justify-content: center;
            align-items: center;
            z-index: 9999999;
            font-family: Arial, sans-serif;
        `;

        // 创建对话框容器
        const dialog = document.createElement('div');
        dialog.style.cssText = `
            background: white;
            border-radius: 12px;
            padding: 30px;
            width: 500px;
            max-width: 90%;
            box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
            animation: slideIn 0.3s ease;
        `;

        // 添加动画
        const style = document.createElement('style');
        style.textContent = `
            @keyframes slideIn {
                from {
                    transform: translateY(-50px);
                    opacity: 0;
                }
                to {
                    transform: translateY(0);
                    opacity: 1;
                }
            }
        `;
        document.head.appendChild(style);

        dialog.innerHTML = `
            <h2 style="margin: 0 0 20px 0; color: #333; font-size: 22px;">
                🔄 批量替换 Tracker
            </h2>
            <div style="margin-bottom: 20px;">
                <label style="display: block; margin-bottom: 8px; color: #555; font-weight: bold;">
                    被替换的 Tracker(原 Tracker):
                </label>
                <input type="text" id="qb-old-tracker" placeholder="例如: http://old-tracker.com:1234/announce" 
                    style="width: 100%; padding: 10px; border: 2px solid #ddd; border-radius: 6px; 
                    font-size: 14px; box-sizing: border-box; transition: border-color 0.3s;">
            </div>
            <div style="margin-bottom: 25px;">
                <label style="display: block; margin-bottom: 8px; color: #555; font-weight: bold;">
                    要替换成的 Tracker(新 Tracker):
                </label>
                <input type="text" id="qb-new-tracker" placeholder="例如: http://new-tracker.com:1234/announce" 
                    style="width: 100%; padding: 10px; border: 2px solid #ddd; border-radius: 6px; 
                    font-size: 14px; box-sizing: border-box; transition: border-color 0.3s;">
            </div>
            <div style="display: flex; gap: 10px; justify-content: flex-end;">
                <button id="qb-cancel-btn" style="padding: 10px 25px; border: 2px solid #ddd; 
                    background: white; color: #666; border-radius: 6px; cursor: pointer; 
                    font-size: 14px; font-weight: bold; transition: all 0.3s;">
                    取消
                </button>
                <button id="qb-confirm-btn" style="padding: 10px 25px; border: none; 
                    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; 
                    border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: bold;
                    transition: all 0.3s;">
                    确定
                </button>
            </div>
            <div id="qb-status" style="margin-top: 15px; padding: 10px; border-radius: 6px; 
                font-size: 13px; display: none;">
            </div>
        `;

        overlay.appendChild(dialog);
        document.body.appendChild(overlay);

        // 输入框焦点效果
        const inputs = dialog.querySelectorAll('input');
        inputs.forEach(input => {
            input.addEventListener('focus', function() {
                this.style.borderColor = '#667eea';
            });
            input.addEventListener('blur', function() {
                this.style.borderColor = '#ddd';
            });
        });

        // 按钮悬停效果
        const cancelBtn = document.getElementById('qb-cancel-btn');
        const confirmBtn = document.getElementById('qb-confirm-btn');

        cancelBtn.addEventListener('mouseenter', function() {
            this.style.background = '#f5f5f5';
            this.style.borderColor = '#999';
        });
        cancelBtn.addEventListener('mouseleave', function() {
            this.style.background = 'white';
            this.style.borderColor = '#ddd';
        });

        confirmBtn.addEventListener('mouseenter', function() {
            this.style.transform = 'scale(1.05)';
        });
        confirmBtn.addEventListener('mouseleave', function() {
            this.style.transform = 'scale(1)';
        });

        // 绑定事件
        cancelBtn.addEventListener('click', () => {
            overlay.remove();
        });

        confirmBtn.addEventListener('click', async () => {
            const oldTracker = document.getElementById('qb-old-tracker').value.trim();
            const newTracker = document.getElementById('qb-new-tracker').value.trim();

            if (!oldTracker || !newTracker) {
                showStatus('error', '❌ 请填写完整的 Tracker 信息!');
                return;
            }

            if (oldTracker === newTracker) {
                showStatus('error', '❌ 新旧 Tracker 不能相同!');
                return;
            }

            // 禁用按钮
            confirmBtn.disabled = true;
            confirmBtn.style.opacity = '0.6';
            confirmBtn.style.cursor = 'not-allowed';
            confirmBtn.textContent = '处理中...';

            await replaceTrackers(oldTracker, newTracker);

            // 恢复按钮
            setTimeout(() => {
                confirmBtn.disabled = false;
                confirmBtn.style.opacity = '1';
                confirmBtn.style.cursor = 'pointer';
                confirmBtn.textContent = '确定';
            }, 2000);
        });
    }

    // 显示状态信息
    function showStatus(type, message) {
        const statusDiv = document.getElementById('qb-status');
        if (!statusDiv) return;

        statusDiv.style.display = 'block';
        statusDiv.textContent = message;

        if (type === 'success') {
            statusDiv.style.background = '#d4edda';
            statusDiv.style.color = '#155724';
            statusDiv.style.border = '1px solid #c3e6cb';
        } else if (type === 'error') {
            statusDiv.style.background = '#f8d7da';
            statusDiv.style.color = '#721c24';
            statusDiv.style.border = '1px solid #f5c6cb';
        } else if (type === 'info') {
            statusDiv.style.background = '#d1ecf1';
            statusDiv.style.color = '#0c5460';
            statusDiv.style.border = '1px solid #bee5eb';
        }
    }

    // qBittorrent API: 登录
    async function qbLogin() {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'POST',
                url: `${QB_CONFIG.host}/api/v2/auth/login`,
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded'
                },
                data: `username=${encodeURIComponent(QB_CONFIG.username)}&password=${encodeURIComponent(QB_CONFIG.password)}`,
                onload: function(response) {
                    if (response.status === 200 && response.responseText === 'Ok.') {
                        // 从响应头中获取 Cookie
                        const cookies = response.responseHeaders.match(/set-cookie: ([^;]+)/gi);
                        if (cookies && cookies.length > 0) {
                            qbCookie = cookies.map(c => c.replace(/set-cookie:\s*/i, '')).join('; ');
                        }
                        resolve(true);
                    } else {
                        reject(new Error('登录失败'));
                    }
                },
                onerror: function(error) {
                    reject(new Error('登录请求失败: ' + error));
                }
            });
        });
    }

    // qBittorrent API: 获取所有种子
    async function qbGetTorrents() {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: `${QB_CONFIG.host}/api/v2/torrents/info`,
                headers: {
                    'Cookie': qbCookie
                },
                onload: function(response) {
                    if (response.status === 200) {
                        try {
                            const torrents = JSON.parse(response.responseText);
                            resolve(torrents);
                        } catch (e) {
                            reject(new Error('解析种子列表失败'));
                        }
                    } else {
                        reject(new Error('获取种子列表失败'));
                    }
                },
                onerror: function(error) {
                    reject(new Error('获取种子列表请求失败'));
                }
            });
        });
    }

    // qBittorrent API: 获取种子的 trackers
    async function qbGetTrackers(hash) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: `${QB_CONFIG.host}/api/v2/torrents/trackers?hash=${hash}`,
                headers: {
                    'Cookie': qbCookie
                },
                onload: function(response) {
                    if (response.status === 200) {
                        try {
                            const trackers = JSON.parse(response.responseText);
                            resolve(trackers);
                        } catch (e) {
                            reject(new Error('解析 trackers 失败'));
                        }
                    } else {
                        reject(new Error('获取 trackers 失败'));
                    }
                },
                onerror: function(error) {
                    reject(new Error('获取 trackers 请求失败'));
                }
            });
        });
    }

    // qBittorrent API: 编辑 tracker
    async function qbEditTracker(hash, oldUrl, newUrl) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'POST',
                url: `${QB_CONFIG.host}/api/v2/torrents/editTracker`,
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded',
                    'Cookie': qbCookie
                },
                data: `hash=${hash}&origUrl=${encodeURIComponent(oldUrl)}&newUrl=${encodeURIComponent(newUrl)}`,
                onload: function(response) {
                    if (response.status === 200) {
                        resolve(true);
                    } else {
                        reject(new Error('编辑 tracker 失败'));
                    }
                },
                onerror: function(error) {
                    reject(new Error('编辑 tracker 请求失败'));
                }
            });
        });
    }

    // 主要功能: 替换 trackers
    async function replaceTrackers(oldTracker, newTracker) {
        try {
            showStatus('info', '⏳ 正在登录 qBittorrent...');

            // 登录
            await qbLogin();
            showStatus('info', '⏳ 正在获取种子列表...');

            // 获取所有种子
            const torrents = await qbGetTorrents();
            if (!torrents || torrents.length === 0) {
                showStatus('error', '❌ 没有找到任何种子!');
                return;
            }

            showStatus('info', `⏳ 找到 ${torrents.length} 个种子,正在批量获取 tracker 信息...`);

            // 一次性获取所有种子的 trackers
            const trackersPromises = torrents.map(torrent => 
                qbGetTrackers(torrent.hash)
                    .then(trackers => ({ torrent, trackers }))
                    .catch(error => {
                        console.error(`获取种子 [${torrent.name}] 的 trackers 失败:`, error);
                        return { torrent, trackers: [] };
                    })
            );

            const allTrackersData = await Promise.all(trackersPromises);
            
            showStatus('info', `⏳ 获取完成,正在筛选需要替换的种子...`);

            // 筛选出包含目标 tracker 的种子
            const torrentsToReplace = allTrackersData.filter(data => 
                data.trackers.some(t => t.url === oldTracker)
            );

            if (torrentsToReplace.length === 0) {
                showStatus('error', '❌ 没有找到匹配的 tracker,请检查输入是否正确!');
                return;
            }

            showStatus('info', `⏳ 找到 ${torrentsToReplace.length} 个种子需要替换,正在并发执行替换...`);

            // 并发批量替换 trackers
            const replacePromises = torrentsToReplace.map(({ torrent }) => 
                qbEditTracker(torrent.hash, oldTracker, newTracker)
                    .then(() => {
                        console.log(`✅ 已替换种子 [${torrent.name}] 的 tracker`);
                        return { success: true, torrent };
                    })
                    .catch(error => {
                        console.error(`❌ 替换种子 [${torrent.name}] 时出错:`, error);
                        return { success: false, torrent, error };
                    })
            );

            const results = await Promise.all(replacePromises);
            
            const successCount = results.filter(r => r.success).length;
            const errorCount = results.filter(r => !r.success).length;

            // 显示最终结果
            if (successCount > 0) {
                showStatus('success', `✅ 完成!成功替换 ${successCount} 个种子的 tracker${errorCount > 0 ? `,失败 ${errorCount} 个` : ''}`);
            } else {
                showStatus('error', `❌ 替换失败!共 ${errorCount} 个种子替换失败`);
            }

        } catch (error) {
            console.error('处理失败:', error);
            showStatus('error', `❌ 操作失败: ${error.message}`);
        }
    }

    // 注册油猴菜单命令
    GM_registerMenuCommand('🔄 替换 qBittorrent Tracker', showDialog);

    console.log('🔄 qBittorrent Tracker 替换工具已加载');
})();