前言:
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.
抢劫/盗窃 “流行”网站的视频、音频。 (有买 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/
' ) - 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>© 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 ,其功能近似。
评论记录:
回复评论: