Greasy Fork

Greasy Fork is available in English.

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);

})();