0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

OpenWrt yt-dlp Web UI

Last updated at Posted at 2025-10-23

注意事項

  • このツールは技術的な学習目的で提供されています
  • 著作権で保護されたコンテンツのダウンロードは違法です
  • 各サイトの利用規約を遵守してください
  • 自己の責任でご使用ください

yt-dlp Web UI

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

インストール
{
#!/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 ""
}

UI画面

http://192.168.1.1/cgi-bin/yt-dlp.sh


リムーブ
{
#!/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メモリなどにすれば、そのまま書き込んでくれます

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?