PikPak Aria2 도우미

PikPak 파일과 폴더를 Aria2로 푸시하여 다운로드합니다.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         PikPak Aria2 Helper
// @name:en      PikPak Aria2 Helper
// @name:ja      PikPak Aria2 ヘルパー
// @name:zh-CN   PikPak Aria2 助手
// @name:zh-TW   PikPak Aria2 助手
// @name:ko      PikPak Aria2 도우미
// @name:ru      PikPak Aria2 Помощник
// @name:es      PikPak Aria2 Ayudante
// @name:pt-BR   PikPak Aria2 Auxiliar
// @name:fr      PikPak Aria2 Assistant
// @name:de      PikPak Aria2 Helfer
// @namespace    https://github.com/CheerChen
// @version      0.1.0
// @description  Push PikPak files and folders to Aria2 for downloading.
// @description:en Push PikPak files and folders to Aria2 for downloading.
// @description:ja PikPakのファイルとフォルダをAria2にプッシュしてダウンロードします。
// @description:zh-CN 将 PikPak 文件和文件夹推送到 Aria2 进行下载。
// @description:zh-TW 將 PikPak 檔案和資料夾推送到 Aria2 進行下載。
// @description:ko PikPak 파일과 폴더를 Aria2로 푸시하여 다운로드합니다.
// @description:ru Отправка файлов и папок PikPak в Aria2 для скачивания.
// @description:es Enviar archivos y carpetas de PikPak a Aria2 para descargar.
// @description:pt-BR Enviar arquivos e pastas do PikPak para o Aria2 para download.
// @description:fr Envoyer les fichiers et dossiers PikPak vers Aria2 pour le téléchargement.
// @description:de PikPak-Dateien und -Ordner zum Herunterladen an Aria2 senden.
// @author       cheerchen37
// @match        *://*mypikpak.com/*
// @match        *://*mypikpak.net/*
// @match        *://*pikpak.me/*
// @require      https://unpkg.com/preact@10/dist/preact.umd.js
// @require      https://unpkg.com/preact@10/hooks/dist/hooks.umd.js
// @require      https://unpkg.com/htm@3/dist/htm.umd.js
// @grant        GM_xmlhttpRequest
// @connect      *
// @icon         https://www.google.com/s2/favicons?domain=mypikpak.com
// @license      MIT
// @homepage     https://github.com/CheerChen/userscripts
// @supportURL   https://github.com/CheerChen/userscripts/issues
// ==/UserScript==

(function () {
    'use strict';

    const { h, render } = preact;
    const { useState, useEffect } = preactHooks;
    const html = htm.bind(h);

    // ─── i18n ───

    const i18n = {
        zh: {
            aria2Download: 'Aria2 下载',
            pushToAria2: '推送到 Aria2',
            configAria2: '配置 Aria2',
            selectAll: '全选',
            name: '名称', size: '大小', createdTime: '创建时间', modifiedTime: '修改时间',
            asc: '升序', desc: '降序',
            selectFiles: '请先选择要推送的文件',
            configFirst: '请先配置 Aria2',
            pushing: '推送中...',
            pushBtn: n => `推送到 Aria2 (${n})`,
            progress: (c, t, s, f) => `推送进度: ${c}/${t} (成功: ${s}, 失败: ${f})`,
            pushDone: (s, f) => f === 0 ? `推送完成!成功 ${s} 个文件` : `推送完成:成功 ${s},失败 ${f}`,
            scanning: name => `正在扫描文件夹: ${name}`,
            preparing: t => `准备推送 ${t} 个文件`,
            connected: 'Aria2 连接正常', disconnected: 'Aria2 连接失败',
            testing: '正在测试连接...', unknown: '连接状态未知',
            testBtn: '测试连接', testingBtn: '测试中...',
            rpcUrl: 'RPC 地址', rpcUrlHint: 'Aria2 RPC 服务地址,通常是 http://127.0.0.1:6800/jsonrpc',
            rpcToken: 'RPC 密钥', rpcTokenHint: '如果 Aria2 设置了 rpc-secret,请在此填写',
            rpcTokenPlaceholder: '没有请留空',
            downloadPath: '下载路径', downloadPathHint: '文件保存路径,例如 /downloads/ 或 D:\\Downloads\\',
            customParams: '其他参数', customParamsHint: '额外参数,以分号分隔,如 user-agent=Mozilla;split=10',
            save: '保存', cancel: '取消',
        },
        en: {
            aria2Download: 'Aria2 Download',
            pushToAria2: 'Push to Aria2',
            configAria2: 'Configure Aria2',
            selectAll: 'Select All',
            name: 'Name', size: 'Size', createdTime: 'Created', modifiedTime: 'Modified',
            asc: 'Asc', desc: 'Desc',
            selectFiles: 'Please select files first',
            configFirst: 'Please configure Aria2 first',
            pushing: 'Pushing...',
            pushBtn: n => `Push to Aria2 (${n})`,
            progress: (c, t, s, f) => `Progress: ${c}/${t} (Success: ${s}, Failed: ${f})`,
            pushDone: (s, f) => f === 0 ? `Done! ${s} file(s) pushed` : `Done: ${s} success, ${f} failed`,
            scanning: name => `Scanning folder: ${name}`,
            preparing: t => `Preparing ${t} file(s)`,
            connected: 'Aria2 connected', disconnected: 'Aria2 connection failed',
            testing: 'Testing connection...', unknown: 'Connection unknown',
            testBtn: 'Test', testingBtn: 'Testing...',
            rpcUrl: 'RPC URL', rpcUrlHint: 'Aria2 RPC address, usually http://127.0.0.1:6800/jsonrpc',
            rpcToken: 'RPC Token', rpcTokenHint: 'Fill in if Aria2 has rpc-secret configured',
            rpcTokenPlaceholder: 'Leave empty if none',
            downloadPath: 'Download Path', downloadPathHint: 'e.g. /downloads/ or D:\\Downloads\\',
            customParams: 'Extra Params', customParamsHint: 'Semicolon-separated, e.g. user-agent=Mozilla;split=10',
            save: 'Save', cancel: 'Cancel',
        },
    };

    const lang = (navigator.language || '').startsWith('zh') ? 'zh' : 'en';
    const t = key => i18n[lang][key];

    // ─── PikPak API ───

    function getHeader() {
        let token = '', captcha = '';
        for (let i = 0; i < localStorage.length; i++) {
            const key = localStorage.key(i);
            if (!key) continue;
            if (key.startsWith('credentials')) {
                const d = JSON.parse(localStorage.getItem(key));
                token = d.token_type + ' ' + d.access_token;
            }
            if (key.startsWith('captcha')) {
                const d = JSON.parse(localStorage.getItem(key));
                captcha = d.captcha_token;
            }
        }
        let deviceId = localStorage.getItem('deviceid') || '';
        if (deviceId.includes('.')) deviceId = deviceId.split('.')[1]?.substring(0, 32) || deviceId;
        return { Authorization: token, 'x-device-id': deviceId, 'x-captcha-token': captcha };
    }

    function getList(parentId) {
        return fetch(`https://api-drive.mypikpak.com/drive/v1/files?thumbnail_size=SIZE_MEDIUM&limit=500&parent_id=${parentId}&with_audit=true&filters=${encodeURIComponent('{"phase":{"eq":"PHASE_TYPE_COMPLETE"},"trashed":{"eq":false}}')}`, {
            headers: { 'Content-Type': 'application/json', ...getHeader() },
        }).then(r => r.json());
    }

    function getDownloadUrl(fileId) {
        return fetch(`https://api-drive.mypikpak.com/drive/v1/files/${fileId}?`, {
            headers: { 'Content-Type': 'application/json', ...getHeader() },
        }).then(r => r.json());
    }

    // ─── Aria2 RPC ───

    function rpcCall(rpcUrl, data) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'POST', url: rpcUrl,
                headers: { 'Content-Type': 'application/json' },
                data: JSON.stringify(data),
                onload: res => {
                    try { resolve(JSON.parse(res.responseText)); }
                    catch { reject(new Error('Invalid response')); }
                },
                onerror: e => reject(new Error(e.statusText || 'Network error')),
            });
        });
    }

    // ─── Config ───

    const CONFIG_KEY = 'pikpak-aria2-helper-config';
    const defaultConfig = { rpcUrl: 'http://127.0.0.1:6800/jsonrpc', rpcToken: '', downloadPath: '', customParams: '', sortBy: 'name', sortDir: 'asc' };
    const getConfig = () => { try { return { ...defaultConfig, ...JSON.parse(localStorage.getItem(CONFIG_KEY)) }; } catch { return { ...defaultConfig }; } };
    const saveConfig = c => localStorage.setItem(CONFIG_KEY, JSON.stringify(c));

    // ─── Helpers ───

    const delay = ms => new Promise(r => setTimeout(r, ms));
    const colors = { secondary: '#606266', success: '#67c23a', danger: '#f56c6c', blue: '#409eff' };

    const formatBytes = b => {
        if (!b || b <= 0) return '';
        const k = 1024, s = ['B', 'KB', 'MB', 'GB'];
        const i = Math.floor(Math.log(b) / Math.log(k));
        return (b / Math.pow(k, i)).toFixed(1) + ' ' + s[i];
    };

    const sortFiles = (list, by, dir) => [...list].sort((a, b) => {
        const af = a.kind === 'drive#folder', bf = b.kind === 'drive#folder';
        if (af !== bf) return af ? -1 : 1;
        let av = a[by], bv = b[by];
        if (by === 'size') { av = parseInt(av || '0'); bv = parseInt(bv || '0'); }
        else if (by.includes('time')) { av = new Date(av).getTime(); bv = new Date(bv).getTime(); }
        else { av = (av || '').toLowerCase(); bv = (bv || '').toLowerCase(); }
        const c = av > bv ? 1 : av < bv ? -1 : 0;
        return dir === 'asc' ? c : -c;
    });

    function testAria2(rpcUrl, rpcToken) {
        const payload = { jsonrpc: '2.0', method: 'aria2.getVersion', id: 1, params: rpcToken ? [`token:${rpcToken}`] : [] };
        return rpcCall(rpcUrl, payload).then(r => !!(r && r.result));
    }

    // ─── Components ───

    function Toast({ message, type }) {
        if (!message) return null;
        const bg = { success: 'rgba(103,194,58,.9)', error: 'rgba(245,108,108,.9)', warning: 'rgba(230,162,60,.9)', info: 'rgba(64,158,255,.9)' };
        const icons = { success: '✓', error: '✕', warning: '⚠', info: 'ℹ' };
        return html`<div style="position:fixed;top:30px;left:50%;transform:translateX(-50%);padding:15px 20px;background:${bg[type] || bg.info};color:#fff;border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,.15);font-size:14px;z-index:10001;display:flex;align-items:center;gap:10px">
            <span style="font-size:18px;font-weight:bold">${icons[type] || icons.info}</span>
            <span>${message}</span>
        </div>`;
    }

    function ConnectionStatus({ status, onTest, testing }) {
        const cfg = { connected: { color: colors.success, text: t('connected') }, disconnected: { color: colors.danger, text: t('disconnected') },
            testing: { color: '#e6a23c', text: t('testing') }, unknown: { color: '#909399', text: t('unknown') } };
        const s = cfg[status] || cfg.unknown;
        return html`<div style="display:flex;justify-content:space-between;align-items:center;padding:12px 16px;background:#f8f9fa;border-radius:8px;margin-bottom:16px;border:1px solid #e9ecef">
            <div style="display:flex;align-items:center;gap:8px">
                <div style="width:10px;height:10px;border-radius:50%;background:${s.color};box-shadow:0 0 0 2px ${s.color}33" />
                <span style="font-size:14px;color:#666">${s.text}</span>
            </div>
            <button onClick=${onTest} disabled=${testing}
                style="padding:6px 12px;font-size:12px;border:1px solid #dcdfe6;border-radius:4px;background:#fff;color:#666;cursor:${testing ? 'not-allowed' : 'pointer'};opacity:${testing ? 0.6 : 1}">
                ${testing ? t('testingBtn') : t('testBtn')}</button>
        </div>`;
    }

    function FileItem({ file, selected, onSelect, status, sortBy }) {
        const icons = { success: '✅', error: '❌', downloading: '⏳' };
        const info = () => {
            if (sortBy === 'size') return file.size && parseInt(file.size) > 0 ? formatBytes(parseInt(file.size)) : '';
            if (sortBy === 'created_time' || sortBy === 'modified_time') return file[sortBy] ? new Date(file[sortBy]).toLocaleString() : '';
            return file.size && parseInt(file.size) > 0 ? formatBytes(parseInt(file.size)) : '';
        };
        return html`<div style="display:flex;align-items:center;padding:10px 0;border-bottom:1px solid #f0f0f0">
            <input type="checkbox" checked=${selected} onChange=${e => onSelect(file.id, e.target.checked)} style="margin-right:12px" />
            <span style="margin-right:10px;font-size:18px">${file.kind === 'drive#folder' ? '📁' : '📄'}</span>
            <div style="flex:1;min-width:0;font-weight:500;word-break:break-word">${file.name}</div>
            <span style="margin-left:16px;font-size:12px;color:${colors.secondary};white-space:nowrap">${info()}</span>
            ${status && html`<span style="margin-left:12px;font-size:16px">${icons[status] || ''}</span>`}
        </div>`;
    }

    function ConfigPanel({ config, onSave, onClose }) {
        const [local, setLocal] = useState(config);
        const [connStatus, setConnStatus] = useState('unknown');
        const [testing, setTesting] = useState(false);

        const doTest = async () => {
            if (!local.rpcUrl) return;
            setTesting(true); setConnStatus('testing');
            try { setConnStatus(await testAria2(local.rpcUrl, local.rpcToken) ? 'connected' : 'disconnected'); }
            catch { setConnStatus('disconnected'); }
            finally { setTesting(false); }
        };

        useEffect(() => { if (local.rpcUrl) doTest(); }, []);

        const handleSave = () => {
            const c = { ...local };
            if (c.downloadPath && !/[/\\]$/.test(c.downloadPath)) c.downloadPath += '/';
            saveConfig(c); onSave(c); onClose();
        };

        const field = (key, label, hint, placeholder) => html`
            <div style="margin-bottom:16px">
                <label style="display:block;margin-bottom:6px;font-weight:500">${label}</label>
                <input type="text" value=${local[key]} placeholder=${placeholder || ''}
                    onInput=${e => setLocal({ ...local, [key]: e.target.value })}
                    style="width:100%;padding:8px 12px;border:1px solid #dcdfe6;border-radius:4px;font-size:14px;box-sizing:border-box" />
                <div style="font-size:12px;color:${colors.secondary};margin-top:4px">${hint}</div>
            </div>`;

        return html`<div style="position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:10000">
            <div style="background:#fff;border-radius:8px;padding:24px;box-shadow:0 10px 25px rgba(0,0,0,.2);width:90%;max-width:500px;max-height:80vh;display:flex;flex-direction:column">
                <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;border-bottom:1px solid #ebeef5;padding-bottom:16px">
                    <h2 style="margin:0;font-size:18px">${t('configAria2')}</h2>
                    <button onClick=${onClose} style="background:none;border:none;font-size:24px;cursor:pointer;color:${colors.secondary}">×</button>
                </div>
                <${ConnectionStatus} status=${connStatus} onTest=${doTest} testing=${testing} />
                <div style="flex:1;overflow-y:auto">
                    ${field('rpcUrl', t('rpcUrl'), t('rpcUrlHint'), 'http://127.0.0.1:6800/jsonrpc')}
                    ${field('rpcToken', t('rpcToken'), t('rpcTokenHint'), t('rpcTokenPlaceholder'))}
                    ${field('downloadPath', t('downloadPath'), t('downloadPathHint'), '/downloads/')}
                    ${field('customParams', t('customParams'), t('customParamsHint'), 'user-agent=xxx;split=10')}
                </div>
                <div style="display:flex;justify-content:flex-end;gap:12px;margin-top:20px;padding-top:16px;border-top:1px solid #ebeef5">
                    <button onClick=${onClose} style="padding:8px 16px;border:1px solid #dcdfe6;border-radius:4px;cursor:pointer;background:#fff">${t('cancel')}</button>
                    <button onClick=${handleSave} style="padding:8px 16px;border:none;border-radius:4px;cursor:pointer;background:${colors.blue};color:#fff">${t('save')}</button>
                </div>
            </div>
        </div>`;
    }

    function Aria2Modal({ onClose }) {
        const [files, setFiles] = useState([]);
        const [selected, setSelected] = useState(new Set());
        const [statuses, setStatuses] = useState({});
        const [pushing, setPushing] = useState(false);
        const [showConfig, setShowConfig] = useState(false);
        const [config, setConfigState] = useState(getConfig());
        const [toast, setToast] = useState(null);
        const [connStatus, setConnStatus] = useState('unknown');
        const [testing, setTesting] = useState(false);
        const [progress, setProgress] = useState({ cur: 0, total: 0, success: 0, failed: 0 });
        const [sortBy, setSortBy_] = useState(config.sortBy || 'name');
        const [sortDir, setSortDir_] = useState(config.sortDir || 'asc');

        const setSortBy = v => { setSortBy_(v); const c = { ...config, sortBy: v }; saveConfig(c); setConfigState(c); };
        const setSortDir = v => { setSortDir_(v); const c = { ...config, sortDir: v }; saveConfig(c); setConfigState(c); };

        const showToastMsg = (message, type = 'info') => {
            setToast({ message, type });
            setTimeout(() => setToast(null), 3000);
        };

        const doTest = async () => {
            if (!config.rpcUrl) return;
            setTesting(true); setConnStatus('testing');
            try { setConnStatus(await testAria2(config.rpcUrl, config.rpcToken) ? 'connected' : 'disconnected'); }
            catch { setConnStatus('disconnected'); }
            finally { setTesting(false); }
        };

        useEffect(() => {
            let pid = location.pathname.split('/').pop();
            if (pid === 'all') pid = '';
            getList(pid).then(r => r.files && setFiles(sortFiles(r.files, sortBy, sortDir))).catch(console.error);
            setTimeout(doTest, 500);
        }, []);

        useEffect(() => { setFiles(f => sortFiles(f, sortBy, sortDir)); }, [sortBy, sortDir]);

        const toggleSelect = (id, on) => setSelected(s => { const n = new Set(s); on ? n.add(id) : n.delete(id); return n; });
        const selectAll = on => setSelected(on ? new Set(files.map(f => f.id)) : new Set());

        const getAllFiles = async () => {
            const allFiles = [], folders = [];
            for (const id of selected) {
                const f = files.find(x => x.id === id);
                if (!f) continue;
                f.kind === 'drive#folder' ? folders.push({ id: f.id, name: f.name, path: f.name }) : allFiles.push({ ...f, path: '' });
            }
            while (folders.length > 0) {
                const folder = folders.shift();
                showToastMsg(t('scanning')(folder.name), 'info');
                try {
                    const res = await getList(folder.id);
                    if (res.files) for (const f of res.files) {
                        f.kind === 'drive#folder'
                            ? folders.push({ id: f.id, name: f.name, path: `${folder.path}/${f.name}` })
                            : allFiles.push({ ...f, path: folder.path });
                    }
                } catch (e) { console.error('Folder scan failed:', folder.name, e); }
            }
            return allFiles;
        };

        const pushToAria = async () => {
            if (selected.size === 0) return showToastMsg(t('selectFiles'), 'warning');
            if (!config.rpcUrl) { showToastMsg(t('configFirst'), 'error'); setShowConfig(true); return; }

            setPushing(true);
            const filesToPush = await getAllFiles();
            let success = 0, failed = 0;
            setProgress({ cur: 0, total: filesToPush.length, success: 0, failed: 0 });
            showToastMsg(t('preparing')(filesToPush.length), 'info');

            for (let i = 0; i < filesToPush.length; i++) {
                const file = filesToPush[i];
                try {
                    const dl = await getDownloadUrl(file.id);
                    if (dl.error_description) throw new Error(dl.error_description);

                    const params = [[ dl.web_content_link ], { out: dl.name }];
                    if (config.downloadPath) params[1].dir = config.downloadPath + (file.path || '');
                    if (config.customParams) config.customParams.split(';').forEach(p => {
                        const [k, v] = p.split('=');
                        if (k && v) params[1][k] = v;
                    });
                    if (config.rpcToken) params.unshift(`token:${config.rpcToken}`);

                    const res = await rpcCall(config.rpcUrl, { id: Date.now(), jsonrpc: '2.0', method: 'aria2.addUri', params });
                    if (res.result) { success++; setStatuses(p => ({ ...p, [file.id]: 'success' })); }
                    else throw new Error(res.error?.message || 'Unknown error');
                } catch {
                    failed++; setStatuses(p => ({ ...p, [file.id]: 'error' }));
                }
                setProgress({ cur: i + 1, total: filesToPush.length, success, failed });
                if (i < filesToPush.length - 1) await delay(100);
            }

            showToastMsg(t('pushDone')(success, failed), failed === 0 ? 'success' : success === 0 ? 'error' : 'warning');
            setPushing(false);
        };

        if (showConfig) return html`<${ConfigPanel} config=${config} onSave=${c => setConfigState(c)} onClose=${() => setShowConfig(false)} />`;

        return html`
            <div style="position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:10000"
                 onClick=${e => e.target === e.currentTarget && onClose()}>
                <${Toast} ...${{ ...toast }} />
                <div style="background:#fff;border-radius:8px;padding:24px;box-shadow:0 10px 25px rgba(0,0,0,.2);width:90%;max-width:800px;max-height:80vh;display:flex;flex-direction:column"
                     onClick=${e => e.stopPropagation()}>
                    <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;border-bottom:1px solid #ebeef5;padding-bottom:16px">
                        <h2 style="margin:0;font-size:18px">${t('pushToAria2')}</h2>
                        <button onClick=${onClose} style="background:none;border:none;font-size:24px;cursor:pointer;color:${colors.secondary}">×</button>
                    </div>

                    <${ConnectionStatus} status=${connStatus} onTest=${doTest} testing=${testing} />

                    <div style="display:flex;justify-content:space-between;align-items:center;padding:12px;background:#f8f9fa;border-radius:6px;margin-bottom:16px">
                        <label style="display:flex;align-items:center">
                            <input type="checkbox" checked=${selected.size === files.length && files.length > 0}
                                onChange=${e => selectAll(e.target.checked)} style="margin-right:8px" />
                            ${t('selectAll')}
                        </label>
                        <div style="display:flex;align-items:center;gap:8px">
                            <select value=${sortBy} onChange=${e => setSortBy(e.target.value)}
                                style="padding:4px 8px;border-radius:4px;border:1px solid #dcdfe6">
                                <option value="name">${t('name')}</option>
                                <option value="size">${t('size')}</option>
                                <option value="created_time">${t('createdTime')}</option>
                                <option value="modified_time">${t('modifiedTime')}</option>
                            </select>
                            <select value=${sortDir} onChange=${e => setSortDir(e.target.value)}
                                style="padding:4px 8px;border-radius:4px;border:1px solid #dcdfe6">
                                <option value="asc">${t('asc')}</option>
                                <option value="desc">${t('desc')}</option>
                            </select>
                        </div>
                    </div>

                    <div style="flex:1;overflow-y:auto;max-height:400px">
                        ${files.map(f => html`<${FileItem} key=${f.id} file=${f} selected=${selected.has(f.id)}
                            onSelect=${toggleSelect} status=${statuses[f.id]} sortBy=${sortBy} />`)}
                    </div>

                    ${pushing && html`<div style="padding:12px;background:#f0f9ff;border-radius:6px;margin-top:16px">
                        ${t('progress')(progress.cur, progress.total, progress.success, progress.failed)}</div>`}

                    <div style="display:flex;justify-content:flex-end;gap:12px;margin-top:20px;padding-top:16px;border-top:1px solid #ebeef5">
                        <button onClick=${() => setShowConfig(true)}
                            style="padding:8px 16px;border:1px solid #dcdfe6;border-radius:4px;cursor:pointer;background:#fff">${t('configAria2')}</button>
                        <button onClick=${pushToAria} disabled=${pushing || selected.size === 0}
                            style="padding:8px 16px;border:none;border-radius:4px;cursor:pointer;color:#fff;background:${pushing || selected.size === 0 ? '#c0c4cc' : colors.blue}">
                            ${pushing ? t('pushing') : t('pushBtn')(selected.size)}</button>
                    </div>
                </div>
            </div>`;
    }

    // ─── Init ───

    function initApp() {
        if (location.pathname === '/') return;
        const ops = document.querySelector('.file-operations');
        if (!ops) return setTimeout(initApp, 1000);
        if (ops.querySelector('.aria2-helper-button')) return;

        const li = document.createElement('li');
        li.className = 'icon-with-label aria2-helper-button';
        li.innerHTML = `
            <a aria-label="${t('aria2Download')}" class="pp-link-button hover-able" href="javascript:void(0)">
                <span class="icon-hover-able pp-icon" style="--icon-color:var(--color-secondary-text);--icon-color-hover:var(--color-primary);display:flex;flex:0 0 24px;width:24px;height:24px">
                    <svg fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
                        <path stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/>
                    </svg>
                </span>
                <span class="label">${t('aria2Download')}</span>
            </a>`;

        li.addEventListener('click', e => {
            e.preventDefault();
            e.stopPropagation();
            if (document.getElementById('pikpak-aria2-helper-modal')) return;
            const container = document.createElement('div');
            container.id = 'pikpak-aria2-helper-modal';
            document.body.appendChild(container);
            render(html`<${Aria2Modal} onClose=${() => { render(null, container); container.remove(); }} />`, container);
        });

        const divider = ops.querySelector('.divider-in-operations');
        divider ? ops.insertBefore(li, divider) : ops.appendChild(li);
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initApp);
    else setTimeout(initApp, 1000);

})();