YouTube MP3 Auto Downloader

Adds a floating button to YouTube pages to download audio as MP3 via a local server, with concurrent download support.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         YouTube MP3 Auto Downloader
// @namespace    Violentmonkey Scripts
// @version      3.0
// @description  Adds a floating button to YouTube pages to download audio as MP3 via a local server, with concurrent download support.
// @match        https://www.youtube.com/*
// @grant        none
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';
    const FLASK_URL = "http://localhost:8888";

    // --- UI Elements ---

    // 1. Container for all our UI
    const uiContainer = document.createElement('div');
    uiContainer.style.cssText = `
        position: fixed;
        bottom: 20px;
        right: 20px;
        z-index: 9999;
        display: flex;
        flex-direction: column;
        align-items: flex-end;
        gap: 10px;
    `;

    // 2. Container for status notifications
    const statusContainer = document.createElement('div');
    statusContainer.id = 'yt-dl-status-container';
    statusContainer.style.cssText = `
        display: flex;
        flex-direction: column;
        align-items: flex-end;
        gap: 8px;
    `;

    // 3. The main download button
    const mainBtn = document.createElement('button');
    mainBtn.id = 'yt-dl-main-btn';
    mainBtn.textContent = '下載 MP3';
    mainBtn.style.cssText = `
        background: #1DA1F2; /* Twitter Blue */
        color: white;
        border: none;
        border-radius: 50px;
        padding: 12px 20px;
        font-size: 16px;
        font-weight: bold;
        cursor: pointer;
        box-shadow: 0 4px 12px rgba(0,0,0,0.2);
        transition: transform 0.2s ease-in-out, background-color 0.2s;
        width: 150px; /* Fixed width for consistent look */
        text-align: center;
    `;

    mainBtn.onmouseover = () => { mainBtn.style.transform = 'scale(1.05)'; };
    mainBtn.onmouseout = () => { mainBtn.style.transform = 'scale(1)'; };

    // --- Logic ---

    function createStatusElement(text) {
        const el = document.createElement('div');
        el.textContent = text;

        el.style.cssText = `
            background-color: rgba(0, 0, 0, 0.7);
            color: white;
            padding: 8px 15px;
            border-radius: 20px;
            font-size: 14px;
            box-shadow: 0 2px 8px rgba(0,0,0,0.2);
            opacity: 0;
            transform: translateX(20px);
            transition: opacity 0.3s, transform 0.3s;
        `;
        statusContainer.appendChild(el);
        // Animate in
        setTimeout(() => {
            el.style.opacity = '1';
            el.style.transform = 'translateX(0)';
        }, 10);
        return el;
    }

    async function handleDownload(task_id, statusEl) {
        // Poll for status
        const pollInterval = setInterval(async () => {
            try {
                const statusResponse = await fetch(`${FLASK_URL}/status/${task_id}`);
                if (!statusResponse.ok) {
                    throw new Error(`狀態檢查失敗: ${statusResponse.statusText}`);
                }

                const { status, message: errorMessage } = await statusResponse.json();

                if (status === 'done') {
                    clearInterval(pollInterval);
                    statusEl.textContent = '準備下載...';
                    statusEl.style.backgroundColor = '#28a745'; // Green

                    // Fetch the actual file
                    const fileResponse = await fetch(`${FLASK_URL}/get-file/${task_id}`);
                    if (!fileResponse.ok) throw new Error(`獲取檔案失敗: ${await fileResponse.text()}`);

                    const disposition = fileResponse.headers.get('Content-Disposition');
                    let filename = 'audio.mp3';
                    if (disposition && disposition.includes('attachment')) {
                        const filenameStarMatch = /filename\*=(?:.+?''(.+))/.exec(disposition);
                        if (filenameStarMatch && filenameStarMatch[1]) {
                            filename = decodeURIComponent(filenameStarMatch[1]);
                        } else {
                            const filenameMatch = /filename="([^"]+)"/.exec(disposition);
                            if (filenameMatch && filenameMatch[1]) filename = decodeURIComponent(filenameMatch[1]);
                        }
                    }

                    const blob = await fileResponse.blob();
                    const downloadUrl = window.URL.createObjectURL(blob);
                    const a = document.createElement('a');
                    a.style.display = 'none';
                    a.href = downloadUrl;
                    a.download = filename;
                    document.body.appendChild(a);
                    a.click();
                    window.URL.revokeObjectURL(downloadUrl);
                    a.remove();

                    statusEl.textContent = '下載完成!';
                    setTimeout(() => statusEl.remove(), 3000);

                } else if (status === 'error') {
                    clearInterval(pollInterval);
                    throw new Error(errorMessage || '伺服器發生未知錯誤。');
                }
            } catch (pollError) {
                clearInterval(pollInterval);
                console.error('Download failed:', pollError);
                statusEl.textContent = `失敗: ${pollError.message.substring(0, 50)}...`;
                statusEl.style.backgroundColor = '#dc3545'; // Red
                // Keep error message on screen longer
                setTimeout(() => statusEl.remove(), 10000);
            }
        }, 2000); // Poll every 2 seconds
    }

    mainBtn.onclick = async () => {
        const url = window.location.href;

        if (!url.includes('/watch?v=') && !url.includes('/shorts/')) {
            alert('請先進入一個 YouTube 影片或 Shorts 頁面。');
            return;
        }

        mainBtn.disabled = true;
        mainBtn.textContent = '請求中...';
        mainBtn.style.backgroundColor = '#ffc107'; // Yellow

        try {
            const startResponse = await fetch(`${FLASK_URL}/start-download?url=${encodeURIComponent(url)}`);
            if (!startResponse.ok) {
                const errorText = await startResponse.text();
                throw new Error(`啟動失敗: ${errorText}`);
            }
            const { task_id } = await startResponse.json();

            // Create a new status element for this task
            const statusEl = createStatusElement('處理中...');
            // Start polling for this specific task
            handleDownload(task_id, statusEl);

        } catch (initialError) {
            alert(`下載失敗!\n請確認伺服器已運行。\n\n錯誤: ${initialError.message}`);
        } finally {
            // Always re-enable the main button
            mainBtn.disabled = false;
            mainBtn.textContent = '下載 MP3';
            mainBtn.style.backgroundColor = '#1DA1F2';
        }
    };

    // Assemble the UI and add to the page
    uiContainer.appendChild(statusContainer);
    uiContainer.appendChild(mainBtn);
    document.body.appendChild(uiContainer);

})();