Bilibili DownloadX

Simple Bilibili video downloader with jQuery

// ==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
    };

})();