yt-dlp Web UI
対応サイト:YouTube, ニコニコ動画, Twitter/X, TikTok など1000以上

インストール
{
#!/bin/sh
# ============================================================
# グローバル変数設定
# ============================================================
DEFAULT_PORT=8080
INSTALL_DIR="/opt/yt-dlp"
TMP_DIR="/tmp/yt-dlp"
LOG_FILE="${TMP_DIR}/download.log"
STATE_FILE="${TMP_DIR}/state.txt"
PLAYLIST_FILE="${TMP_DIR}/playlist.json"
DETAIL_LOG_FILE="${TMP_DIR}/detail.log"
ERROR_FILE="${TMP_DIR}/error.txt"
INIT_SCRIPT="/etc/init.d/yt-dlp"
# ============================================================
echo "=== yt-dlp Flask Web UI Setup (SSE Real-time) ==="
echo ""
echo "=== Installation Locations ==="
echo "Application: ${INSTALL_DIR}/"
echo "Temp files: ${TMP_DIR}/"
echo "Init script: ${INIT_SCRIPT}"
echo ""
# 既存インストールのチェック
if [ -d "$INSTALL_DIR" ] || [ -d "$TMP_DIR" ] || [ -f "$INIT_SCRIPT" ]; then
echo "WARNING: Previous installation detected"
echo "This will overwrite existing files."
printf "Continue? [y/N]: "
read -r ANSWER
case "$ANSWER" in
[yY]*)
echo "Removing previous installation..."
/etc/init.d/yt-dlp stop 2>/dev/null
/etc/init.d/yt-dlp disable 2>/dev/null
killall yt-dlp 2>/dev/null
killall ffmpeg 2>/dev/null
pkill -f "python3.*yt-dlp-server.py" 2>/dev/null
rm -rf "$INSTALL_DIR"
rm -rf "$TMP_DIR"
rm -f "$INIT_SCRIPT"
;;
*)
echo "Installation cancelled"
exit 0
;;
esac
fi
REQUIRED_PKGS="yt-dlp ffmpeg python3 python3-pip"
if command -v apk > /dev/null 2>&1; then
PKG_MANAGER="apk"
PKG_INSTALL="apk add"
PKG_UPDATE="apk update"
elif command -v opkg > /dev/null 2>&1; then
PKG_MANAGER="opkg"
PKG_INSTALL="opkg install"
PKG_UPDATE="opkg update"
else
echo "Error: Package manager not found"
exit 1
fi
MISSING_PKGS=""
TOTAL_SIZE=0
echo ""
echo "Checking dependencies..."
for PKG in $REQUIRED_PKGS; do
if ! command -v $PKG > /dev/null 2>&1 && ! opkg list-installed | grep -q "^$PKG "; then
MISSING_PKGS="$MISSING_PKGS $PKG"
if [ "$PKG_MANAGER" = "apk" ]; then
SIZE=$(apk info -s $PKG 2>/dev/null | grep "installed size" | awk '{print $3}')
if echo "$SIZE" | grep -q "KiB"; then
SIZE=$(echo "$SIZE" | sed 's/KiB//' | awk '{print int($1 * 1024)}')
elif echo "$SIZE" | grep -q "MiB"; then
SIZE=$(echo "$SIZE" | sed 's/MiB//' | awk '{print int($1 * 1024 * 1024)}')
fi
else
SIZE=$(opkg info $PKG 2>/dev/null | grep "^Size:" | awk '{print $2}')
fi
if [ -n "$SIZE" ] && [ "$SIZE" -gt 0 ]; then
TOTAL_SIZE=$((TOTAL_SIZE + SIZE))
fi
fi
done
if [ -n "$MISSING_PKGS" ]; then
echo ""
echo "Required packages:$MISSING_PKGS"
AVAILABLE=$(df / | awk 'NR==2 {print $4}')
AVAILABLE=$((AVAILABLE * 1024))
TOTAL_SIZE_MB=$((TOTAL_SIZE / 1024 / 1024))
AVAILABLE_MB=$((AVAILABLE / 1024 / 1024))
echo ""
echo "Required space: ${TOTAL_SIZE_MB} MB"
echo "Available space: ${AVAILABLE_MB} MB"
if [ $TOTAL_SIZE -gt $AVAILABLE ]; then
echo ""
echo "Error: Not enough space"
echo "Cannot install"
exit 1
fi
REMAINING=$((AVAILABLE - TOTAL_SIZE))
REMAINING_MB=$((REMAINING / 1024 / 1024))
echo "Space after installation: ${REMAINING_MB} MB"
echo ""
printf "Install packages? [Y/n]: "
read -r ANSWER
case "$ANSWER" in
[nN]*)
echo "Installation cancelled"
exit 1
;;
*)
echo "Installing packages..."
$PKG_UPDATE
$PKG_INSTALL $MISSING_PKGS
if [ $? -ne 0 ]; then
echo "Error: Package installation failed"
exit 1
fi
;;
esac
fi
echo ""
echo "Installing Flask..."
pip3 install flask --break-system-packages 2>/dev/null || pip3 install flask
echo ""
echo "======================================"
echo "Port Configuration"
echo "======================================"
printf "\033[34mEnter port number [default: %s]: \033[0m" "$DEFAULT_PORT"
read -r USER_PORT
if [ -z "$USER_PORT" ]; then
SERVER_PORT=$DEFAULT_PORT
elif echo "$USER_PORT" | grep -q '^[0-9]\+$' && [ "$USER_PORT" -ge 1024 ] && [ "$USER_PORT" -le 65535 ]; then
SERVER_PORT=$USER_PORT
else
echo "Invalid port number. Using default: $DEFAULT_PORT"
SERVER_PORT=$DEFAULT_PORT
fi
echo "Using port: $SERVER_PORT"
echo ""
echo ""
echo "Creating directories..."
mkdir -p "$INSTALL_DIR"
mkdir -p "$TMP_DIR"
echo "Creating Flask application..."
cat > "$INSTALL_DIR/yt-dlp-server.py" << 'EOFPYTHON'
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# ============================================================
# グローバル変数設定(このセクションを編集してポート番号を変更可能)
# ============================================================
PORT = __PORT__
HOST = '0.0.0.0'
TMP_DIR = "__TMP_DIR__"
# ============================================================
import os
import json
import subprocess
import time
import threading
import signal
from flask import Flask, render_template_string, request, Response, jsonify
from urllib.parse import unquote_plus
app = Flask(__name__)
STATE_FILE = os.path.join(TMP_DIR, "state.txt")
LOG_FILE = os.path.join(TMP_DIR, "download.log")
DETAIL_LOG_FILE = os.path.join(TMP_DIR, "detail.log")
PLAYLIST_FILE = os.path.join(TMP_DIR, "playlist.json")
ERROR_FILE = os.path.join(TMP_DIR, "error.txt")
# TMP_DIRが存在しない場合は作成
os.makedirs(TMP_DIR, exist_ok=True)
class DownloadState:
def __init__(self):
self.lock = threading.Lock()
self.url = ""
self.dest = ""
self.action = ""
self.site_type = "youtube"
self.download_running = False
self.download_pid = None
def load(self):
with self.lock:
if os.path.exists(STATE_FILE):
try:
with open(STATE_FILE, 'r') as f:
for line in f:
if '=' in line:
key, value = line.strip().split('=', 1)
if key == 'URL':
self.url = value
elif key == 'DEST':
self.dest = value
elif key == 'ACTION':
self.action = value
elif key == 'SITE_TYPE':
self.site_type = value
except:
pass
def save(self):
with self.lock:
try:
with open(STATE_FILE, 'w') as f:
f.write(f"URL={self.url}\n")
f.write(f"DEST={self.dest}\n")
f.write(f"ACTION={self.action}\n")
f.write(f"SITE_TYPE={self.site_type}\n")
except:
pass
def is_downloading(self):
with self.lock:
try:
result = subprocess.run(['ps'], capture_output=True, text=True)
return 'yt-dlp' in result.stdout
except:
return False
state = DownloadState()
TRANSLATIONS = {
'ja': {
'title': 'yt-dlp Web UI',
'supportedSites': '対応サイト: YouTube, ニコニコ動画, Twitter/X, TikTok など1000以上のサイト',
'videoUrl': '動画URL',
'urlPlaceholder': 'https://www.youtube.com/watch?v=...',
'analyzeButton': 'URL解析',
'analyzing': '解析中...',
'playlistInfo': 'プレイリスト情報',
'alertUrlRequired': 'URLを入力してください',
'alertSelectVideos': 'ダウンロードする動画を選択してください',
'playlistCount': 'プレイリスト内の動画数',
'singleVideo': '単一動画',
'selectAll': '全選択',
'deselectAll': '全解除',
'destination': '保存先',
'format': 'フォーマット',
'formatDefault': 'デフォルト',
'formatBest': '最高画質 (MP4)',
'format720p': '720p (MP4)',
'format480p': '480p (MP4)',
'formatAudio': '音声のみ (MP3)',
'downloadButton': 'ダウンロード開始',
'stopButton': 'ダウンロード停止',
'downloading': 'ダウンロード中',
'logTitle': 'ダウンロードログ',
'subtitle': 'YouTube, ニコニコ動画, Twitter/X, TikTok など1000以上のサイトに対応',
'parallel': '並列DL数',
'ytdlpOptions': 'yt-dlpオプション',
'optionsPlaceholder': '例: --write-thumbnail --write-description',
'confirmStop': 'ダウンロードを停止しますか?'
},
'en': {
'title': 'yt-dlp Web UI',
'supportedSites': 'Supported Sites: YouTube, Niconico, Twitter/X, TikTok and 1000+ sites',
'videoUrl': 'Video URL',
'urlPlaceholder': 'https://www.youtube.com/watch?v=...',
'analyzeButton': 'Analyze URL',
'analyzing': 'Analyzing...',
'alertUrlRequired': 'Please enter a URL',
'alertSelectVideos': 'Please select videos to download',
'playlistInfo': 'Playlist Information',
'playlistCount': 'Number of videos',
'singleVideo': 'Single Video',
'selectAll': 'Select All',
'deselectAll': 'Deselect All',
'destination': 'Destination',
'format': 'Format',
'formatDefault': 'Default',
'formatBest': 'Best Quality (MP4)',
'format720p': '720p (MP4)',
'format480p': '480p (MP4)',
'formatAudio': 'Audio Only (MP3)',
'downloadButton': 'Start Download',
'stopButton': 'Stop Download',
'downloading': 'Downloading...',
'logTitle': 'Download Log',
'subtitle': 'Supports YouTube, Niconico, Twitter/X, TikTok and 1000+ sites',
'parallel': 'Parallel Downloads',
'ytdlpOptions': 'yt-dlp Options',
'optionsPlaceholder': 'e.g., --write-thumbnail --write-description',
'confirmStop': 'Stop download?'
}
}
HTML_TEMPLATE = '''<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ t.title }}</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #fafafa;
color: #1a1a1a;
line-height: 1.6;
}
.container {
max-width: 900px;
margin: 0 auto;
padding: 40px 20px;
}
h1 {
font-size: 28px;
font-weight: 600;
margin-bottom: 10px;
color: #1a1a1a;
letter-spacing: -0.5px;
}
.subtitle {
color: #666;
margin-bottom: 30px;
font-size: 14px;
}
.form-group {
margin-bottom: 24px;
}
label {
display: block;
margin-bottom: 8px;
font-weight: 500;
font-size: 14px;
color: #333;
}
input[type="text"], textarea {
width: 100%;
padding: 12px 16px;
border: 1px solid #d0d0d0;
border-radius: 4px;
font-size: 14px;
transition: border-color 0.2s;
background: white;
font-family: inherit;
}
textarea {
min-height: 80px;
resize: vertical;
}
input[type="text"]:focus, textarea:focus {
outline: none;
border-color: #1a1a1a;
}
select {
width: 100%;
padding: 12px 16px;
border: 1px solid #d0d0d0;
border-radius: 4px;
font-size: 14px;
background: white;
cursor: pointer;
}
select:focus {
outline: none;
border-color: #1a1a1a;
}
button {
width: 100%;
padding: 14px 24px;
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
background: #1a1a1a;
color: white;
}
button:hover {
background: #333;
}
button:disabled {
background: #ccc;
cursor: not-allowed;
}
.btn-secondary {
background: white;
color: #1a1a1a;
border: 1px solid #d0d0d0;
}
.btn-secondary:hover {
background: #f5f5f5;
}
.btn-stop {
background: #dc3545;
color: white;
}
.btn-stop:hover {
background: #c82333;
}
.btn-small {
width: auto;
padding: 8px 16px;
font-size: 13px;
display: inline-block;
margin-right: 8px;
}
.playlist-section {
background: white;
border: 1px solid #e0e0e0;
border-radius: 4px;
padding: 20px;
margin-bottom: 24px;
}
.playlist-section h3 {
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
color: #1a1a1a;
}
.playlist-count {
font-size: 14px;
color: #666;
margin-bottom: 16px;
}
.playlist-controls {
margin-bottom: 16px;
}
.playlist-list {
max-height: 400px;
overflow-y: auto;
border: 1px solid #e0e0e0;
border-radius: 4px;
}
.playlist-item {
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
display: flex;
align-items: center;
transition: background 0.2s;
}
.playlist-item:last-child {
border-bottom: none;
}
.playlist-item:hover {
background: #f5f5f5;
}
.playlist-item input[type="checkbox"] {
margin-right: 12px;
width: 16px;
height: 16px;
cursor: pointer;
}
.playlist-item label {
margin: 0;
font-weight: normal;
cursor: pointer;
flex: 1;
font-size: 14px;
color: #1a1a1a;
}
.log-container {
background: #1a1a1a;
border-radius: 4px;
padding: 20px;
margin-top: 30px;
}
.log-container h3 {
font-size: 14px;
font-weight: 600;
color: #ccc;
margin-bottom: 12px;
}
.log-content {
font-family: "Courier New", monospace;
font-size: 12px;
color: #e0e0e0;
white-space: pre-wrap;
word-wrap: break-word;
line-height: 1.5;
max-height: 400px;
overflow-y: auto;
}
.options-link {
color: #0066cc;
text-decoration: underline;
}
.options-link:hover {
color: #0052a3;
}
.button-group {
display: flex;
gap: 10px;
}
.button-group button {
flex: 1;
}
</style>
</head>
<body>
<div class="container">
<h1>{{ t.title }}</h1>
<p class="subtitle">{{ t.subtitle }}</p>
<form method="POST" id="mainForm">
<div class="form-group">
<label>{{ t.videoUrl }}</label>
<input type="text" name="url" id="url-input" value="{{ url }}" placeholder="{{ t.urlPlaceholder }}" required>
</div>
<div class="form-group">
<button type="button" class="btn-secondary" onclick="analyzeUrl()">{{ t.analyzeButton }}</button>
</div>
{% if playlist_data %}
<div class="playlist-section">
<h3>{{ t.playlistInfo }}</h3>
{% if playlist_data|length > 1 %}
<div class="playlist-count">{{ t.playlistCount }}: {{ playlist_data|length }}</div>
<div class="playlist-controls">
<button type="button" class="btn-small btn-secondary" onclick="toggleAll(true)">{{ t.selectAll }}</button>
<button type="button" class="btn-small btn-secondary" onclick="toggleAll(false)">{{ t.deselectAll }}</button>
</div>
<div class="playlist-list">
{% for idx, item in playlist_data %}
<div class="playlist-item">
<input type="checkbox" name="video_id" value="{{ item.id }}" id="video_{{ idx }}" checked>
<label for="video_{{ idx }}">{{ idx }}. {{ item.title }}</label>
</div>
{% endfor %}
</div>
{% else %}
<div class="playlist-count">{{ t.singleVideo }}: {{ playlist_data[0][1].title }}</div>
<input type="hidden" name="video_id" value="{{ url }}">
{% endif %}
</div>
{% endif %}
<div class="form-group">
<label>{{ t.destination }}</label>
<input type="text" name="dest" value="{{ dest or '/tmp/yt-dlp' }}" placeholder="/tmp/yt-dlp">
</div>
<div class="form-group">
<label>{{ t.parallel }}</label>
<select name="parallel">
<option value="1">1</option>
<option value="2">2</option>
<option value="3" selected>3</option>
<option value="4">4</option>
<option value="5">5</option>
</select>
</div>
<div class="form-group">
<label>{{ t.format }}</label>
<select name="format">
<option value="default">{{ t.formatDefault }}</option>
<option value="best">{{ t.formatBest }}</option>
<option value="720p">{{ t.format720p }}</option>
<option value="480p">{{ t.format480p }}</option>
<option value="audio">{{ t.formatAudio }}</option>
</select>
</div>
<div class="form-group">
<label><a href="https://github.com/yt-dlp/yt-dlp#usage-and-options" target="_blank" class="options-link">{{ t.ytdlpOptions }}</a></label>
<textarea name="custom_options" placeholder="{{ t.optionsPlaceholder }}"></textarea>
</div>
<div class="form-group">
<div class="button-group" id="button-group">
<button type="button" id="downloadBtn" onclick="startDownload()">{{ t.downloadButton }}</button>
</div>
</div>
</form>
<div class="log-container">
<h3>{{ t.logTitle }}</h3>
<div class="log-content" id="log-content"></div>
</div>
</div>
<script>
let eventSource = null;
let isDownloading = false;
function analyzeUrl() {
const url = document.getElementById('url-input').value;
if (!url) {
alert('{{ t.alertUrlRequired }}');
return;
}
const btn = event.target;
const originalText = btn.textContent;
btn.textContent = '{{ t.analyzing }}';
btn.disabled = true;
const formData = new FormData();
formData.append('action', 'analyze');
formData.append('url', url);
formData.append('dest', document.querySelector('input[name="dest"]').value);
fetch('/', {
method: 'POST',
body: formData
})
.then(response => response.text())
.then(html => {
document.open();
document.write(html);
document.close();
})
.catch(error => {
console.error('Analyze error:', error);
btn.textContent = originalText;
btn.disabled = false;
});
}
function startDownload() {
const checkedBoxes = document.querySelectorAll('input[name="video_id"]:checked');
const hiddenInputs = document.querySelectorAll('input[name="video_id"][type="hidden"]');
if (checkedBoxes.length === 0 && hiddenInputs.length === 0) {
alert('{{ t.alertSelectVideos }}');
return;
}
const downloadBtn = document.getElementById('downloadBtn');
downloadBtn.disabled = true;
downloadBtn.textContent = '{{ t.downloading }}';
const formData = new FormData(document.getElementById('mainForm'));
formData.append('action', 'download');
fetch('/', {
method: 'POST',
body: formData
})
.then(response => response.text())
.then(() => {
addStopButton();
startStatusMonitoring();
})
.catch(err => {
console.error('Download error:', err);
downloadBtn.disabled = false;
downloadBtn.textContent = '{{ t.downloadButton }}';
});
}
function stopDownload() {
if (confirm('{{ t.confirmStop }}')) {
fetch('/stop', {
method: 'POST'
})
.then(() => {
if (eventSource) {
eventSource.close();
eventSource = null;
}
removeStopButton();
const downloadBtn = document.getElementById('downloadBtn');
downloadBtn.disabled = false;
downloadBtn.textContent = '{{ t.downloadButton }}';
});
}
}
function addStopButton() {
const buttonGroup = document.getElementById('button-group');
const downloadBtn = document.getElementById('downloadBtn');
if (!document.getElementById('stopBtn')) {
const stopBtn = document.createElement('button');
stopBtn.type = 'button';
stopBtn.id = 'stopBtn';
stopBtn.className = 'btn-stop';
stopBtn.textContent = '{{ t.stopButton }}';
stopBtn.onclick = stopDownload;
buttonGroup.appendChild(stopBtn);
}
}
function removeStopButton() {
const stopBtn = document.getElementById('stopBtn');
if (stopBtn) {
stopBtn.remove();
}
}
function startStatusMonitoring() {
if (eventSource) {
eventSource.close();
}
eventSource = new EventSource('/status');
const logContent = document.getElementById('log-content');
const downloadBtn = document.getElementById('downloadBtn');
eventSource.addEventListener('status', function(e) {
const data = JSON.parse(e.data);
if (data.status === 'idle') {
downloadBtn.disabled = false;
downloadBtn.textContent = '{{ t.downloadButton }}';
removeStopButton();
eventSource.close();
eventSource = null;
} else if (data.status === 'downloading') {
downloadBtn.disabled = true;
downloadBtn.textContent = '{{ t.downloading }}';
}
});
eventSource.addEventListener('log', function(e) {
const data = JSON.parse(e.data);
logContent.textContent = data.log;
logContent.scrollTop = logContent.scrollHeight;
});
eventSource.onerror = function() {
console.log('SSE connection lost, reconnecting...');
};
}
function toggleAll(checked) {
const checkboxes = document.querySelectorAll('input[name="video_id"]:not([type="hidden"])');
checkboxes.forEach(cb => cb.checked = checked);
}
window.addEventListener('beforeunload', function() {
if (eventSource) {
eventSource.close();
}
});
// ページロード時にダウンロード中かチェック
fetch('/api/check-status')
.then(response => response.json())
.then(data => {
if (data.downloading) {
addStopButton();
const downloadBtn = document.getElementById('downloadBtn');
downloadBtn.disabled = true;
downloadBtn.textContent = '{{ t.downloading }}';
startStatusMonitoring();
}
});
</script>
</body>
</html>
'''
def get_log_content():
try:
if os.path.exists(LOG_FILE):
with open(LOG_FILE, 'r', encoding='utf-8') as f:
return f.read()
except:
pass
return ""
def run_download(url, dest, parallel, format_type, video_ids, custom_options):
try:
if not os.path.exists(LOG_FILE):
with open(LOG_FILE, 'w') as f:
f.write("")
os.makedirs(dest, exist_ok=True)
sleep_intervals = {1: 5, 2: 4, 3: 3, 4: 2, 5: 1}
rate_limits = {1: "1M", 2: "2M", 3: "", 4: "", 5: ""}
retries_config = {1: 15, 2: 12, 3: 10, 4: 8, 5: 5}
sleep_interval = sleep_intervals.get(parallel, 3)
rate_limit = rate_limits.get(parallel, "")
retries = retries_config.get(parallel, 10)
state.load()
site_type = state.site_type
if site_type == "youtube":
ytdlp_opts = f"--extractor-args youtube:player_client=android --retries {retries} --socket-timeout 30"
else:
ytdlp_opts = f"--retries {retries} --socket-timeout 30"
if rate_limit:
ytdlp_opts += f" --limit-rate {rate_limit}"
if custom_options:
ytdlp_opts += f" {custom_options}"
format_options = {
'audio': '-x --audio-format mp3',
'best': '-f "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best"',
'720p': '-f "bestvideo[height<=720][ext=mp4]+bestaudio[ext=m4a]/best[height<=720]"',
'480p': '-f "bestvideo[height<=480][ext=mp4]+bestaudio[ext=m4a]/best[height<=480]"',
'default': ''
}
format_opt = format_options.get(format_type, '')
def download_video(video_id):
start_time = time.time()
try:
cmd_title = f'yt-dlp {ytdlp_opts} --get-filename -o "%(title)s.%(ext)s" "{video_id}"'
title = subprocess.run(cmd_title, shell=True, capture_output=True, text=True).stdout.strip() or "Unknown"
with open(LOG_FILE, 'a', encoding='utf-8') as log:
log.write(f"[START] {title} {time.strftime('%H:%M:%S')}\n")
cmd = f'yt-dlp {ytdlp_opts} {format_opt} -o "{dest}/%(title)s.%(ext)s" "{video_id}"'
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
elapsed = int(time.time() - start_time)
elapsed_min = elapsed // 60
elapsed_sec = elapsed % 60
with open(LOG_FILE, 'a', encoding='utf-8') as log:
if result.returncode == 0:
log.write(f"[SUCCESS] {title} {time.strftime('%H:%M:%S')} {elapsed_min}m{elapsed_sec}s\n")
else:
log.write(f"[FAIL] {title} {time.strftime('%H:%M:%S')}\n")
if result.stderr:
log.write(f"Error: {result.stderr[:200]}\n")
except Exception as e:
with open(LOG_FILE, 'a', encoding='utf-8') as log:
log.write(f"[ERROR] {video_id}: {str(e)}\n")
threads = []
for video_id in video_ids:
while len([t for t in threads if t.is_alive()]) >= parallel:
time.sleep(1)
thread = threading.Thread(target=download_video, args=(video_id,))
thread.start()
threads.append(thread)
time.sleep(sleep_interval)
for thread in threads:
thread.join()
with open(LOG_FILE, 'a', encoding='utf-8') as log:
log.write(f"\n[COMPLETED] All downloads finished at {time.strftime('%H:%M:%S')}\n")
state.action = "completed"
state.save()
except Exception as e:
with open(LOG_FILE, 'a', encoding='utf-8') as log:
log.write(f"\n[ERROR] Download process failed: {str(e)}\n")
state.action = "error"
state.save()
@app.route('/', methods=['GET', 'POST'])
def index():
state.load()
lang = request.accept_languages.best_match(['ja', 'en']) or 'en'
t = TRANSLATIONS.get(lang, TRANSLATIONS['en'])
playlist_data = None
url = state.url or ""
dest = state.dest or "/tmp/yt-dlp"
if request.method == 'POST':
action = request.form.get('action')
url = request.form.get('url', '')
dest = request.form.get('dest', '/tmp/yt-dlp')
if action == 'analyze' and url:
try:
if 'youtube.com' in url or 'youtu.be' in url:
state.site_type = 'youtube'
cmd = f'yt-dlp --extractor-args "youtube:player_client=android" --flat-playlist --playlist-end 999999 --dump-json "{url}"'
else:
state.site_type = 'other'
cmd = f'yt-dlp --dump-json "{url}"'
result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=30)
if result.returncode == 0 and result.stdout:
with open(PLAYLIST_FILE, 'w', encoding='utf-8') as f:
f.write(result.stdout)
playlist_data = []
for idx, line in enumerate(result.stdout.strip().split('\n'), 1):
try:
data = json.loads(line)
playlist_data.append((idx, {
'id': data.get('id', ''),
'title': data.get('title', 'No Title')
}))
except:
continue
state.url = url
state.dest = dest
state.action = 'analyzed'
state.save()
except Exception as e:
print(f"Analyze error: {e}")
elif action == 'download':
video_ids = request.form.getlist('video_id')
parallel = int(request.form.get('parallel', 3))
format_type = request.form.get('format', 'default')
custom_options = request.form.get('custom_options', '').strip()
if video_ids:
state.url = url
state.dest = dest
state.action = 'downloading'
state.save()
thread = threading.Thread(
target=run_download,
args=(url, dest, parallel, format_type, video_ids, custom_options)
)
thread.daemon = True
thread.start()
if state.action == 'analyzed' and os.path.exists(PLAYLIST_FILE):
try:
with open(PLAYLIST_FILE, 'r', encoding='utf-8') as f:
playlist_data = []
for idx, line in enumerate(f, 1):
try:
data = json.loads(line.strip())
playlist_data.append((idx, {
'id': data.get('id', ''),
'title': data.get('title', 'No Title')
}))
except:
continue
except:
pass
return render_template_string(HTML_TEMPLATE, t=t, url=url, dest=dest, playlist_data=playlist_data)
@app.route('/stop', methods=['POST'])
def stop_download():
try:
subprocess.run(['killall', 'yt-dlp'], check=False)
subprocess.run(['killall', 'ffmpeg'], check=False)
with open(LOG_FILE, 'a', encoding='utf-8') as log:
log.write(f"\n[STOPPED] Download stopped by user at {time.strftime('%H:%M:%S')}\n")
state.action = "stopped"
state.save()
return jsonify({'status': 'stopped'})
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}), 500
@app.route('/status')
def status_stream():
def generate():
last_log = ""
retry_count = 0
max_retries = 3
while True:
try:
is_downloading = state.is_downloading()
status_data = {
'status': 'downloading' if is_downloading else 'idle'
}
yield f"event: status\ndata: {json.dumps(status_data)}\n\n"
current_log = get_log_content()
if current_log != last_log:
log_data = {'log': current_log}
yield f"event: log\ndata: {json.dumps(log_data)}\n\n"
last_log = current_log
if not is_downloading and state.action == 'completed':
break
time.sleep(1)
retry_count = 0
except GeneratorExit:
break
except Exception as e:
print(f"SSE error: {e}")
retry_count += 1
if retry_count >= max_retries:
break
time.sleep(1)
return Response(generate(), mimetype='text/event-stream')
@app.route('/api/check-status')
def check_status():
state.load()
return jsonify({
'downloading': state.is_downloading(),
'action': state.action
})
if __name__ == '__main__':
if os.path.exists(LOG_FILE):
os.remove(LOG_FILE)
if os.path.exists(STATE_FILE):
os.remove(STATE_FILE)
if os.path.exists(PLAYLIST_FILE):
os.remove(PLAYLIST_FILE)
print(f"Starting yt-dlp Flask server on http://{HOST}:{PORT}")
app.run(host=HOST, port=PORT, threaded=True)
EOFPYTHON
# ポート番号とTMP_DIRをsedで置き換え
sed -i "s/__PORT__/${SERVER_PORT}/g" "$INSTALL_DIR/yt-dlp-server.py"
sed -i "s|__TMP_DIR__|${TMP_DIR}|g" "$INSTALL_DIR/yt-dlp-server.py"
chmod +x "$INSTALL_DIR/yt-dlp-server.py"
cat > "$INIT_SCRIPT" << EOFINIT
#!/bin/sh /etc/rc.common
START=99
STOP=10
USE_PROCD=1
PROG=$INSTALL_DIR/yt-dlp-server.py
start_service() {
procd_open_instance
procd_set_param command python3 \$PROG
procd_set_param respawn
procd_set_param stdout 1
procd_set_param stderr 1
procd_close_instance
}
stop_service() {
pkill -f "python3.*yt-dlp-server.py"
}
EOFINIT
chmod +x "$INIT_SCRIPT"
"$INIT_SCRIPT" enable
"$INIT_SCRIPT" start
LAN_IP=$(uci get network.lan.ipaddr 2>/dev/null)
LAN_IP=${LAN_IP%%/*}
if [ -z "$LAN_IP" ]; then
LAN_IP=$(ip -4 addr show br-lan | grep -oP '(?<=inet\s)\d+(\.\d+){3}' | head -n1)
fi
[ -z "$LAN_IP" ] && LAN_IP="192.168.1.1"
echo ""
echo "======================================"
echo "Setup Complete!"
echo "======================================"
echo ""
echo "Flask Server URL:"
echo "http://${LAN_IP}:${SERVER_PORT}"
echo ""
echo "Features:"
echo "- Real-time SSE status updates"
echo "- Download stop function"
echo "- yt-dlp options reference"
echo "- Auto-detection of completion"
echo ""
echo "Installation Locations:"
echo "- Application: ${INSTALL_DIR}/"
echo "- Temp files: ${TMP_DIR}/"
echo "- Init script: ${INIT_SCRIPT}"
echo ""
echo "Configuration:"
echo "- Port: ${SERVER_PORT}"
echo "- Edit port: nano ${INSTALL_DIR}/yt-dlp-server.py"
echo ""
echo "Service commands:"
echo "${INIT_SCRIPT} start"
echo "${INIT_SCRIPT} stop"
echo "${INIT_SCRIPT} restart"
echo "======================================"
echo ""
echo "Flask Server URL:"
printf "\033[32mhttp://${LAN_IP}:${SERVER_PORT}\033[0m\n"
echo ""
}
リムーブ
{
#!/bin/sh
# if command -v apk > /dev/null 2>&1; then
# apk del yt-dlp ffmpeg python3 python3-pip
# elif command -v opkg > /dev/null 2>&1; then
# opkg remove yt-dlp ffmpeg python3 python3-pip
# fi
/etc/init.d/yt-dlp stop 2>/dev/null
/etc/init.d/yt-dlp disable 2>/dev/null
killall yt-dlp 2>/dev/null
killall ffmpeg 2>/dev/null
pkill -f "python3.*yt-dlp-server.py" 2>/dev/null
rm -rf /opt/yt-dlp
rm -f /etc/init.d/yt-dlp
rm -rf /tmp/yt-dlp
echo "yt-dlp Flask Web UI removed successfully"
}
あとがき
とりあえず動きます
DL先をUSBメモリなどにすれば、そのまま書き込んでくれます