< Project-30.GitHub_Apps/3.cobalt > imputnet/cobalt 用来下载视频的,以前它自己有个web, 现在没了 自己做个web套套 占用 9016 商品

前言:

GitHub 上的一个用于下载视频的项目: Cobalt 链接:https://github.com/imputnet/cobalt

以前有个 web 界面,后来升级没了。 刚才用 yt-dlp 想下载个视频又报错,看了一下,pip 升级后 docker 能用了,但 PC 上可能用了 VPN 报的要验证不是 bot ...

花点儿功夫,顺手把这个去年想做的界面搞定了。

简约界面

使用英文,说是因打字时总会有错字...

功能介绍:

cobalt:

cobalt helps you save anything from your favorite websites: video, audio, photos or gifs. just paste the link and you’re ready to rock!

no ads, trackers, paywalls, or other nonsense. just a convenient web app that works anywhere, whenever you need it.

https://cobalt.tools/about/general

抢劫/盗窃 “流行”网站的视频、音频。  (有买 Youtube Premium ,下载 Youtube 视频只是对这几个GitHub感兴趣,视频不会保存,版权是要尊重的。)

Cobalt Downloader  Web:

  • 下载主流网站的视频、音频
  • youtube 可以选择码率,为了省流量,默认选择是 480p
  • 只是调用 Cobalt API
  • 使用 docker-compose.yml 在 docker 部署,快~
  • 使用 flask 占用 9016 端口
  • js html css 是独立文件
  • Cobalt API 占用 9000 端口

Cobalt + Web 完整结构

1. 在 docker 上部署 Cobalt Instance 

GitHub 参考:https://github.com/imputnet/cobalt/blob/main/docs/run-an-instance.md

因为 Cobalt 用的是 docker compose up -d 来安装,没有 image 占地儿。Web UI 我也用这方法。

简单步骤:

        a. NAS 上给 Cobalt 项目做个目录,例:
[/share/Multimedia/2024-MyProgramFiles/30.GitHub_Apps/3.cobalt-main/cobalt] #
        b. 把 docker-compose.yml 放到这个目录里

GitHub 上的 sample file:  https://github.com/imputnet/cobalt/blob/main/docs/examples/docker-compose.example.yml

稍微学习了一下文件的格式,修改后如下:

如果你是第一次部署,建议使用我的这个 docker-compose.yml 文件,成功后再继续修改。

c. 完整的 docker-compose.yml:
services:
    cobalt-api:
        image: ghcr.io/imputnet/cobalt:10
        init: true
        read_only: true
        restart: unless-stopped
        container_name: cobalt-api
        ports:
            - 9000:9000/tcp
        environment:
            API_URL: "http://192.168.1.8:9000/"
            # 添加以下环境变量来启用格式列表
            DURATION_LIMIT: "10800"    # 3小时视频限制
            RATELIMIT_MAX: "30"        # 每个时间窗口允许的最大请求数
            RATELIMIT_WINDOW: "60"     # 时间窗口(秒)
            TUNNEL_LIFESPAN: "90"      # 下载链接有效期(秒)

        labels:
            - com.centurylinklabs.watchtower.scope=cobalt

    watchtower:
        image: ghcr.io/containrrr/watchtower
        restart: unless-stopped
        command: --cleanup --scope cobalt --interval 900 --include-restarting
        volumes:
            - /var/run/docker.sock:/var/run/docker.sock
d. 说明:

以下你可以自行修改:

  • 我没有使用 API KEY
  • cobalt-api 端口占用 9000
  • API_URL 使用的是我自己的 NAS IP 地址
e. 在 docker 上部署

进入 Cobalt 目录,运行: docker compose up -d

 [/share/Multimedia/2024-MyProgramFiles/30.GitHub_Apps/3.cobalt-main/cobalt] # docker compose up -d

完成后,会看到:

2. 部署 Web

a. 目录结构

cobalt-main/app/
│
├── app.py                  # Main application file 
├── requirements.txt        # dependencies
├── docker-compose.yml      # Docker configuration
├── Dockerfile              # Docker image build instructions
├── key.pem                 # SSL/TLS private key
├── cert.pem                # SSL/TLS certificate
├── templates/              # HTML directory
│   ├── 404.html            # Not Found errors
│   ├── 500.html            # Internal errors
│   ├── base.html           # Base 
│   └── index.html          # home page
│
└── static/                 
    ├── download.js         # JavaScript 
    ├── favicon.ico         
    └── css/
        └── style.css       # CSS 

b. 完整代码文件

1) app.py

from flask import Flask, request, jsonify, render_template, send_from_directory
import requests
import ssl
import os
import logging
from logging.handlers import RotatingFileHandler
from dotenv import load_dotenv

# 加载环境变量
load_dotenv()

# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
handler = RotatingFileHandler('logs/app.log', maxBytes=10000000, backupCount=5)
handler.setFormatter(logging.Formatter(
    '[%(asctime)s] %(levelname)s in %(module)s: %(message)s'
))
logger.addHandler(handler)

# Flask app configuration
app = Flask(__name__,
           template_folder='templates',  
           static_folder='static')

# 生产环境设置
app.config['ENV'] = os.getenv('FLASK_ENV', 'production')
app.config['DEBUG'] = os.getenv('FLASK_DEBUG', '0') == '1'

# Cobalt API configuration
COBALT_API_URL = os.getenv('COBALT_API_URL', 'http://192.168.1.8:9000')
API_KEY = os.getenv('COBALT_API_KEY')

# Security headers
@app.after_request
def add_security_headers(response):
    response.headers['X-Content-Type-Options'] = 'nosniff'
    response.headers['X-Frame-Options'] = 'DENY'
    response.headers['X-XSS-Protection'] = '1; mode=block'
    response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
    return response

@app.route('/', methods=['GET'])
def index():
    """Render the main page."""
    try:
        logger.info("Attempting to render index.html")
        return render_template('index.html')
    except Exception as e:
        logger.error(f"Error rendering template: {str(e)}")
        return render_template('500.html'), 500

@app.route('/static/<path:path>')
def serve_static(path):
    """Serve static files."""
    return send_from_directory('static', path)

@app.route('/api/download', methods=['POST'])
def download():
    """Handle download requests by proxying them to Cobalt API."""
    try:
        # 1. Validate request data
        data = request.get_json()
        if not data or 'url' not in data:
            logger.warning("Missing URL in request")
            return jsonify({
                'status': 'error',
                'message': 'URL is required'
            }), 400

        # 2. Log request info
        logger.info(f"Processing download request for URL: {data['url']}")
        logger.info(f"Using Cobalt API URL: {COBALT_API_URL}")

        # 3. Build base payload with mandatory parameters
        payload = {
            'url': data['url'],
            'filenameStyle': 'pretty'
        }

        # 4. Add mode-specific parameters
        mode = data.get('mode', 'auto')
        payload['downloadMode'] = mode

        # Handle video modes
        if mode in ['auto', 'mute']:
            payload.update({
                'videoQuality': data.get('videoQuality', 'max'),
                'youtubeVideoCodec': 'h264'
            })
        # Handle audio mode
        elif mode == 'audio':
            payload.update({
                'audioFormat': data.get('audioFormat', 'mp3'),
                'audioBitrate': data.get('audioBitrate', '320')
            })

        # 5. Add optional service-specific parameters
        if data.get('tiktokFullAudio'):
            payload['tiktokFullAudio'] = True
        
        if data.get('tiktokH265') is not None:
            payload['tiktokH265'] = data['tiktokH265']
        
        if data.get('twitterGif') is not None:
            payload['twitterGif'] = data['twitterGif']
        
        if data.get('youtubeDubLang'):
            payload['youtubeDubLang'] = data['youtubeDubLang']
        
        if data.get('youtubeHLS') is not None:
            payload['youtubeHLS'] = data['youtubeHLS']

        # 6. Add optional global parameters
        if data.get('alwaysProxy') is not None:
            payload['alwaysProxy'] = data['alwaysProxy']
        
        if data.get('disableMetadata') is not None:
            payload['disableMetadata'] = data['disableMetadata']

        # 7. Prepare request headers
        headers = {
            'Content-Type': 'application/json',
            'Accept': 'application/json'
        }

        # 8. Add API authentication if configured
        if API_KEY:
            headers['Authorization'] = f'Api-Key {API_KEY}'
            logger.info("Using API key authentication")

        # 9. Send request to Cobalt API
        logger.info(f"Sending request to Cobalt API with payload: {payload}")
        response = requests.post(
            f"{COBALT_API_URL}/",
            headers=headers,
            json=payload,
            timeout=30
        )

        # 10. Log response info
        logger.info(f"Cobalt API response status: {response.status_code}")
        logger.info(f"Cobalt API response headers: {dict(response.headers)}")

        # 11. Handle successful response
        if response.status_code == 200:
            logger.info("Successfully received response from Cobalt API")
            cobalt_data = response.json()
            logger.info(f"Cobalt API response data: {cobalt_data}")
            return jsonify(cobalt_data)

        # 12. Handle error response
        logger.error(f"Cobalt API error response: {response.text}")
        return jsonify({
            'status': 'error',
            'message': f'API Error: {response.status_code}'
        }), response.status_code

    except requests.RequestException as e:
        # 13. Handle network errors
        logger.error(f"Network error details: {str(e)}")
        return jsonify({
            'status': 'error',
            'message': f'Network error: {str(e)}'
        }), 500

    except Exception as e:
        # 14. Handle unexpected errors
        logger.error(f"Unexpected error details: {str(e)}")
        return jsonify({
            'status': 'error',
            'message': str(e)
        }), 500

@app.errorhandler(404)
def not_found_error(error):
    """Handle 404 errors."""
    logger.warning(f"404 error: {request.url}")
    return render_template('404.html'), 404

@app.errorhandler(500)
def internal_error(error):
    """Handle 500 errors."""
    logger.error(f"500 error: {error}")
    return render_template('500.html'), 500

if __name__ == '__main__':
    # Ensure logs directory exists
    os.makedirs('logs', exist_ok=True)
    
    logger.info("Starting Flask application...")
    logger.info(f"Working directory: {os.getcwd()}")
    logger.info(f"Directory contents: {os.listdir()}")
    logger.info(f"Templates directory contents: {os.listdir('templates') if os.path.exists('templates') else 'No templates directory'}")

    # SSL configuration
    context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
    try:
        context.load_cert_chain('cert.pem', 'key.pem')
        logger.info("Successfully loaded SSL certificates")
        app.run(host='0.0.0.0', port=9016, ssl_context=context)
    except Exception as e:
        logger.error(f"SSL Error: {e}")
        logger.info("Falling back to HTTP...")
        app.run(host='0.0.0.0', port=9016)

2) requirements.txt

flask
requests
python-dotenv
gunicorn

建议使用版本号,别学我。

3) docker-compose.yml

services:
  flask-app:
    build: .
    container_name: flask-cobalt
    network_mode: "host"  # 使用主机网络模式
    environment:
      - COBALT_API_URL=http://192.168.1.8:9000
      - FLASK_ENV=production
      - FLASK_DEBUG=1
    volumes:
      - ./static:/app/static:ro
      - ./templates:/app/templates:ro
      - ./cert.pem:/app/cert.pem:ro
      - ./key.pem:/app/key.pem:ro
      - ./logs:/app/logs
    restart: unless-stopped

COBALT_API_URL=http://192.168.1.8:9000 要与 cobalt docker-compose.yml 中的 environment:
API_URL: 一至

4) Dockerfile

FROM python:3.12-slim

WORKDIR /app

ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

RUN chmod 600 key.pem && chmod 644 cert.pem

CMD ["gunicorn", "--certfile=cert.pem", "--keyfile=key.pem", "--bind", "0.0.0.0:9016", "app:app"]

你需要自己准备 SSL 要用的 两个文件 cert.pem 与 key.pem 支持 SSH 连接。 如果没有,会运行在 http 下。

5) 404.html

{% extends "base.html" %}

{% block content %}
<div class="error-container">
    <h1>404 - Page Not Found</h1>
    <p>The page you're looking for doesn't exist.</p>
    <a href="/" class="back-link">Return to Home</a>
</div>
{% endblock content %}

6) 500.html 

{% extends "base.html" %}

{% block content %}
<div class="error-container">
    <h1>500 - Server Error</h1>
    <p>Something went wrong on our end. Please try again later.</p>
    <a href="/" class="back-link">Return to Home</a>
</div>
{% endblock content %}

7) base.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{% block title %}Cobalt Downloader{% endblock title %}</title>
    <link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}">
    <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
    <script src="{{ url_for('static', filename='download.js') }}" defer></script>
</head>
<body>
    <header>
        <nav>
            <div class="container">
                <h1>Cobalt Downloader</h1>
            </div>
        </nav>
    </header>

    <main class="container">
        {% block content %}{% endblock content %}
    </main>

    <footer>
        <div class="container">
            <p>&copy; 2024 Cobalt Downloader</p>
        </div>
    </footer>
</body>
</html>

8) index.html

{% extends "base.html" %}

{% block content %}
<style>
.format-list {
    margin-top: 1rem;
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
}

.format-button {
    background-color: var(--primary-color);
    color: white;
    border: none;
    padding: 0.5rem 1rem;
    border-radius: 4px;
    cursor: pointer;
    font-size: 0.9rem;
    transition: background-color 0.2s;
}

.format-button:hover {
    background-color: var(--secondary-color);
}

.option-group {
    background: #f8f9fa;
    padding: 1rem;
    border-radius: 4px;
    margin-bottom: 1rem;
}

.option-group h3 {
    font-size: 1rem;
    margin-bottom: 0.5rem;
    color: #666;
}

.option-description {
    font-size: 0.85rem;
    color: #666;
    margin-top: 0.25rem;
}

.select-wrapper {
    position: relative;
    margin-bottom: 1rem;
}

.select-wrapper::after {
    content: "▼";
    font-size: 0.8rem;
    position: absolute;
    right: 1rem;
    top: 50%;
    transform: translateY(-50%);
    pointer-events: none;
    color: #666;
}
</style>

<div class="download-form">
    <!-- URL Input -->
    <div class="form-group">
        <input type="url" id="url" placeholder="Enter video/audio URL" required>
    </div>

    <!-- Video Options -->
    <div class="option-group">
        <h3>Download Options</h3>
        <!-- Download Mode -->
        <div class="select-wrapper">
            <select id="mode" class="form-select">
                <option value="auto">Auto (Video with Audio)</option>
                <option value="audio">Audio Only</option>
                <option value="mute">Video Only (No Audio)</option>
            </select>
            <p class="option-description">Choose how you want to download the content</p>
        </div>

        <!-- Video Quality -->
        <div class="select-wrapper">
            <select id="videoQuality" class="form-select">
                <option value="max">Best Quality</option>
                <option value="2160">4K (2160p)</option>
                <option value="1440">2K (1440p)</option>
                <option value="1080">1080p</option>
                <option value="720">720p</option>
                <option value="480" selected>Default 480p</option> <!-- also need to modify download.js paylod value -->>
                <option value="360">360p</option>
            </select>
            <p class="option-description">Select video quality (for video downloads)</p>
        </div>

        <!-- Audio Quality (for audio mode) -->
        <div class="select-wrapper">
            <select id="audioBitrate" class="form-select">
                <option value="320">320 kbps</option>
                <option value="256">256 kbps</option>
                <option value="128">128 kbps</option>
                <option value="96">96 kbps</option>
                <option value="64">64 kbps</option>
            </select>
            <p class="option-description">Select audio quality (for audio-only downloads)</p>
        </div>
    </div>

    <!-- Download Button -->
    <button onclick="checkFormats()" class="download-btn">Download</button>

    <!-- Result Area -->
    <div id="result" class="result" style="display:none;">
        <div class="result-content">
            <p id="status"></p>
            <div id="format-list"></div>
            <a id="download-link" href="#" target="_blank" style="display:none;">Download File</a>
        </div>
    </div>
</div>
{% endblock content %}

9) download.js

// Initialize UI state
document.addEventListener('DOMContentLoaded', function() {
    resetResults();
});

// Main format check function
// Main format check function
function checkFormats() {
    const url = document.getElementById('url').value;
    if (!validateUrl(url)) {
        showError('Please enter a valid URL');
        return;
    }

    const payload = {
        url: url,
        filenameStyle: "pretty",
        downloadMode: document.getElementById('mode').value,
        videoQuality: document.getElementById('videoQuality').value || '480',  // 添加质量选择
        youtubeVideoCodec: 'h264'
    };

    const button = document.querySelector('.download-btn');
    const status = document.getElementById('status');
    const result = document.getElementById('result');
    
    button.disabled = true;
    status.textContent = 'Preparing download...';
    result.style.display = 'block';

    // 添加 TikTok 选项
    if (document.getElementById('tiktokFullAudio').checked) {
        payload.tiktokFullAudio = true;
    }

    // 添加 Twitter 选项
    if (document.getElementById('twitterGif').checked) {
        payload.twitterGif = true;
    }

    fetch('/api/download', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'Accept': 'application/json'
        },
        body: JSON.stringify(payload)
    })
    .then(response => response.json())
    .then(data => {
        button.disabled = false;
        if (data.status === 'tunnel' || data.status === 'redirect') {
            handleDirectDownload(data);
        } else {
            showError('Unexpected response from server');
        }
    })
    .catch(error => {
        button.disabled = false;
        showError(error.message);
    });
}

// Handle direct download response
function handleDirectDownload(data) {
    const status = document.getElementById('status');
    const downloadLink = document.getElementById('download-link');
    
    status.textContent = 'Ready to download!';
    downloadLink.href = data.url;
    downloadLink.textContent = data.filename || 'Download File';
    downloadLink.style.display = 'block';
}

// Handle format picker response
function handleFormatPicker(data) {
    const status = document.getElementById('status');
    const formatList = document.getElementById('format-list');
    
    status.textContent = 'Available formats:';
    formatList.innerHTML = '';
    
    const formatDiv = document.createElement('div');
    formatDiv.className = 'format-list';

    // Sort formats by quality (highest first)
    if (data.picker && Array.isArray(data.picker)) {
        data.picker.sort((a, b) => {
            // Extract numeric value from quality (e.g., "1080p" -> 1080)
            const getQualityNumber = (quality) => {
                if (!quality) return 0;
                const match = quality.match(/(\d+)/);
                return match ? parseInt(match[1]) : 0;
            };
            return getQualityNumber(b.quality) - getQualityNumber(a.quality);
        });

        // Create buttons for each format
        data.picker.forEach((item, index) => {
            const button = document.createElement('button');
            button.className = 'format-button';
            
            // Build detailed format description
            let description = [];
            
            // Add quality info
            if (item.quality) {
                description.push(item.quality);
            }

            // Add type (VIDEO/AUDIO)
            if (item.type) {
                description.push(item.type.toUpperCase());
            }

            // Add codec info
            if (item.codec) {
                description.push(`[${item.codec}]`);
            }

            // Add bitrate if available
            if (item.bitrate) {
                description.push(`${item.bitrate}kbps`);
            }

            // Add filesize if available
            if (item.filesize) {
                const size = formatFileSize(item.filesize);
                description.push(size);
            }

            button.textContent = description.join(' - ');
            button.onclick = () => startDownload(item.url, data.filename || `file_${index + 1}`);
            
            // Add hover tooltip with full details
            button.title = `Quality: ${item.quality || 'N/A'}\nCodec: ${item.codec || 'N/A'}\nBitrate: ${item.bitrate || 'N/A'}kbps`;
            
            formatDiv.appendChild(button);
        });
    }
    
    formatList.appendChild(formatDiv);
}

function handleAudioFormatPicker(data, mode) {
    const status = document.getElementById('status');
    const formatList = document.getElementById('format-list');
    
    status.textContent = 'Available audio formats:';
    formatList.innerHTML = '';
    
    const formatDiv = document.createElement('div');
    formatDiv.className = 'format-list';

    // Sort formats by bitrate (highest first)
    if (data.picker && Array.isArray(data.picker)) {
        data.picker
            .filter(item => mode === 'audio' ? item.type?.toLowerCase() === 'audio' : true)
            .sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0))
            .forEach((item, index) => {
                const button = document.createElement('button');
                button.className = 'format-button';
                
                // Build format description
                let description = [];
                
                if (item.codec) {
                    description.push(item.codec.toUpperCase());
                }
                
                if (item.bitrate) {
                    description.push(`${item.bitrate}kbps`);
                }

                if (item.filesize) {
                    const size = formatFileSize(item.filesize);
                    description.push(size);
                }

                button.textContent = description.join(' - ');
                button.onclick = () => startDownload(item.url, data.filename || `file_${index + 1}`);
                
                // Add hover tooltip
                button.title = `Format: ${item.codec || 'N/A'}\nBitrate: ${item.bitrate || 'N/A'}kbps`;
                
                formatDiv.appendChild(button);
            });
    }
    
    formatList.appendChild(formatDiv);

    // If no formats were found, show message
    if (!formatDiv.children.length) {
        status.textContent = 'No audio formats available';
    }
}

// Utility function to format file size
function formatFileSize(bytes) {
    if (!bytes) return '';
    const units = ['B', 'KB', 'MB', 'GB'];
    let size = bytes;
    let unitIndex = 0;
    
    while (size >= 1024 && unitIndex < units.length - 1) {
        size /= 1024;
        unitIndex++;
    }
    
    return `${size.toFixed(1)} ${units[unitIndex]}`;
}

// Start download for selected format
function startDownload(url, filename) {
    const downloadLink = document.getElementById('download-link');
    downloadLink.href = url;
    downloadLink.textContent = filename;
    downloadLink.style.display = 'block';
    document.getElementById('status').textContent = 'Ready to download!';
    downloadLink.click(); // Auto-start download
}

// Reset UI elements
function resetResults() {
    const result = document.getElementById('result');
    const status = document.getElementById('status');
    const formatList = document.getElementById('format-list');
    const downloadLink = document.getElementById('download-link');
    
    result.style.display = 'none';
    status.textContent = '';
    formatList.innerHTML = '';
    downloadLink.style.display = 'none';
}

// Show error message
function showError(message) {
    const status = document.getElementById('status');
    const result = document.getElementById('result');
    
    status.textContent = `Error: ${message}`;
    result.style.display = 'block';
}

// Validate URL format
function validateUrl(url) {
    if (!url) return false;
    try {
        new URL(url);
        return true;
    } catch {
        return false;
    }
}

10) style.css

:root {
    --primary-color: #2196F3;
    --secondary-color: #1976D2;
    --background-color: #f5f5f5;
    --text-color: #333;
    --border-color: #ddd;
}

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
    line-height: 1.6;
    color: var(--text-color);
    background-color: var(--background-color);
}

.container {
    max-width: 800px;
    margin: 0 auto;
    padding: 0 20px;
}

header {
    background-color: var(--primary-color);
    color: white;
    padding: 1rem 0;
    box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}

header h1 {
    margin: 0;
    font-size: 1.5rem;
}

main {
    padding: 2rem 0;
}

.download-form {
    background: white;
    padding: 2rem;
    border-radius: 8px;
    box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}

.form-group {
    margin-bottom: 1rem;
}

input[type="url"],
select {
    width: 100%;
    padding: 0.75rem;
    border: 1px solid var(--border-color);
    border-radius: 4px;
    font-size: 1rem;
}

.audio-options {
    background: #f8f9fa;
    padding: 1rem;
    border-radius: 4px;
    margin-bottom: 1rem;
}

.download-btn {
    background-color: var(--primary-color);
    color: white;
    border: none;
    padding: 0.75rem 1.5rem;
    border-radius: 4px;
    cursor: pointer;
    font-size: 1rem;
    width: 100%;
    transition: background-color 0.2s;
}

.download-btn:hover {
    background-color: var(--secondary-color);
}

.download-btn:disabled {
    background-color: var(--border-color);
    cursor: not-allowed;
}

.result {
    margin-top: 1rem;
    padding: 1rem;
    border-radius: 4px;
    background: #f8f9fa;
}

.result-content {
    text-align: center;
}

#download-link {
    display: inline-block;
    margin-top: 0.5rem;
    color: var(--primary-color);
    text-decoration: none;
    font-weight: bold;
}

#download-link:hover {
    text-decoration: underline;
}

footer {
    text-align: center;
    padding: 2rem 0;
    color: #666;
    font-size: 0.9rem;
}

@media (max-width: 600px) {
    .download-form {
        padding: 1rem;
    }
}

/* 在 static/css/style.css 中添加 */

.error-container {
    text-align: center;
    padding: 2rem;
    background: white;
    border-radius: 8px;
    box-shadow: 0 2px 4px rgba(0,0,0,0.1);
    margin: 2rem auto;
    max-width: 600px;
}

.error-container h1 {
    color: var(--primary-color);
    margin-bottom: 1rem;
}

.back-link {
    display: inline-block;
    margin-top: 1rem;
    padding: 0.5rem 1rem;
    background-color: var(--primary-color);
    color: white;
    text-decoration: none;
    border-radius: 4px;
    transition: background-color 0.2s;
}

.back-link:hover {
    background-color: var(--secondary-color);
}

/* 在 static/css/style.css 中添加 */

.video-options,
.audio-options,
.advanced-options {
    background: #f8f9fa;
    padding: 1rem;
    border-radius: 4px;
    margin-bottom: 1rem;
}

.checkbox-label {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    font-size: 0.9rem;
    color: #666;
}

.checkbox-label input[type="checkbox"] {
    width: auto;
    margin: 0;
}

c. 部署 WEB UI

 到 docker-compose.yml 所在目录

运行: docker compose up -d

[/share/Multimedia/2024-MyProgramFiles/30.GitHub_Apps/3.cobalt-main/app] # docker compose up -d

成功后会看到:

使用:

  •  粘贴视频网站 URL
  • 选择: VA / Audio / Video no sound
  • 视频码率
  • 音频码率

在上传的 index.html 中取消  Advanced Options 部分,因为没测试,只是按照 api.me 的内容增加。 

如果只是下载视频,推荐用我另一下项目: Project-20 YT-DLP ,其功能近似。

连接:<Project-20 YT-DLP> 给视频网站下载工具 yt-dlp/yt-dlp 加个页面 python web v1.1 added subtitle , auto paste_python yt-dlp-CSDN博客

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值