Bilibili DownloadX

Simple Bilibili video downloader with jQuery

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name          Bilibili DownloadX
// @version       1.1.1
// @description   Simple Bilibili video downloader with jQuery
// @author        Claude Assist
// @copyright     2025, Claude Assist
// @license       MIT
// @match         *://www.bilibili.com/video/av*
// @match         *://www.bilibili.com/video/BV*
// @match         *://www.bilibili.com/bangumi/play/ep*
// @match         *://www.bilibili.com/bangumi/play/ss*
// @match         *://www.bilibili.com/cheese/play/ep*
// @match         *://www.bilibili.com/cheese/play/ss*
// @require       https://static.hdslb.com/js/jquery.min.js
// @grant         none
// @namespace https://github.com/injahow/bilibili-download-only
// ==/UserScript==

(function() {
    'use strict';

    // ===== Configuration Settings =====
    const CONFIG = {
        // Download settings
        download_type: 'blob', // blob | rpc | ariang | web
        format: 'mp4', // mp4 | flv | dash
        base_api: 'https://api.bilibili.com/x/player/playurl', // Official Bilibili API

        // RPC settings (for RPC download)
        rpc_domain: 'http://localhost',
        rpc_port: '16800',
        rpc_token: '',
        rpc_dir: '',

        // AriaNG settings (for AriaNG download)
        ariang_host: 'http://ariang.injahow.com/',
        aria2c_connection_level: 'min', // min | mid | max

        // Other settings
        auto_download: false,
        video_quality: '80', // Default quality
        request_type: 'local' // local | remote
    };

    // ===== Quality Mapping Table =====
    const QUALITY_MAP = {
        "8K Ultra HD": 127,
        "4K Ultra HD": 120,
        "1080P 60fps": 116,
        "1080P High Bitrate": 112,
        "1080P HD": 80,
        "720P HD": 64,
        "480P SD": 32,
        "360P Smooth": 16,
        "Auto": 32
    };

    // ===== CDN Host Mapping =====
    const HOST_MAP = {
        local: null, // Local CDN
        bd: "upos-sz-mirrorbd.bilivideo.com",
        ks3: "upos-sz-mirrorks3.bilivideo.com",
        kodob: "upos-sz-mirrorkodob.bilivideo.com",
        cos: "upos-sz-mirrorcos.bilivideo.com",
        hk: "cn-hk-eq-bcache-01.bilivideo.com"
    };

    // ===== Message Display System =====
    class MessageSystem {
        static show(type, message) {
            const colors = {
                success: '#4caf50',
                error: '#f44336',
                warning: '#ff9800',
                info: '#2196f3'
            };

            // Create message container if not exists
            if (!$('#bp_messages')[0]) {
                $('body').append('<div id="bp_messages" style="position:fixed;top:20px;left:50%;transform:translateX(-50%);z-index:10000;"></div>');
            }

            const msgId = 'msg_' + Date.now();
            const msgElement = `<div id="${msgId}" style="background:${colors[type]};color:white;padding:10px 20px;border-radius:4px;margin:5px auto;opacity:0;transition:opacity 0.3s;">
                ${message}
            </div>`;

            $('#bp_messages').append(msgElement);

            // Show animation
            setTimeout(() => $(`#${msgId}`).css('opacity', '1'), 10);

            // Auto remove after 3 seconds
            setTimeout(() => $(`#${msgId}`).animate({opacity: 0}, 300, () => $(`#${msgId}`).remove()), 3000);
        }

        static confirm(message, onConfirm, onCancel) {
            const html = `
                <div id="bp_confirm" style="position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);z-index:10001;">
                    <div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);background:white;padding:20px;border-radius:8px;min-width:300px;text-align:center;">
                        <p>${message}</p>
                        <button id="bp_confirm_yes" style="padding:8px 16px;margin:0 10px;background:#4caf50;color:white;border:none;border-radius:4px;cursor:pointer;">Confirm</button>
                        <button id="bp_confirm_no" style="padding:8px 16px;margin:0 10px;background:#f44336;color:white;border:none;border-radius:4px;cursor:pointer;">Cancel</button>
                    </div>
                </div>
            `;

            $('body').append(html);

            $('#bp_confirm_yes').click(() => {
                $('#bp_confirm').remove();
                if (onConfirm) onConfirm();
            });

            $('#bp_confirm_no').click(() => {
                $('#bp_confirm').remove();
                if (onCancel) onCancel();
            });
        }
    }

    // ===== Video Information Processor =====
    class VideoInfo {
        static getCurrentVideoInfo() {
            try {
                // Handle standard video pages
                if (window.__INITIAL_STATE__ && window.__INITIAL_STATE__.videoData) {
                    const state = window.__INITIAL_STATE__;
                    const currentPage = state.p || 1;
                    const pageData = state.videoData.pages?.[currentPage - 1] || state.videoData.pages?.[0];

                    return {
                        type: 'video',
                        aid: String(state.videoData.aid),
                        bvid: state.videoData.bvid,
                        cid: String(pageData?.cid || state.videoData.cid || 0),
                        epid: '',
                        sid: '',
                        title: pageData ? `${state.videoData.title} P${currentPage} ${pageData.part}` : state.videoData.title,
                        pages: state.videoData.pages?.length || 1,
                        currentPage: currentPage
                    };
                }

                // Handle bangumi/anime pages
                if (window.__INITIAL_STATE__ && window.__INITIAL_STATE__.epInfo) {
                    const state = window.__INITIAL_STATE__;
                    return {
                        type: 'bangumi',
                        aid: String(state.epInfo.aid),
                        bvid: state.epInfo.bvid,
                        cid: String(state.epInfo.cid),
                        epid: String(state.epInfo.id),
                        sid: String(state.mediaInfo?.season_id || ''),
                        title: state.epInfo.long_title ? `${state.mediaInfo.title} ${state.epInfo.title_format} ${state.epInfo.long_title}` : state.mediaInfo.title,
                        pages: 1
                    };
                }

                // Handle course pages
                if (window.__INITIAL_STATE__ && window.__INITIAL_STATE__.videoInfo) {
                    const state = window.__INITIAL_STATE__;
                    return {
                        type: 'cheese',
                        aid: String(state.videoInfo.aid),
                        bvid: state.videoInfo.bvid,
                        cid: String(state.videoInfo.cid),
                        epid: String(state.videoInfo.id),
                        sid: String(state.seasonInfo?.id || ''),
                        title: state.videoInfo.title,
                        pages: 1
                    };
                }

                // Parse from URL as fallback
                return this.parseFromURL();

            } catch (error) {
                console.error('Failed to get video info:', error);
                return this.parseFromURL();
            }
        }

        // Parse video info from URL
        static parseFromURL() {
            try {
                const url = window.location.href;
                const urlMatch = url.match(/\/(video|bangumi|cheese)\/(?:av(\d+)|BV([A-Za-z0-9]+)|ep(\d+)|ss(\d+))/);

                if (!urlMatch) return null;

                const [, type, avId, bvId, epId, ssId] = urlMatch;

                if (type === 'video') {
                    return {
                        type: 'video',
                        aid: avId || '',
                        bvid: bvId || '',
                        cid: '',
                        epid: '',
                        sid: '',
                        title: document.title.replace(' - bilibili', ''),
                        pages: 1
                    };
                } else if (type === 'bangumi') {
                    return {
                        type: 'bangumi',
                        aid: '',
                        bvid: '',
                        cid: '',
                        epid: epId || '',
                        sid: ssId || '',
                        title: document.title.replace(' - bilibili', ''),
                        pages: 1
                    };
                } else if (type === 'cheese') {
                    return {
                        type: 'cheese',
                        aid: '',
                        bvid: '',
                        cid: '',
                        epid: epId || '',
                        sid: ssId || '',
                        title: document.title.replace(' - bilibili', ''),
                        pages: 1
                    };
                }

                return null;
            } catch (error) {
                console.error('URL parsing failed:', error);
                return null;
            }
        }

        static getVideoQuality() {
            try {
                // Try to get current selected quality
                const qualitySelectors = [
                    'li.bpx-player-ctrl-quality-menu-item.bpx-state-active',
                    'li.squirtle-select-item.active',
                    '.bilibili-player-video-quality-menu-list .active'
                ];

                for (const selector of qualitySelectors) {
                    const element = $(selector);
                    if (element.length > 0) {
                        const quality = element.attr('data-value') || element.attr('data-qn');
                        if (quality) return quality;
                    }
                }

                // Get from player
                if (window.player && window.player.getQuality) {
                    return window.player.getQuality();
                }

                return CONFIG.video_quality;
            } catch (error) {
                console.warn('Failed to get quality:', error);
                return CONFIG.video_quality;
            }
        }

        // Get available quality options
        static getAvailableQualities() {
            try {
                const qualities = [];
                const qualityElements = $('li.bpx-player-ctrl-quality-menu-item, li.squirtle-select-item');

                qualityElements.each(function() {
                    const quality = $(this).attr('data-value') || $(this).attr('data-qn');
                    if (quality && !qualities.includes(quality)) {
                        qualities.push(quality);
                    }
                });

                return qualities.length > 0 ? qualities : ['80', '64', '32', '16'];
            } catch (error) {
                console.warn('Failed to get available qualities:', error);
                return ['80', '64', '32', '16'];
            }
        }
    }

    // ===== Download Core System =====
    class DownloadManager {
        constructor() {
            this.isDownloading = false;
            this.controller = null;
        }

        showProgress(message, percent, loaded, total) {
            const progressHtml = `
                <div style="margin: 8px 0; padding: 8px; background: #f8f9fa; border-radius: 6px; border-left: 3px solid #4caf50;">
                    <div style="font-size: 12px; color: #333; margin-bottom: 4px;">${message}</div>
                    <div style="width: 100%; background: #e0e0e0; height: 6px; border-radius: 3px; overflow: hidden;">
                        <div style="width: ${percent || 0}%; height: 100%; background: linear-gradient(90deg,#4caf50,#66bb6a); transition: width 0.3s;"></div>
                    </div>
                    <div style="font-size: 11px; margin-top: 4px; color: #666;">
                        ${loaded ? `${this.formatBytes(loaded)} / ${this.formatBytes(total)}` : ''}
                    </div>
                </div>
            `;

            // Add to download panel instead of floating
            if (!$('#bp_download_progress')[0]) {
                $('#bp_content').append('<div id="bp_download_progress"></div>');
            }

            $('#bp_download_progress').html(progressHtml);

            // Auto remove when complete
            if (percent >= 100) {
                setTimeout(() => {
                    $('#bp_download_progress').slideUp(300, function() {
                        $(this).remove();
                    });
                }, 2000);
            }
        }

        formatBytes(bytes) {
            if (bytes === 0) return '0 B';
            const k = 1024;
            const sizes = ['B', 'KB', 'MB', 'GB'];
            const i = Math.floor(Math.log(bytes) / Math.log(k));
            return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
        }

        // Main download function - supports multiple download methods
        async download(url, filename, type = CONFIG.download_type) {
            if (this.isDownloading) {
                MessageSystem.show('warning', 'Download in progress, please wait');
                return;
            }

            this.isDownloading = true;
            filename = this.sanitizeFilename(filename);

            try {
                switch (type) {
                    case 'blob':
                        await this.downloadAsBlob(url, filename);
                        break;
                    case 'web':
                        this.downloadViaBrowser(url, filename);
                        break;
                    case 'rpc':
                        await this.downloadViaRPC(url, filename);
                        break;
                    case 'ariang':
                        this.downloadViaAriaNG(url, filename);
                        break;
                    default:
                        throw new Error('Unsupported download method');
                }

                MessageSystem.show('success', `Download task submitted: ${filename}`);
            } catch (error) {
                MessageSystem.show('error', `Download failed: ${error.message}`);
            } finally {
                this.isDownloading = false;
            }
        }

        // Blob download (recommended)
        async downloadAsBlob(url, filename) {
            return new Promise((resolve, reject) => {
                const xhr = new XMLHttpRequest();
                xhr.open('GET', url);
                xhr.responseType = 'blob';

                xhr.onprogress = (e) => {
                    if (e.lengthComputable) {
                        const percent = (e.loaded / e.total) * 100;
                        this.showProgress(`Downloading: ${filename}`, percent, e.loaded, e.total);
                    }
                };

                xhr.onload = () => {
                    if (xhr.status === 200) {
                        const blob = xhr.response;
                        this.saveBlob(blob, filename);
                        resolve();
                    } else {
                        reject(new Error(`HTTP ${xhr.status}`));
                    }
                };

                xhr.onerror = () => reject(new Error('Network connection failed'));
                xhr.send();
            });
        }

        // Direct browser download
        downloadViaBrowser(url, filename) {
            const a = document.createElement('a');
            a.href = url;
            a.download = filename;
            a.style.display = 'none';
            document.body.appendChild(a);
            a.click();
            document.body.removeChild(a);
        }

        // RPC download
        async downloadViaRPC(url, filename) {
            const taskData = {
                url: url,
                filename: filename,
                rpc_dir: CONFIG.rpc_dir
            };

            const rpcBody = {
                id: btoa(`BParse_${Date.now()}_${Math.random()}`),
                jsonrpc: '2.0',
                method: 'aria2.addUri',
                params: [
                    `token:${CONFIG.rpc_token}`,
                    [url],
                    {
                        out: filename,
                        dir: CONFIG.rpc_dir,
                        header: [`User-Agent: ${navigator.userAgent}`]
                    }
                ]
            };

            try {
                const response = await fetch(`${CONFIG.rpc_domain}:${CONFIG.rpc_port}/jsonrpc`, {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify(rpcBody)
                });

                if (response.ok) {
                    MessageSystem.show('success', 'RPC download task submitted');
                } else {
                    throw new Error('RPC server response error');
                }
            } catch (error) {
                throw new Error(`RPC download failed: ${error.message}`);
            }
        }

        // AriaNG download
        downloadViaAriaNG(url, filename) {
            const ariaUrl = `${CONFIG.ariang_host}#!/new/task?url=${encodeURIComponent(btoa(url))}&out=${encodeURIComponent(filename)}`;
            window.open(ariaUrl, '_blank');
        }

        // Save blob to local
        saveBlob(blob, filename) {
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url;
            a.download = filename;
            a.style.display = 'none';
            document.body.appendChild(a);
            a.click();
            document.body.removeChild(a);
            URL.revokeObjectURL(url);
        }

        // Sanitize filename
        sanitizeFilename(filename) {
            return filename.replace(/[\/\\*|<>:"?]/g, '_')
                          .replace(///g, '_')
                          .replace(/\/g, '_')
                          .replace(/|/g, '|')
                          .replace(/</g, '<')
                          .replace(/>/g, '>')
                          .replace(/:/g, ':')
                          .replace(/*/g, '*')
                          .replace(/?/g, '?')
                          .replace(/"/g, "'");
        }

        // Cancel download
        cancelDownload() {
            if (this.controller) {
                this.controller.abort();
                this.controller = null;
            }
            $('#bp_progress').remove();
            this.isDownloading = false;
        }
    }

    // ===== API Request System =====
    class APIRequest {
        constructor() {
            this.baseAPI = CONFIG.base_api;
            this.sessionId = Date.now() + Math.random();
            this.lastRequest = null;
            this.retryCount = 0;
            this.maxRetries = 3;
        }

        // Get video download URL
        async getVideoURL(videoInfo, quality = null) {
            const requestId = Date.now() + Math.random();

            const params = {
                avid: videoInfo.aid,
                bvid: videoInfo.bvid,
                cid: videoInfo.cid,
                qn: quality || VideoInfo.getVideoQuality(),
                fnver: 0,
                fnval: CONFIG.format === 'dash' ? 4048 : 0,
                fourk: 1,
                platform: 'html5',
                high_quality: 1
            };

            // Add bangumi specific parameters
            if (videoInfo.type === 'bangumi') {
                params.ep_id = videoInfo.epid;
                params.season_id = videoInfo.sid;
            }

            const apiUrl = this.buildAPIURL(params);

            try {
                MessageSystem.show('info', 'Requesting video URL...');

                const response = await this.makeRequest(apiUrl);
                const data = await response.json();

                if (data.code === 0 && (data.result || data.data)) {
                    return this.processVideoResult(data.result || data.data);
                } else if (data.code === -352) {
                    // Need to retry with backup method
                    return await this.retryWithBackup(videoInfo, quality);
                } else {
                    throw new Error(data.message || `API Error Code: ${data.code}`);
                }

            } catch (error) {
                MessageSystem.show('error', `API request failed: ${error.message}`);

                // Retry if attempts remaining
                if (this.retryCount < this.maxRetries) {
                    this.retryCount++;
                    MessageSystem.show('info', `Retrying (${this.retryCount}/${this.maxRetries})...`);
                    await this.delay(1000 * this.retryCount); // Incremental delay
                    return this.getVideoURL(videoInfo, quality);
                }

                throw error;
            }
        }

        // Send request - use XMLHttpRequest to avoid CORS issues
        async makeRequest(url) {
            return new Promise((resolve, reject) => {
                const xhr = new XMLHttpRequest();
                xhr.open('GET', url, true);
                xhr.withCredentials = true; // Include cookies

                // Set only safe headers
                xhr.setRequestHeader('Accept', 'application/json, text/plain, */*');

                xhr.onreadystatechange = function() {
                    if (xhr.readyState === 4) {
                        if (xhr.status >= 200 && xhr.status < 300) {
                            try {
                                const data = JSON.parse(xhr.responseText);
                                resolve({
                                    ok: true,
                                    json: () => Promise.resolve(data)
                                });
                            } catch (e) {
                                resolve({
                                    ok: true,
                                    json: () => Promise.resolve({ code: -1, message: 'Response parsing failed' })
                                });
                            }
                        } else {
                            reject(new Error(`HTTP ${xhr.status}: ${xhr.statusText}`));
                        }
                    }
                };

                xhr.onerror = function() {
                    reject(new Error('Network request failed'));
                };

                xhr.send();
            });
        }

        // Backup request method
        async retryWithBackup(videoInfo, quality) {
            MessageSystem.show('info', 'Trying backup request method...');

            const backupParams = {
                avid: videoInfo.aid,
                bvid: videoInfo.bvid,
                cid: videoInfo.cid,
                qn: quality || VideoInfo.getVideoQuality(),
                fnver: 0,
                fnval: CONFIG.format === 'dash' ? 4048 : 0,
                fourk: 1
            };

            const backupUrl = `https://api.bilibili.com/x/player/playurl?${this.buildQueryString(backupParams)}`;

            try {
                const response = await this.makeRequest(backupUrl);
                const data = await response.json();

                if (data.code === 0 && data.data) {
                    return this.processVideoResult(data.data);
                }
            } catch (error) {
                console.warn('Backup request also failed:', error);
            }

            throw new Error('All request methods failed');
        }

        // Delay function
        delay(ms) {
            return new Promise(resolve => setTimeout(resolve, ms));
        }

        // Build API URL
        buildAPIURL(params) {
            const queryString = Object.entries(params)
                .filter(([key, value]) => value !== '' && value !== null && value !== undefined)
                .map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
                .join('&');

            return `${this.baseAPI}?${queryString}`;
        }

        // Build query string
        buildQueryString(params) {
            return Object.entries(params)
                .filter(([key, value]) => value !== '' && value !== null && value !== undefined)
                .map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
                .join('&');
        }

        // Process API response result
        processVideoResult(result) {
            // Handle DASH format
            if (result.dash) {
                const videos = result.dash.video || [];
                const audios = result.dash.audio || [];

                if (videos.length === 0) {
                    throw new Error('No available video streams');
                }

                return {
                    type: 'dash',
                    video: this.selectBestMatch(videos),
                    audio: audios.length > 0 ? this.selectBestAudio(audios) : null,
                    quality: result.quality || result.dash.video[0].id
                };
            }

            // Handle normal format
            if (result.durl && result.durl.length > 0) {
                return {
                    type: 'normal',
                    url: result.durl[0].url,
                    backup_url: result.durl[0].backup_url || null,
                    quality: result.quality
                };
            }

            throw new Error('Unable to parse video URL');
        }

        // Select best matching video quality
        selectBestMatch(videos) {
            const targetQuality = parseInt(VideoInfo.getVideoQuality());

            // Sort by quality (high to low)
            videos.sort((a, b) => b.id - a.id);

            // Find best match
            for (const video of videos) {
                if (video.id <= targetQuality) {
                    return video.base_url;
                }
            }

            // If not found, select lowest quality
            return videos[videos.length - 1].base_url;
        }

        // Select best audio
        selectBestAudio(audios) {
            if (audios.length === 0) return null;

            // Prefer high quality audio
            audios.sort((a, b) => b.id - a.id);
            return audios[0].base_url;
        }
    }

    // ===== Download Queue System =====
    class DownloadQueue {
        constructor() {
            this.queue = [];
            this.isProcessing = false;
        }

        add(videoInfo) {
            // Check if already in queue
            const exists = this.queue.find(item => item.aid === videoInfo.aid && item.bvid === videoInfo.bvid);
            if (exists) {
                MessageSystem.show('warning', 'Video already in download queue');
                return;
            }

            this.queue.push({
                ...videoInfo,
                id: Date.now(),
                status: 'pending',
                addedAt: new Date()
            });

            this.updateUI();
            MessageSystem.show('success', `Added to queue: ${videoInfo.title}`);
        }

        remove(id) {
            this.queue = this.queue.filter(item => item.id !== id);
            this.updateUI();
        }

        updateUI() {
            const queueHtml = this.queue.map(item => `
                <div style="display:flex;align-items:flex-start;padding:5px 7px;border:1px solid #e0e0e0;border-radius:4px;margin-bottom:3px;background:#fafafa;min-height:28px;">
                    <div style="flex:1;min-width:0;margin-right:5px;max-width:calc(100% - 22px);">
                        <div style="font-size:10px;font-weight:500;color:#333;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;line-height:1.3;max-height:1.3em;">${item.title}</div>
                        <div style="font-size:9px;color:#666;margin-top:1px;">${item.status}</div>
                    </div>
                    <button onclick="window.BilibiliDownload.removeFromQueue(${item.id})" style="width:14px;height:14px;border-radius:50%;background:#ff4757;color:white;border:none;cursor:pointer;font-size:9px;display:flex;align-items:center;justify-content:center;flex-shrink:0;margin-top:1px;">×</button>
                </div>
            `).join('');

            if (!$('#bp_queue')[0]) {
                $('#bp_content').append('<div id="bp_queue" style="margin-top:8px;max-height:200px;overflow-y:auto;overflow-x:hidden;scrollbar-width:thin;scrollbar-color:#ccc #f0f0f0;"></div>');
            }

            $('#bp_queue').html(queueHtml || '<div style="text-align:center;color:#999;font-size:11px;padding:20px 10px;">No videos in queue</div>');

            // Adjust panel height based on content
            this.adjustPanelHeight();
        }

        adjustPanelHeight() {
            const queue = $('#bp_queue');
            const content = $('#bp_content');
            const panel = $('#bp_controls');

            if (queue.length && content.length && panel.length) {
                const queueHeight = Math.min(queue[0].scrollHeight, 200); // Match the new max-height
                const contentHeight = content[0].scrollHeight;
                const newHeight = Math.min(contentHeight + 40, 700); // Allow more height for better containment

                panel.css('max-height', newHeight + 'px');
            }
        }

        async processQueue() {
            if (this.isProcessing || this.queue.length === 0) return;

            this.isProcessing = true;

            for (const item of this.queue) {
                if (item.status === 'pending') {
                    try {
                        item.status = 'downloading';
                        this.updateUI();

                        // If cid is missing or pending, try to get it from the video page
                        let videoInfo = { ...item };
                        if (!item.cid || item.cid === '' || item.cid === 'pending') {
                            try {
                                item.status = 'getting_cid';
                                this.updateUI();

                                // Fetch the video page to get cid
                                const response = await fetch(item.url);
                                const html = await response.text();

                                // Extract cid from the HTML
                                const cidMatch = html.match(/"cid":(\d+)/) || html.match(/cid=(\d+)/);
                                if (cidMatch) {
                                    videoInfo.cid = cidMatch[1];
                                    MessageSystem.show('info', `Got CID: ${videoInfo.cid} for ${item.title}`);
                                } else {
                                    throw new Error('Could not extract CID from video page');
                                }
                            } catch (cidError) {
                                console.warn('Failed to get CID:', cidError);
                                item.status = 'failed';
                                this.updateUI();
                                continue;
                            }
                        }

                        const apiRequest = new APIRequest();
                        const videoData = await apiRequest.getVideoURL(videoInfo, CONFIG.video_quality);

                        const filename = `${videoInfo.title}.${CONFIG.format}`;
                        const downloadManager = new DownloadManager();

                        if (videoData.type === 'dash' && videoData.video && videoData.audio) {
                            await downloadManager.download(videoData.video, `video_temp.mp4`, CONFIG.download_type);
                            await downloadManager.download(videoData.audio, `audio_temp.m4a`, CONFIG.download_type);
                        } else {
                            await downloadManager.download(videoData.url, filename, CONFIG.download_type);
                        }

                        item.status = 'completed';
                        this.updateUI();

                    } catch (error) {
                        item.status = 'failed';
                        this.updateUI();
                        console.error('Download failed:', error);
                        MessageSystem.show('error', `Failed to download ${item.title}: ${error.message}`);
                    }
                }
            }

            this.isProcessing = false;
        }
    }

    // ===== UI Interface System =====
    class UIController {
        static init() {
            this.downloadQueue = new DownloadQueue();
            this.createFloatingButton();
            this.bindEvents();
            this.addVideoCardButtons();
        }

        static createFloatingButton() {
            const floatingHtml = `
                <div id="bp_floating" style="position:fixed;bottom:80px;right:20px;z-index:1000;">
                    <button id="bp_main_btn" style="width:56px;height:56px;border-radius:50%;background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);color:white;border:none;cursor:pointer;font-size:20px;display:flex;align-items:center;justify-content:center;box-shadow:0 4px 20px rgba(102,126,234,0.4);transition:all 0.3s ease;backdrop-filter:blur(10px);">
                        📥
                    </button>
                </div>
            `;

            if (!$('#bp_floating')[0]) {
                $('body').append(floatingHtml);
            }
        }

        static createControls() {
            const controlsHtml = `
                <style>
                    #bp_controls.bp-dragging {
                        opacity: 0.9;
                        transform: rotate(2deg);
                        box-shadow: 0 8px 30px rgba(0,0,0,0.3);
                    }
                    #bp_controls.bp-dragging * {
                        pointer-events: none;
                    }
                </style>
                <div id="bp_controls" style="position:fixed;top:60px;right:20px;z-index:1000;background:white;padding:15px;border:1px solid #e0e0e0;border-radius:12px;box-shadow:0 4px 20px rgba(0,0,0,0.15);min-width:200px;max-width:280px;max-height:600px;backdrop-filter:blur(10px);transition:all 0.3s ease;display:none;">
                    <div id="bp_header" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;cursor:pointer;user-select:none;">
                        <h3 style="margin:0;font-size:14px;color:#333;font-weight:600;">📥 Download</h3>
                        <button id="bp_toggle" style="width:20px;height:20px;border-radius:50%;background:#f0f0f0;color:#666;border:none;cursor:pointer;font-size:11px;display:flex;align-items:center;justify-content:center;transition:all 0.2s;">−</button>
                    </div>

                    <div id="bp_content" style="transition:all 0.3s ease;">
                        <div style="margin-bottom:8px;">
                            <label style="display:block;font-size:12px;margin-bottom:3px;color:#555;font-weight:500;">Quality</label>
                            <select id="bp_quality" style="width:100%;padding:6px;border:1px solid #e0e0e0;border-radius:4px;font-size:12px;background:#fafafa;">
                                <option value="127">8K Ultra HD</option>
                                <option value="120">4K Ultra HD</option>
                                <option value="112">1080P High</option>
                                <option value="80" selected>1080P HD</option>
                                <option value="64">720P HD</option>
                                <option value="32">480P SD</option>
                                <option value="16">360P</option>
                            </select>
                        </div>

                        <div style="margin-bottom:8px;">
                            <label style="display:block;font-size:12px;margin-bottom:3px;color:#555;font-weight:500;">Format</label>
                            <select id="bp_format" style="width:100%;padding:6px;border:1px solid #e0e0e0;border-radius:4px;font-size:12px;background:#fafafa;">
                                <option value="mp4" selected>MP4</option>
                                <option value="dash">DASH</option>
                                <option value="flv">FLV</option>
                            </select>
                        </div>

                        <div style="margin-bottom:10px;">
                            <label style="display:block;font-size:12px;margin-bottom:3px;color:#555;font-weight:500;">Method</label>
                            <select id="bp_method" style="width:100%;padding:6px;border:1px solid #e0e0e0;border-radius:4px;font-size:12px;background:#fafafa;">
                                <option value="blob" selected>Direct</option>
                                <option value="rpc">RPC</option>
                                <option value="ariang">AriaNG</option>
                                <option value="web">Browser</option>
                            </select>
                        </div>

                        <button id="bp_download_video" style="width:100%;padding:8px;background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);color:white;border:none;border-radius:6px;cursor:pointer;font-size:13px;font-weight:600;letter-spacing:0.5px;transition:all 0.2s;margin-bottom:8px;">🎬 Download Current</button>

                        <button id="bp_process_queue" style="width:100%;padding:6px;background:#4caf50;color:white;border:none;border-radius:4px;cursor:pointer;font-size:12px;font-weight:500;letter-spacing:0.5px;transition:all 0.2s;margin-bottom:8px;">▶ Start Queue</button>

                        <div id="bp_queue" style="max-height:200px;overflow-y:auto;border:1px solid #f0f0f0;border-radius:4px;padding:4px;background:#fafbfc;"></div>
                    </div>
                </div>
            `;

            if (!$('#bp_controls')[0]) {
                $('body').append(controlsHtml);
            }
        }

        static addVideoCardButtons() {
            // Add + buttons to video cards
            const addButtonsToCards = () => {
                $('.video-page-card-small').each(function() {
                    if (!$(this).find('.bp-add-btn').length) {
                        const addBtn = `
                            <button class="bp-add-btn" style="position:absolute;top:8px;right:8px;width:28px;height:28px;border-radius:50%;background:#4caf50;color:white;border:none;cursor:pointer;font-size:16px;display:flex;align-items:center;justify-content:center;z-index:10;opacity:0;transition:opacity 0.2s;">+</button>
                        `;
                        $(this).css('position', 'relative').append(addBtn);
                    }
                });
            };

            // Initial add
            addButtonsToCards();

            // Re-add on dynamic content changes
            const observer = new MutationObserver(() => {
                addButtonsToCards();
            });

            observer.observe(document.body, {
                childList: true,
                subtree: true
            });

            // Hover effects
            $(document).on('mouseenter', '.video-page-card-small', function() {
                $(this).find('.bp-add-btn').css('opacity', '1');
            });

            $(document).on('mouseleave', '.video-page-card-small', function() {
                $(this).find('.bp-add-btn').css('opacity', '0');
            });

            // Click handler
            $(document).on('click', '.bp-add-btn', function(e) {
                e.preventDefault();
                e.stopPropagation();

                const card = $(this).closest('.video-page-card-small');
                const link = card.find('a[href*="/video/"]').first();
                const href = link.attr('href');

                if (href) {
                    // Extract video info from URL
                    const urlMatch = href.match(/\/video\/(av\d+|BV[A-Za-z0-9]+)/);
                    if (urlMatch) {
                        const videoId = urlMatch[1];
                        const title = card.find('.title').text().trim() || 'Unknown Title';

                        // Try to get cid from various sources
                        let cid = '';

                        // Try to get from data attributes
                        cid = card.attr('data-cid') || card.attr('data-aid') || '';

                        // If still no cid, try to get it from the video page data
                        if (!cid) {
                            // For now, we'll add to queue without cid and get it later
                            // This prevents the validation error
                            cid = 'pending'; // Special marker to get cid later
                        }

                        const videoInfo = {
                            type: 'video',
                            aid: videoId.startsWith('av') ? videoId.substring(2) : '',
                            bvid: videoId.startsWith('BV') ? videoId : '',
                            cid: cid,
                            title: title,
                            url: href
                        };

                        window.BilibiliDownload.addToQueue(videoInfo);
                    }
                }
            });
        }

        static bindEvents() {
            let isCollapsed = false;
            let panelVisible = false;
            let isDragging = false;
            let dragOffset = { x: 0, y: 0 };

            // Floating button click - show/hide panel
            $('#bp_main_btn').click(function() {
                if (!panelVisible) {
                    // Show panel
                    UIController.createControls();
                    $('#bp_controls').fadeIn(300);
                    panelVisible = true;
                } else {
                    // Hide panel
                    $('#bp_controls').fadeOut(300);
                    panelVisible = false;
                }
            });

            // Toggle show/hide - click on header (for when panel is visible)
            $(document).on('click', '#bp_header', function() {
                const content = $('#bp_content');
                const toggleBtn = $('#bp_toggle');

                if (isCollapsed) {
                    // Expand
                    content.slideDown(300);
                    toggleBtn.text('−');
                    toggleBtn.css('transform', 'rotate(0deg)');
                    isCollapsed = false;
                } else {
                    // Collapse
                    content.slideUp(300);
                    toggleBtn.text('+');
                    toggleBtn.css('transform', 'rotate(180deg)');
                    isCollapsed = true;
                }
            });

            // Drag functionality for the panel
            $(document).on('mousedown', '#bp_header', function(e) {
                if (e.target === $('#bp_toggle')[0]) return; // Don't drag if clicking toggle button

                isDragging = true;
                const panel = $('#bp_controls');
                const panelRect = panel[0].getBoundingClientRect();

                dragOffset.x = e.clientX - panelRect.left;
                dragOffset.y = e.clientY - panelRect.top;

                // Add dragging class for visual feedback
                panel.addClass('bp-dragging');
                panel.css('cursor', 'grabbing');

                // Prevent text selection during drag
                e.preventDefault();
                return false;
            });

            $(document).on('mousemove', function(e) {
                if (!isDragging) return;

                const panel = $('#bp_controls');
                const newX = e.clientX - dragOffset.x;
                const newY = e.clientY - dragOffset.y;

                // Keep panel within viewport bounds
                const maxX = window.innerWidth - panel.outerWidth();
                const maxY = window.innerHeight - panel.outerHeight();

                const clampedX = Math.max(0, Math.min(newX, maxX));
                const clampedY = Math.max(0, Math.min(newY, maxY));

                panel.css({
                    left: clampedX + 'px',
                    top: clampedY + 'px',
                    right: 'auto' // Override the fixed positioning
                });
            });

            $(document).on('mouseup', function() {
                if (isDragging) {
                    isDragging = false;
                    const panel = $('#bp_controls');
                    panel.removeClass('bp-dragging');
                    panel.css('cursor', '');
                }
            });

            // Add hover cursor for draggable header
            $(document).on('mouseenter', '#bp_header', function() {
                if (!isDragging) {
                    $(this).css('cursor', 'grab');
                }
            });

            $(document).on('mouseleave', '#bp_header', function() {
                if (!isDragging) {
                    $(this).css('cursor', '');
                }
            });

            // Download current video button
            $(document).on('click', '#bp_download_video', async function() {
                const videoInfo = VideoInfo.getCurrentVideoInfo();
                if (!videoInfo) {
                    MessageSystem.show('error', 'Unable to get video info');
                    return;
                }

                const quality = $('#bp_quality').val();
                const format = $('#bp_format').val();
                const method = $('#bp_method').val();

                try {
                    MessageSystem.show('info', `Requesting quality: ${quality}`);

                    // Update global config
                    CONFIG.video_quality = quality;
                    CONFIG.format = format;
                    CONFIG.download_type = method;

                    // Request video URL
                    const apiRequest = new APIRequest();
                    const videoData = await apiRequest.getVideoURL(videoInfo, quality);

                    // Filename
                    const filename = `${videoInfo.title}.${CONFIG.format}`;

                    // Download
                    const downloadManager = new DownloadManager();

                    if (videoData.type === 'dash' && videoData.video && videoData.audio) {
                        // DASH format needs merging
                        MessageSystem.confirm('DASH format requires merging video and audio, may take some time', async () => {
                            // Download and merge - need to extend download manager for merge logic
                            await downloadManager.download(videoData.video, `video_temp.mp4`, method);
                            await downloadManager.download(videoData.audio, `audio_temp.m4a`, method);
                            MessageSystem.show('info', 'Please use video editing software to manually merge video and audio files');
                        });
                    } else {
                        // Show download URL info
                        console.log('Download URL:', videoData.url);
                        console.log('Filename:', filename);
                        console.log('Download method:', method);

                        await downloadManager.download(videoData.url, filename, method);
                    }

                } catch (error) {
                    MessageSystem.show('error', `Download failed: ${error.message}`);
                }
            });

            // Process queue button
            $(document).on('click', '#bp_process_queue', async function() {
                if (UIController.downloadQueue.queue.length === 0) {
                    MessageSystem.show('warning', 'No videos in queue');
                    return;
                }

                $(this).prop('disabled', true).text('⏳ Processing...');
                await UIController.downloadQueue.processQueue();
                $(this).prop('disabled', false).text('▶ Start Queue');
            });
        }

        static updateVideoInfo(videoInfo) {
            if (videoInfo) {
                $('#bp_controls').prepend(`<div style="margin-bottom:10px;font-size:12px;color:#666;border-bottom:1px solid #eee;padding-bottom:5px;">${videoInfo.title}</div>`);
            }
        }
    }

    // ===== Initialize Script =====
    function initDownloadUserscript() {
        console.log('🎬 Bilibili Download Script v1.1.0 loading...');

        // Check if on supported page
        if (!isSupportedPage()) {
            console.log('Current page does not support download functionality');
            return;
        }

        // Initialize UI controls
        UIController.init();

        // Wait for page to fully load
        waitForPageLoad().then(() => {
            // Check current page and initialize
            let checkInterval = setInterval(() => {
                const videoInfo = VideoInfo.getCurrentVideoInfo();
                if (videoInfo && videoInfo.aid && videoInfo.cid) {
                    UIController.updateVideoInfo(videoInfo);
                    clearInterval(checkInterval);

                    MessageSystem.show('success', `Loaded! Current video: ${videoInfo.title}`);
                    console.log('Video info:', videoInfo);
                }
            }, 1000);

            // Show warning if video not detected after 10 seconds
            setTimeout(() => {
                if (!$('#bp_controls')[0]) {
                    MessageSystem.show('warning', 'This page may not support download functionality or video info has not loaded yet');
                }
            }, 10000);
        });
    }

    // Check if on supported page
    function isSupportedPage() {
        const supportedPatterns = [
            /www\.bilibili\.com\/video\/(av|BV)/,
            /www\.bilibili\.com\/bangumi\/play\/(ep|ss)/,
            /www\.bilibili\.com\/cheese\/play\/(ep|ss)/,
            /www\.bilibili\.com\/list\//
        ];

        return supportedPatterns.some(pattern => pattern.test(window.location.href));
    }

    // Wait for page to fully load
    function waitForPageLoad() {
        return new Promise((resolve) => {
            // If page is already complete
            if (document.readyState === 'complete') {
                resolve();
                return;
            }

            // Wait for page load event
            window.addEventListener('load', () => {
                // Extra wait time to ensure dynamic content loads
                setTimeout(resolve, 1000);
            });

            // Or listen for specific Bilibili elements
            const checkBilibiliElements = () => {
                if (window.__INITIAL_STATE__ ||
                    document.querySelector('.bpx-player-container') ||
                    document.querySelector('.player-container')) {
                    resolve();
                } else {
                    setTimeout(checkBilibiliElements, 500);
                }
            };

            // Start checking Bilibili elements after 2 seconds
            setTimeout(checkBilibiliElements, 2000);
        });
    }

    // ===== Start Script =====
    // Initialize after page load
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', () => {
            // Extra wait to ensure all resources are loaded
            setTimeout(initDownloadUserscript, 500);
        });
    } else {
        // Page already loaded, initialize shortly
        setTimeout(initDownloadUserscript, 500);
    }

    // Add global object for developer debugging
    window.BilibiliDownload = {
        getVideoInfo: VideoInfo.getCurrentVideoInfo,
        getVideoURL: (videoInfo) => new APIRequest().getVideoURL(videoInfo),
        download: (url, filename, method) => new DownloadManager().download(url, filename, method),
        cancelDownload: () => new DownloadManager().cancelDownload(),
        addToQueue: (videoInfo) => UIController.downloadQueue.add(videoInfo),
        removeFromQueue: (id) => UIController.downloadQueue.remove(id),
        processQueue: () => UIController.downloadQueue.processQueue(),
        config: CONFIG
    };

})();