前言:
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/<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>© 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 ,其功能近似。