Update web server, UI components, and add new features (archive, queue, download engine, socket support)
This commit is contained in:
parent
405767a562
commit
415fe2324b
77
.dockerignore
Normal file
77
.dockerignore
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
web/web-app/dist/
|
||||||
|
web/web-app/node_modules/
|
||||||
|
web/web-app/test-results/
|
||||||
|
web/web-app/playwright-report/
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
docker-compose.yml
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Docs
|
||||||
|
*.md
|
||||||
|
LICENSE
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
nohup.out
|
||||||
|
gunicorn-access.log
|
||||||
|
gunicorn-error.log
|
||||||
|
app.log
|
||||||
|
|
||||||
|
# Test files
|
||||||
|
test_*.py
|
||||||
|
tests/
|
||||||
|
manual_test_*.py
|
||||||
|
|
||||||
|
# Scripts (not needed in container)
|
||||||
|
run.sh
|
||||||
|
install.sh
|
||||||
|
|
||||||
|
# Lock files
|
||||||
|
uv.lock
|
||||||
|
package-lock.json
|
||||||
|
!web/web-app/package-lock.json
|
||||||
|
|
||||||
|
# Part files and downloads
|
||||||
|
*.part
|
||||||
|
*.mp4
|
||||||
|
*.mkv
|
||||||
|
*.webm
|
||||||
|
|
||||||
|
# Egg info
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# Config backups
|
||||||
|
*.bak
|
||||||
35
AGENTS.md
35
AGENTS.md
@ -111,6 +111,20 @@ The API server runs on port 4096 by default.
|
|||||||
docker-compose up --build
|
docker-compose up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Persistent volumes:**
|
||||||
|
- `youtube-config` → `/app/.config/youtube_cli/` (queue.json, archive.db, config.json)
|
||||||
|
- `youtube-logs` → `/app/logs/` (youtube-cli.log)
|
||||||
|
- `/mnt/mediaserver/Youtube` → `/mnt/mediaserver/Youtube` (downloaded videos)
|
||||||
|
|
||||||
|
**Viewing logs:**
|
||||||
|
```bash
|
||||||
|
# Live logs
|
||||||
|
docker logs -f youtube-web
|
||||||
|
|
||||||
|
# Persistent log file (survives container restart)
|
||||||
|
docker exec youtube-web cat /app/logs/youtube-cli.log
|
||||||
|
```
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
**Core dependencies (requirements.txt):**
|
**Core dependencies (requirements.txt):**
|
||||||
@ -129,6 +143,11 @@ docker-compose up --build
|
|||||||
# Run the API test script
|
# Run the API test script
|
||||||
python test_api.py
|
python test_api.py
|
||||||
|
|
||||||
|
# Run web server tests (inside container)
|
||||||
|
docker exec youtube-web python3 /app/web/server/tests/test_queue_store.py
|
||||||
|
docker exec youtube-web python3 /app/web/server/tests/test_queue_api.py
|
||||||
|
docker exec youtube-web python3 /app/web/server/tests/test_download_recovery.py
|
||||||
|
|
||||||
# Run all tests with pytest (if configured)
|
# Run all tests with pytest (if configured)
|
||||||
pytest
|
pytest
|
||||||
```
|
```
|
||||||
@ -142,6 +161,22 @@ python test_api.py
|
|||||||
pytest test_api.py::test_function_name
|
pytest test_api.py::test_function_name
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### CRITICAL: Run tests before deploying
|
||||||
|
**Always run the web server tests before rebuilding and restarting the container:**
|
||||||
|
```bash
|
||||||
|
# Copy test files to container
|
||||||
|
docker cp web/server/tests/test_queue_store.py youtube-web:/app/web/server/tests/
|
||||||
|
docker cp web/server/tests/test_queue_api.py youtube-web:/app/web/server/tests/
|
||||||
|
docker cp web/server/tests/test_download_recovery.py youtube-web:/app/web/server/tests/
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
docker exec youtube-web python3 /app/web/server/tests/test_queue_store.py
|
||||||
|
docker exec youtube-web python3 /app/web/server/tests/test_queue_api.py
|
||||||
|
docker exec youtube-web python3 /app/web/server/tests/test_download_recovery.py
|
||||||
|
```
|
||||||
|
|
||||||
|
If any test fails, fix the issue before deploying.
|
||||||
|
|
||||||
## Code Style Guidelines
|
## Code Style Guidelines
|
||||||
|
|
||||||
### Python Conventions
|
### Python Conventions
|
||||||
|
|||||||
71
Dockerfile
71
Dockerfile
@ -1,23 +1,74 @@
|
|||||||
FROM python:3.9-slim
|
# ============================================
|
||||||
|
# Stage 1: Build React frontend
|
||||||
|
# ============================================
|
||||||
|
FROM node:20-alpine AS frontend-builder
|
||||||
|
|
||||||
# Set working directory
|
WORKDIR /build
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy requirements first (for better caching)
|
# Copy frontend package files
|
||||||
COPY requirements-api.txt .
|
COPY web/web-app/package.json web/web-app/package-lock.json ./
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
RUN pip install --no-cache-dir -r requirements-api.txt
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy frontend source
|
||||||
|
COPY web/web-app/ ./
|
||||||
|
|
||||||
|
# Build production bundle
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Stage 2: Python server
|
||||||
|
# ============================================
|
||||||
|
FROM python:3.11-slim AS server
|
||||||
|
|
||||||
|
# Install system dependencies for yt-dlp/ffmpeg/deno
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
curl \
|
||||||
|
ffmpeg \
|
||||||
|
ca-certificates \
|
||||||
|
unzip \
|
||||||
|
git \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* && \
|
||||||
|
curl -fsSL https://deno.land/install.sh | sh && \
|
||||||
|
ln -s /root/.deno/bin/deno /usr/local/bin/deno
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy Python requirements first for better caching
|
||||||
|
COPY requirements.txt requirements-api.txt ./
|
||||||
|
COPY web/requirements-web.txt ./requirements-web.txt
|
||||||
|
|
||||||
|
# Install Python dependencies, yt-dlp nightly for latest YouTube patches
|
||||||
|
RUN pip install --no-cache-dir --upgrade pip && \
|
||||||
|
pip install --no-cache-dir -r requirements.txt && \
|
||||||
|
pip install --no-cache-dir -r requirements-api.txt && \
|
||||||
|
pip install --no-cache-dir -r requirements-web.txt && \
|
||||||
|
pip install --no-cache-dir --upgrade "yt-dlp[default] @ git+https://github.com/yt-dlp/yt-dlp.git"
|
||||||
|
|
||||||
# Copy application code
|
# Copy application code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Copy built frontend from stage 1
|
||||||
|
COPY --from=frontend-builder /build/dist web/web-app/dist
|
||||||
|
|
||||||
|
# Create download and config directories
|
||||||
|
RUN mkdir -p /downloads /app/.config/youtube_cli/logs
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV PORT=4096
|
||||||
|
ENV DOWNLOAD_DIR=/downloads
|
||||||
|
ENV CONFIG_DIR=/app/.config/youtube_cli
|
||||||
|
ENV DOCKER=true
|
||||||
|
|
||||||
# Expose port
|
# Expose port
|
||||||
EXPOSE 4096
|
EXPOSE 4096
|
||||||
|
|
||||||
# Health check
|
# Health check
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
||||||
CMD curl -f http://localhost:4096/health || exit 1
|
CMD curl -f http://localhost:4096/api/health || exit 1
|
||||||
|
|
||||||
# Run the application with timeout protection
|
# Run the application
|
||||||
CMD ["timeout", "3600", "python", "app.py"]
|
WORKDIR /app/web/server
|
||||||
|
CMD ["python", "app.py"]
|
||||||
3
app.py
3
app.py
@ -13,7 +13,8 @@ from flask import Flask, jsonify, request
|
|||||||
from youtube_cli.main import YouTubeCLI
|
from youtube_cli.main import YouTubeCLI
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
LOG_DIR = Path.home() / ".config" / "youtube_cli" / "logs"
|
_config_dir = os.environ.get("CONFIG_DIR", str(Path.home() / ".config" / "youtube_cli"))
|
||||||
|
LOG_DIR = Path(_config_dir) / "logs"
|
||||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
LOG_FILE = LOG_DIR / "app.log"
|
LOG_FILE = LOG_DIR / "app.log"
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,36 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
youtube-api:
|
youtube-web:
|
||||||
build: .
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: youtube-web
|
||||||
ports:
|
ports:
|
||||||
- "4096:4096"
|
- "4096:4096"
|
||||||
environment:
|
environment:
|
||||||
- FLASK_ENV=production
|
- PORT=4096
|
||||||
|
- DOWNLOAD_DIR=/mnt/mediaserver/Youtube
|
||||||
|
- CONFIG_DIR=/app/.config/youtube_cli
|
||||||
|
- LOG_DIR=/app/logs
|
||||||
|
- FLASK_DEBUG=false
|
||||||
|
- USE_GUNICORN=false
|
||||||
|
- PYTHONUNBUFFERED=1
|
||||||
|
volumes:
|
||||||
|
# NAS mount for downloads
|
||||||
|
- /mnt/mediaserver/Youtube:/mnt/mediaserver/Youtube
|
||||||
|
# Persist app config, queue, archive
|
||||||
|
- youtube-config:/app/.config/youtube_cli
|
||||||
|
# Persist logs
|
||||||
|
- youtube-logs:/app/logs
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:4096/api/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
youtube-config:
|
||||||
|
driver: local
|
||||||
|
youtube-logs:
|
||||||
|
driver: local
|
||||||
@ -32,7 +32,7 @@ classifiers = [
|
|||||||
"Topic :: Utilities",
|
"Topic :: Utilities",
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"yt-dlp==2026.3.3",
|
"yt-dlp",
|
||||||
"rich>=13.0.0",
|
"rich>=13.0.0",
|
||||||
"requests>=2.28.0",
|
"requests>=2.28.0",
|
||||||
]
|
]
|
||||||
@ -63,6 +63,9 @@ tui = [
|
|||||||
"textual>=0.40.0",
|
"textual>=0.40.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tool.uv]
|
||||||
|
prerelease = "allow"
|
||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
packages = ["youtube_cli", "youtube_tui", "youtube_tui.screens", "youtube_tui.widgets", "youtube_tui.models", "youtube_tui.services"]
|
packages = ["youtube_cli", "youtube_tui", "youtube_tui.screens", "youtube_tui.widgets", "youtube_tui.models", "youtube_tui.services"]
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
# Core dependencies
|
# Core dependencies
|
||||||
yt-dlp>=2023.12.0
|
yt-dlp
|
||||||
rich>=13.0.0
|
rich>=13.0.0
|
||||||
requests>=2.28.0
|
requests>=2.28.0
|
||||||
|
|
||||||
|
|||||||
2
setup.py
2
setup.py
@ -37,7 +37,7 @@ setup(
|
|||||||
],
|
],
|
||||||
python_requires=">=3.7",
|
python_requires=">=3.7",
|
||||||
install_requires=[
|
install_requires=[
|
||||||
"yt-dlp>=2023.12.0",
|
"yt-dlp",
|
||||||
"rich>=13.0.0",
|
"rich>=13.0.0",
|
||||||
"requests>=2.28.0",
|
"requests>=2.28.0",
|
||||||
],
|
],
|
||||||
|
|||||||
13
uv.lock
generated
13
uv.lock
generated
@ -1,7 +1,10 @@
|
|||||||
version = 1
|
version = 1
|
||||||
revision = 2
|
revision = 3
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
|
|
||||||
|
[options]
|
||||||
|
prerelease-mode = "allow"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "backports-asyncio-runner"
|
name = "backports-asyncio-runner"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
@ -988,15 +991,15 @@ requires-dist = [
|
|||||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" },
|
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" },
|
||||||
{ name = "textual", marker = "extra == 'tui'", specifier = ">=0.40.0" },
|
{ name = "textual", marker = "extra == 'tui'", specifier = ">=0.40.0" },
|
||||||
{ name = "types-requests", marker = "extra == 'dev'", specifier = ">=2.28.0" },
|
{ name = "types-requests", marker = "extra == 'dev'", specifier = ">=2.28.0" },
|
||||||
{ name = "yt-dlp", specifier = "==2026.3.3" },
|
{ name = "yt-dlp" },
|
||||||
]
|
]
|
||||||
provides-extras = ["dev", "api", "tui"]
|
provides-extras = ["dev", "api", "tui"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yt-dlp"
|
name = "yt-dlp"
|
||||||
version = "2026.3.3"
|
version = "2026.5.16.233954.dev0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/66/6f/7427d23609353e5ef3470ff43ef551b8bd7b166dd4fef48957f0d0e040fe/yt_dlp-2026.3.3.tar.gz", hash = "sha256:3db7969e3a8964dc786bdebcffa2653f31123bf2a630f04a17bdafb7bbd39952", size = 3118658, upload-time = "2026-03-03T16:54:53.909Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/6c/cc/7cbf9ebc344183bc268da96acd45e5bbb248e02aeca008634f7b6c063a5c/yt_dlp-2026.5.16.233954.dev0.tar.gz", hash = "sha256:ec95d5d290773d5fba45f75d9beb7ab42dd6728a0cafd7781484de49257154cd", size = 3130756, upload-time = "2026-05-16T23:43:46.83Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/7e/a4/8b5cd28ab87aef48ef15e74241befec3445496327db028f34147a9e0f14f/yt_dlp-2026.3.3-py3-none-any.whl", hash = "sha256:166c6e68c49ba526474bd400e0129f58aa522c2896204aa73be669c3d2f15e63", size = 3315599, upload-time = "2026-03-03T16:54:51.899Z" },
|
{ url = "https://files.pythonhosted.org/packages/fb/55/006e968c326d4ed2d75f96221c5e25bf653df5c3e13e18060c65a641917b/yt_dlp-2026.5.16.233954.dev0-py3-none-any.whl", hash = "sha256:1ecb8f5e36a3502d004fa2d886d26db0dcc1a395be6711d7a50b4cba3c20dbf7", size = 3317545, upload-time = "2026-05-16T23:43:44.394Z" },
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,3 +1,7 @@
|
|||||||
flask>=2.0.0
|
flask>=2.0.0
|
||||||
flask-cors>=3.0.0
|
flask-cors>=3.0.0
|
||||||
yt-dlp>=2023.12.0
|
flask-socketio>=5.0.0
|
||||||
|
gunicorn>=21.0.0
|
||||||
|
yt-dlp
|
||||||
|
sqlalchemy>=2.0.0
|
||||||
|
eventlet>=0.33.0
|
||||||
@ -2,56 +2,68 @@
|
|||||||
"""
|
"""
|
||||||
Flask API Server for YouTube Web Interface
|
Flask API Server for YouTube Web Interface
|
||||||
Provides REST API endpoints for searching, downloading, and managing YouTube content
|
Provides REST API endpoints for searching, downloading, and managing YouTube content
|
||||||
|
Uses Flask-SocketIO for real-time progress updates
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import uuid
|
from datetime import datetime, timezone
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from flask import Flask, jsonify, request
|
from flask import Flask, send_from_directory
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
|
from flask_socketio import SocketIO
|
||||||
|
|
||||||
# Add parent directory to path to import YouTubeCLI
|
# Add parent directory to path to import YouTubeCLI
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||||
|
|
||||||
|
# Add server directory to path for local imports
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
from youtube_cli.main import YouTubeCLI
|
from youtube_cli.main import YouTubeCLI
|
||||||
|
from models.queue_store import QueueStore
|
||||||
|
from models.archive import ArchiveDB
|
||||||
|
from download_engine import DownloadEngine
|
||||||
|
from routes import search_bp, download_bp, queue_bp, archive_bp
|
||||||
|
|
||||||
# Initialize Flask app
|
# Initialize YouTubeCLI
|
||||||
app = Flask(__name__)
|
|
||||||
CORS(app, resources={r"/api/*": {"origins": "*"}})
|
|
||||||
|
|
||||||
# Create YouTubeCLI instance
|
|
||||||
yt_cli = YouTubeCLI()
|
yt_cli = YouTubeCLI()
|
||||||
|
|
||||||
|
# Initialize Flask app
|
||||||
|
static_dir = str(Path(__file__).parent.parent / 'web-app' / 'dist')
|
||||||
|
app = Flask(__name__, static_folder=static_dir, static_url_path='/')
|
||||||
|
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'youtube-web-secret')
|
||||||
|
CORS(app, resources={r"/api/*": {"origins": "*"}}, supports_credentials=True)
|
||||||
|
|
||||||
# ==================== Utility Functions ====================
|
# Initialize SocketIO - use threading mode (compatible with gunicorn gthread worker)
|
||||||
|
socketio = SocketIO(app, cors_allowed_origins="*", async_mode='threading')
|
||||||
|
|
||||||
def get_config():
|
# Persistent config dir from env var
|
||||||
"""Get current configuration from YouTubeCLI instance."""
|
config_dir = os.environ.get('CONFIG_DIR', str(Path.home() / '.config' / 'youtube_cli'))
|
||||||
return yt_cli.config
|
Path(config_dir).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Persistent file logging
|
||||||
|
log_dir = os.environ.get('LOG_DIR', '/app/logs')
|
||||||
|
Path(log_dir).mkdir(parents=True, exist_ok=True)
|
||||||
|
file_handler = logging.FileHandler(str(Path(log_dir) / 'youtube-cli.log'))
|
||||||
|
file_handler.setLevel(logging.INFO)
|
||||||
|
file_handler.setFormatter(logging.Formatter('%(asctime)s [%(levelname)s] %(name)s: %(message)s'))
|
||||||
|
root_logger = logging.getLogger()
|
||||||
|
root_logger.setLevel(logging.INFO)
|
||||||
|
root_logger.addHandler(file_handler)
|
||||||
|
|
||||||
def make_response(data, status=200):
|
# Initialize components with persistent paths
|
||||||
"""Create a JSON response with proper structure."""
|
queue_store = QueueStore(store_path=str(Path(config_dir) / 'queue.json'))
|
||||||
return jsonify({
|
archive_db = ArchiveDB(db_path=str(Path(config_dir) / 'archive.db'))
|
||||||
"success": True,
|
download_engine = DownloadEngine(queue_store, archive_db, yt_cli, socketio)
|
||||||
"data": data
|
|
||||||
}), status
|
|
||||||
|
|
||||||
|
|
||||||
def make_error_response(message, status=400):
|
|
||||||
"""Create an error response."""
|
|
||||||
return jsonify({
|
|
||||||
"success": False,
|
|
||||||
"error": message
|
|
||||||
}), status
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== Health & Config ====================
|
# ==================== Health & Config ====================
|
||||||
|
|
||||||
|
from utils import make_response, make_error_response
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/health', methods=['GET'])
|
@app.route('/api/health', methods=['GET'])
|
||||||
def health_check():
|
def health_check():
|
||||||
"""Health check endpoint."""
|
"""Health check endpoint."""
|
||||||
@ -59,7 +71,7 @@ def health_check():
|
|||||||
yt_dlp_version = yt_cli.get_yt_dlp_version()
|
yt_dlp_version = yt_cli.get_yt_dlp_version()
|
||||||
return make_response({
|
return make_response({
|
||||||
"status": "healthy",
|
"status": "healthy",
|
||||||
"timestamp": datetime.utcnow().isoformat(),
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
"yt_dlp_version": yt_dlp_version
|
"yt_dlp_version": yt_dlp_version
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -67,10 +79,10 @@ def health_check():
|
|||||||
|
|
||||||
|
|
||||||
@app.route('/api/config', methods=['GET'])
|
@app.route('/api/config', methods=['GET'])
|
||||||
def get_config_endpoint():
|
def get_config():
|
||||||
"""Get current configuration."""
|
"""Get current configuration."""
|
||||||
try:
|
try:
|
||||||
config = get_config()
|
config = yt_cli.config
|
||||||
return make_response({
|
return make_response({
|
||||||
"download_dir": config.get("download_dir"),
|
"download_dir": config.get("download_dir"),
|
||||||
"default_locations": config.get("default_locations", []),
|
"default_locations": config.get("default_locations", []),
|
||||||
@ -86,7 +98,7 @@ def get_config_endpoint():
|
|||||||
def get_categories():
|
def get_categories():
|
||||||
"""Get available download categories."""
|
"""Get available download categories."""
|
||||||
try:
|
try:
|
||||||
config = get_config()
|
config = yt_cli.config
|
||||||
categories = yt_cli.get_categories(config)
|
categories = yt_cli.get_categories(config)
|
||||||
return make_response({
|
return make_response({
|
||||||
"categories": categories,
|
"categories": categories,
|
||||||
@ -96,433 +108,48 @@ def get_categories():
|
|||||||
return make_error_response(f"Failed to get categories: {str(e)}", 500)
|
return make_error_response(f"Failed to get categories: {str(e)}", 500)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Register Blueprints ====================
|
||||||
|
|
||||||
# ==================== Search ====================
|
app.register_blueprint(search_bp)
|
||||||
|
app.register_blueprint(download_bp)
|
||||||
|
app.register_blueprint(queue_bp)
|
||||||
|
app.register_blueprint(archive_bp)
|
||||||
|
|
||||||
@app.route('/api/search', methods=['GET'])
|
|
||||||
def search():
|
|
||||||
"""Search for videos."""
|
|
||||||
try:
|
|
||||||
query = request.args.get('q', '').strip()
|
|
||||||
page = int(request.args.get('page', 1))
|
|
||||||
|
|
||||||
if not query:
|
# ==================== WebSocket Events ====================
|
||||||
return make_error_response("Search query is required", 400)
|
|
||||||
|
|
||||||
if page < 1:
|
@socketio.on('connect')
|
||||||
return make_error_response("Page must be greater than 0", 400)
|
def handle_connect():
|
||||||
|
"""Handle client WebSocket connection."""
|
||||||
|
from flask_socketio import emit
|
||||||
|
emit('connected', {'message': 'Connected to server'})
|
||||||
|
|
||||||
config = get_config()
|
|
||||||
videos = yt_cli.search_videos(query, config, page=page, return_results=True)
|
|
||||||
|
|
||||||
return make_response({
|
@socketio.on('disconnect')
|
||||||
"query": query,
|
def handle_disconnect():
|
||||||
"page": page,
|
"""Handle client WebSocket disconnection."""
|
||||||
"videos": videos
|
pass
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
return make_error_response(f"Search failed: {str(e)}", 500)
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== Download ====================
|
# ==================== Static File Serving ====================
|
||||||
|
|
||||||
# Queue to track downloads
|
|
||||||
download_queue = {}
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/download', methods=['POST'])
|
|
||||||
def download_video():
|
|
||||||
"""Download a video."""
|
|
||||||
try:
|
|
||||||
data = request.get_json()
|
|
||||||
url = data.get('url', '').strip() if data else ''
|
|
||||||
category = data.get('category') if data else None
|
|
||||||
network_folder = data.get('network_folder') if data else None
|
|
||||||
|
|
||||||
if not url:
|
|
||||||
return make_error_response("Video URL is required", 400)
|
|
||||||
|
|
||||||
config = get_config()
|
|
||||||
|
|
||||||
# Generate queue ID
|
|
||||||
queue_id = str(uuid.uuid4())
|
|
||||||
|
|
||||||
# Create queue item
|
|
||||||
queue_item = {
|
|
||||||
"id": queue_id,
|
|
||||||
"url": url,
|
|
||||||
"category": category,
|
|
||||||
"network_folder": network_folder,
|
|
||||||
"status": "pending",
|
|
||||||
"created_at": datetime.utcnow().isoformat(),
|
|
||||||
"progress": 0,
|
|
||||||
"message": "Queued for download"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Add to queue
|
|
||||||
download_queue[queue_id] = queue_item
|
|
||||||
|
|
||||||
# Start download in background (simplified - would need threading in production)
|
|
||||||
# For now, we do it synchronously
|
|
||||||
def process_download():
|
|
||||||
try:
|
|
||||||
download_queue[queue_id]["status"] = "downloading"
|
|
||||||
download_queue[queue_id]["message"] = "Starting download..."
|
|
||||||
|
|
||||||
# Download the video
|
|
||||||
success = yt_cli.download_video(
|
|
||||||
url=url,
|
|
||||||
config=config,
|
|
||||||
network_folder=network_folder,
|
|
||||||
category=category
|
|
||||||
)
|
|
||||||
|
|
||||||
if success:
|
|
||||||
download_queue[queue_id]["status"] = "completed"
|
|
||||||
download_queue[queue_id]["message"] = "Download completed successfully"
|
|
||||||
download_queue[queue_id]["completed_at"] = datetime.utcnow().isoformat()
|
|
||||||
else:
|
|
||||||
download_queue[queue_id]["status"] = "failed"
|
|
||||||
download_queue[queue_id]["message"] = "Download failed"
|
|
||||||
download_queue[queue_id]["completed_at"] = datetime.utcnow().isoformat()
|
|
||||||
except Exception as e:
|
|
||||||
download_queue[queue_id]["status"] = "failed"
|
|
||||||
download_queue[queue_id]["message"] = str(e)
|
|
||||||
download_queue[queue_id]["completed_at"] = datetime.utcnow().isoformat()
|
|
||||||
|
|
||||||
# Process download (synchronous for now)
|
|
||||||
process_download()
|
|
||||||
|
|
||||||
return make_response({
|
|
||||||
"queue_id": queue_id,
|
|
||||||
"status": download_queue[queue_id]["status"],
|
|
||||||
"message": download_queue[queue_id]["message"]
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
return make_error_response(f"Download failed: {str(e)}", 500)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/download/playlist', methods=['POST'])
|
|
||||||
def download_playlist():
|
|
||||||
"""Download a playlist."""
|
|
||||||
try:
|
|
||||||
data = request.get_json()
|
|
||||||
url = data.get('url', '').strip() if data else ''
|
|
||||||
category = data.get('category') if data else None
|
|
||||||
network_folder = data.get('network_folder') if data else None
|
|
||||||
|
|
||||||
if not url:
|
|
||||||
return make_error_response("Playlist URL is required", 400)
|
|
||||||
|
|
||||||
config = get_config()
|
|
||||||
|
|
||||||
# Generate queue ID
|
|
||||||
queue_id = str(uuid.uuid4())
|
|
||||||
|
|
||||||
# Create queue item
|
|
||||||
queue_item = {
|
|
||||||
"id": queue_id,
|
|
||||||
"url": url,
|
|
||||||
"category": category,
|
|
||||||
"network_folder": network_folder,
|
|
||||||
"type": "playlist",
|
|
||||||
"status": "pending",
|
|
||||||
"created_at": datetime.utcnow().isoformat(),
|
|
||||||
"progress": 0,
|
|
||||||
"message": "Queued for download",
|
|
||||||
"videos": []
|
|
||||||
}
|
|
||||||
|
|
||||||
# Add to queue
|
|
||||||
download_queue[queue_id] = queue_item
|
|
||||||
|
|
||||||
# Start playlist download
|
|
||||||
def process_playlist_download():
|
|
||||||
try:
|
|
||||||
download_queue[queue_id]["status"] = "downloading"
|
|
||||||
download_queue[queue_id]["message"] = "Starting playlist download..."
|
|
||||||
|
|
||||||
# Download the playlist
|
|
||||||
result = yt_cli.download_playlist(
|
|
||||||
url=url,
|
|
||||||
config=config,
|
|
||||||
network_folder=network_folder,
|
|
||||||
category=category
|
|
||||||
)
|
|
||||||
|
|
||||||
if result:
|
|
||||||
download_queue[queue_id]["status"] = "completed"
|
|
||||||
download_queue[queue_id]["message"] = "Playlist download completed successfully"
|
|
||||||
download_queue[queue_id]["completed_at"] = datetime.utcnow().isoformat()
|
|
||||||
download_queue[queue_id]["videos"] = result
|
|
||||||
else:
|
|
||||||
download_queue[queue_id]["status"] = "failed"
|
|
||||||
download_queue[queue_id]["message"] = "Playlist download failed"
|
|
||||||
download_queue[queue_id]["completed_at"] = datetime.utcnow().isoformat()
|
|
||||||
except Exception as e:
|
|
||||||
download_queue[queue_id]["status"] = "failed"
|
|
||||||
download_queue[queue_id]["message"] = str(e)
|
|
||||||
download_queue[queue_id]["completed_at"] = datetime.utcnow().isoformat()
|
|
||||||
|
|
||||||
# Process playlist download (synchronous for now)
|
|
||||||
process_playlist_download()
|
|
||||||
|
|
||||||
return make_response({
|
|
||||||
"queue_id": queue_id,
|
|
||||||
"status": download_queue[queue_id]["status"],
|
|
||||||
"message": download_queue[queue_id]["message"],
|
|
||||||
"videos": download_queue[queue_id].get("videos", [])
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
return make_error_response(f"Playlist download failed: {str(e)}", 500)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== Archive ====================
|
|
||||||
|
|
||||||
@app.route('/api/archive', methods=['GET'])
|
|
||||||
def get_archive():
|
|
||||||
"""Get download archive."""
|
|
||||||
try:
|
|
||||||
archive = yt_cli.downloaded_videos
|
|
||||||
return make_response({
|
|
||||||
"archive": archive,
|
|
||||||
"total": len(archive)
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
return make_error_response(f"Failed to get archive: {str(e)}", 500)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/archive/<video_id>', methods=['DELETE'])
|
|
||||||
def remove_from_archive(video_id):
|
|
||||||
"""Remove a video from the archive."""
|
|
||||||
try:
|
|
||||||
# Remove from archive
|
|
||||||
if video_id in yt_cli.downloaded_videos:
|
|
||||||
del yt_cli.downloaded_videos[video_id]
|
|
||||||
yt_cli.save_archive()
|
|
||||||
return make_response({"message": f"Video {video_id} removed from archive"})
|
|
||||||
else:
|
|
||||||
return make_error_response(f"Video {video_id} not found in archive", 404)
|
|
||||||
except Exception as e:
|
|
||||||
return make_error_response(f"Failed to remove from archive: {str(e)}", 500)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== Queue ====================
|
|
||||||
|
|
||||||
@app.route('/api/queue', methods=['GET'])
|
|
||||||
def get_queue():
|
|
||||||
"""Get all queue items."""
|
|
||||||
try:
|
|
||||||
return make_response({
|
|
||||||
"queue": list(download_queue.values()),
|
|
||||||
"total": len(download_queue)
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
return make_error_response(f"Failed to get queue: {str(e)}", 500)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/queue/<queue_id>', methods=['DELETE'])
|
|
||||||
def remove_from_queue(queue_id):
|
|
||||||
"""Remove an item from the queue."""
|
|
||||||
try:
|
|
||||||
if queue_id in download_queue:
|
|
||||||
del download_queue[queue_id]
|
|
||||||
return make_response({"message": f"Item {queue_id} removed from queue"})
|
|
||||||
else:
|
|
||||||
return make_error_response(f"Queue item {queue_id} not found", 404)
|
|
||||||
except Exception as e:
|
|
||||||
return make_error_response(f"Failed to remove from queue: {str(e)}", 500)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/queue/<queue_id>/retry', methods=['POST'])
|
|
||||||
def retry_download(queue_id):
|
|
||||||
"""Retry a failed download."""
|
|
||||||
try:
|
|
||||||
if queue_id not in download_queue:
|
|
||||||
return make_error_response(f"Queue item {queue_id} not found", 404)
|
|
||||||
|
|
||||||
item = download_queue[queue_id]
|
|
||||||
|
|
||||||
# Reset queue item status
|
|
||||||
item["status"] = "pending"
|
|
||||||
item["message"] = "Retry queued"
|
|
||||||
item["progress"] = 0
|
|
||||||
|
|
||||||
# Re-download based on type
|
|
||||||
if item.get("type") == "playlist":
|
|
||||||
return download_playlist_wrapper(queue_id)
|
|
||||||
else:
|
|
||||||
return download_video_wrapper(queue_id)
|
|
||||||
except Exception as e:
|
|
||||||
return make_error_response(f"Retry failed: {str(e)}", 500)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/queue/<queue_id>/cancel', methods=['POST'])
|
|
||||||
def cancel_download(queue_id):
|
|
||||||
"""Cancel a download."""
|
|
||||||
try:
|
|
||||||
if queue_id not in download_queue:
|
|
||||||
return make_error_response(f"Queue item {queue_id} not found", 404)
|
|
||||||
|
|
||||||
item = download_queue[queue_id]
|
|
||||||
|
|
||||||
if item["status"] in ["completed", "failed"]:
|
|
||||||
return make_error_response(f"Cannot cancel {item['status']} download", 400)
|
|
||||||
|
|
||||||
item["status"] = "cancelled"
|
|
||||||
item["message"] = "Download cancelled by user"
|
|
||||||
|
|
||||||
return make_response({
|
|
||||||
"queue_id": queue_id,
|
|
||||||
"status": item["status"],
|
|
||||||
"message": item["message"]
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
return make_error_response(f"Failed to cancel download: {str(e)}", 500)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/queue/<queue_id>/status', methods=['GET'])
|
|
||||||
def get_queue_status(queue_id):
|
|
||||||
"""Get the status of a queue item."""
|
|
||||||
try:
|
|
||||||
if queue_id not in download_queue:
|
|
||||||
return make_error_response(f"Queue item {queue_id} not found", 404)
|
|
||||||
|
|
||||||
return make_response({
|
|
||||||
"queue_id": queue_id,
|
|
||||||
"status": download_queue[queue_id]["status"]
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
return make_error_response(f"Failed to get queue status: {str(e)}", 500)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/queue/clear/completed', methods=['POST'])
|
|
||||||
def clear_completed():
|
|
||||||
"""Clear completed items from the queue."""
|
|
||||||
try:
|
|
||||||
completed_ids = [
|
|
||||||
queue_id for queue_id, item in download_queue.items()
|
|
||||||
if item["status"] == "completed"
|
|
||||||
]
|
|
||||||
|
|
||||||
for queue_id in completed_ids:
|
|
||||||
del download_queue[queue_id]
|
|
||||||
|
|
||||||
return make_response({
|
|
||||||
"cleared": completed_ids,
|
|
||||||
"count": len(completed_ids)
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
return make_error_response(f"Failed to clear completed items: {str(e)}", 500)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/queue/clear/failed', methods=['POST'])
|
|
||||||
def clear_failed():
|
|
||||||
"""Clear failed items from the queue."""
|
|
||||||
try:
|
|
||||||
failed_ids = [
|
|
||||||
queue_id for queue_id, item in download_queue.items()
|
|
||||||
if item["status"] == "failed"
|
|
||||||
]
|
|
||||||
|
|
||||||
for queue_id in failed_ids:
|
|
||||||
del download_queue[queue_id]
|
|
||||||
|
|
||||||
return make_response({
|
|
||||||
"cleared": failed_ids,
|
|
||||||
"count": len(failed_ids)
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
return make_error_response(f"Failed to clear failed items: {str(e)}", 500)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== Helper Functions ====================
|
|
||||||
|
|
||||||
def download_video_wrapper(queue_id):
|
|
||||||
"""Wrapper to re-download a video from queue."""
|
|
||||||
try:
|
|
||||||
item = download_queue[queue_id]
|
|
||||||
config = get_config()
|
|
||||||
|
|
||||||
item["status"] = "downloading"
|
|
||||||
item["message"] = "Starting download..."
|
|
||||||
|
|
||||||
success = yt_cli.download_video(
|
|
||||||
url=item["url"],
|
|
||||||
config=config,
|
|
||||||
network_folder=item.get("network_folder"),
|
|
||||||
category=item.get("category")
|
|
||||||
)
|
|
||||||
|
|
||||||
if success:
|
|
||||||
item["status"] = "completed"
|
|
||||||
item["message"] = "Download completed successfully"
|
|
||||||
item["completed_at"] = datetime.utcnow().isoformat()
|
|
||||||
else:
|
|
||||||
item["status"] = "failed"
|
|
||||||
item["message"] = "Download failed"
|
|
||||||
item["completed_at"] = datetime.utcnow().isoformat()
|
|
||||||
|
|
||||||
return make_response({
|
|
||||||
"queue_id": queue_id,
|
|
||||||
"status": item["status"],
|
|
||||||
"message": item["message"]
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
download_queue[queue_id]["status"] = "failed"
|
|
||||||
download_queue[queue_id]["message"] = str(e)
|
|
||||||
return make_error_response(f"Download failed: {str(e)}", 500)
|
|
||||||
|
|
||||||
|
|
||||||
def download_playlist_wrapper(queue_id):
|
|
||||||
"""Wrapper to re-download a playlist from queue."""
|
|
||||||
try:
|
|
||||||
item = download_queue[queue_id]
|
|
||||||
config = get_config()
|
|
||||||
|
|
||||||
item["status"] = "downloading"
|
|
||||||
item["message"] = "Starting playlist download..."
|
|
||||||
|
|
||||||
result = yt_cli.download_playlist(
|
|
||||||
url=item["url"],
|
|
||||||
config=config,
|
|
||||||
network_folder=item.get("network_folder"),
|
|
||||||
category=item.get("category")
|
|
||||||
)
|
|
||||||
|
|
||||||
if result:
|
|
||||||
item["status"] = "completed"
|
|
||||||
item["message"] = "Playlist download completed successfully"
|
|
||||||
item["completed_at"] = datetime.utcnow().isoformat()
|
|
||||||
item["videos"] = result
|
|
||||||
else:
|
|
||||||
item["status"] = "failed"
|
|
||||||
item["message"] = "Playlist download failed"
|
|
||||||
item["completed_at"] = datetime.utcnow().isoformat()
|
|
||||||
|
|
||||||
return make_response({
|
|
||||||
"queue_id": queue_id,
|
|
||||||
"status": item["status"],
|
|
||||||
"message": item["message"],
|
|
||||||
"videos": item.get("videos", [])
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
download_queue[queue_id]["status"] = "failed"
|
|
||||||
download_queue[queue_id]["message"] = str(e)
|
|
||||||
return make_error_response(f"Playlist download failed: {str(e)}", 500)
|
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def serve_home():
|
||||||
|
"""Serve React app home page."""
|
||||||
|
static_path = app.static_folder or static_dir
|
||||||
|
return send_from_directory(static_path, 'index.html')
|
||||||
|
|
||||||
|
|
||||||
# ==================== Error Handlers ====================
|
# ==================== Error Handlers ====================
|
||||||
|
|
||||||
@app.errorhandler(404)
|
@app.errorhandler(404)
|
||||||
def not_found(error):
|
def not_found(error):
|
||||||
"""Handle 404 errors."""
|
"""Handle 404 errors - serve index.html for SPA routes."""
|
||||||
|
from flask import request
|
||||||
|
# For SPA routes (not API), serve index.html
|
||||||
|
if not request.path.startswith('/api'):
|
||||||
|
static_path = app.static_folder or static_dir
|
||||||
|
return send_from_directory(static_path, 'index.html')
|
||||||
return make_error_response("Endpoint not found", 404)
|
return make_error_response("Endpoint not found", 404)
|
||||||
|
|
||||||
|
|
||||||
@ -535,8 +162,8 @@ def internal_error(error):
|
|||||||
# ==================== Run Server ====================
|
# ==================== Run Server ====================
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
# Default to port 4096
|
|
||||||
port = int(os.environ.get('PORT', 4096))
|
port = int(os.environ.get('PORT', 4096))
|
||||||
debug = os.environ.get('FLASK_DEBUG', 'false').lower() == 'true'
|
debug = os.environ.get('FLASK_DEBUG', 'false').lower() == 'true'
|
||||||
|
|
||||||
app.run(host='0.0.0.0', port=port, debug=debug)
|
# Use eventlet for WebSocket support (required by Flask-SocketIO)
|
||||||
|
socketio.run(app, host='0.0.0.0', port=port, debug=debug, allow_unsafe_werkzeug=True)
|
||||||
133
web/server/banned_terms.txt
Normal file
133
web/server/banned_terms.txt
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
# Banned search terms (case-insensitive matching)
|
||||||
|
# Lines starting with # are comments
|
||||||
|
# Any search query containing these terms will be blocked
|
||||||
|
|
||||||
|
# Sexual content
|
||||||
|
porn
|
||||||
|
pornography
|
||||||
|
pornstar
|
||||||
|
hardcore
|
||||||
|
softcore
|
||||||
|
xxx
|
||||||
|
sex
|
||||||
|
sexting
|
||||||
|
nude
|
||||||
|
naked
|
||||||
|
nudes
|
||||||
|
nude
|
||||||
|
nnn
|
||||||
|
no nut november
|
||||||
|
nnn challenge
|
||||||
|
nude haul
|
||||||
|
masturbat
|
||||||
|
orgasm
|
||||||
|
ejaculat
|
||||||
|
sex toy
|
||||||
|
sex toys
|
||||||
|
adult film
|
||||||
|
adult video
|
||||||
|
erotic
|
||||||
|
erotica
|
||||||
|
fetish
|
||||||
|
bondage
|
||||||
|
bdsm
|
||||||
|
fisting
|
||||||
|
anal
|
||||||
|
pornhub
|
||||||
|
xvideos
|
||||||
|
youporn
|
||||||
|
redtube
|
||||||
|
xnxx
|
||||||
|
xvideo
|
||||||
|
|
||||||
|
# Animated sexual content
|
||||||
|
hentai
|
||||||
|
hanime
|
||||||
|
ecchi
|
||||||
|
rule34
|
||||||
|
nsfw
|
||||||
|
netorare
|
||||||
|
loli
|
||||||
|
shota
|
||||||
|
ero
|
||||||
|
eroge
|
||||||
|
doujinshi
|
||||||
|
doujin
|
||||||
|
3d hentai
|
||||||
|
3d porn
|
||||||
|
animated porn
|
||||||
|
cartoon porn
|
||||||
|
anime porn
|
||||||
|
anime hentai
|
||||||
|
anime nude
|
||||||
|
anime naked
|
||||||
|
anime sex
|
||||||
|
anime xxx
|
||||||
|
anime nsfw
|
||||||
|
anime ero
|
||||||
|
anime erotic
|
||||||
|
anime fetish
|
||||||
|
|
||||||
|
# Related terms
|
||||||
|
strip
|
||||||
|
striptease
|
||||||
|
peepshow
|
||||||
|
sex worker
|
||||||
|
escort
|
||||||
|
prostitut
|
||||||
|
cam girl
|
||||||
|
cam girl
|
||||||
|
cam show
|
||||||
|
onlyfans
|
||||||
|
bikini
|
||||||
|
swimwear
|
||||||
|
key hole dress
|
||||||
|
tight dress
|
||||||
|
try on
|
||||||
|
only fans
|
||||||
|
fansly
|
||||||
|
cam model
|
||||||
|
cam model
|
||||||
|
threesome
|
||||||
|
swinger
|
||||||
|
swingers
|
||||||
|
hotwife
|
||||||
|
hotwif
|
||||||
|
cuckold
|
||||||
|
amateur sex
|
||||||
|
amateur porn
|
||||||
|
amateur nude
|
||||||
|
amateur naked
|
||||||
|
amateur xxx
|
||||||
|
amateur erotic
|
||||||
|
|
||||||
|
# Goon/gooning
|
||||||
|
goon
|
||||||
|
gooning
|
||||||
|
gooner
|
||||||
|
goone
|
||||||
|
|
||||||
|
# TikTok models
|
||||||
|
tiktok models
|
||||||
|
tiktok model
|
||||||
|
|
||||||
|
# Milk content
|
||||||
|
hot milk
|
||||||
|
high protein milk
|
||||||
|
|
||||||
|
# JOI
|
||||||
|
joi
|
||||||
|
join
|
||||||
|
countdown
|
||||||
|
|
||||||
|
# ASMR
|
||||||
|
asmr
|
||||||
|
|
||||||
|
# Instructions
|
||||||
|
instructions
|
||||||
|
|
||||||
|
# Prone
|
||||||
|
prone
|
||||||
|
|
||||||
|
# Massage
|
||||||
|
massage
|
||||||
527
web/server/download_engine.py
Normal file
527
web/server/download_engine.py
Normal file
@ -0,0 +1,527 @@
|
|||||||
|
"""Download engine using yt-dlp Python API with progress callbacks and sequential queue processing."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import yt_dlp
|
||||||
|
|
||||||
|
from models import QueueItem, ArchiveItem
|
||||||
|
from models.archive import ArchiveDB
|
||||||
|
from models.queue_store import QueueStore
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadEngine:
|
||||||
|
"""Handles video/playlist downloads with sequential queue processing."""
|
||||||
|
|
||||||
|
def __init__(self, queue_store: QueueStore, archive_db: ArchiveDB,
|
||||||
|
yt_cli=None, socketio=None):
|
||||||
|
self.queue_store = queue_store
|
||||||
|
self.archive_db = archive_db
|
||||||
|
self.yt_cli = yt_cli
|
||||||
|
self.socketio = socketio
|
||||||
|
self._active_download_id = None
|
||||||
|
self._yt_dlp_instance = None
|
||||||
|
self._queue_lock = threading.Lock()
|
||||||
|
self._queue_processor_thread = None
|
||||||
|
self._stop_event = threading.Event()
|
||||||
|
self._recover_in_progress_downloads()
|
||||||
|
self._start_queue_processor()
|
||||||
|
|
||||||
|
def _recover_in_progress_downloads(self):
|
||||||
|
"""Recover downloads that were in progress when the server crashed."""
|
||||||
|
try:
|
||||||
|
items = self.queue_store.get_all()
|
||||||
|
recovered = 0
|
||||||
|
for item in items:
|
||||||
|
if item.status == "downloading":
|
||||||
|
logger.info(f"Recovering in-progress download: {item.id} ({item.title})")
|
||||||
|
self.queue_store.update_status(item.id, "pending")
|
||||||
|
self.queue_store.update_progress(item.id, 0.0)
|
||||||
|
recovered += 1
|
||||||
|
if recovered > 0:
|
||||||
|
logger.info(f"Recovered {recovered} in-progress download(s) from crash")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to recover in-progress downloads: {e}")
|
||||||
|
|
||||||
|
def _start_queue_processor(self):
|
||||||
|
"""Start the background queue processor thread."""
|
||||||
|
self._stop_event.clear()
|
||||||
|
self._queue_processor_thread = threading.Thread(
|
||||||
|
target=self._queue_processor_loop, daemon=True
|
||||||
|
)
|
||||||
|
self._queue_processor_thread.start()
|
||||||
|
logger.info("Queue processor started")
|
||||||
|
|
||||||
|
def _queue_processor_loop(self):
|
||||||
|
"""Main loop that processes one download at a time."""
|
||||||
|
while not self._stop_event.is_set():
|
||||||
|
with self._queue_lock:
|
||||||
|
if self._active_download_id is not None:
|
||||||
|
time.sleep(1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Find next pending item
|
||||||
|
all_items = self.queue_store.get_all()
|
||||||
|
pending = [item for item in all_items if item.status == "pending"]
|
||||||
|
if not pending:
|
||||||
|
time.sleep(2)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Sort by added_at to process oldest first
|
||||||
|
pending.sort(key=lambda x: x.added_at)
|
||||||
|
next_item = pending[0]
|
||||||
|
|
||||||
|
# Mark as downloading
|
||||||
|
self._active_download_id = next_item.id
|
||||||
|
self.queue_store.update_status(next_item.id, "downloading")
|
||||||
|
self.queue_store.update_progress(next_item.id, 0.0)
|
||||||
|
|
||||||
|
self._broadcast(next_item.id, "download:status", {
|
||||||
|
"queueId": next_item.id, "status": "downloading", "progress": 0
|
||||||
|
})
|
||||||
|
|
||||||
|
# Run the actual download (blocks until done)
|
||||||
|
if next_item.item_type == "playlist":
|
||||||
|
self._run_playlist_download(next_item)
|
||||||
|
else:
|
||||||
|
self._run_video_download(next_item)
|
||||||
|
|
||||||
|
# Release lock for next iteration
|
||||||
|
with self._queue_lock:
|
||||||
|
self._active_download_id = None
|
||||||
|
self._yt_dlp_instance = None
|
||||||
|
|
||||||
|
def _run_video_download(self, item: QueueItem):
|
||||||
|
"""Run a single video download synchronously."""
|
||||||
|
config = self.yt_cli.config
|
||||||
|
url = item.url
|
||||||
|
queue_id = item.id
|
||||||
|
category = item.category
|
||||||
|
network_folder = item.network_folder
|
||||||
|
quality = item.quality
|
||||||
|
|
||||||
|
base_dir = Path(config["download_dir"])
|
||||||
|
if not base_dir.exists():
|
||||||
|
base_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
download_dir = base_dir / category if category else base_dir
|
||||||
|
download_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Build yt-dlp options with thumbnail support
|
||||||
|
ytdlp_args = config.get("yt_dlp_args", {})
|
||||||
|
default_format = ytdlp_args.get("format", "bestvideo[height<=1080]+bestaudio/best")
|
||||||
|
|
||||||
|
# Apply user quality preference
|
||||||
|
fmt = self._build_format(quality, default_format)
|
||||||
|
logger.info(f"Download {queue_id}: quality={quality}, format={fmt}")
|
||||||
|
|
||||||
|
# Use %(title)s.%(ext)s template so thumbnail gets same base name
|
||||||
|
output_template = str(download_dir / "%(title)s.%(ext)s")
|
||||||
|
|
||||||
|
ydl_opts = {
|
||||||
|
"format": fmt,
|
||||||
|
"outtmpl": output_template,
|
||||||
|
"write_thumbnail": True,
|
||||||
|
"thumbnail_format": "jpg",
|
||||||
|
"no_warnings": False,
|
||||||
|
"restrict_filenames": True,
|
||||||
|
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||||
|
"retries": 5,
|
||||||
|
"fragment_retries": 5,
|
||||||
|
"extract_retries": 3,
|
||||||
|
"concurrent_fragment_downloads": 4,
|
||||||
|
"overwrites": True,
|
||||||
|
"continuedl": True,
|
||||||
|
"extractor_args": {"youtube": {"player_client": ["web", "ios", "android", "tv", "mediaconnect"]}},
|
||||||
|
}
|
||||||
|
|
||||||
|
video_info = {}
|
||||||
|
downloaded_filepath = None
|
||||||
|
|
||||||
|
def progress_callback(d):
|
||||||
|
nonlocal downloaded_filepath
|
||||||
|
if d["status"] == "downloading":
|
||||||
|
total = d.get("total_bytes") or 1
|
||||||
|
progress = d.get("downloaded_bytes", 0) / total * 100
|
||||||
|
speed = d.get("speed")
|
||||||
|
speed_str = f"{speed / 1024 / 1024:.1f} MB/s" if speed else None
|
||||||
|
eta = d.get("eta")
|
||||||
|
eta_str = f"{int(eta)}s" if eta else None
|
||||||
|
self.queue_store.update_progress(queue_id, progress, speed_str, eta_str)
|
||||||
|
self._broadcast(queue_id, "download:progress", {
|
||||||
|
"queueId": queue_id,
|
||||||
|
"progress": round(progress, 1),
|
||||||
|
"speed": speed_str,
|
||||||
|
"eta": eta_str,
|
||||||
|
})
|
||||||
|
elif d["status"] == "finished":
|
||||||
|
downloaded_filepath = d.get("filename", "")
|
||||||
|
self.queue_store.update_progress(queue_id, 100.0)
|
||||||
|
self._broadcast(queue_id, "download:progress", {
|
||||||
|
"queueId": queue_id, "progress": 100, "speed": None, "eta": None
|
||||||
|
})
|
||||||
|
|
||||||
|
ydl_opts["progress_hooks"] = [progress_callback]
|
||||||
|
|
||||||
|
try:
|
||||||
|
ydl = yt_dlp.YoutubeDL(ydl_opts)
|
||||||
|
self._yt_dlp_instance = ydl
|
||||||
|
|
||||||
|
# Pre-fetch metadata
|
||||||
|
try:
|
||||||
|
info = ydl.extract_info(url, download=False)
|
||||||
|
if info:
|
||||||
|
video_info.update(info)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to pre-fetch metadata: {e}")
|
||||||
|
|
||||||
|
ydl.download([url])
|
||||||
|
|
||||||
|
# Find the actual downloaded file
|
||||||
|
if not downloaded_filepath:
|
||||||
|
downloaded_filepath = self._find_downloaded_file(download_dir, video_info)
|
||||||
|
|
||||||
|
file_size = 0
|
||||||
|
if downloaded_filepath and os.path.exists(downloaded_filepath):
|
||||||
|
file_size = os.path.exists(downloaded_filepath) and os.path.getsize(downloaded_filepath) or 0
|
||||||
|
|
||||||
|
# Check thumbnail was downloaded
|
||||||
|
thumbnail_path = None
|
||||||
|
if downloaded_filepath:
|
||||||
|
base = os.path.splitext(downloaded_filepath)[0]
|
||||||
|
for ext in ['.jpg', '.jpeg', '.webp', '.png']:
|
||||||
|
tp = base + ext
|
||||||
|
if os.path.exists(tp):
|
||||||
|
thumbnail_path = tp
|
||||||
|
break
|
||||||
|
|
||||||
|
self.queue_store.update_status(
|
||||||
|
queue_id, "completed",
|
||||||
|
download_path=downloaded_filepath or "",
|
||||||
|
file_size=str(file_size)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Archive
|
||||||
|
vid = video_info.get("id", "")
|
||||||
|
if vid:
|
||||||
|
archive_item = ArchiveItem(
|
||||||
|
video_id=vid,
|
||||||
|
title=video_info.get("title", "Unknown Title"),
|
||||||
|
url=video_info.get("webpage_url", url),
|
||||||
|
description=video_info.get("description", ""),
|
||||||
|
thumbnail=video_info.get("thumbnail", ""),
|
||||||
|
channel=video_info.get("uploader", ""),
|
||||||
|
views=video_info.get("view_count", 0) or 0,
|
||||||
|
duration=self._format_duration(video_info.get("duration", 0)),
|
||||||
|
category=category or "",
|
||||||
|
download_path=downloaded_filepath or "",
|
||||||
|
file_size=file_size,
|
||||||
|
download_date=datetime.now(timezone.utc).isoformat(),
|
||||||
|
)
|
||||||
|
self.archive_db.add_video(archive_item)
|
||||||
|
|
||||||
|
# Network share copy
|
||||||
|
if network_folder and config.get("network_share_path") and downloaded_filepath:
|
||||||
|
self._copy_to_network_share(downloaded_filepath, config, network_folder)
|
||||||
|
# Also copy thumbnail if it exists
|
||||||
|
if thumbnail_path:
|
||||||
|
self._copy_to_network_share(thumbnail_path, config, network_folder)
|
||||||
|
|
||||||
|
self._broadcast(queue_id, "download:complete", {
|
||||||
|
"queueId": queue_id,
|
||||||
|
"downloadPath": downloaded_filepath or "",
|
||||||
|
"fileSize": file_size,
|
||||||
|
"thumbnailPath": thumbnail_path,
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Download failed for {queue_id}: {type(e).__name__}: {e}", exc_info=True)
|
||||||
|
self.queue_store.update_status(queue_id, "failed", error_message=f"{type(e).__name__}: {e}")
|
||||||
|
self._broadcast(queue_id, "download:failed", {
|
||||||
|
"queueId": queue_id, "error": f"{type(e).__name__}: {e}"
|
||||||
|
})
|
||||||
|
|
||||||
|
def _run_playlist_download(self, item: QueueItem):
|
||||||
|
"""Run a playlist download synchronously."""
|
||||||
|
config = self.yt_cli.config
|
||||||
|
url = item.url
|
||||||
|
queue_id = item.id
|
||||||
|
category = item.category
|
||||||
|
network_folder = item.network_folder
|
||||||
|
quality = item.quality
|
||||||
|
|
||||||
|
base_dir = Path(config["download_dir"])
|
||||||
|
if not base_dir.exists():
|
||||||
|
base_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
download_dir = base_dir / category if category else base_dir
|
||||||
|
download_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Get playlist title
|
||||||
|
playlist_title = "Unknown Playlist"
|
||||||
|
try:
|
||||||
|
ydl_info = yt_dlp.YoutubeDL({
|
||||||
|
"flat_playlist": True,
|
||||||
|
"no_warnings": True,
|
||||||
|
})
|
||||||
|
info = ydl_info.extract_info(url, download=False)
|
||||||
|
if info:
|
||||||
|
playlist_title = info.get("title", "Unknown Playlist")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
playlist_dir = download_dir / playlist_title
|
||||||
|
playlist_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
ytdlp_args = config.get("yt_dlp_args", {})
|
||||||
|
default_format = ytdlp_args.get("format", "bestvideo[height<=1080]+bestaudio/best")
|
||||||
|
|
||||||
|
# Apply user quality preference
|
||||||
|
fmt = self._build_format(quality, default_format)
|
||||||
|
|
||||||
|
ydl_opts = {
|
||||||
|
"format": fmt,
|
||||||
|
"outtmpl": str(playlist_dir / "%(title)s.%(ext)s"),
|
||||||
|
"write_thumbnail": True,
|
||||||
|
"thumbnail_format": "jpg",
|
||||||
|
"no_warnings": False,
|
||||||
|
"restrict_filenames": True,
|
||||||
|
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||||
|
"retries": 5,
|
||||||
|
"fragment_retries": 5,
|
||||||
|
"extract_retries": 3,
|
||||||
|
"concurrent_fragment_downloads": 4,
|
||||||
|
"overwrites": True,
|
||||||
|
"continuedl": True,
|
||||||
|
"extractor_args": {"youtube": {"player_client": ["web", "ios", "android", "tv", "mediaconnect"]}},
|
||||||
|
}
|
||||||
|
|
||||||
|
total_videos = None
|
||||||
|
completed_videos = 0
|
||||||
|
|
||||||
|
def progress_callback(d):
|
||||||
|
nonlocal completed_videos
|
||||||
|
if d["status"] == "downloading":
|
||||||
|
if total_videos:
|
||||||
|
progress = (completed_videos / total_videos) * 100
|
||||||
|
else:
|
||||||
|
total = d.get("total_bytes") or 1
|
||||||
|
progress = d.get("downloaded_bytes", 0) / total * 100
|
||||||
|
speed = d.get("speed")
|
||||||
|
speed_str = f"{speed / 1024 / 1024:.1f} MB/s" if speed else None
|
||||||
|
eta = d.get("eta")
|
||||||
|
eta_str = f"{int(eta)}s" if eta else None
|
||||||
|
self.queue_store.update_progress(queue_id, progress, speed_str, eta_str)
|
||||||
|
self._broadcast(queue_id, "download:progress", {
|
||||||
|
"queueId": queue_id, "progress": round(progress, 1),
|
||||||
|
"speed": speed_str, "eta": eta_str
|
||||||
|
})
|
||||||
|
elif d["status"] == "finished":
|
||||||
|
completed_videos += 1
|
||||||
|
if total_videos:
|
||||||
|
progress = (completed_videos / total_videos) * 100
|
||||||
|
else:
|
||||||
|
progress = 100
|
||||||
|
self.queue_store.update_progress(queue_id, progress)
|
||||||
|
self._broadcast(queue_id, "download:progress", {
|
||||||
|
"queueId": queue_id, "progress": round(progress, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
ydl_opts["progress_hooks"] = [progress_callback]
|
||||||
|
|
||||||
|
try:
|
||||||
|
ydl = yt_dlp.YoutubeDL(ydl_opts)
|
||||||
|
self._yt_dlp_instance = ydl
|
||||||
|
|
||||||
|
try:
|
||||||
|
info = ydl.extract_info(url, download=False)
|
||||||
|
if info and "entries" in info:
|
||||||
|
total_videos = len(info["entries"])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
ydl.download([url])
|
||||||
|
|
||||||
|
self.queue_store.update_status(queue_id, "completed")
|
||||||
|
self._broadcast(queue_id, "download:complete", {
|
||||||
|
"queueId": queue_id, "videoCount": completed_videos
|
||||||
|
})
|
||||||
|
|
||||||
|
# Archive playlist
|
||||||
|
playlist_id = None
|
||||||
|
id_match = re.search(r"(?:list=|\/)([0-9A-Za-z_-]{30,})", url)
|
||||||
|
if id_match:
|
||||||
|
playlist_id = id_match.group(1)
|
||||||
|
|
||||||
|
archive_item = ArchiveItem(
|
||||||
|
video_id=f"playlist_{playlist_id or 'unknown'}",
|
||||||
|
title=f"Playlist: {playlist_title}",
|
||||||
|
url=url,
|
||||||
|
category=category or "",
|
||||||
|
download_path=str(playlist_dir),
|
||||||
|
download_date=datetime.now(timezone.utc).isoformat(),
|
||||||
|
item_type="playlist",
|
||||||
|
)
|
||||||
|
self.archive_db.add_video(archive_item)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Playlist download failed for {queue_id}: {type(e).__name__}: {e}", exc_info=True)
|
||||||
|
self.queue_store.update_status(queue_id, "failed", error_message=f"{type(e).__name__}: {e}")
|
||||||
|
self._broadcast(queue_id, "download:failed", {
|
||||||
|
"queueId": queue_id, "error": f"{type(e).__name__}: {e}"
|
||||||
|
})
|
||||||
|
|
||||||
|
def enqueue_download(self, item: QueueItem):
|
||||||
|
"""Add a video to the queue (will be processed in order)."""
|
||||||
|
self.queue_store.add_item(item)
|
||||||
|
self._broadcast(item.id, "queue:enqueued", {
|
||||||
|
"queueId": item.id,
|
||||||
|
"status": "pending",
|
||||||
|
"message": "Added to queue"
|
||||||
|
})
|
||||||
|
|
||||||
|
def download_video(self, queue_id: str, url: str, config: dict,
|
||||||
|
category: str = None, network_folder: str = None,
|
||||||
|
quality: str = None):
|
||||||
|
"""Start a video download directly (bypasses queue)."""
|
||||||
|
item = self.queue_store.get_item(queue_id)
|
||||||
|
if not item or queue_id in (self._active_download_id,):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Mark immediately and run synchronously in a thread
|
||||||
|
self.queue_store.update_status(queue_id, "downloading")
|
||||||
|
self.queue_store.update_progress(queue_id, 0.0)
|
||||||
|
self._broadcast(queue_id, "download:status", {
|
||||||
|
"queueId": queue_id, "status": "downloading", "progress": 0
|
||||||
|
})
|
||||||
|
|
||||||
|
def _run_direct():
|
||||||
|
direct_item = self.queue_store.get_item(queue_id)
|
||||||
|
if direct_item:
|
||||||
|
self._run_video_download(direct_item)
|
||||||
|
with self._queue_lock:
|
||||||
|
if self._active_download_id == queue_id:
|
||||||
|
self._active_download_id = None
|
||||||
|
self._yt_dlp_instance = None
|
||||||
|
|
||||||
|
threading.Thread(target=_run_direct, daemon=True).start()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def download_playlist(self, queue_id: str, url: str, config: dict,
|
||||||
|
category: str = None, network_folder: str = None,
|
||||||
|
quality: str = None):
|
||||||
|
"""Start a playlist download directly (bypasses queue)."""
|
||||||
|
item = self.queue_store.get_item(queue_id)
|
||||||
|
if not item or queue_id == self._active_download_id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.queue_store.update_status(queue_id, "downloading")
|
||||||
|
self.queue_store.update_progress(queue_id, 0.0)
|
||||||
|
self._broadcast(queue_id, "download:status", {
|
||||||
|
"queueId": queue_id, "status": "downloading", "progress": 0
|
||||||
|
})
|
||||||
|
|
||||||
|
def _run_direct():
|
||||||
|
direct_item = self.queue_store.get_item(queue_id)
|
||||||
|
if direct_item:
|
||||||
|
self._run_playlist_download(direct_item)
|
||||||
|
with self._queue_lock:
|
||||||
|
if self._active_download_id == queue_id:
|
||||||
|
self._active_download_id = None
|
||||||
|
self._yt_dlp_instance = None
|
||||||
|
|
||||||
|
threading.Thread(target=_run_direct, daemon=True).start()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def cancel_download(self, queue_id: str) -> bool:
|
||||||
|
"""Cancel an active or pending download."""
|
||||||
|
item = self.queue_store.get_item(queue_id)
|
||||||
|
if not item:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# If it's the currently active download, try to cancel it
|
||||||
|
if queue_id == self._active_download_id:
|
||||||
|
ydl = self._yt_dlp_instance
|
||||||
|
if ydl:
|
||||||
|
try:
|
||||||
|
ydl.quiet = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self.queue_store.update_status(queue_id, "cancelled")
|
||||||
|
with self._queue_lock:
|
||||||
|
self._active_download_id = None
|
||||||
|
self._yt_dlp_instance = None
|
||||||
|
self._broadcast(queue_id, "download:status", {
|
||||||
|
"queueId": queue_id, "status": "cancelled"
|
||||||
|
})
|
||||||
|
return True
|
||||||
|
|
||||||
|
# If it's pending in queue, just mark as cancelled
|
||||||
|
if item.status == "pending":
|
||||||
|
self.queue_store.update_status(queue_id, "cancelled")
|
||||||
|
self._broadcast(queue_id, "download:status", {
|
||||||
|
"queueId": queue_id, "status": "cancelled"
|
||||||
|
})
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _find_downloaded_file(self, directory: Path, video_info: dict) -> Optional[str]:
|
||||||
|
"""Find the most recently downloaded video file in a directory."""
|
||||||
|
try:
|
||||||
|
video_extensions = ['.mp4', '.mkv', '.webm', '.flv']
|
||||||
|
files = []
|
||||||
|
for f in directory.iterdir():
|
||||||
|
if f.is_file() and f.suffix.lower() in video_extensions:
|
||||||
|
files.append(f)
|
||||||
|
if files:
|
||||||
|
latest = max(files, key=lambda f: f.stat().st_mtime)
|
||||||
|
return str(latest)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _broadcast(self, queue_id: str, event: str, data: dict):
|
||||||
|
"""Broadcast a WebSocket event."""
|
||||||
|
if self.socketio:
|
||||||
|
try:
|
||||||
|
self.socketio.emit(event, data)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _copy_to_network_share(self, filepath: str, config: dict, network_folder: str):
|
||||||
|
"""Copy downloaded file to network share."""
|
||||||
|
try:
|
||||||
|
import shutil
|
||||||
|
network_path = Path(config["network_share_path"])
|
||||||
|
dest_dir = network_path / network_folder
|
||||||
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
src = Path(filepath)
|
||||||
|
if src.exists():
|
||||||
|
shutil.copy2(src, dest_dir / src.name)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to copy to network share: {e}")
|
||||||
|
|
||||||
|
def _build_format(self, quality: Optional[str], default_format: str) -> str:
|
||||||
|
"""Build yt-dlp format string based on quality setting."""
|
||||||
|
if quality and quality != "best":
|
||||||
|
return f"bestvideo[height<={quality}]+bestaudio/best"
|
||||||
|
return default_format
|
||||||
|
|
||||||
|
def _format_duration(self, seconds):
|
||||||
|
"""Convert seconds to MM:SS or HH:MM:SS format."""
|
||||||
|
if not seconds:
|
||||||
|
return "0:00"
|
||||||
|
hours = int(seconds // 3600)
|
||||||
|
minutes = int((seconds % 3600) // 60)
|
||||||
|
secs = int(seconds % 60)
|
||||||
|
if hours > 0:
|
||||||
|
return f"{hours}:{minutes:02d}:{secs:02d}"
|
||||||
|
return f"{minutes}:{secs:02d}"
|
||||||
34
web/server/gunicorn.conf.py
Normal file
34
web/server/gunicorn.conf.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
"""Gunicorn configuration for YouTube Web Server."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Server binding
|
||||||
|
bind = f"0.0.0.0:{os.environ.get('PORT', 4096)}"
|
||||||
|
|
||||||
|
# Worker configuration - use gthread for threading-based SocketIO
|
||||||
|
worker_class = "gthread"
|
||||||
|
workers = 1
|
||||||
|
|
||||||
|
# Thread configuration
|
||||||
|
threads = 4
|
||||||
|
|
||||||
|
# Timeout configuration
|
||||||
|
timeout = 120
|
||||||
|
graceful_timeout = 60
|
||||||
|
|
||||||
|
# Logging - use stdout/stderr in Docker, files locally
|
||||||
|
import os
|
||||||
|
if os.environ.get('DOCKER'):
|
||||||
|
accesslog = "-"
|
||||||
|
errorlog = "-"
|
||||||
|
loglevel = "info"
|
||||||
|
else:
|
||||||
|
accesslog = "/home/user/repos/youtube-cli/web/server/gunicorn-access.log"
|
||||||
|
errorlog = "/home/user/repos/youtube-cli/web/server/gunicorn-error.log"
|
||||||
|
loglevel = "debug"
|
||||||
|
|
||||||
|
# Process naming
|
||||||
|
proc_name = "youtube-web-server"
|
||||||
|
|
||||||
|
# Preload app - disabled as it causes yt-dlp C extension issues after fork
|
||||||
|
preload_app = False
|
||||||
@ -0,0 +1,122 @@
|
|||||||
|
"""Data models for the web application."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SearchResult:
|
||||||
|
"""Represents a YouTube search result."""
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
url: str
|
||||||
|
thumbnail: str
|
||||||
|
author: str
|
||||||
|
length: str
|
||||||
|
view_count: Optional[int] = None
|
||||||
|
is_short: bool = False
|
||||||
|
is_playlist: bool = False
|
||||||
|
description: str = ""
|
||||||
|
published: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class QueueItem:
|
||||||
|
"""Represents a download queue item."""
|
||||||
|
id: str
|
||||||
|
video_id: str
|
||||||
|
title: str
|
||||||
|
url: str
|
||||||
|
thumbnail: str = ""
|
||||||
|
status: str = "pending" # pending, downloading, completed, failed, cancelled
|
||||||
|
progress: float = 0.0
|
||||||
|
category: str = ""
|
||||||
|
network_folder: Optional[str] = None
|
||||||
|
added_at: str = ""
|
||||||
|
completed_at: Optional[str] = None
|
||||||
|
error_message: Optional[str] = None
|
||||||
|
download_path: Optional[str] = None
|
||||||
|
file_size: Optional[str] = None
|
||||||
|
speed: Optional[str] = None
|
||||||
|
eta: Optional[str] = None
|
||||||
|
item_type: str = "video" # video or playlist
|
||||||
|
quality: Optional[str] = None # e.g. "360", "480", "720", "1080", "best"
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if not self.added_at:
|
||||||
|
self.added_at = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"videoId": self.video_id,
|
||||||
|
"title": self.title,
|
||||||
|
"url": self.url,
|
||||||
|
"thumbnail": self.thumbnail,
|
||||||
|
"status": self.status,
|
||||||
|
"progress": self.progress,
|
||||||
|
"category": self.category,
|
||||||
|
"network_folder": self.network_folder,
|
||||||
|
"addedAt": self.added_at,
|
||||||
|
"completedAt": self.completed_at,
|
||||||
|
"errorMessage": self.error_message,
|
||||||
|
"downloadPath": self.download_path,
|
||||||
|
"fileSize": self.file_size,
|
||||||
|
"speed": self.speed,
|
||||||
|
"eta": self.eta,
|
||||||
|
"type": self.item_type,
|
||||||
|
"quality": self.quality,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ArchiveItem:
|
||||||
|
"""Represents a downloaded video in the archive."""
|
||||||
|
video_id: str
|
||||||
|
title: str
|
||||||
|
url: str
|
||||||
|
description: str = ""
|
||||||
|
thumbnail: str = ""
|
||||||
|
channel: str = ""
|
||||||
|
views: int = 0
|
||||||
|
duration: str = ""
|
||||||
|
category: str = ""
|
||||||
|
download_path: str = ""
|
||||||
|
network_share_path: Optional[str] = None
|
||||||
|
file_size: Optional[int] = None
|
||||||
|
download_date: str = ""
|
||||||
|
item_type: str = "video" # video or playlist
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if not self.download_date:
|
||||||
|
self.download_date = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"videoId": self.video_id,
|
||||||
|
"title": self.title,
|
||||||
|
"url": self.url,
|
||||||
|
"description": self.description,
|
||||||
|
"thumbnail": self.thumbnail,
|
||||||
|
"channel": self.channel,
|
||||||
|
"views": self.views,
|
||||||
|
"duration": self.duration,
|
||||||
|
"category": self.category,
|
||||||
|
"downloadPath": self.download_path,
|
||||||
|
"networkSharePath": self.network_share_path,
|
||||||
|
"fileSize": self.file_size,
|
||||||
|
"downloadDate": self.download_date,
|
||||||
|
"type": self.item_type,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SearchRecent:
|
||||||
|
"""Represents a recent search query."""
|
||||||
|
query: str
|
||||||
|
searched_at: str = ""
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if not self.searched_at:
|
||||||
|
self.searched_at = datetime.now(timezone.utc).isoformat()
|
||||||
238
web/server/models/archive.py
Normal file
238
web/server/models/archive.py
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
"""SQLAlchemy models for the archive database."""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy import Column, Integer, String, BigInteger, DateTime, create_engine
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ArchiveVideo(Base):
|
||||||
|
"""SQLite model for archived downloads."""
|
||||||
|
__tablename__ = "archive_videos"
|
||||||
|
|
||||||
|
row_id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
video_id = Column(String(50), unique=True, nullable=False, index=True)
|
||||||
|
title = Column(String(500), nullable=False)
|
||||||
|
url = Column(String(1000), nullable=False)
|
||||||
|
description = Column(String(5000), default="")
|
||||||
|
thumbnail = Column(String(1000), default="")
|
||||||
|
channel = Column(String(200), default="")
|
||||||
|
views = Column(BigInteger, default=0)
|
||||||
|
duration = Column(String(20), default="")
|
||||||
|
category = Column(String(100), default="", index=True)
|
||||||
|
download_path = Column(String(2000), default="")
|
||||||
|
network_share_path = Column(String(2000))
|
||||||
|
file_size = Column(BigInteger, default=0)
|
||||||
|
download_date = Column(DateTime, default=datetime.utcnow)
|
||||||
|
item_type = Column(String(20), default="video") # video or playlist
|
||||||
|
|
||||||
|
|
||||||
|
class ArchiveDB:
|
||||||
|
"""Database manager for the archive."""
|
||||||
|
|
||||||
|
def __init__(self, db_path: str = None):
|
||||||
|
if db_path is None:
|
||||||
|
db_path = str(Path.home() / ".config" / "youtube_cli" / "archive.db")
|
||||||
|
self.db_path = db_path
|
||||||
|
self.engine = create_engine(f"sqlite:///{self.db_path}")
|
||||||
|
self.Session = sessionmaker(bind=self.engine)
|
||||||
|
self._create_tables()
|
||||||
|
|
||||||
|
def _create_tables(self):
|
||||||
|
"""Create all tables if they don't exist."""
|
||||||
|
Base.metadata.create_all(self.engine)
|
||||||
|
|
||||||
|
def add_video(self, video: "ArchiveItem") -> "ArchiveVideo":
|
||||||
|
"""Add a video to the archive."""
|
||||||
|
session = self.Session()
|
||||||
|
try:
|
||||||
|
# Check if video already exists
|
||||||
|
existing = session.query(ArchiveVideo).filter_by(video_id=video.video_id).first()
|
||||||
|
if existing:
|
||||||
|
# Update existing record
|
||||||
|
existing.title = video.title
|
||||||
|
existing.url = video.url
|
||||||
|
existing.description = video.description
|
||||||
|
existing.thumbnail = video.thumbnail
|
||||||
|
existing.channel = video.channel
|
||||||
|
existing.views = video.views
|
||||||
|
existing.duration = video.duration
|
||||||
|
existing.category = video.category
|
||||||
|
existing.download_path = video.download_path
|
||||||
|
existing.network_share_path = video.network_share_path
|
||||||
|
existing.file_size = video.file_size or 0
|
||||||
|
existing.item_type = video.item_type
|
||||||
|
else:
|
||||||
|
# Create new record
|
||||||
|
archive_video = ArchiveVideo(
|
||||||
|
video_id=video.video_id,
|
||||||
|
title=video.title,
|
||||||
|
url=video.url,
|
||||||
|
description=video.description,
|
||||||
|
thumbnail=video.thumbnail,
|
||||||
|
channel=video.channel,
|
||||||
|
views=video.views,
|
||||||
|
duration=video.duration,
|
||||||
|
category=video.category,
|
||||||
|
download_path=video.download_path,
|
||||||
|
network_share_path=video.network_share_path,
|
||||||
|
file_size=video.file_size or 0,
|
||||||
|
download_date=datetime.fromisoformat(video.download_date) if video.download_date else datetime.now(timezone.utc),
|
||||||
|
item_type=video.item_type,
|
||||||
|
)
|
||||||
|
session.add(archive_video)
|
||||||
|
session.commit()
|
||||||
|
return existing or archive_video
|
||||||
|
except Exception as e:
|
||||||
|
session.rollback()
|
||||||
|
raise e
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def get_videos(self, page: int = 1, limit: int = 24, search: str = None,
|
||||||
|
category: str = None, start_date: str = None, end_date: str = None):
|
||||||
|
"""Get archived videos with pagination and filtering."""
|
||||||
|
session = self.Session()
|
||||||
|
try:
|
||||||
|
query = session.query(ArchiveVideo)
|
||||||
|
|
||||||
|
if search:
|
||||||
|
search_pattern = f"%{search}%"
|
||||||
|
query = query.filter(
|
||||||
|
(ArchiveVideo.title.like(search_pattern)) |
|
||||||
|
(ArchiveVideo.channel.like(search_pattern))
|
||||||
|
)
|
||||||
|
|
||||||
|
if category:
|
||||||
|
query = query.filter(ArchiveVideo.category == category)
|
||||||
|
|
||||||
|
if start_date:
|
||||||
|
query = query.filter(ArchiveVideo.download_date >= datetime.fromisoformat(start_date))
|
||||||
|
|
||||||
|
if end_date:
|
||||||
|
query = query.filter(ArchiveVideo.download_date <= datetime.fromisoformat(end_date))
|
||||||
|
|
||||||
|
total = query.count()
|
||||||
|
offset = (page - 1) * limit
|
||||||
|
videos = query.order_by(ArchiveVideo.download_date.desc()).offset(offset).limit(limit).all()
|
||||||
|
|
||||||
|
return videos, total
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def get_video(self, video_id: str):
|
||||||
|
"""Get a single video by ID."""
|
||||||
|
session = self.Session()
|
||||||
|
try:
|
||||||
|
return session.query(ArchiveVideo).filter_by(video_id=video_id).first()
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def delete_video(self, video_id: str) -> bool:
|
||||||
|
"""Delete a video from the archive."""
|
||||||
|
session = self.Session()
|
||||||
|
try:
|
||||||
|
video = session.query(ArchiveVideo).filter_by(video_id=video_id).first()
|
||||||
|
if video:
|
||||||
|
session.delete(video)
|
||||||
|
session.commit()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
session.rollback()
|
||||||
|
raise e
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def clear_archive(self) -> int:
|
||||||
|
"""Clear all videos from the archive. Returns count of deleted videos."""
|
||||||
|
session = self.Session()
|
||||||
|
try:
|
||||||
|
count = session.query(ArchiveVideo).count()
|
||||||
|
session.query(ArchiveVideo).delete()
|
||||||
|
session.commit()
|
||||||
|
return count
|
||||||
|
except Exception as e:
|
||||||
|
session.rollback()
|
||||||
|
raise e
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def get_stats(self):
|
||||||
|
"""Get archive statistics."""
|
||||||
|
from sqlalchemy import func
|
||||||
|
session = self.Session()
|
||||||
|
try:
|
||||||
|
total = session.query(ArchiveVideo).count()
|
||||||
|
total_size = session.query(func.coalesce(func.sum(ArchiveVideo.file_size), 0)).scalar()
|
||||||
|
categories = session.query(ArchiveVideo.category, func.count(ArchiveVideo.row_id)) \
|
||||||
|
.group_by(ArchiveVideo.category).all()
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"totalSize": f"{total_size / (1024 * 1024):.1f} MB" if total_size else "0 MB",
|
||||||
|
"categories": {cat: count for cat, count in categories if cat}
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def get_categories(self) -> list:
|
||||||
|
"""Get unique categories from the archive."""
|
||||||
|
from sqlalchemy import func
|
||||||
|
session = self.Session()
|
||||||
|
try:
|
||||||
|
categories = session.query(ArchiveVideo.category).filter(
|
||||||
|
ArchiveVideo.category != ""
|
||||||
|
).distinct().all()
|
||||||
|
return [cat[0] for cat in categories]
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def export_archive(self, fmt: str = "json"):
|
||||||
|
"""Export archive data as JSON or CSV."""
|
||||||
|
session = self.Session()
|
||||||
|
try:
|
||||||
|
videos = session.query(ArchiveVideo).order_by(ArchiveVideo.download_date.desc()).all()
|
||||||
|
if fmt == "csv":
|
||||||
|
return self._to_csv(videos)
|
||||||
|
return self._to_json(videos)
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def _to_json(self, videos):
|
||||||
|
"""Convert archive videos to JSON."""
|
||||||
|
import json
|
||||||
|
items = []
|
||||||
|
for v in videos:
|
||||||
|
items.append({
|
||||||
|
"videoId": v.video_id,
|
||||||
|
"title": v.title,
|
||||||
|
"url": v.url,
|
||||||
|
"description": v.description,
|
||||||
|
"thumbnail": v.thumbnail,
|
||||||
|
"channel": v.channel,
|
||||||
|
"views": v.views,
|
||||||
|
"duration": v.duration,
|
||||||
|
"category": v.category,
|
||||||
|
"downloadPath": v.download_path,
|
||||||
|
"networkSharePath": v.network_share_path,
|
||||||
|
"fileSize": v.file_size,
|
||||||
|
"downloadDate": v.download_date.isoformat() if v.download_date else "",
|
||||||
|
"type": v.item_type,
|
||||||
|
})
|
||||||
|
return json.dumps({"items": items, "total": len(items)}, indent=2)
|
||||||
|
|
||||||
|
def _to_csv(self, videos):
|
||||||
|
"""Convert archive videos to CSV."""
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.writer(output)
|
||||||
|
writer.writerow(["videoId", "title", "url", "channel", "category", "downloadDate", "fileSize"])
|
||||||
|
for v in videos:
|
||||||
|
writer.writerow([v.video_id, v.title, v.url, v.channel, v.category,
|
||||||
|
v.download_date.isoformat() if v.download_date else "", v.file_size or 0])
|
||||||
|
return output.getvalue()
|
||||||
205
web/server/models/queue_store.py
Normal file
205
web/server/models/queue_store.py
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
"""JSON-backed queue store with file locking for thread safety."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from threading import Lock
|
||||||
|
|
||||||
|
from models import QueueItem
|
||||||
|
|
||||||
|
|
||||||
|
class QueueStore:
|
||||||
|
"""Persistent queue backed by a JSON file."""
|
||||||
|
|
||||||
|
def __init__(self, store_path: str = None):
|
||||||
|
if store_path is None:
|
||||||
|
store_path = str(Path.home() / ".config" / "youtube_cli" / "queue.json")
|
||||||
|
self.store_path = store_path
|
||||||
|
self._lock = Lock()
|
||||||
|
self._ensure_file()
|
||||||
|
|
||||||
|
def _ensure_file(self):
|
||||||
|
"""Create the store file if it doesn't exist."""
|
||||||
|
store_dir = Path(self.store_path).parent
|
||||||
|
store_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
if not Path(self.store_path).exists():
|
||||||
|
with open(self.store_path, "w") as f:
|
||||||
|
json.dump({}, f)
|
||||||
|
|
||||||
|
def _load(self) -> dict:
|
||||||
|
"""Load queue data from file."""
|
||||||
|
try:
|
||||||
|
with open(self.store_path, "r") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
with open(self.store_path, "w") as f:
|
||||||
|
json.dump({}, f)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _save(self, data: dict):
|
||||||
|
"""Save queue data to file."""
|
||||||
|
with open(self.store_path, "w") as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
|
||||||
|
def add_item(self, item: QueueItem) -> QueueItem:
|
||||||
|
"""Add an item to the queue."""
|
||||||
|
with self._lock:
|
||||||
|
data = self._load()
|
||||||
|
data[item.id] = item.to_dict()
|
||||||
|
self._save(data)
|
||||||
|
return item
|
||||||
|
|
||||||
|
def get_all(self) -> list:
|
||||||
|
"""Get all queue items."""
|
||||||
|
with self._lock:
|
||||||
|
data = self._load()
|
||||||
|
items = []
|
||||||
|
for item_id, item_data in data.items():
|
||||||
|
item = self._dict_to_item(item_data)
|
||||||
|
items.append(item)
|
||||||
|
return items
|
||||||
|
|
||||||
|
def get_item(self, queue_id: str) -> QueueItem:
|
||||||
|
"""Get a specific queue item."""
|
||||||
|
with self._lock:
|
||||||
|
data = self._load()
|
||||||
|
item_data = data.get(queue_id)
|
||||||
|
if item_data:
|
||||||
|
return self._dict_to_item(item_data)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def update_item(self, queue_id: str, updates: dict) -> QueueItem:
|
||||||
|
"""Update fields of a queue item."""
|
||||||
|
with self._lock:
|
||||||
|
data = self._load()
|
||||||
|
if queue_id not in data:
|
||||||
|
return None
|
||||||
|
data[queue_id].update(updates)
|
||||||
|
self._save(data)
|
||||||
|
item = self._dict_to_item(data[queue_id])
|
||||||
|
return item
|
||||||
|
|
||||||
|
def update_progress(self, queue_id: str, progress: float, speed: str = None, eta: str = None):
|
||||||
|
"""Update download progress for a queue item."""
|
||||||
|
with self._lock:
|
||||||
|
data = self._load()
|
||||||
|
if queue_id in data:
|
||||||
|
data[queue_id]["progress"] = progress
|
||||||
|
if speed:
|
||||||
|
data[queue_id]["speed"] = speed
|
||||||
|
if eta:
|
||||||
|
data[queue_id]["eta"] = eta
|
||||||
|
self._save(data)
|
||||||
|
|
||||||
|
def update_status(self, queue_id: str, status: str, error_message: str = None,
|
||||||
|
download_path: str = None, file_size: str = None):
|
||||||
|
"""Update download status for a queue item."""
|
||||||
|
with self._lock:
|
||||||
|
data = self._load()
|
||||||
|
if queue_id in data:
|
||||||
|
data[queue_id]["status"] = status
|
||||||
|
if status in ("completed", "failed"):
|
||||||
|
data[queue_id]["completedAt"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
if error_message:
|
||||||
|
data[queue_id]["errorMessage"] = error_message
|
||||||
|
if download_path:
|
||||||
|
data[queue_id]["downloadPath"] = download_path
|
||||||
|
if file_size:
|
||||||
|
data[queue_id]["fileSize"] = file_size
|
||||||
|
if status == "completed":
|
||||||
|
data[queue_id]["progress"] = 100.0
|
||||||
|
self._save(data)
|
||||||
|
|
||||||
|
def remove_item(self, queue_id: str) -> bool:
|
||||||
|
"""Remove an item from the queue."""
|
||||||
|
with self._lock:
|
||||||
|
data = self._load()
|
||||||
|
if queue_id in data:
|
||||||
|
del data[queue_id]
|
||||||
|
self._save(data)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def clear_completed(self) -> int:
|
||||||
|
"""Clear all completed items. Returns count of removed items."""
|
||||||
|
with self._lock:
|
||||||
|
data = self._load()
|
||||||
|
completed_ids = [qid for qid, item in data.items() if item["status"] == "completed"]
|
||||||
|
for qid in completed_ids:
|
||||||
|
del data[qid]
|
||||||
|
self._save(data)
|
||||||
|
return len(completed_ids)
|
||||||
|
|
||||||
|
def clear_failed(self) -> int:
|
||||||
|
"""Clear all failed items. Returns count of removed items."""
|
||||||
|
with self._lock:
|
||||||
|
data = self._load()
|
||||||
|
failed_ids = [qid for qid, item in data.items() if item["status"] == "failed"]
|
||||||
|
for qid in failed_ids:
|
||||||
|
del data[qid]
|
||||||
|
self._save(data)
|
||||||
|
return len(failed_ids)
|
||||||
|
|
||||||
|
def clear_all(self) -> int:
|
||||||
|
"""Clear all items from the queue. Returns count of removed items."""
|
||||||
|
with self._lock:
|
||||||
|
count = len(self._load())
|
||||||
|
self._save({})
|
||||||
|
return count
|
||||||
|
|
||||||
|
def get_stats(self) -> dict:
|
||||||
|
"""Get queue statistics."""
|
||||||
|
items = self.get_all()
|
||||||
|
return {
|
||||||
|
"total": len(items),
|
||||||
|
"pending": sum(1 for i in items if i.status == "pending"),
|
||||||
|
"downloading": sum(1 for i in items if i.status == "downloading"),
|
||||||
|
"completed": sum(1 for i in items if i.status == "completed"),
|
||||||
|
"failed": sum(1 for i in items if i.status == "failed"),
|
||||||
|
"cancelled": sum(1 for i in items if i.status == "cancelled"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def reorder_item(self, queue_id: str, direction: str) -> bool:
|
||||||
|
"""Reorder a queue item (up/down). Returns True if reordered."""
|
||||||
|
with self._lock:
|
||||||
|
data = self._load()
|
||||||
|
ids = list(data.keys())
|
||||||
|
if queue_id not in ids:
|
||||||
|
return False
|
||||||
|
idx = ids.index(queue_id)
|
||||||
|
if direction == "up" and idx > 0:
|
||||||
|
ids[idx], ids[idx - 1] = ids[idx - 1], ids[idx]
|
||||||
|
elif direction == "down" and idx < len(ids) - 1:
|
||||||
|
ids[idx], ids[idx + 1] = ids[idx + 1], ids[idx]
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
# Rebuild dict in new order
|
||||||
|
new_data = {}
|
||||||
|
for kid in ids:
|
||||||
|
new_data[kid] = data[kid]
|
||||||
|
self._save(new_data)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _dict_to_item(self, data: dict) -> QueueItem:
|
||||||
|
"""Convert a dictionary to a QueueItem."""
|
||||||
|
return QueueItem(
|
||||||
|
id=data["id"],
|
||||||
|
video_id=data.get("videoId", ""),
|
||||||
|
title=data.get("title", ""),
|
||||||
|
url=data.get("url", ""),
|
||||||
|
thumbnail=data.get("thumbnail", ""),
|
||||||
|
status=data.get("status", "pending"),
|
||||||
|
progress=data.get("progress", 0.0),
|
||||||
|
category=data.get("category", ""),
|
||||||
|
network_folder=data.get("network_folder"),
|
||||||
|
added_at=data.get("addedAt", data.get("created_at", datetime.now(timezone.utc).isoformat())),
|
||||||
|
completed_at=data.get("completedAt"),
|
||||||
|
error_message=data.get("errorMessage", data.get("message")),
|
||||||
|
download_path=data.get("downloadPath"),
|
||||||
|
file_size=data.get("fileSize"),
|
||||||
|
speed=data.get("speed"),
|
||||||
|
eta=data.get("eta"),
|
||||||
|
item_type=data.get("type", "video"),
|
||||||
|
quality=data.get("quality"),
|
||||||
|
)
|
||||||
1142
web/server/nohup.out
Normal file
1142
web/server/nohup.out
Normal file
File diff suppressed because one or more lines are too long
@ -0,0 +1,8 @@
|
|||||||
|
"""Routes package - exports all blueprint modules."""
|
||||||
|
|
||||||
|
from routes.search import search_bp
|
||||||
|
from routes.download import download_bp
|
||||||
|
from routes.queue import queue_bp
|
||||||
|
from routes.archive import archive_bp
|
||||||
|
|
||||||
|
__all__ = ['search_bp', 'download_bp', 'queue_bp', 'archive_bp']
|
||||||
233
web/server/routes/archive.py
Normal file
233
web/server/routes/archive.py
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
"""Archive API endpoints with SQLite backend."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from flask import Blueprint, request, Response, send_file
|
||||||
|
from utils import make_response, make_error_response
|
||||||
|
from models import ArchiveItem
|
||||||
|
|
||||||
|
archive_bp = Blueprint('archive', __name__, url_prefix='/api')
|
||||||
|
|
||||||
|
|
||||||
|
@archive_bp.route('/archive', methods=['GET'])
|
||||||
|
def get_archive():
|
||||||
|
"""Get archived videos with pagination and filtering."""
|
||||||
|
from app import archive_db
|
||||||
|
try:
|
||||||
|
page = int(request.args.get('page', 1))
|
||||||
|
limit = int(request.args.get('limit', 24))
|
||||||
|
search = request.args.get('search')
|
||||||
|
category = request.args.get('category')
|
||||||
|
start_date = request.args.get('startDate')
|
||||||
|
end_date = request.args.get('endDate')
|
||||||
|
|
||||||
|
videos, total = archive_db.get_videos(
|
||||||
|
page=page, limit=limit, search=search,
|
||||||
|
category=category, start_date=start_date, end_date=end_date
|
||||||
|
)
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for v in videos:
|
||||||
|
items.append({
|
||||||
|
"videoId": v.video_id,
|
||||||
|
"title": v.title,
|
||||||
|
"url": v.url,
|
||||||
|
"description": v.description,
|
||||||
|
"thumbnail": v.thumbnail,
|
||||||
|
"channel": v.channel,
|
||||||
|
"views": v.views,
|
||||||
|
"duration": v.duration,
|
||||||
|
"category": v.category,
|
||||||
|
"downloadPath": v.download_path,
|
||||||
|
"networkSharePath": v.network_share_path,
|
||||||
|
"fileSize": v.file_size,
|
||||||
|
"downloadDate": v.download_date.isoformat() if v.download_date else "",
|
||||||
|
"type": v.item_type,
|
||||||
|
})
|
||||||
|
|
||||||
|
return make_response({
|
||||||
|
"archive": items,
|
||||||
|
"items": items,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"hasMore": page * limit < total,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return make_error_response(f"Failed to get archive: {str(e)}", 500)
|
||||||
|
|
||||||
|
|
||||||
|
@archive_bp.route('/archive/<video_id>', methods=['GET'])
|
||||||
|
def get_archive_item(video_id):
|
||||||
|
"""Get a single archive item."""
|
||||||
|
from app import archive_db
|
||||||
|
try:
|
||||||
|
video = archive_db.get_video(video_id)
|
||||||
|
if not video:
|
||||||
|
return make_error_response(f"Video {video_id} not found in archive", 404)
|
||||||
|
|
||||||
|
return make_response({
|
||||||
|
"videoId": video.video_id,
|
||||||
|
"title": video.title,
|
||||||
|
"url": video.url,
|
||||||
|
"description": video.description,
|
||||||
|
"thumbnail": video.thumbnail,
|
||||||
|
"channel": video.channel,
|
||||||
|
"views": video.views,
|
||||||
|
"duration": video.duration,
|
||||||
|
"category": video.category,
|
||||||
|
"downloadPath": video.download_path,
|
||||||
|
"networkSharePath": video.network_share_path,
|
||||||
|
"fileSize": video.file_size,
|
||||||
|
"downloadDate": video.download_date.isoformat() if video.download_date else "",
|
||||||
|
"type": video.item_type,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return make_error_response(f"Failed to get archive item: {str(e)}", 500)
|
||||||
|
|
||||||
|
|
||||||
|
@archive_bp.route('/archive/<video_id>/stream', methods=['GET'])
|
||||||
|
def stream_video(video_id):
|
||||||
|
"""Stream a downloaded video file."""
|
||||||
|
from app import archive_db
|
||||||
|
try:
|
||||||
|
video = archive_db.get_video(video_id)
|
||||||
|
if not video or not video.download_path:
|
||||||
|
return make_error_response(f"Video {video_id} not found or has no file", 404)
|
||||||
|
|
||||||
|
if not os.path.exists(video.download_path):
|
||||||
|
return make_error_response(f"Video file not found: {video.download_path}", 404)
|
||||||
|
|
||||||
|
return send_file(
|
||||||
|
video.download_path,
|
||||||
|
mimetype='video/mp4',
|
||||||
|
as_attachment=False,
|
||||||
|
conditional=True,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return make_error_response(f"Failed to stream video: {str(e)}", 500)
|
||||||
|
|
||||||
|
|
||||||
|
@archive_bp.route('/archive/<video_id>', methods=['DELETE'])
|
||||||
|
def remove_from_archive(video_id):
|
||||||
|
"""Remove a video from the archive."""
|
||||||
|
from app import archive_db
|
||||||
|
try:
|
||||||
|
removed = archive_db.delete_video(video_id)
|
||||||
|
if not removed:
|
||||||
|
return make_error_response(f"Video {video_id} not found in archive", 404)
|
||||||
|
return make_response({"message": f"Video {video_id} removed from archive"})
|
||||||
|
except Exception as e:
|
||||||
|
return make_error_response(f"Failed to remove from archive: {str(e)}", 500)
|
||||||
|
|
||||||
|
|
||||||
|
@archive_bp.route('/archive', methods=['DELETE'])
|
||||||
|
def clear_archive():
|
||||||
|
"""Clear the entire archive."""
|
||||||
|
from app import archive_db
|
||||||
|
try:
|
||||||
|
count = archive_db.clear_archive()
|
||||||
|
return make_response({
|
||||||
|
"message": "Archive cleared",
|
||||||
|
"count": count
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return make_error_response(f"Failed to clear archive: {str(e)}", 500)
|
||||||
|
|
||||||
|
|
||||||
|
@archive_bp.route('/archive/stats', methods=['GET'])
|
||||||
|
def get_archive_stats():
|
||||||
|
"""Get archive statistics."""
|
||||||
|
from app import archive_db
|
||||||
|
try:
|
||||||
|
stats = archive_db.get_stats()
|
||||||
|
return make_response(stats)
|
||||||
|
except Exception as e:
|
||||||
|
return make_error_response(f"Failed to get archive stats: {str(e)}", 500)
|
||||||
|
|
||||||
|
|
||||||
|
@archive_bp.route('/archive/categories', methods=['GET'])
|
||||||
|
def get_archive_categories():
|
||||||
|
"""Get unique categories from the archive."""
|
||||||
|
from app import archive_db
|
||||||
|
try:
|
||||||
|
categories = archive_db.get_categories()
|
||||||
|
# Flatten SQLAlchemy row tuples to plain strings
|
||||||
|
flat = [c[0] if isinstance(c, (tuple, list)) else c for c in categories]
|
||||||
|
return make_response(flat)
|
||||||
|
except Exception as e:
|
||||||
|
return make_error_response(f"Failed to get categories: {str(e)}", 500)
|
||||||
|
|
||||||
|
|
||||||
|
@archive_bp.route('/archive/export', methods=['GET'])
|
||||||
|
def export_archive():
|
||||||
|
"""Export archive data as JSON or CSV."""
|
||||||
|
from app import archive_db
|
||||||
|
try:
|
||||||
|
fmt = request.args.get('format', 'json')
|
||||||
|
if fmt not in ('json', 'csv'):
|
||||||
|
return make_error_response("Format must be 'json' or 'csv'", 400)
|
||||||
|
|
||||||
|
data = archive_db.export_archive(fmt)
|
||||||
|
|
||||||
|
if fmt == 'json':
|
||||||
|
mimetype = 'application/json'
|
||||||
|
filename = 'archive.json'
|
||||||
|
else:
|
||||||
|
mimetype = 'text/csv'
|
||||||
|
filename = 'archive.csv'
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
data,
|
||||||
|
mimetype=mimetype,
|
||||||
|
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return make_error_response(f"Failed to export archive: {str(e)}", 500)
|
||||||
|
|
||||||
|
|
||||||
|
@archive_bp.route('/archive/import', methods=['POST'])
|
||||||
|
def import_archive():
|
||||||
|
"""Import archive data from a JSON file."""
|
||||||
|
from app import archive_db
|
||||||
|
try:
|
||||||
|
if 'file' not in request.files:
|
||||||
|
return make_error_response("No file provided", 400)
|
||||||
|
|
||||||
|
file = request.files['file']
|
||||||
|
if not file.filename.endswith('.json'):
|
||||||
|
return make_error_response("Only JSON files are supported", 400)
|
||||||
|
|
||||||
|
import json
|
||||||
|
data = json.loads(file.read())
|
||||||
|
items = data.get('items', data if isinstance(data, list) else [])
|
||||||
|
|
||||||
|
imported = 0
|
||||||
|
for item in items:
|
||||||
|
video_id = item.get('videoId', item.get('video_id', ''))
|
||||||
|
if not video_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
archive_item = ArchiveItem(
|
||||||
|
video_id=video_id,
|
||||||
|
title=item.get('title', 'Unknown'),
|
||||||
|
url=item.get('url', ''),
|
||||||
|
description=item.get('description', ''),
|
||||||
|
thumbnail=item.get('thumbnail', ''),
|
||||||
|
channel=item.get('channel', ''),
|
||||||
|
views=item.get('views', 0) or 0,
|
||||||
|
duration=item.get('duration', ''),
|
||||||
|
category=item.get('category', ''),
|
||||||
|
download_path=item.get('downloadPath', item.get('download_path', '')),
|
||||||
|
network_share_path=item.get('networkSharePath', item.get('network_share_path')),
|
||||||
|
file_size=item.get('fileSize', item.get('file_size', 0)) or 0,
|
||||||
|
download_date=item.get('downloadDate', item.get('download_date', '')),
|
||||||
|
item_type=item.get('type', 'video'),
|
||||||
|
)
|
||||||
|
archive_db.add_video(archive_item)
|
||||||
|
imported += 1
|
||||||
|
|
||||||
|
return make_response({
|
||||||
|
"success": True,
|
||||||
|
"imported": imported
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return make_error_response(f"Failed to import archive: {str(e)}", 500)
|
||||||
213
web/server/routes/download.py
Normal file
213
web/server/routes/download.py
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
"""Download-related API endpoints."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from flask import Blueprint, request
|
||||||
|
from utils import make_response, make_error_response
|
||||||
|
from models import QueueItem
|
||||||
|
|
||||||
|
download_bp = Blueprint('download', __name__, url_prefix='/api')
|
||||||
|
|
||||||
|
|
||||||
|
@download_bp.route('/download', methods=['POST'])
|
||||||
|
def download_video():
|
||||||
|
"""Queue a video for download or start an existing queue item."""
|
||||||
|
from app import download_engine, queue_store, yt_cli
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
url = data.get('url', '').strip() if data else ''
|
||||||
|
category = data.get('category') if data else None
|
||||||
|
network_folder = data.get('network_folder') if data else None
|
||||||
|
quality = data.get('quality') if data else None
|
||||||
|
queue_id = data.get('queueId') if data else None
|
||||||
|
|
||||||
|
# If queueId provided, use existing queue item
|
||||||
|
if queue_id:
|
||||||
|
item = queue_store.get_item(queue_id)
|
||||||
|
if not item:
|
||||||
|
return make_error_response("Queue item not found", 404)
|
||||||
|
if item.status != "pending":
|
||||||
|
return make_error_response(f"Cannot download item with status '{item.status}'", 400)
|
||||||
|
|
||||||
|
# Check if already downloaded
|
||||||
|
if yt_cli.is_video_downloaded(item.video_id):
|
||||||
|
return make_error_response(f"Video {item.video_id} has already been downloaded", 409)
|
||||||
|
|
||||||
|
# Enqueue for sequential processing
|
||||||
|
download_engine.enqueue_download(item)
|
||||||
|
|
||||||
|
return make_response({
|
||||||
|
"queueId": queue_id,
|
||||||
|
"status": "pending",
|
||||||
|
"message": "Added to download queue",
|
||||||
|
}), 202
|
||||||
|
|
||||||
|
# No queueId — create new queue item
|
||||||
|
if not url:
|
||||||
|
return make_error_response("Video URL is required", 400)
|
||||||
|
|
||||||
|
# Extract video ID from URL
|
||||||
|
import re
|
||||||
|
video_id = None
|
||||||
|
id_match = re.search(r"(?:v=|\/)([0-9A-Za-z_-]{11})", url)
|
||||||
|
if id_match:
|
||||||
|
video_id = id_match.group(1)
|
||||||
|
|
||||||
|
# Check if already downloaded
|
||||||
|
if video_id and yt_cli.is_video_downloaded(video_id):
|
||||||
|
return make_error_response(f"Video {video_id} has already been downloaded", 409)
|
||||||
|
|
||||||
|
# Generate queue ID
|
||||||
|
queue_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
# Get video info for title/thumbnail
|
||||||
|
title = "Unknown Title"
|
||||||
|
thumbnail = ""
|
||||||
|
try:
|
||||||
|
import yt_dlp
|
||||||
|
ydl_opts = {
|
||||||
|
'dump_single_json': True,
|
||||||
|
'no_warnings': True,
|
||||||
|
'quiet': True,
|
||||||
|
'no_progress': True,
|
||||||
|
'write_thumbnail': False,
|
||||||
|
}
|
||||||
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
|
info = ydl.extract_info(url, download=False)
|
||||||
|
if info:
|
||||||
|
title = info.get("title", "Unknown Title")
|
||||||
|
thumbnail = info.get("thumbnail", "")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Create queue item
|
||||||
|
queue_item = QueueItem(
|
||||||
|
id=queue_id,
|
||||||
|
video_id=video_id or "",
|
||||||
|
title=title,
|
||||||
|
url=url,
|
||||||
|
thumbnail=thumbnail,
|
||||||
|
category=category or "",
|
||||||
|
network_folder=network_folder,
|
||||||
|
quality=quality,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Enqueue for sequential processing
|
||||||
|
download_engine.enqueue_download(queue_item)
|
||||||
|
|
||||||
|
return make_response({
|
||||||
|
"queueId": queue_id,
|
||||||
|
"status": "pending",
|
||||||
|
"message": "Added to download queue",
|
||||||
|
}), 202
|
||||||
|
except Exception as e:
|
||||||
|
return make_error_response(f"Failed to queue download: {str(e)}", 500)
|
||||||
|
|
||||||
|
|
||||||
|
@download_bp.route('/download/playlist', methods=['POST'])
|
||||||
|
def download_playlist():
|
||||||
|
"""Queue a playlist for download."""
|
||||||
|
from app import download_engine, queue_store, yt_cli
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
url = data.get('url', '').strip() if data else ''
|
||||||
|
category = data.get('category') if data else None
|
||||||
|
network_folder = data.get('network_folder') if data else None
|
||||||
|
quality = data.get('quality') if data else None
|
||||||
|
|
||||||
|
if not url:
|
||||||
|
return make_error_response("Playlist URL is required", 400)
|
||||||
|
|
||||||
|
# Generate queue ID
|
||||||
|
queue_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
# Get playlist title
|
||||||
|
title = "Unknown Playlist"
|
||||||
|
try:
|
||||||
|
import yt_dlp
|
||||||
|
ydl_opts = {
|
||||||
|
'flat_playlist': True,
|
||||||
|
'dump_single_json': True,
|
||||||
|
'no_warnings': True,
|
||||||
|
'quiet': True,
|
||||||
|
'no_progress': True,
|
||||||
|
}
|
||||||
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
|
info = ydl.extract_info(url, download=False)
|
||||||
|
if info:
|
||||||
|
title = info.get("title", "Unknown Playlist")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Create queue item
|
||||||
|
queue_item = QueueItem(
|
||||||
|
id=queue_id,
|
||||||
|
video_id="",
|
||||||
|
title=title,
|
||||||
|
url=url,
|
||||||
|
category=category or "",
|
||||||
|
network_folder=network_folder,
|
||||||
|
item_type="playlist",
|
||||||
|
quality=quality,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Enqueue for sequential processing
|
||||||
|
download_engine.enqueue_download(queue_item)
|
||||||
|
|
||||||
|
return make_response({
|
||||||
|
"queueId": queue_id,
|
||||||
|
"status": "pending",
|
||||||
|
"message": "Added to download queue",
|
||||||
|
}), 202
|
||||||
|
except Exception as e:
|
||||||
|
return make_error_response(f"Failed to queue playlist: {str(e)}", 500)
|
||||||
|
|
||||||
|
|
||||||
|
@download_bp.route('/download/direct', methods=['POST'])
|
||||||
|
def download_video_direct():
|
||||||
|
"""Download a video directly (legacy endpoint, synchronous)."""
|
||||||
|
from app import queue_store, yt_cli
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
url = data.get('url', '').strip() if data else ''
|
||||||
|
category = data.get('category') if data else None
|
||||||
|
network_folder = data.get('network_folder') if data else None
|
||||||
|
quality = data.get('quality') if data else None
|
||||||
|
|
||||||
|
if not url:
|
||||||
|
return make_error_response("Video URL is required", 400)
|
||||||
|
|
||||||
|
# Extract video ID from URL
|
||||||
|
import re
|
||||||
|
video_id = None
|
||||||
|
id_match = re.search(r"(?:v=|\/)([0-9A-Za-z_-]{11})", url)
|
||||||
|
if id_match:
|
||||||
|
video_id = id_match.group(1)
|
||||||
|
|
||||||
|
# Check if already downloaded
|
||||||
|
if video_id and yt_cli.is_video_downloaded(video_id):
|
||||||
|
return make_error_response(f"Video {video_id} has already been downloaded", 409)
|
||||||
|
|
||||||
|
# Generate queue ID
|
||||||
|
queue_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
# Create queue item
|
||||||
|
queue_item = QueueItem(
|
||||||
|
id=queue_id,
|
||||||
|
video_id=video_id or "",
|
||||||
|
title="Unknown Title",
|
||||||
|
url=url,
|
||||||
|
category=category or "",
|
||||||
|
network_folder=network_folder,
|
||||||
|
quality=quality,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Enqueue for sequential processing
|
||||||
|
download_engine.enqueue_download(queue_item)
|
||||||
|
|
||||||
|
return make_response({
|
||||||
|
"queueId": queue_id,
|
||||||
|
"status": "pending",
|
||||||
|
"message": "Added to download queue",
|
||||||
|
}), 202
|
||||||
|
except Exception as e:
|
||||||
|
return make_error_response(f"Failed to queue direct download: {str(e)}", 500)
|
||||||
281
web/server/routes/queue.py
Normal file
281
web/server/routes/queue.py
Normal file
@ -0,0 +1,281 @@
|
|||||||
|
"""Queue management API endpoints."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from flask import Blueprint, request
|
||||||
|
from utils import make_response, make_error_response
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
queue_bp = Blueprint('queue', __name__, url_prefix='/api')
|
||||||
|
|
||||||
|
|
||||||
|
@queue_bp.route('/queue', methods=['GET'])
|
||||||
|
def get_queue():
|
||||||
|
"""Get all queue items."""
|
||||||
|
from app import queue_store
|
||||||
|
try:
|
||||||
|
items = queue_store.get_all()
|
||||||
|
items_data = [item.to_dict() for item in items]
|
||||||
|
stats = queue_store.get_stats()
|
||||||
|
return make_response({
|
||||||
|
"queue": items_data,
|
||||||
|
"total": stats["total"],
|
||||||
|
"pendingCount": stats["pending"],
|
||||||
|
"downloadingCount": stats["downloading"],
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return make_error_response(f"Failed to get queue: {str(e)}", 500)
|
||||||
|
|
||||||
|
|
||||||
|
@queue_bp.route('/queue', methods=['POST'])
|
||||||
|
def add_to_queue():
|
||||||
|
"""Add a video to the download queue."""
|
||||||
|
from app import queue_store, download_engine, yt_cli
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
if not data:
|
||||||
|
return make_error_response("Request body is required", 400)
|
||||||
|
|
||||||
|
url = data.get('url', '').strip()
|
||||||
|
title = data.get('title', 'Unknown Title')
|
||||||
|
video_id = data.get('videoId', '')
|
||||||
|
thumbnail = data.get('thumbnail', '')
|
||||||
|
category = data.get('category', '')
|
||||||
|
network_folder = data.get('network_folder')
|
||||||
|
quality = data.get('quality')
|
||||||
|
|
||||||
|
if not url:
|
||||||
|
return make_error_response("Video URL is required", 400)
|
||||||
|
|
||||||
|
logger.info(f"Queue add request: title={title}, videoId={video_id}, url={url}, category={category}")
|
||||||
|
|
||||||
|
# Generate queue ID
|
||||||
|
import uuid
|
||||||
|
queue_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
# Create queue item
|
||||||
|
from models import QueueItem
|
||||||
|
queue_item = QueueItem(
|
||||||
|
id=queue_id,
|
||||||
|
video_id=video_id,
|
||||||
|
title=title,
|
||||||
|
url=url,
|
||||||
|
thumbnail=thumbnail,
|
||||||
|
category=category,
|
||||||
|
network_folder=network_folder,
|
||||||
|
quality=quality,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Enqueue item (will be processed in order by queue processor)
|
||||||
|
download_engine.enqueue_download(queue_item)
|
||||||
|
logger.info(f"Queue added successfully: queueId={queue_id}, title={title}")
|
||||||
|
|
||||||
|
return make_response(queue_item.to_dict()), 202
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to add to queue: title={title}, videoId={video_id}, error={str(e)}")
|
||||||
|
return make_error_response(f"Failed to add to queue: {str(e)}", 500)
|
||||||
|
|
||||||
|
|
||||||
|
@queue_bp.route('/queue', methods=['DELETE'])
|
||||||
|
def clear_queue():
|
||||||
|
"""Clear the entire queue."""
|
||||||
|
from app import queue_store, socketio
|
||||||
|
try:
|
||||||
|
count = queue_store.clear_all()
|
||||||
|
socketio.emit("queue:cleared")
|
||||||
|
return make_response({
|
||||||
|
"message": f"Queue cleared",
|
||||||
|
"count": count
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return make_error_response(f"Failed to clear queue: {str(e)}", 500)
|
||||||
|
|
||||||
|
|
||||||
|
@queue_bp.route('/queue/<queue_id>', methods=['GET'])
|
||||||
|
def get_queue_item(queue_id):
|
||||||
|
"""Get a specific queue item."""
|
||||||
|
from app import queue_store
|
||||||
|
try:
|
||||||
|
item = queue_store.get_item(queue_id)
|
||||||
|
if not item:
|
||||||
|
return make_error_response(f"Queue item {queue_id} not found", 404)
|
||||||
|
return make_response(item.to_dict())
|
||||||
|
except Exception as e:
|
||||||
|
return make_error_response(f"Failed to get queue item: {str(e)}", 500)
|
||||||
|
|
||||||
|
|
||||||
|
@queue_bp.route('/queue/<queue_id>', methods=['DELETE'])
|
||||||
|
def remove_from_queue(queue_id):
|
||||||
|
"""Remove an item from the queue."""
|
||||||
|
from app import queue_store, socketio
|
||||||
|
try:
|
||||||
|
removed = queue_store.remove_item(queue_id)
|
||||||
|
if not removed:
|
||||||
|
return make_error_response(f"Queue item {queue_id} not found", 404)
|
||||||
|
socketio.emit("queue:removed", {"queueId": queue_id})
|
||||||
|
return make_response({"message": f"Item {queue_id} removed from queue"})
|
||||||
|
except Exception as e:
|
||||||
|
return make_error_response(f"Failed to remove from queue: {str(e)}", 500)
|
||||||
|
|
||||||
|
|
||||||
|
@queue_bp.route('/queue/<queue_id>', methods=['PUT'])
|
||||||
|
def update_queue_item(queue_id):
|
||||||
|
"""Update a queue item's fields."""
|
||||||
|
from app import queue_store
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
if not data:
|
||||||
|
return make_error_response("Request body is required", 400)
|
||||||
|
|
||||||
|
item = queue_store.update_item(queue_id, data)
|
||||||
|
if not item:
|
||||||
|
return make_error_response(f"Queue item {queue_id} not found", 404)
|
||||||
|
return make_response(item.to_dict())
|
||||||
|
except Exception as e:
|
||||||
|
return make_error_response(f"Failed to update queue item: {str(e)}", 500)
|
||||||
|
|
||||||
|
|
||||||
|
@queue_bp.route('/queue/<queue_id>/retry', methods=['POST'])
|
||||||
|
def retry_download(queue_id):
|
||||||
|
"""Retry a failed download."""
|
||||||
|
from app import queue_store, download_engine, yt_cli
|
||||||
|
try:
|
||||||
|
item = queue_store.get_item(queue_id)
|
||||||
|
if not item:
|
||||||
|
return make_error_response(f"Queue item {queue_id} not found", 404)
|
||||||
|
|
||||||
|
# Reset status
|
||||||
|
queue_store.update_status(queue_id, "pending")
|
||||||
|
queue_store.update_progress(queue_id, 0)
|
||||||
|
|
||||||
|
# Re-download based on type
|
||||||
|
config = yt_cli.config
|
||||||
|
if item.item_type == "playlist":
|
||||||
|
download_engine.download_playlist(
|
||||||
|
queue_id=queue_id,
|
||||||
|
url=item.url,
|
||||||
|
config=config,
|
||||||
|
category=item.category,
|
||||||
|
network_folder=item.network_folder,
|
||||||
|
quality=item.quality,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
download_engine.download_video(
|
||||||
|
queue_id=queue_id,
|
||||||
|
url=item.url,
|
||||||
|
config=config,
|
||||||
|
category=item.category,
|
||||||
|
network_folder=item.network_folder,
|
||||||
|
quality=item.quality,
|
||||||
|
)
|
||||||
|
|
||||||
|
return make_response({
|
||||||
|
"queueId": queue_id,
|
||||||
|
"status": "downloading",
|
||||||
|
"message": "Download retry started"
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return make_error_response(f"Retry failed: {str(e)}", 500)
|
||||||
|
|
||||||
|
|
||||||
|
@queue_bp.route('/queue/<queue_id>/cancel', methods=['POST'])
|
||||||
|
def cancel_download(queue_id):
|
||||||
|
"""Cancel a download."""
|
||||||
|
from app import queue_store, download_engine
|
||||||
|
try:
|
||||||
|
item = queue_store.get_item(queue_id)
|
||||||
|
if not item:
|
||||||
|
return make_error_response(f"Queue item {queue_id} not found", 404)
|
||||||
|
|
||||||
|
if item.status in ("completed", "failed"):
|
||||||
|
return make_error_response(f"Cannot cancel {item.status} download", 400)
|
||||||
|
|
||||||
|
# Try to cancel active download
|
||||||
|
download_engine.cancel_download(queue_id)
|
||||||
|
|
||||||
|
return make_response({
|
||||||
|
"queueId": queue_id,
|
||||||
|
"status": "cancelled",
|
||||||
|
"message": "Download cancelled"
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return make_error_response(f"Failed to cancel download: {str(e)}", 500)
|
||||||
|
|
||||||
|
|
||||||
|
@queue_bp.route('/queue/<queue_id>/status', methods=['GET'])
|
||||||
|
def get_queue_status(queue_id):
|
||||||
|
"""Get the status of a queue item."""
|
||||||
|
from app import queue_store
|
||||||
|
try:
|
||||||
|
item = queue_store.get_item(queue_id)
|
||||||
|
if not item:
|
||||||
|
return make_error_response(f"Queue item {queue_id} not found", 404)
|
||||||
|
return make_response({
|
||||||
|
"queueId": queue_id,
|
||||||
|
"status": item.status,
|
||||||
|
"progress": item.progress,
|
||||||
|
"speed": item.speed,
|
||||||
|
"eta": item.eta,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return make_error_response(f"Failed to get queue status: {str(e)}", 500)
|
||||||
|
|
||||||
|
|
||||||
|
@queue_bp.route('/queue/<queue_id>/move', methods=['POST'])
|
||||||
|
def move_queue_item(queue_id):
|
||||||
|
"""Reorder a queue item."""
|
||||||
|
from app import queue_store
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
direction = data.get('direction', '').strip() if data else ''
|
||||||
|
|
||||||
|
if direction not in ('up', 'down'):
|
||||||
|
return make_error_response("Direction must be 'up' or 'down'", 400)
|
||||||
|
|
||||||
|
moved = queue_store.reorder_item(queue_id, direction)
|
||||||
|
if not moved:
|
||||||
|
return make_error_response(f"Could not move item {queue_id} {direction}", 400)
|
||||||
|
return make_response({"message": f"Item moved {direction}"})
|
||||||
|
except Exception as e:
|
||||||
|
return make_error_response(f"Failed to move queue item: {str(e)}", 500)
|
||||||
|
|
||||||
|
|
||||||
|
@queue_bp.route('/queue/clear/completed', methods=['POST'])
|
||||||
|
def clear_completed():
|
||||||
|
"""Clear completed items from the queue."""
|
||||||
|
from app import queue_store
|
||||||
|
try:
|
||||||
|
count = queue_store.clear_completed()
|
||||||
|
return make_response({
|
||||||
|
"cleared": count,
|
||||||
|
"count": count
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return make_error_response(f"Failed to clear completed items: {str(e)}", 500)
|
||||||
|
|
||||||
|
|
||||||
|
@queue_bp.route('/queue/clear/failed', methods=['POST'])
|
||||||
|
def clear_failed():
|
||||||
|
"""Clear failed items from the queue."""
|
||||||
|
from app import queue_store
|
||||||
|
try:
|
||||||
|
count = queue_store.clear_failed()
|
||||||
|
return make_response({
|
||||||
|
"cleared": count,
|
||||||
|
"count": count
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return make_error_response(f"Failed to clear failed items: {str(e)}", 500)
|
||||||
|
|
||||||
|
|
||||||
|
@queue_bp.route('/queue/stats', methods=['GET'])
|
||||||
|
def get_queue_stats():
|
||||||
|
"""Get queue statistics."""
|
||||||
|
from app import queue_store
|
||||||
|
try:
|
||||||
|
stats = queue_store.get_stats()
|
||||||
|
return make_response(stats)
|
||||||
|
except Exception as e:
|
||||||
|
return make_error_response(f"Failed to get queue stats: {str(e)}", 500)
|
||||||
319
web/server/routes/search.py
Normal file
319
web/server/routes/search.py
Normal file
@ -0,0 +1,319 @@
|
|||||||
|
"""Search-related API endpoints."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import json
|
||||||
|
import yt_dlp
|
||||||
|
from pathlib import Path
|
||||||
|
from flask import Blueprint, request
|
||||||
|
from utils import make_response, make_error_response
|
||||||
|
|
||||||
|
search_bp = Blueprint('search', __name__, url_prefix='/api')
|
||||||
|
|
||||||
|
# Recent searches storage (use CONFIG_DIR env var for persistence in Docker)
|
||||||
|
config_dir = os.environ.get('CONFIG_DIR', str(Path.home() / '.config' / 'youtube_cli'))
|
||||||
|
recent_searches_file = Path(config_dir) / "recent_searches.json"
|
||||||
|
|
||||||
|
# Banned search terms (loaded from file)
|
||||||
|
_banned_terms_file = Path(__file__).parent.parent / "banned_terms.txt"
|
||||||
|
_banned_terms = []
|
||||||
|
|
||||||
|
|
||||||
|
def _load_banned_terms():
|
||||||
|
"""Load banned search terms from file."""
|
||||||
|
global _banned_terms
|
||||||
|
if _banned_terms_file.exists():
|
||||||
|
try:
|
||||||
|
with open(_banned_terms_file, 'r') as f:
|
||||||
|
terms = []
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if line and not line.startswith('#'):
|
||||||
|
terms.append(line.lower())
|
||||||
|
_banned_terms = terms
|
||||||
|
except Exception:
|
||||||
|
_banned_terms = []
|
||||||
|
|
||||||
|
|
||||||
|
def _levenshtein(s1: str, s2: str) -> int:
|
||||||
|
"""Calculate Levenshtein distance between two strings."""
|
||||||
|
if len(s1) < len(s2):
|
||||||
|
return _levenshtein(s2, s1)
|
||||||
|
if len(s2) == 0:
|
||||||
|
return len(s1)
|
||||||
|
prev_row = range(len(s2) + 1)
|
||||||
|
for i, c1 in enumerate(s1):
|
||||||
|
curr_row = [i + 1]
|
||||||
|
for j, c2 in enumerate(s2):
|
||||||
|
insertions = prev_row[j + 1] + 1
|
||||||
|
deletions = curr_row[j] + 1
|
||||||
|
substitutions = prev_row[j] + (c1 != c2)
|
||||||
|
curr_row.append(min(insertions, deletions, substitutions))
|
||||||
|
prev_row = curr_row
|
||||||
|
return prev_row[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def _is_banned(query: str) -> bool:
|
||||||
|
"""Check if a search query contains any banned terms (exact or fuzzy match)."""
|
||||||
|
if not _banned_terms:
|
||||||
|
_load_banned_terms()
|
||||||
|
query_lower = query.lower()
|
||||||
|
|
||||||
|
# Exact match check
|
||||||
|
for term in _banned_terms:
|
||||||
|
if term in query_lower:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Fuzzy match check for words in the query
|
||||||
|
query_words = query_lower.split()
|
||||||
|
for word in query_words:
|
||||||
|
# Skip very short words (3 chars or less)
|
||||||
|
if len(word) <= 3:
|
||||||
|
continue
|
||||||
|
for term in _banned_terms:
|
||||||
|
# Only fuzzy match for terms with similar length
|
||||||
|
if abs(len(word) - len(term)) > 2:
|
||||||
|
continue
|
||||||
|
# Allow up to 2 character differences for terms 4+ chars
|
||||||
|
threshold = 2
|
||||||
|
if _levenshtein(word, term) <= threshold:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# Load banned terms at startup
|
||||||
|
_load_banned_terms()
|
||||||
|
|
||||||
|
|
||||||
|
def _load_recent_searches():
|
||||||
|
"""Load recent searches from file."""
|
||||||
|
if recent_searches_file.exists():
|
||||||
|
try:
|
||||||
|
with open(recent_searches_file, 'r') as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _save_recent_searches(searches):
|
||||||
|
"""Save recent searches to file."""
|
||||||
|
recent_searches_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(recent_searches_file, 'w') as f:
|
||||||
|
json.dump(searches, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
@search_bp.route('/search', methods=['GET'])
|
||||||
|
def search():
|
||||||
|
"""Search for YouTube videos using yt-dlp Python API."""
|
||||||
|
try:
|
||||||
|
query = request.args.get('q', '').strip()
|
||||||
|
page = int(request.args.get('page', 1))
|
||||||
|
limit = int(request.args.get('limit', 15))
|
||||||
|
|
||||||
|
if not query:
|
||||||
|
return make_error_response("Search query is required", 400)
|
||||||
|
|
||||||
|
if page < 1:
|
||||||
|
return make_error_response("Page must be greater than 0", 400)
|
||||||
|
|
||||||
|
if _is_banned(query):
|
||||||
|
return make_error_response("This search is not allowed. Please choose different keywords.", 400)
|
||||||
|
|
||||||
|
sanitized_query = re.sub(r'[^\w\s\-\'"\.]+', "", query)
|
||||||
|
search_query = f"ytsearch{limit * page}:{sanitized_query}"
|
||||||
|
|
||||||
|
ydl_opts = {
|
||||||
|
'extract_flat': True,
|
||||||
|
'no_warnings': True,
|
||||||
|
'quiet': True,
|
||||||
|
'no_progress': True,
|
||||||
|
'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||||
|
}
|
||||||
|
|
||||||
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
|
info = ydl.extract_info(search_query, download=False)
|
||||||
|
|
||||||
|
if not info:
|
||||||
|
return make_response({
|
||||||
|
"query": query,
|
||||||
|
"page": page,
|
||||||
|
"results": [],
|
||||||
|
"total": 0,
|
||||||
|
"hasMore": False,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Get all entries then slice to the correct page range
|
||||||
|
all_entries = info.get('entries', [info]) if isinstance(info, dict) else info
|
||||||
|
all_entries = all_entries or []
|
||||||
|
start_idx = limit * (page - 1)
|
||||||
|
end_idx = limit * page
|
||||||
|
entries = all_entries[start_idx:end_idx]
|
||||||
|
results = []
|
||||||
|
for entry in (entries or []):
|
||||||
|
if not entry:
|
||||||
|
continue
|
||||||
|
vid_id = entry.get('id', '')
|
||||||
|
url = entry.get('url', '') or entry.get('webpage_url', '') or f'https://www.youtube.com/watch?v={vid_id}'
|
||||||
|
duration = entry.get('duration', 0) or 0
|
||||||
|
duration_str = f"{int(duration // 60)}:{int(duration % 60):02d}" if duration else "0:00"
|
||||||
|
thumbnail = entry.get("thumbnail", "") or f"https://i.ytimg.com/vi/{vid_id}/hqdefault.jpg"
|
||||||
|
results.append({
|
||||||
|
"id": vid_id,
|
||||||
|
"videoId": vid_id,
|
||||||
|
"title": entry.get("title", "Unknown Title"),
|
||||||
|
"description": "",
|
||||||
|
"thumbnail": thumbnail,
|
||||||
|
"url": url,
|
||||||
|
"duration": duration_str,
|
||||||
|
"views": str(entry.get("view_count", 0) or 0),
|
||||||
|
"channel": entry.get("uploader", "Unknown"),
|
||||||
|
"isShort": "/shorts/" in url,
|
||||||
|
"published": "",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Save to recent searches (only if not banned)
|
||||||
|
if not _is_banned(query):
|
||||||
|
searches = _load_recent_searches()
|
||||||
|
if query not in searches:
|
||||||
|
searches.insert(0, query)
|
||||||
|
searches = searches[:10]
|
||||||
|
_save_recent_searches(searches)
|
||||||
|
|
||||||
|
return make_response({
|
||||||
|
"query": query,
|
||||||
|
"page": page,
|
||||||
|
"results": results,
|
||||||
|
"total": len(results),
|
||||||
|
"hasMore": len(results) >= limit,
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return make_error_response(f"Search failed: {str(e)}", 500)
|
||||||
|
|
||||||
|
|
||||||
|
@search_bp.route('/recent-searches', methods=['GET'])
|
||||||
|
def get_recent_searches():
|
||||||
|
"""Get list of recent search queries."""
|
||||||
|
try:
|
||||||
|
searches = _load_recent_searches()
|
||||||
|
return make_response(searches)
|
||||||
|
except Exception as e:
|
||||||
|
return make_error_response(f"Failed to load recent searches: {str(e)}", 500)
|
||||||
|
|
||||||
|
|
||||||
|
@search_bp.route('/recent-searches', methods=['POST'])
|
||||||
|
def save_recent_search():
|
||||||
|
"""Save a search query to recent searches."""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
query = data.get('query', '').strip() if data else ''
|
||||||
|
if not query:
|
||||||
|
return make_error_response("Search query is required", 400)
|
||||||
|
|
||||||
|
searches = _load_recent_searches()
|
||||||
|
if query in searches:
|
||||||
|
searches.remove(query)
|
||||||
|
searches.insert(0, query)
|
||||||
|
searches = searches[:10]
|
||||||
|
_save_recent_searches(searches)
|
||||||
|
return make_response(searches)
|
||||||
|
except Exception as e:
|
||||||
|
return make_error_response(f"Failed to save recent search: {str(e)}", 500)
|
||||||
|
|
||||||
|
|
||||||
|
@search_bp.route('/recent-searches', methods=['DELETE'])
|
||||||
|
def clear_recent_searches():
|
||||||
|
"""Clear recent search history."""
|
||||||
|
try:
|
||||||
|
_save_recent_searches([])
|
||||||
|
return make_response({"message": "Recent searches cleared"})
|
||||||
|
except Exception as e:
|
||||||
|
return make_error_response(f"Failed to clear recent searches: {str(e)}", 500)
|
||||||
|
|
||||||
|
|
||||||
|
@search_bp.route('/recent-searches/<path:query>', methods=['DELETE'])
|
||||||
|
def remove_recent_search(query):
|
||||||
|
"""Remove a single recent search."""
|
||||||
|
try:
|
||||||
|
searches = _load_recent_searches()
|
||||||
|
if query in searches:
|
||||||
|
searches.remove(query)
|
||||||
|
_save_recent_searches(searches)
|
||||||
|
return make_response({"message": f"Search '{query}' removed", "searches": searches})
|
||||||
|
except Exception as e:
|
||||||
|
return make_error_response(f"Failed to remove recent search: {str(e)}", 500)
|
||||||
|
|
||||||
|
|
||||||
|
@search_bp.route('/video/<video_id>', methods=['GET'])
|
||||||
|
def get_video_details(video_id):
|
||||||
|
"""Get video details by YouTube video ID."""
|
||||||
|
try:
|
||||||
|
url = f"https://www.youtube.com/watch?v={video_id}"
|
||||||
|
ydl_opts = {
|
||||||
|
'dump_single_json': True,
|
||||||
|
'no_warnings': True,
|
||||||
|
'quiet': True,
|
||||||
|
'no_progress': True,
|
||||||
|
'write_thumbnail': False,
|
||||||
|
}
|
||||||
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
|
info = ydl.extract_info(url, download=False)
|
||||||
|
|
||||||
|
if not info:
|
||||||
|
return make_error_response("Video not found", 404)
|
||||||
|
|
||||||
|
return make_response({
|
||||||
|
"id": info.get("id", video_id),
|
||||||
|
"videoId": info.get("id", video_id),
|
||||||
|
"title": info.get("title", "Unknown Title"),
|
||||||
|
"description": info.get("description", ""),
|
||||||
|
"thumbnail": info.get("thumbnail", ""),
|
||||||
|
"url": info.get("webpage_url", url),
|
||||||
|
"duration": info.get("duration_string", "0:00"),
|
||||||
|
"views": str(info.get("view_count", 0) or 0),
|
||||||
|
"channel": info.get("uploader", "Unknown"),
|
||||||
|
"isShort": "/shorts/" in url,
|
||||||
|
"published": info.get("upload_date", ""),
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return make_error_response(f"Failed to get video details: {str(e)}", 500)
|
||||||
|
|
||||||
|
|
||||||
|
@search_bp.route('/info', methods=['GET'])
|
||||||
|
def get_video_info():
|
||||||
|
"""Get video info by URL."""
|
||||||
|
try:
|
||||||
|
url = request.args.get('url', '').strip()
|
||||||
|
if not url:
|
||||||
|
return make_error_response("URL is required", 400)
|
||||||
|
|
||||||
|
ydl_opts = {
|
||||||
|
'dump_single_json': True,
|
||||||
|
'no_warnings': True,
|
||||||
|
'quiet': True,
|
||||||
|
'no_progress': True,
|
||||||
|
'write_thumbnail': False,
|
||||||
|
}
|
||||||
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
|
info = ydl.extract_info(url, download=False)
|
||||||
|
|
||||||
|
if not info:
|
||||||
|
return make_error_response("Video not found", 404)
|
||||||
|
|
||||||
|
return make_response({
|
||||||
|
"id": info.get("id", ""),
|
||||||
|
"videoId": info.get("id", ""),
|
||||||
|
"title": info.get("title", "Unknown Title"),
|
||||||
|
"description": info.get("description", ""),
|
||||||
|
"thumbnail": info.get("thumbnail", ""),
|
||||||
|
"url": info.get("webpage_url", url),
|
||||||
|
"duration": info.get("duration_string", "0:00"),
|
||||||
|
"views": str(info.get("view_count", 0) or 0),
|
||||||
|
"channel": info.get("uploader", "Unknown"),
|
||||||
|
"isShort": "/shorts/" in url,
|
||||||
|
"published": info.get("upload_date", ""),
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return make_error_response(f"Failed to get video info: {str(e)}", 500)
|
||||||
119
web/server/tests/test_download_recovery.py
Normal file
119
web/server/tests/test_download_recovery.py
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
"""Tests for download recovery after server crash."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from models import QueueItem
|
||||||
|
from models.queue_store import QueueStore
|
||||||
|
|
||||||
|
|
||||||
|
def test_recovery_resets_downloading_to_pending():
|
||||||
|
"""Test that downloads stuck in 'downloading' status are reset to 'pending' on restart."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
path = os.path.join(tmpdir, "test_queue.json")
|
||||||
|
store = QueueStore(store_path=path)
|
||||||
|
# Simulate a download that was in progress when server crashed
|
||||||
|
item = QueueItem(
|
||||||
|
id="crash1",
|
||||||
|
video_id="vid1",
|
||||||
|
title="Crashed Download",
|
||||||
|
url="https://youtube.com/watch?v=vid1",
|
||||||
|
category="General",
|
||||||
|
status="downloading",
|
||||||
|
progress=45.0,
|
||||||
|
)
|
||||||
|
store.add_item(item)
|
||||||
|
# Simulate recovery
|
||||||
|
items = store.get_all()
|
||||||
|
recovered = 0
|
||||||
|
for item in items:
|
||||||
|
if item.status == "downloading":
|
||||||
|
store.update_status(item.id, "pending")
|
||||||
|
store.update_progress(item.id, 0.0)
|
||||||
|
recovered += 1
|
||||||
|
assert recovered == 1
|
||||||
|
item = store.get_item("crash1")
|
||||||
|
assert item.status == "pending"
|
||||||
|
assert item.progress == 0.0
|
||||||
|
print("PASS: test_recovery_resets_downloading_to_pending")
|
||||||
|
|
||||||
|
|
||||||
|
def test_recovery_keeps_pending_items():
|
||||||
|
"""Test that pending downloads are not affected by recovery."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
path = os.path.join(tmpdir, "test_queue.json")
|
||||||
|
store = QueueStore(store_path=path)
|
||||||
|
item = QueueItem(
|
||||||
|
id="pending1",
|
||||||
|
video_id="vid1",
|
||||||
|
title="Pending Download",
|
||||||
|
url="https://youtube.com/watch?v=vid1",
|
||||||
|
category="General",
|
||||||
|
status="pending",
|
||||||
|
progress=0.0,
|
||||||
|
)
|
||||||
|
store.add_item(item)
|
||||||
|
# Simulate recovery
|
||||||
|
items = store.get_all()
|
||||||
|
for item in items:
|
||||||
|
if item.status == "downloading":
|
||||||
|
store.update_status(item.id, "pending")
|
||||||
|
store.update_progress(item.id, 0.0)
|
||||||
|
item = store.get_item("pending1")
|
||||||
|
assert item.status == "pending"
|
||||||
|
assert item.progress == 0.0
|
||||||
|
print("PASS: test_recovery_keeps_pending_items")
|
||||||
|
|
||||||
|
|
||||||
|
def test_recovery_handles_multiple_downloads():
|
||||||
|
"""Test that multiple in-progress downloads are recovered."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
path = os.path.join(tmpdir, "test_queue.json")
|
||||||
|
store = QueueStore(store_path=path)
|
||||||
|
store.add_item(QueueItem(id="a", video_id="1", title="A", url="https://y.com/1", status="downloading", progress=30.0))
|
||||||
|
store.add_item(QueueItem(id="b", video_id="2", title="B", url="https://y.com/2", status="downloading", progress=60.0))
|
||||||
|
store.add_item(QueueItem(id="c", video_id="3", title="C", url="https://y.com/3", status="pending", progress=0.0))
|
||||||
|
# Simulate recovery
|
||||||
|
items = store.get_all()
|
||||||
|
recovered = 0
|
||||||
|
for item in items:
|
||||||
|
if item.status == "downloading":
|
||||||
|
store.update_status(item.id, "pending")
|
||||||
|
store.update_progress(item.id, 0.0)
|
||||||
|
recovered += 1
|
||||||
|
assert recovered == 2
|
||||||
|
assert store.get_item("a").status == "pending"
|
||||||
|
assert store.get_item("b").status == "pending"
|
||||||
|
assert store.get_item("c").status == "pending"
|
||||||
|
print("PASS: test_recovery_handles_multiple_downloads")
|
||||||
|
|
||||||
|
|
||||||
|
def test_recovery_preserves_completed():
|
||||||
|
"""Test that completed downloads are not affected by recovery."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
path = os.path.join(tmpdir, "test_queue.json")
|
||||||
|
store = QueueStore(store_path=path)
|
||||||
|
store.add_item(QueueItem(id="done1", video_id="1", title="Done", url="https://y.com/1", status="completed", progress=100.0))
|
||||||
|
# Simulate recovery
|
||||||
|
items = store.get_all()
|
||||||
|
for item in items:
|
||||||
|
if item.status == "downloading":
|
||||||
|
store.update_status(item.id, "pending")
|
||||||
|
store.update_progress(item.id, 0.0)
|
||||||
|
item = store.get_item("done1")
|
||||||
|
assert item.status == "completed"
|
||||||
|
assert item.progress == 100.0
|
||||||
|
print("PASS: test_recovery_preserves_completed")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_recovery_resets_downloading_to_pending()
|
||||||
|
test_recovery_keeps_pending_items()
|
||||||
|
test_recovery_handles_multiple_downloads()
|
||||||
|
test_recovery_preserves_completed()
|
||||||
|
print("\nAll recovery tests passed!")
|
||||||
177
web/server/tests/test_queue_api.py
Normal file
177
web/server/tests/test_queue_api.py
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
"""Tests for queue API endpoints - verify the Flask routes work correctly."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from flask import Flask
|
||||||
|
from models import QueueItem
|
||||||
|
from models.queue_store import QueueStore
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(store):
|
||||||
|
"""Create a minimal Flask app with queue routes for testing."""
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config["queue_store"] = store
|
||||||
|
|
||||||
|
# Create a mock download engine that actually adds to store
|
||||||
|
def mock_enqueue(item):
|
||||||
|
store.add_item(item)
|
||||||
|
mock_engine = MagicMock()
|
||||||
|
mock_engine.enqueue_download = mock_enqueue
|
||||||
|
|
||||||
|
# Create a mock yt_cli
|
||||||
|
mock_yt_cli = MagicMock()
|
||||||
|
mock_yt_cli.config = {"download_dir": "/tmp"}
|
||||||
|
|
||||||
|
# Mock the app module imports
|
||||||
|
import sys as test_sys
|
||||||
|
mock_app_module = MagicMock()
|
||||||
|
mock_app_module.queue_store = store
|
||||||
|
mock_app_module.download_engine = mock_engine
|
||||||
|
mock_app_module.yt_cli = mock_yt_cli
|
||||||
|
test_sys.modules["app"] = mock_app_module
|
||||||
|
|
||||||
|
from routes.queue import queue_bp
|
||||||
|
app.register_blueprint(queue_bp)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def test_queue_api_get_empty():
|
||||||
|
"""Test GET /api/queue returns empty queue."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
path = os.path.join(tmpdir, "test_queue.json")
|
||||||
|
store = QueueStore(store_path=path)
|
||||||
|
app = create_app(store)
|
||||||
|
with app.test_client() as client:
|
||||||
|
resp = client.get("/api/queue")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = json.loads(resp.data)
|
||||||
|
assert data["total"] == 0
|
||||||
|
assert data["queue"] == []
|
||||||
|
assert data["pendingCount"] == 0
|
||||||
|
assert data["downloadingCount"] == 0
|
||||||
|
print("PASS: test_queue_api_get_empty")
|
||||||
|
|
||||||
|
|
||||||
|
def test_queue_api_add_and_get():
|
||||||
|
"""Test POST /api/queue then GET /api/queue."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
path = os.path.join(tmpdir, "test_queue.json")
|
||||||
|
store = QueueStore(store_path=path)
|
||||||
|
app = create_app(store)
|
||||||
|
|
||||||
|
with app.test_client() as client:
|
||||||
|
resp = client.post("/api/queue", json={
|
||||||
|
"videoId": "test123",
|
||||||
|
"title": "Test Video",
|
||||||
|
"thumbnail": "https://img.youtube.com/vi/test123/hqdefault.jpg",
|
||||||
|
"category": "General",
|
||||||
|
"url": "https://www.youtube.com/watch?v=test123",
|
||||||
|
"quality": "1080"
|
||||||
|
})
|
||||||
|
assert resp.status_code == 202
|
||||||
|
data = json.loads(resp.data)
|
||||||
|
assert data["videoId"] == "test123"
|
||||||
|
assert data["category"] == "General"
|
||||||
|
|
||||||
|
resp = client.get("/api/queue")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = json.loads(resp.data)
|
||||||
|
assert data["total"] >= 1
|
||||||
|
assert any(item["videoId"] == "test123" for item in data["queue"])
|
||||||
|
print("PASS: test_queue_api_add_and_get")
|
||||||
|
|
||||||
|
|
||||||
|
def test_queue_api_remove():
|
||||||
|
"""Test DELETE /api/queue/<id>."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
path = os.path.join(tmpdir, "test_queue.json")
|
||||||
|
store = QueueStore(store_path=path)
|
||||||
|
item = QueueItem(
|
||||||
|
id="rmtest1",
|
||||||
|
video_id="vid1",
|
||||||
|
title="Remove Me",
|
||||||
|
url="https://youtube.com/watch?v=vid1",
|
||||||
|
category="General"
|
||||||
|
)
|
||||||
|
store.add_item(item)
|
||||||
|
|
||||||
|
app = create_app(store)
|
||||||
|
with app.test_client() as client:
|
||||||
|
resp = client.delete("/api/queue/rmtest1")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
resp = client.get("/api/queue")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = json.loads(resp.data)
|
||||||
|
assert data["total"] == 0
|
||||||
|
print("PASS: test_queue_api_remove")
|
||||||
|
|
||||||
|
|
||||||
|
def test_queue_api_clear():
|
||||||
|
"""Test DELETE /api/queue clears all items."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
path = os.path.join(tmpdir, "test_queue.json")
|
||||||
|
store = QueueStore(store_path=path)
|
||||||
|
store.add_item(QueueItem(id="a", video_id="1", title="A", url="https://y.com/1"))
|
||||||
|
store.add_item(QueueItem(id="b", video_id="2", title="B", url="https://y.com/2"))
|
||||||
|
|
||||||
|
app = create_app(store)
|
||||||
|
with app.test_client() as client:
|
||||||
|
resp = client.delete("/api/queue")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = json.loads(resp.data)
|
||||||
|
assert data["count"] == 2
|
||||||
|
|
||||||
|
resp = client.get("/api/queue")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = json.loads(resp.data)
|
||||||
|
assert data["total"] == 0
|
||||||
|
print("PASS: test_queue_api_clear")
|
||||||
|
|
||||||
|
|
||||||
|
def test_queue_api_corrupted_file_recovery():
|
||||||
|
"""Test that the queue API recovers from corrupted JSON file."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
path = os.path.join(tmpdir, "test_queue.json")
|
||||||
|
with open(path, "w") as f:
|
||||||
|
f.write('{"corrupted": true, "invalid": "char \x00 here"}')
|
||||||
|
|
||||||
|
store = QueueStore(store_path=path)
|
||||||
|
app = create_app(store)
|
||||||
|
with app.test_client() as client:
|
||||||
|
resp = client.get("/api/queue")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = json.loads(resp.data)
|
||||||
|
assert data["total"] == 0
|
||||||
|
assert data["queue"] == []
|
||||||
|
print("PASS: test_queue_api_corrupted_file_recovery")
|
||||||
|
|
||||||
|
|
||||||
|
def test_queue_api_missing_url():
|
||||||
|
"""Test POST /api/queue returns 400 when URL is missing."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
path = os.path.join(tmpdir, "test_queue.json")
|
||||||
|
store = QueueStore(store_path=path)
|
||||||
|
app = create_app(store)
|
||||||
|
|
||||||
|
with app.test_client() as client:
|
||||||
|
resp = client.post("/api/queue", json={"videoId": "x", "title": "No URL"})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
print("PASS: test_queue_api_missing_url")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_queue_api_get_empty()
|
||||||
|
test_queue_api_add_and_get()
|
||||||
|
test_queue_api_remove()
|
||||||
|
test_queue_api_clear()
|
||||||
|
test_queue_api_corrupted_file_recovery()
|
||||||
|
test_queue_api_missing_url()
|
||||||
|
print("\nAll API tests passed!")
|
||||||
224
web/server/tests/test_queue_store.py
Normal file
224
web/server/tests/test_queue_store.py
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
"""Tests for QueueStore - JSON persistence, corruption recovery, and CRUD operations."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from models import QueueItem
|
||||||
|
from models.queue_store import QueueStore
|
||||||
|
|
||||||
|
|
||||||
|
def make_item(item_id="test1", video_id="abc123", title="Test Video", status="pending", **kwargs):
|
||||||
|
return QueueItem(
|
||||||
|
id=item_id,
|
||||||
|
video_id=video_id,
|
||||||
|
title=title,
|
||||||
|
url=f"https://youtube.com/watch?v={video_id}",
|
||||||
|
thumbnail="https://img.youtube.com/vi/" + video_id + "/hqdefault.jpg",
|
||||||
|
status=status,
|
||||||
|
progress=kwargs.get("progress", 0.0),
|
||||||
|
category=kwargs.get("category", "General"),
|
||||||
|
added_at=kwargs.get("added_at", "2026-01-01T00:00:00+00:00"),
|
||||||
|
quality=kwargs.get("quality", "1080"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_empty_store():
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
path = os.path.join(tmpdir, "test_queue.json")
|
||||||
|
store = QueueStore(store_path=path)
|
||||||
|
assert store.get_all() == []
|
||||||
|
print("PASS: test_create_empty_store")
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_and_get_item():
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
path = os.path.join(tmpdir, "test_queue.json")
|
||||||
|
store = QueueStore(store_path=path)
|
||||||
|
item = make_item()
|
||||||
|
store.add_item(item)
|
||||||
|
items = store.get_all()
|
||||||
|
assert len(items) == 1
|
||||||
|
assert items[0].id == "test1"
|
||||||
|
assert items[0].title == "Test Video"
|
||||||
|
print("PASS: test_add_and_get_item")
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_status():
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
path = os.path.join(tmpdir, "test_queue.json")
|
||||||
|
store = QueueStore(store_path=path)
|
||||||
|
store.add_item(make_item())
|
||||||
|
store.update_status("test1", "completed", download_path="/foo/bar.mp4", file_size="100MB")
|
||||||
|
item = store.get_item("test1")
|
||||||
|
assert item.status == "completed"
|
||||||
|
assert item.download_path == "/foo/bar.mp4"
|
||||||
|
assert item.file_size == "100MB"
|
||||||
|
assert item.progress == 100.0
|
||||||
|
print("PASS: test_update_status")
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_progress():
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
path = os.path.join(tmpdir, "test_queue.json")
|
||||||
|
store = QueueStore(store_path=path)
|
||||||
|
store.add_item(make_item())
|
||||||
|
store.update_progress("test1", 45.5, speed="1.2MB/s", eta="5m")
|
||||||
|
item = store.get_item("test1")
|
||||||
|
assert item.progress == 45.5
|
||||||
|
assert item.speed == "1.2MB/s"
|
||||||
|
assert item.eta == "5m"
|
||||||
|
print("PASS: test_update_progress")
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_item():
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
path = os.path.join(tmpdir, "test_queue.json")
|
||||||
|
store = QueueStore(store_path=path)
|
||||||
|
store.add_item(make_item())
|
||||||
|
assert store.remove_item("test1") is True
|
||||||
|
assert store.get_all() == []
|
||||||
|
assert store.remove_item("nonexistent") is False
|
||||||
|
print("PASS: test_remove_item")
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_completed():
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
path = os.path.join(tmpdir, "test_queue.json")
|
||||||
|
store = QueueStore(store_path=path)
|
||||||
|
store.add_item(make_item(item_id="done1", status="completed"))
|
||||||
|
store.add_item(make_item(item_id="done2", status="completed"))
|
||||||
|
store.add_item(make_item(item_id="pend1", status="pending"))
|
||||||
|
removed = store.clear_completed()
|
||||||
|
assert removed == 2
|
||||||
|
assert len(store.get_all()) == 1
|
||||||
|
assert store.get_item("pend1").status == "pending"
|
||||||
|
print("PASS: test_clear_completed")
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_failed():
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
path = os.path.join(tmpdir, "test_queue.json")
|
||||||
|
store = QueueStore(store_path=path)
|
||||||
|
store.add_item(make_item(item_id="fail1", status="failed"))
|
||||||
|
store.add_item(make_item(item_id="pend1", status="pending"))
|
||||||
|
removed = store.clear_failed()
|
||||||
|
assert removed == 1
|
||||||
|
assert len(store.get_all()) == 1
|
||||||
|
print("PASS: test_clear_failed")
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_all():
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
path = os.path.join(tmpdir, "test_queue.json")
|
||||||
|
store = QueueStore(store_path=path)
|
||||||
|
store.add_item(make_item(item_id="a"))
|
||||||
|
store.add_item(make_item(item_id="b"))
|
||||||
|
removed = store.clear_all()
|
||||||
|
assert removed == 2
|
||||||
|
assert len(store.get_all()) == 0
|
||||||
|
print("PASS: test_clear_all")
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_stats():
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
path = os.path.join(tmpdir, "test_queue.json")
|
||||||
|
store = QueueStore(store_path=path)
|
||||||
|
store.add_item(make_item(item_id="p1", status="pending"))
|
||||||
|
store.add_item(make_item(item_id="d1", status="downloading"))
|
||||||
|
store.add_item(make_item(item_id="c1", status="completed"))
|
||||||
|
store.add_item(make_item(item_id="f1", status="failed"))
|
||||||
|
stats = store.get_stats()
|
||||||
|
assert stats["total"] == 4
|
||||||
|
assert stats["pending"] == 1
|
||||||
|
assert stats["downloading"] == 1
|
||||||
|
assert stats["completed"] == 1
|
||||||
|
assert stats["failed"] == 1
|
||||||
|
print("PASS: test_get_stats")
|
||||||
|
|
||||||
|
|
||||||
|
def test_corrupted_json_recovery():
|
||||||
|
"""Test that corrupted JSON file is handled gracefully."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
path = os.path.join(tmpdir, "test_queue.json")
|
||||||
|
with open(path, "w") as f:
|
||||||
|
f.write('{"id": "test1", "title": "Video with invalid char: \x01\x02\x03"}')
|
||||||
|
store = QueueStore(store_path=path)
|
||||||
|
items = store.get_all()
|
||||||
|
assert items == []
|
||||||
|
assert store.get_item("test1") is None
|
||||||
|
print("PASS: test_corrupted_json_recovery")
|
||||||
|
|
||||||
|
|
||||||
|
def test_corrupted_json_with_control_chars():
|
||||||
|
"""Test recovery from control character corruption (the actual bug)."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
path = os.path.join(tmpdir, "test_queue.json")
|
||||||
|
with open(path, "w") as f:
|
||||||
|
f.write('{"test1": {"id": "test1", "title": "Error: Some long message\nwith\ncontrol\x00chars"}}')
|
||||||
|
store = QueueStore(store_path=path)
|
||||||
|
items = store.get_all()
|
||||||
|
assert items == []
|
||||||
|
print("PASS: test_corrupted_json_with_control_chars")
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_file_recovery():
|
||||||
|
"""Test recovery from empty file."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
path = os.path.join(tmpdir, "test_queue.json")
|
||||||
|
with open(path, "w") as f:
|
||||||
|
f.write("")
|
||||||
|
store = QueueStore(store_path=path)
|
||||||
|
items = store.get_all()
|
||||||
|
assert items == []
|
||||||
|
print("PASS: test_empty_file_recovery")
|
||||||
|
|
||||||
|
|
||||||
|
def test_persistence_across_instances():
|
||||||
|
"""Test that data persists when creating new QueueStore instance."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
path = os.path.join(tmpdir, "test_queue.json")
|
||||||
|
store1 = QueueStore(store_path=path)
|
||||||
|
store1.add_item(make_item())
|
||||||
|
del store1
|
||||||
|
store2 = QueueStore(store_path=path)
|
||||||
|
items = store2.get_all()
|
||||||
|
assert len(items) == 1
|
||||||
|
assert items[0].id == "test1"
|
||||||
|
print("PASS: test_persistence_across_instances")
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_message_with_special_chars():
|
||||||
|
"""Test that error messages with special characters don't corrupt the file."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
path = os.path.join(tmpdir, "test_queue.json")
|
||||||
|
store = QueueStore(store_path=path)
|
||||||
|
store.add_item(make_item())
|
||||||
|
special_msg = "Error: Connection timeout\nRetrying...\nFailed after 3 attempts"
|
||||||
|
store.update_status("test1", "failed", error_message=special_msg)
|
||||||
|
item = store.get_item("test1")
|
||||||
|
assert item.error_message == special_msg
|
||||||
|
assert item.status == "failed"
|
||||||
|
print("PASS: test_error_message_with_special_chars")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_create_empty_store()
|
||||||
|
test_add_and_get_item()
|
||||||
|
test_update_status()
|
||||||
|
test_update_progress()
|
||||||
|
test_remove_item()
|
||||||
|
test_clear_completed()
|
||||||
|
test_clear_failed()
|
||||||
|
test_clear_all()
|
||||||
|
test_get_stats()
|
||||||
|
test_corrupted_json_recovery()
|
||||||
|
test_corrupted_json_with_control_chars()
|
||||||
|
test_empty_file_recovery()
|
||||||
|
test_persistence_across_instances()
|
||||||
|
test_error_message_with_special_chars()
|
||||||
|
print("\nAll tests passed!")
|
||||||
20
web/server/utils.py
Normal file
20
web/server/utils.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
"""Shared utility functions for the web server."""
|
||||||
|
|
||||||
|
from flask import jsonify
|
||||||
|
|
||||||
|
|
||||||
|
def make_response(data, status=200):
|
||||||
|
"""Create a JSON response. Returns data directly (no wrapper)."""
|
||||||
|
resp = jsonify(data)
|
||||||
|
resp.status_code = status
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def make_error_response(message, status=400):
|
||||||
|
"""Create an error response."""
|
||||||
|
resp = jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": message
|
||||||
|
})
|
||||||
|
resp.status_code = status
|
||||||
|
return resp
|
||||||
5
web/web-app/.env.example
Normal file
5
web/web-app/.env.example
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# API base URL (default: /api for same-origin, or full URL for cross-origin)
|
||||||
|
# VITE_API_BASE_URL=http://localhost:4096/api
|
||||||
|
|
||||||
|
# WebSocket URL (default: window.location.origin for same-origin)
|
||||||
|
# VITE_WS_URL=http://localhost:4096
|
||||||
366
web/web-app/package-lock.json
generated
366
web/web-app/package-lock.json
generated
@ -8,9 +8,11 @@
|
|||||||
"name": "youtube-web-app",
|
"name": "youtube-web-app",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"axios": "^1.6.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-router-dom": "^6.20.0"
|
"react-router-dom": "^6.20.0",
|
||||||
|
"socket.io-client": "^4.7.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.40.0",
|
"@playwright/test": "^1.40.0",
|
||||||
@ -922,9 +924,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -939,9 +938,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -956,9 +952,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -973,9 +966,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -990,9 +980,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -1007,9 +994,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -1024,9 +1008,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -1041,9 +1022,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -1058,9 +1036,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -1075,9 +1050,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -1092,9 +1064,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -1109,9 +1078,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -1126,9 +1092,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -1219,6 +1182,11 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@socket.io/component-emitter": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="
|
||||||
|
},
|
||||||
"node_modules/@types/babel__core": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
@ -1348,6 +1316,11 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/asynckit": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
|
||||||
|
},
|
||||||
"node_modules/autoprefixer": {
|
"node_modules/autoprefixer": {
|
||||||
"version": "10.4.27",
|
"version": "10.4.27",
|
||||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz",
|
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz",
|
||||||
@ -1385,6 +1358,16 @@
|
|||||||
"postcss": "^8.1.0"
|
"postcss": "^8.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/axios": {
|
||||||
|
"version": "1.16.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
|
||||||
|
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
|
||||||
|
"dependencies": {
|
||||||
|
"follow-redirects": "^1.16.0",
|
||||||
|
"form-data": "^4.0.5",
|
||||||
|
"proxy-from-env": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.13",
|
"version": "2.10.13",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.13.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.13.tgz",
|
||||||
@ -1458,6 +1441,18 @@
|
|||||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/call-bind-apply-helpers": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/camelcase-css": {
|
"node_modules/camelcase-css": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
||||||
@ -1527,6 +1522,17 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/combined-stream": {
|
||||||
|
"version": "1.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
|
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||||
|
"dependencies": {
|
||||||
|
"delayed-stream": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/commander": {
|
"node_modules/commander": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
|
||||||
@ -1568,7 +1574,6 @@
|
|||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
@ -1582,6 +1587,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/delayed-stream": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/didyoumean": {
|
"node_modules/didyoumean": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
||||||
@ -1596,6 +1609,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/dunder-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"gopd": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.329",
|
"version": "1.5.329",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.329.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.329.tgz",
|
||||||
@ -1603,6 +1629,67 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/engine.io-client": {
|
||||||
|
"version": "6.6.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz",
|
||||||
|
"integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==",
|
||||||
|
"dependencies": {
|
||||||
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
|
"debug": "~4.4.1",
|
||||||
|
"engine.io-parser": "~5.2.1",
|
||||||
|
"ws": "~8.18.3",
|
||||||
|
"xmlhttprequest-ssl": "~2.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/engine.io-parser": {
|
||||||
|
"version": "5.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
||||||
|
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-define-property": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-errors": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-object-atoms": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-set-tostringtag": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"get-intrinsic": "^1.2.6",
|
||||||
|
"has-tostringtag": "^1.0.2",
|
||||||
|
"hasown": "^2.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||||
@ -1705,6 +1792,40 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/follow-redirects": {
|
||||||
|
"version": "1.16.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||||
|
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"debug": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/form-data": {
|
||||||
|
"version": "4.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||||
|
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||||
|
"dependencies": {
|
||||||
|
"asynckit": "^0.4.0",
|
||||||
|
"combined-stream": "^1.0.8",
|
||||||
|
"es-set-tostringtag": "^2.1.0",
|
||||||
|
"hasown": "^2.0.2",
|
||||||
|
"mime-types": "^2.1.12"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fraction.js": {
|
"node_modules/fraction.js": {
|
||||||
"version": "5.3.4",
|
"version": "5.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||||
@ -1738,7 +1859,6 @@
|
|||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
@ -1754,6 +1874,41 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-intrinsic": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
"es-define-property": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"es-object-atoms": "^1.1.1",
|
||||||
|
"function-bind": "^1.1.2",
|
||||||
|
"get-proto": "^1.0.1",
|
||||||
|
"gopd": "^1.2.0",
|
||||||
|
"has-symbols": "^1.1.0",
|
||||||
|
"hasown": "^2.0.2",
|
||||||
|
"math-intrinsics": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||||
|
"dependencies": {
|
||||||
|
"dunder-proto": "^1.0.1",
|
||||||
|
"es-object-atoms": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/glob-parent": {
|
"node_modules/glob-parent": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||||
@ -1767,11 +1922,46 @@
|
|||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/gopd": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/has-symbols": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/has-tostringtag": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||||
|
"dependencies": {
|
||||||
|
"has-symbols": "^1.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/hasown": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"function-bind": "^1.1.2"
|
"function-bind": "^1.1.2"
|
||||||
@ -1926,6 +2116,14 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/math-intrinsics": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/merge2": {
|
"node_modules/merge2": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||||
@ -1950,11 +2148,29 @@
|
|||||||
"node": ">=8.6"
|
"node": ">=8.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mime-db": {
|
||||||
|
"version": "1.52.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
|
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime-types": {
|
||||||
|
"version": "2.1.35",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||||
|
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": "1.52.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/mz": {
|
"node_modules/mz": {
|
||||||
@ -2267,6 +2483,14 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/proxy-from-env": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/queue-microtask": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
@ -2498,6 +2722,32 @@
|
|||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/socket.io-client": {
|
||||||
|
"version": "4.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
|
||||||
|
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
|
||||||
|
"dependencies": {
|
||||||
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
|
"debug": "~4.4.1",
|
||||||
|
"engine.io-client": "~6.6.1",
|
||||||
|
"socket.io-parser": "~4.2.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/socket.io-parser": {
|
||||||
|
"version": "4.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
|
||||||
|
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
|
||||||
|
"dependencies": {
|
||||||
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
|
"debug": "~4.4.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@ -2800,6 +3050,34 @@
|
|||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.18.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||||
|
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xmlhttprequest-ssl": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|||||||
@ -10,9 +10,11 @@
|
|||||||
"test:e2e": "playwright test"
|
"test:e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"axios": "^1.6.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-router-dom": "^6.20.0"
|
"react-router-dom": "^6.20.0",
|
||||||
|
"socket.io-client": "^4.7.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.40.0",
|
"@playwright/test": "^1.40.0",
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { Routes, Route } from "react-router-dom";
|
import { Routes, Route } from "react-router-dom";
|
||||||
|
import DirectPage from "./pages/DirectPage";
|
||||||
import SearchPage from "./pages/SearchPage";
|
import SearchPage from "./pages/SearchPage";
|
||||||
import SearchResults from "./pages/SearchResults";
|
import SearchResults from "./pages/SearchResults";
|
||||||
import Queue from "./pages/Queue";
|
import Queue from "./pages/Queue";
|
||||||
@ -15,6 +16,7 @@ function App() {
|
|||||||
<Route path="/results" element={<SearchResults />} />
|
<Route path="/results" element={<SearchResults />} />
|
||||||
<Route path="/queue" element={<Queue />} />
|
<Route path="/queue" element={<Queue />} />
|
||||||
<Route path="/archive" element={<Archive />} />
|
<Route path="/archive" element={<Archive />} />
|
||||||
|
<Route path="/direct" element={<DirectPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
|
const apiBaseURL = import.meta.env.VITE_API_BASE_URL || "/api";
|
||||||
|
|
||||||
export const apiClient = axios.create({
|
export const apiClient = axios.create({
|
||||||
baseURL: "http://localhost:4096/api",
|
baseURL: apiBaseURL,
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
|||||||
@ -10,6 +10,7 @@ export interface QueueItem {
|
|||||||
category: string;
|
category: string;
|
||||||
addedAt: string;
|
addedAt: string;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
|
quality?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QueueResponse {
|
export interface QueueResponse {
|
||||||
@ -25,6 +26,7 @@ export interface AddToQueueRequest {
|
|||||||
thumbnail: string;
|
thumbnail: string;
|
||||||
category: string;
|
category: string;
|
||||||
url: string;
|
url: string;
|
||||||
|
quality?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getQueue(): Promise<QueueResponse> {
|
export async function getQueue(): Promise<QueueResponse> {
|
||||||
@ -89,3 +91,33 @@ export async function getQueueStats(): Promise<{
|
|||||||
}>("/queue/stats");
|
}>("/queue/stats");
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function startDownload(queueId: string): Promise<{
|
||||||
|
queueId: string;
|
||||||
|
status: string;
|
||||||
|
message: string;
|
||||||
|
}> {
|
||||||
|
const response = await apiClient.post<{
|
||||||
|
queueId: string;
|
||||||
|
status: string;
|
||||||
|
message: string;
|
||||||
|
}>("/download", { queueId });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function retryQueueItem(queueId: string): Promise<{
|
||||||
|
queueId: string;
|
||||||
|
status: string;
|
||||||
|
message: string;
|
||||||
|
}> {
|
||||||
|
const response = await apiClient.post<{
|
||||||
|
queueId: string;
|
||||||
|
status: string;
|
||||||
|
message: string;
|
||||||
|
}>(`/queue/${queueId}/retry`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelQueueItem(queueId: string): Promise<void> {
|
||||||
|
await apiClient.post(`/queue/${queueId}/cancel`);
|
||||||
|
}
|
||||||
|
|||||||
@ -37,30 +37,34 @@ export async function searchVideos(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Transform server response format to frontend interface
|
// Handle server errors that return 200 but have error field
|
||||||
// Server wraps response in {success: true, data: {...}}
|
|
||||||
const serverData = response.data as any;
|
const serverData = response.data as any;
|
||||||
|
if (serverData.error) {
|
||||||
|
throw new Error(serverData.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server returns {results, total, page, hasMore, query} directly
|
||||||
const actualData = serverData.data || serverData;
|
const actualData = serverData.data || serverData;
|
||||||
const videos = actualData.videos || [];
|
const videos = actualData.results || [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
results: videos.map((v: any) => ({
|
results: videos.map((v: any) => ({
|
||||||
id: v.id,
|
id: v.id,
|
||||||
videoId: v.id,
|
videoId: v.videoId || v.id,
|
||||||
title: v.title,
|
title: v.title,
|
||||||
description: "",
|
description: v.description || "",
|
||||||
thumbnail: v.thumbnail,
|
thumbnail: v.thumbnail || `https://i.ytimg.com/vi/${v.id}/hqdefault.jpg`,
|
||||||
url: v.url,
|
url: v.url,
|
||||||
category: "General",
|
category: "General",
|
||||||
duration: v.length || "0:00",
|
duration: v.duration || "0:00",
|
||||||
views: v.view_count ? String(v.view_count) : "0",
|
views: v.views || "0",
|
||||||
channel: v.author || v.channel || "Unknown",
|
channel: v.channel || "Unknown",
|
||||||
isShort: v.is_short || false,
|
isShort: v.isShort || false,
|
||||||
published: "2024-01-01",
|
published: v.published || "",
|
||||||
})),
|
})),
|
||||||
total: videos.length,
|
total: videos.length,
|
||||||
page: params.page || 1,
|
page: actualData.page || params.page || 1,
|
||||||
hasMore: videos.length >= 15,
|
hasMore: actualData.hasMore || videos.length >= 15,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -85,6 +89,10 @@ export async function clearRecentSearches(): Promise<void> {
|
|||||||
await apiClient.delete("/recent-searches");
|
await apiClient.delete("/recent-searches");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function removeRecentSearch(query: string): Promise<void> {
|
||||||
|
await apiClient.delete(`/recent-searches/${encodeURIComponent(query)}`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function saveRecentSearch(query: string): Promise<void> {
|
export async function saveRecentSearch(query: string): Promise<void> {
|
||||||
await apiClient.post("/recent-searches", { query });
|
await apiClient.post("/recent-searches", { query });
|
||||||
}
|
}
|
||||||
|
|||||||
60
web/web-app/src/api/socket.ts
Normal file
60
web/web-app/src/api/socket.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { io, Socket } from "socket.io-client";
|
||||||
|
|
||||||
|
let socket: Socket | null = null;
|
||||||
|
|
||||||
|
export function getSocket(): Socket {
|
||||||
|
if (!socket) {
|
||||||
|
const wsURL = import.meta.env.VITE_WS_URL || window.location.origin;
|
||||||
|
socket = io(wsURL, {
|
||||||
|
transports: ["websocket", "polling"],
|
||||||
|
reconnection: true,
|
||||||
|
reconnectionDelay: 1000,
|
||||||
|
reconnectionAttempts: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("connect", () => {
|
||||||
|
console.log("[WS] Connected");
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("disconnect", () => {
|
||||||
|
console.log("[WS] Disconnected");
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("connect_error", (err) => {
|
||||||
|
console.error("[WS] Connection error:", err.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return socket;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function disconnectSocket(): void {
|
||||||
|
if (socket) {
|
||||||
|
socket.disconnect();
|
||||||
|
socket = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProgressUpdate {
|
||||||
|
queueId: string;
|
||||||
|
progress: number;
|
||||||
|
speed?: string | null;
|
||||||
|
eta?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatusUpdate {
|
||||||
|
queueId: string;
|
||||||
|
status: string;
|
||||||
|
progress?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompleteUpdate {
|
||||||
|
queueId: string;
|
||||||
|
downloadPath?: string;
|
||||||
|
fileSize?: number;
|
||||||
|
videoCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FailedUpdate {
|
||||||
|
queueId: string;
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
@ -1,10 +1,31 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
|
|
||||||
|
const QUALITY_OPTIONS = [
|
||||||
|
{ value: "360", label: "360p" },
|
||||||
|
{ value: "480", label: "480p" },
|
||||||
|
{ value: "720", label: "720p" },
|
||||||
|
{ value: "1080", label: "1080p" },
|
||||||
|
{ value: "best", label: "Best" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STORAGE_KEY = "youtube_cli_quality";
|
||||||
|
|
||||||
|
function getStoredQuality(): string {
|
||||||
|
return localStorage.getItem(STORAGE_KEY) || "1080";
|
||||||
|
}
|
||||||
|
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const [quality, setQuality] = useState(getStoredQuality);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(STORAGE_KEY, quality);
|
||||||
|
}, [quality]);
|
||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ path: "/", label: "Search", icon: "search" },
|
{ path: "/", label: "Search", icon: "search" },
|
||||||
|
{ path: "/direct", label: "Direct", icon: "link" },
|
||||||
{ path: "/queue", label: "Queue", icon: "queue" },
|
{ path: "/queue", label: "Queue", icon: "queue" },
|
||||||
{ path: "/archive", label: "Archive", icon: "archive" },
|
{ path: "/archive", label: "Archive", icon: "archive" },
|
||||||
];
|
];
|
||||||
@ -43,9 +64,34 @@ export default function Navbar() {
|
|||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label
|
||||||
|
htmlFor="qualitySelect"
|
||||||
|
className="text-sm text-slate-400"
|
||||||
|
>
|
||||||
|
Max Resolution
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="qualitySelect"
|
||||||
|
value={quality}
|
||||||
|
onChange={(e) => setQuality(e.target.value)}
|
||||||
|
className="px-3 py-1.5 bg-slate-900 border border-slate-600 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-red-600"
|
||||||
|
>
|
||||||
|
{QUALITY_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getQuality(): string {
|
||||||
|
return getStoredQuality();
|
||||||
|
}
|
||||||
73
web/web-app/src/components/VideoPlayerModal.tsx
Normal file
73
web/web-app/src/components/VideoPlayerModal.tsx
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
interface VideoPlayerModalProps {
|
||||||
|
videoId: string;
|
||||||
|
title: string;
|
||||||
|
onClose: () => void;
|
||||||
|
localVideoPath?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VideoPlayerModal({ videoId, title, onClose, localVideoPath }: VideoPlayerModalProps) {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleKey);
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", handleKey);
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (videoRef.current && localVideoPath) {
|
||||||
|
videoRef.current.play().catch(() => {});
|
||||||
|
}
|
||||||
|
}, [localVideoPath]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-slate-900 rounded-xl overflow-hidden w-full max-w-4xl mx-4 shadow-2xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-700">
|
||||||
|
<h2 className="text-white font-semibold truncate pr-4">{title}</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-slate-400 hover:text-white transition-colors flex-shrink-0"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="aspect-video bg-black">
|
||||||
|
{localVideoPath ? (
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
src={localVideoPath}
|
||||||
|
controls
|
||||||
|
className="w-full h-full"
|
||||||
|
autoPlay
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<iframe
|
||||||
|
src={`https://www.youtube.com/embed/${videoId}?autoplay=1&rel=0`}
|
||||||
|
title={title}
|
||||||
|
className="w-full h-full"
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen"
|
||||||
|
allowFullScreen
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import VideoPlayerModal from "../components/VideoPlayerModal";
|
||||||
import {
|
import {
|
||||||
getArchive,
|
getArchive,
|
||||||
deleteFromArchive,
|
deleteFromArchive,
|
||||||
@ -36,6 +37,7 @@ export default function Archive() {
|
|||||||
const [selectedCategory, setSelectedCategory] = useState("");
|
const [selectedCategory, setSelectedCategory] = useState("");
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [categories, setCategories] = useState<string[]>([]);
|
const [categories, setCategories] = useState<string[]>([]);
|
||||||
|
const [playingVideo, setPlayingVideo] = useState<{ videoId: string; title: string; downloadPath?: string | null } | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchArchive();
|
fetchArchive();
|
||||||
@ -201,6 +203,17 @@ export default function Archive() {
|
|||||||
alt={video.title}
|
alt={video.title}
|
||||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||||
/>
|
/>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/0 group-hover:bg-black/30 transition-all duration-300">
|
||||||
|
<button
|
||||||
|
onClick={() => setPlayingVideo({ videoId: video.videoId, title: video.title, downloadPath: video.downloadPath })}
|
||||||
|
className="opacity-0 group-hover:opacity-100 transform scale-75 group-hover:scale-100 transition-all duration-300 p-3 bg-red-600/90 hover:bg-red-600 rounded-full shadow-lg"
|
||||||
|
title="Play video"
|
||||||
|
>
|
||||||
|
<svg className="w-8 h-8 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M8 5v14l11-7z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div className="absolute bottom-2 right-2 bg-slate-900/90 text-white text-xs px-2 py-1 rounded">
|
<div className="absolute bottom-2 right-2 bg-slate-900/90 text-white text-xs px-2 py-1 rounded">
|
||||||
{video.duration}
|
{video.duration}
|
||||||
</div>
|
</div>
|
||||||
@ -279,6 +292,15 @@ export default function Archive() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{playingVideo && (
|
||||||
|
<VideoPlayerModal
|
||||||
|
videoId={playingVideo.videoId}
|
||||||
|
title={playingVideo.title}
|
||||||
|
onClose={() => setPlayingVideo(null)}
|
||||||
|
localVideoPath={playingVideo.downloadPath ? `/api/archive/${playingVideo.videoId}/stream` : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
157
web/web-app/src/pages/DirectPage.tsx
Normal file
157
web/web-app/src/pages/DirectPage.tsx
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { addToQueue } from "../api/queue";
|
||||||
|
import { getQuality } from "../components/Navbar";
|
||||||
|
|
||||||
|
interface VideoInfo {
|
||||||
|
id: string;
|
||||||
|
videoId: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
thumbnail: string;
|
||||||
|
url: string;
|
||||||
|
duration: string;
|
||||||
|
views: string;
|
||||||
|
channel: string;
|
||||||
|
isShort: boolean;
|
||||||
|
published: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DirectPage() {
|
||||||
|
const [url, setUrl] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [videoInfo, setVideoInfo] = useState<VideoInfo | null>(null);
|
||||||
|
const [added, setAdded] = useState(false);
|
||||||
|
|
||||||
|
const handleFetchInfo = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!url.trim()) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setVideoInfo(null);
|
||||||
|
setAdded(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/info?url=${encodeURIComponent(url.trim())}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json();
|
||||||
|
throw new Error(err.error || "Failed to fetch video info");
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
setVideoInfo(data);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || "Could not fetch video info. Check the URL and try again.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = async () => {
|
||||||
|
if (!videoInfo) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await addToQueue({
|
||||||
|
videoId: videoInfo.videoId,
|
||||||
|
title: videoInfo.title,
|
||||||
|
thumbnail: videoInfo.thumbnail,
|
||||||
|
category: "General",
|
||||||
|
url: videoInfo.url,
|
||||||
|
quality: getQuality(),
|
||||||
|
});
|
||||||
|
setAdded(true);
|
||||||
|
} catch (err) {
|
||||||
|
setError("Failed to add video to queue.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-900 text-white">
|
||||||
|
<div className="container mx-auto px-4 py-12">
|
||||||
|
<h1 className="text-3xl font-bold mb-8 text-center">Download by Link</h1>
|
||||||
|
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
<form onSubmit={handleFetchInfo} className="mb-8">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
placeholder="Paste YouTube URL (e.g., https://youtube.com/watch?v=...)"
|
||||||
|
className="flex-1 px-4 py-3 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-red-600"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !url.trim()}
|
||||||
|
className="px-6 py-3 bg-red-600 hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg font-semibold transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? "..." : "Fetch"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 p-4 bg-red-600/20 border border-red-500/50 rounded-lg text-red-500">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{added && (
|
||||||
|
<div className="mb-6 p-4 bg-green-500/20 border border-green-500/50 rounded-lg text-green-500">
|
||||||
|
Added to queue! You can track progress on the Queue page.
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setUrl("");
|
||||||
|
setVideoInfo(null);
|
||||||
|
setAdded(false);
|
||||||
|
}}
|
||||||
|
className="ml-4 underline hover:text-green-400"
|
||||||
|
>
|
||||||
|
Download another
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{videoInfo && !added && (
|
||||||
|
<div className="bg-slate-800 rounded-xl overflow-hidden">
|
||||||
|
<div className="flex flex-col md:flex-row gap-4 p-4">
|
||||||
|
<div className="w-full md:w-64 aspect-video md:aspect-auto flex-shrink-0">
|
||||||
|
<img
|
||||||
|
src={videoInfo.thumbnail}
|
||||||
|
alt={videoInfo.title}
|
||||||
|
className="w-full h-full object-cover rounded-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h2 className="text-xl font-semibold mb-2">{videoInfo.title}</h2>
|
||||||
|
<div className="text-sm text-slate-400 mb-1">
|
||||||
|
{videoInfo.channel}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4 text-sm text-slate-500">
|
||||||
|
<span>Duration: {videoInfo.duration}s</span>
|
||||||
|
<span>Views: {videoInfo.views}</span>
|
||||||
|
{videoInfo.isShort && (
|
||||||
|
<span className="inline-block bg-red-600 text-white text-[10px] px-1.5 py-0.5 rounded">
|
||||||
|
SHORT
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
disabled={loading}
|
||||||
|
className="mt-4 px-6 py-2 bg-red-600 hover:bg-red-700 disabled:opacity-50 rounded-lg font-semibold transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? "Adding..." : "Add to Queue"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,21 +1,28 @@
|
|||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect, useCallback } from "react"
|
||||||
import { getQueue, removeFromQueue, clearQueue } from "../api/queue"
|
import VideoPlayerModal from "../components/VideoPlayerModal"
|
||||||
|
import { getQueue, removeFromQueue, clearQueue, startDownload, retryQueueItem, cancelQueueItem } from "../api/queue"
|
||||||
|
import { getSocket } from "../api/socket"
|
||||||
|
|
||||||
interface QueueItem {
|
interface QueueItem {
|
||||||
id: string
|
id: string
|
||||||
videoId: string
|
videoId: string
|
||||||
title: string
|
title: string
|
||||||
thumbnail: string
|
thumbnail: string
|
||||||
status: "pending" | "downloading" | "completed" | "failed"
|
status: "pending" | "downloading" | "completed" | "failed" | "cancelled"
|
||||||
progress: number
|
progress: number
|
||||||
category: string
|
category: string
|
||||||
addedAt: string
|
addedAt: string
|
||||||
errorMessage?: string
|
errorMessage?: string
|
||||||
|
downloadPath?: string
|
||||||
|
fileSize?: string
|
||||||
|
speed?: string
|
||||||
|
eta?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Queue() {
|
export default function Queue() {
|
||||||
const [queueItems, setQueueItems] = useState<QueueItem[]>([])
|
const [queueItems, setQueueItems] = useState<QueueItem[]>([])
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [playingVideo, setPlayingVideo] = useState<{ videoId: string; title: string; downloadPath?: string | null } | null>(null)
|
||||||
const [stats, setStats] = useState({
|
const [stats, setStats] = useState({
|
||||||
total: 0,
|
total: 0,
|
||||||
pending: 0,
|
pending: 0,
|
||||||
@ -24,13 +31,7 @@ export default function Queue() {
|
|||||||
failed: 0
|
failed: 0
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchQueue = useCallback(async () => {
|
||||||
fetchQueue()
|
|
||||||
const interval = setInterval(fetchQueue, 5000)
|
|
||||||
return () => clearInterval(interval)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const fetchQueue = async () => {
|
|
||||||
try {
|
try {
|
||||||
const response = await getQueue()
|
const response = await getQueue()
|
||||||
setQueueItems(response.items)
|
setQueueItems(response.items)
|
||||||
@ -46,7 +47,87 @@ export default function Queue() {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchQueue()
|
||||||
|
const interval = setInterval(fetchQueue, 10000)
|
||||||
|
|
||||||
|
const socket = getSocket()
|
||||||
|
|
||||||
|
socket.on("download:progress", (data: any) => {
|
||||||
|
setQueueItems(prev =>
|
||||||
|
prev.map(item =>
|
||||||
|
item.id === data.queueId
|
||||||
|
? { ...item, progress: data.progress, speed: data.speed, eta: data.eta }
|
||||||
|
: item
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on("download:status", (data: any) => {
|
||||||
|
setQueueItems(prev =>
|
||||||
|
prev.map(item =>
|
||||||
|
item.id === data.queueId
|
||||||
|
? { ...item, status: data.status, progress: data.progress ?? item.progress }
|
||||||
|
: item
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on("download:complete", (data: any) => {
|
||||||
|
setQueueItems(prev =>
|
||||||
|
prev.map(item =>
|
||||||
|
item.id === data.queueId
|
||||||
|
? { ...item, status: "completed", progress: 100, downloadPath: data.downloadPath, fileSize: data.fileSize?.toString() }
|
||||||
|
: item
|
||||||
|
)
|
||||||
|
)
|
||||||
|
setStats(prev => ({
|
||||||
|
...prev,
|
||||||
|
downloading: prev.downloading - 1,
|
||||||
|
completed: prev.completed + 1
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on("download:failed", (data: any) => {
|
||||||
|
setQueueItems(prev =>
|
||||||
|
prev.map(item =>
|
||||||
|
item.id === data.queueId
|
||||||
|
? { ...item, status: "failed", errorMessage: data.error }
|
||||||
|
: item
|
||||||
|
)
|
||||||
|
)
|
||||||
|
setStats(prev => ({
|
||||||
|
...prev,
|
||||||
|
downloading: prev.downloading - 1,
|
||||||
|
failed: prev.failed + 1
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on("queue:enqueued", (_data: any) => {
|
||||||
|
fetchQueue()
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on("queue:removed", (_data: any) => {
|
||||||
|
fetchQueue()
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on("queue:cleared", () => {
|
||||||
|
fetchQueue()
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval)
|
||||||
|
socket.off("download:progress")
|
||||||
|
socket.off("download:status")
|
||||||
|
socket.off("download:complete")
|
||||||
|
socket.off("download:failed")
|
||||||
|
socket.off("queue:enqueued")
|
||||||
|
socket.off("queue:removed")
|
||||||
|
socket.off("queue:cleared")
|
||||||
|
}
|
||||||
|
}, [fetchQueue])
|
||||||
|
|
||||||
const handleRemove = async (queueId: string) => {
|
const handleRemove = async (queueId: string) => {
|
||||||
try {
|
try {
|
||||||
@ -58,6 +139,36 @@ export default function Queue() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleStartDownload = async (queueId: string) => {
|
||||||
|
try {
|
||||||
|
await startDownload(queueId)
|
||||||
|
fetchQueue()
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to start download:", err)
|
||||||
|
alert("Failed to start download")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRetry = async (queueId: string) => {
|
||||||
|
try {
|
||||||
|
await retryQueueItem(queueId)
|
||||||
|
fetchQueue()
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to retry:", err)
|
||||||
|
alert("Failed to retry download")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancel = async (queueId: string) => {
|
||||||
|
try {
|
||||||
|
await cancelQueueItem(queueId)
|
||||||
|
fetchQueue()
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to cancel:", err)
|
||||||
|
alert("Failed to cancel download")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleClearQueue = async () => {
|
const handleClearQueue = async () => {
|
||||||
if (window.confirm("Are you sure you want to clear the entire queue?")) {
|
if (window.confirm("Are you sure you want to clear the entire queue?")) {
|
||||||
try {
|
try {
|
||||||
@ -76,6 +187,7 @@ export default function Queue() {
|
|||||||
case "downloading": return "text-blue-500"
|
case "downloading": return "text-blue-500"
|
||||||
case "completed": return "text-green-500"
|
case "completed": return "text-green-500"
|
||||||
case "failed": return "text-red-500"
|
case "failed": return "text-red-500"
|
||||||
|
case "cancelled": return "text-gray-500"
|
||||||
default: return "text-slate-400"
|
default: return "text-slate-400"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -135,9 +247,20 @@ export default function Queue() {
|
|||||||
{queueItems.map((item) => (
|
{queueItems.map((item) => (
|
||||||
<div key={item.id} className="bg-slate-800 rounded-xl p-6 relative group hover:bg-slate-750 transition-colors">
|
<div key={item.id} className="bg-slate-800 rounded-xl p-6 relative group hover:bg-slate-750 transition-colors">
|
||||||
<div className="flex flex-col md:flex-row gap-6">
|
<div className="flex flex-col md:flex-row gap-6">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0 relative">
|
||||||
<div className="aspect-video w-32 rounded-lg overflow-hidden bg-slate-700">
|
<div className="aspect-video w-32 rounded-lg overflow-hidden bg-slate-700 relative">
|
||||||
<img src={item.thumbnail} alt={item.title} className="w-full h-full object-cover" />
|
<img src={item.thumbnail} alt={item.title} className="w-full h-full object-cover" />
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/0 group-hover:bg-black/30 transition-all duration-300">
|
||||||
|
<button
|
||||||
|
onClick={() => setPlayingVideo({ videoId: item.videoId, title: item.title, downloadPath: item.downloadPath })}
|
||||||
|
className="opacity-0 group-hover:opacity-100 transform scale-75 group-hover:scale-100 transition-all duration-300 p-2 bg-red-600/90 hover:bg-red-600 rounded-full shadow-lg"
|
||||||
|
title="Play video"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M8 5v14l11-7z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@ -147,9 +270,31 @@ export default function Queue() {
|
|||||||
<span className={`text-sm ${getStatusColor(item.status)} font-medium`}>
|
<span className={`text-sm ${getStatusColor(item.status)} font-medium`}>
|
||||||
{item.status.charAt(0).toUpperCase() + item.status.slice(1)}
|
{item.status.charAt(0).toUpperCase() + item.status.slice(1)}
|
||||||
</span>
|
</span>
|
||||||
|
{item.status === "pending" && (
|
||||||
|
<button onClick={() => handleStartDownload(item.id)} className="text-slate-500 hover:text-green-500 transition-colors" title="Start download">
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-3.197a.75.75 0 011.06-1.06l3.75 3.75a.75.75 0 010 1.06l-3.75 3.75a.75.75 0 11-1.06-1.06l3.197-3.197z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{item.status === "failed" && (
|
||||||
|
<button onClick={() => handleRetry(item.id)} className="text-slate-500 hover:text-yellow-500 transition-colors" title="Retry download">
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{item.status === "downloading" && (
|
||||||
|
<button onClick={() => handleCancel(item.id)} className="text-slate-500 hover:text-orange-500 transition-colors" title="Cancel download">
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button onClick={() => handleRemove(item.id)} className="text-slate-500 hover:text-red-500 transition-colors" title="Remove from queue">
|
<button onClick={() => handleRemove(item.id)} className="text-slate-500 hover:text-red-500 transition-colors" title="Remove from queue">
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -165,19 +310,30 @@ export default function Queue() {
|
|||||||
<div className="flex mb-2 items-center justify-between">
|
<div className="flex mb-2 items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-xs font-semibold inline-block text-blue-500">
|
<span className="text-xs font-semibold inline-block text-blue-500">
|
||||||
{item.status === "completed" ? "Downloaded" : item.status === "failed" ? "Failed" : "Progress"}
|
{item.status === "completed" ? "Downloaded" : item.status === "failed" ? "Failed" : item.status === "cancelled" ? "Cancelled" : "Progress"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<span className="text-xs font-semibold inline-block text-blue-500">
|
<span className="text-xs font-semibold inline-block text-blue-500">
|
||||||
{Math.round(item.progress)}%
|
{Math.round(item.progress)}%
|
||||||
</span>
|
</span>
|
||||||
|
{item.speed && (
|
||||||
|
<span className="text-xs font-semibold inline-block text-slate-400 ml-2">
|
||||||
|
{item.speed}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{item.eta && (
|
||||||
|
<span className="text-xs font-semibold inline-block text-slate-400">
|
||||||
|
ETA: {item.eta}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-hidden h-2 mb-4 text-xs flex rounded bg-slate-700">
|
<div className="overflow-hidden h-2 mb-4 text-xs flex rounded bg-slate-700">
|
||||||
<div style={{ width: `${item.progress}%` }} className={`shadow-none flex flex-col text-center whitespace-nowrap text-white justify-center transition-all duration-300 ${
|
<div style={{ width: `${item.progress}%` }} className={`shadow-none flex flex-col text-center whitespace-nowrap text-white justify-center transition-all duration-300 ${
|
||||||
item.status === "failed" ? "bg-red-500" :
|
item.status === "failed" ? "bg-red-500" :
|
||||||
item.status === "completed" ? "bg-green-500" :
|
item.status === "completed" ? "bg-green-500" :
|
||||||
|
item.status === "cancelled" ? "bg-gray-500" :
|
||||||
item.status === "downloading" ? "bg-blue-500" :
|
item.status === "downloading" ? "bg-blue-500" :
|
||||||
"bg-yellow-500"
|
"bg-yellow-500"
|
||||||
}`}></div>
|
}`}></div>
|
||||||
@ -199,6 +355,15 @@ export default function Queue() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{playingVideo && (
|
||||||
|
<VideoPlayerModal
|
||||||
|
videoId={playingVideo.videoId}
|
||||||
|
title={playingVideo.title}
|
||||||
|
onClose={() => setPlayingVideo(null)}
|
||||||
|
localVideoPath={playingVideo.downloadPath ? `/api/archive/${playingVideo.videoId}/stream` : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -1,15 +1,36 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { searchVideos, getRecentSearches } from "../api/search";
|
import { searchVideos, getRecentSearches, clearRecentSearches as apiClearRecentSearches, removeRecentSearch as apiRemoveRecentSearch } from "../api/search";
|
||||||
|
import { addToQueue } from "../api/queue";
|
||||||
|
import { getQuality } from "../components/Navbar";
|
||||||
|
|
||||||
|
interface Video {
|
||||||
|
id: string;
|
||||||
|
videoId: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
thumbnail: string;
|
||||||
|
url: string;
|
||||||
|
category: string;
|
||||||
|
duration: string;
|
||||||
|
views: string;
|
||||||
|
channel: string;
|
||||||
|
isShort: boolean;
|
||||||
|
published: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function SearchPage() {
|
export default function SearchPage() {
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [recentSearches, setRecentSearches] = useState<string[]>([]);
|
const [recentSearches, setRecentSearches] = useState<string[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [searchResults, setSearchResults] = useState<any[]>([]);
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||||
|
const [searchResults, setSearchResults] = useState<Video[]>([]);
|
||||||
|
const [currentQuery, setCurrentQuery] = useState("");
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [hasMore, setHasMore] = useState(false);
|
||||||
const [hasSearched, setHasSearched] = useState(false);
|
const [hasSearched, setHasSearched] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -25,61 +46,162 @@ export default function SearchPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const performSearch = useCallback(
|
||||||
|
async (query: string, page: number, append: boolean = false) => {
|
||||||
|
try {
|
||||||
|
const response = await searchVideos({
|
||||||
|
query,
|
||||||
|
page,
|
||||||
|
limit: 15,
|
||||||
|
});
|
||||||
|
|
||||||
|
const videos: Video[] = response.results.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
videoId: r.id,
|
||||||
|
title: r.title,
|
||||||
|
description: r.description,
|
||||||
|
thumbnail: r.thumbnail,
|
||||||
|
url: r.url,
|
||||||
|
category: "General",
|
||||||
|
duration: r.duration,
|
||||||
|
views: r.views,
|
||||||
|
channel: r.channel,
|
||||||
|
isShort: r.isShort,
|
||||||
|
published: r.published,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (append) {
|
||||||
|
setSearchResults((prev) => [...prev, ...videos]);
|
||||||
|
} else {
|
||||||
|
setSearchResults(videos);
|
||||||
|
}
|
||||||
|
|
||||||
|
setHasMore(response.hasMore);
|
||||||
|
return response;
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || "Failed to search videos. Please try again.");
|
||||||
|
console.error("Search error:", err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const handleSearch = async (e: React.FormEvent) => {
|
const handleSearch = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!searchQuery.trim()) return;
|
if (!searchQuery.trim()) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setCurrentPage(1);
|
||||||
|
setCurrentQuery(searchQuery);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await searchVideos({
|
const response = await performSearch(searchQuery, 1, false);
|
||||||
query: searchQuery,
|
if (response) {
|
||||||
page: 1,
|
setHasSearched(true);
|
||||||
limit: 15,
|
if (!recentSearches.includes(searchQuery)) {
|
||||||
});
|
const newSearches = [searchQuery, ...recentSearches].slice(0, 10);
|
||||||
setSearchResults(response.results);
|
setRecentSearches(newSearches);
|
||||||
setHasSearched(true);
|
}
|
||||||
|
|
||||||
if (!recentSearches.includes(searchQuery)) {
|
|
||||||
const newSearches = [searchQuery, ...recentSearches].slice(0, 10);
|
|
||||||
setRecentSearches(newSearches);
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
setError("Failed to search videos. Please try again.");
|
|
||||||
console.error("Search error:", err);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleVideoClick = (video: any) => {
|
const loadMore = useCallback(async () => {
|
||||||
|
if (isLoadingMore || !hasMore || !currentQuery) return;
|
||||||
|
|
||||||
|
setIsLoadingMore(true);
|
||||||
|
const nextPage = currentPage + 1;
|
||||||
|
setCurrentPage(nextPage);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await performSearch(currentQuery, nextPage, true);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingMore(false);
|
||||||
|
}
|
||||||
|
}, [isLoadingMore, hasMore, currentQuery, currentPage, performSearch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loadMoreRef.current) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0].isIntersecting) {
|
||||||
|
loadMore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: "200px" },
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(loadMoreRef.current);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [loadMore]);
|
||||||
|
|
||||||
|
const handleVideoClick = (video: Video) => {
|
||||||
sessionStorage.setItem("searchResults", JSON.stringify(searchResults));
|
sessionStorage.setItem("searchResults", JSON.stringify(searchResults));
|
||||||
sessionStorage.setItem("currentVideo", JSON.stringify(video));
|
sessionStorage.setItem("currentVideo", JSON.stringify(video));
|
||||||
navigate("/results");
|
navigate("/results");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDownloadFromSearch = async (
|
||||||
|
e: React.MouseEvent,
|
||||||
|
video: Video,
|
||||||
|
) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
try {
|
||||||
|
const selectedCategory =
|
||||||
|
(document.getElementById(`category-${video.id}`) as HTMLSelectElement)
|
||||||
|
?.value || "General";
|
||||||
|
await addToQueue({
|
||||||
|
videoId: video.id,
|
||||||
|
title: video.title,
|
||||||
|
thumbnail: video.thumbnail,
|
||||||
|
category: selectedCategory,
|
||||||
|
url: video.url,
|
||||||
|
quality: getQuality(),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to add to queue:", err);
|
||||||
|
alert("Failed to add video to queue.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleRecentSearch = (query: string) => {
|
const handleRecentSearch = (query: string) => {
|
||||||
setSearchQuery(query);
|
setSearchQuery(query);
|
||||||
searchVideos({ query, page: 1, limit: 15 })
|
setCurrentQuery(query);
|
||||||
|
setCurrentPage(1);
|
||||||
|
setIsLoading(true);
|
||||||
|
performSearch(query, 1, false)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
setSearchResults(response.results);
|
if (response) {
|
||||||
setHasSearched(true);
|
setHasSearched(true);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.finally(() => setIsLoading(false));
|
||||||
setError("Failed to search videos. Please try again.");
|
|
||||||
console.error("Search error:", err);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearRecentSearches = async () => {
|
const clearRecentSearches = async () => {
|
||||||
try {
|
try {
|
||||||
|
await apiClearRecentSearches();
|
||||||
setRecentSearches([]);
|
setRecentSearches([]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to clear recent searches:", err);
|
console.error("Failed to clear recent searches:", err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRemoveRecentSearch = async (e: React.MouseEvent, query: string) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
try {
|
||||||
|
await apiRemoveRecentSearch(query);
|
||||||
|
setRecentSearches((prev) => prev.filter((s) => s !== query));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to remove recent search:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-4xl mx-auto">
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
@ -138,13 +260,25 @@ export default function SearchPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{recentSearches.map((search, index) => (
|
{recentSearches.map((search, index) => (
|
||||||
<button
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
onClick={() => handleRecentSearch(search)}
|
className="group relative"
|
||||||
className="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-slate-200 rounded-lg transition-colors text-sm"
|
|
||||||
>
|
>
|
||||||
{search}
|
<button
|
||||||
</button>
|
onClick={() => handleRecentSearch(search)}
|
||||||
|
className="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-slate-200 rounded-lg transition-colors text-sm pr-8"
|
||||||
|
>
|
||||||
|
{search}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleRemoveRecentSearch(e, search)}
|
||||||
|
className="absolute right-1 top-1/2 -translate-y-1/2 w-5 h-5 flex items-center justify-center rounded opacity-0 group-hover:opacity-100 hover:bg-slate-600 text-slate-400 hover:text-red-400 transition-all"
|
||||||
|
>
|
||||||
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -177,6 +311,26 @@ export default function SearchPage() {
|
|||||||
<div className="absolute bottom-2 right-2 bg-slate-900/90 text-white text-xs px-2 py-1 rounded">
|
<div className="absolute bottom-2 right-2 bg-slate-900/90 text-white text-xs px-2 py-1 rounded">
|
||||||
{video.duration}
|
{video.duration}
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleDownloadFromSearch(e, video)}
|
||||||
|
className="absolute inset-0 flex items-center justify-center bg-slate-900/60 opacity-0 group-hover:opacity-100 transition-opacity duration-200"
|
||||||
|
>
|
||||||
|
<div className="w-12 h-12 bg-red-600 rounded-full flex items-center justify-center shadow-lg hover:bg-red-700 transition-colors">
|
||||||
|
<svg
|
||||||
|
className="w-6 h-6 text-white"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<h3 className="font-semibold text-white mb-2 line-clamp-2 group-hover:text-red-500 transition-colors">
|
<h3 className="font-semibold text-white mb-2 line-clamp-2 group-hover:text-red-500 transition-colors">
|
||||||
@ -198,6 +352,23 @@ export default function SearchPage() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{hasMore && (
|
||||||
|
<div ref={loadMoreRef} className="flex justify-center py-8">
|
||||||
|
{isLoadingMore && (
|
||||||
|
<div className="flex items-center gap-2 text-slate-400">
|
||||||
|
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-red-600"></div>
|
||||||
|
<span>Loading more results...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasMore && searchResults.length > 0 && (
|
||||||
|
<div className="text-center py-6 text-slate-500">
|
||||||
|
No more results
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { addToQueue } from "../api/queue";
|
import { addToQueue } from "../api/queue";
|
||||||
|
import { getQuality } from "../components/Navbar";
|
||||||
|
|
||||||
interface Video {
|
interface Video {
|
||||||
id: string;
|
id: string;
|
||||||
@ -52,12 +53,16 @@ export default function SearchResults() {
|
|||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
|
const selectedCategory =
|
||||||
|
(document.getElementById("downloadCategory") as HTMLSelectElement)
|
||||||
|
?.value || video.category || "General";
|
||||||
await addToQueue({
|
await addToQueue({
|
||||||
videoId: video.videoId,
|
videoId: video.videoId,
|
||||||
title: video.title,
|
title: video.title,
|
||||||
thumbnail: video.thumbnail,
|
thumbnail: video.thumbnail,
|
||||||
category: video.category || "General",
|
category: selectedCategory,
|
||||||
url: video.url,
|
url: video.url,
|
||||||
|
quality: getQuality(),
|
||||||
});
|
});
|
||||||
alert("Video added to download queue!");
|
alert("Video added to download queue!");
|
||||||
navigate("/queue");
|
navigate("/queue");
|
||||||
|
|||||||
10
web/web-app/src/vite-env.d.ts
vendored
Normal file
10
web/web-app/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_BASE_URL?: string;
|
||||||
|
readonly VITE_WS_URL?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
@ -21,7 +21,8 @@ from rich.table import Table
|
|||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
LOG_FILE = Path.home() / ".config" / "youtube_cli" / "logs" / "app.log"
|
_config_dir = os.environ.get("CONFIG_DIR", str(Path.home() / ".config" / "youtube_cli"))
|
||||||
|
LOG_FILE = Path(_config_dir) / "logs" / "app.log"
|
||||||
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Use RotatingFileHandler for log rotation (10MB, 5 backups)
|
# Use RotatingFileHandler for log rotation (10MB, 5 backups)
|
||||||
@ -58,9 +59,8 @@ class YouTubeCLI:
|
|||||||
self.original_query = None
|
self.original_query = None
|
||||||
self.current_page = 1
|
self.current_page = 1
|
||||||
# Use a proper user directory for the archive file
|
# Use a proper user directory for the archive file
|
||||||
self.archive_file = (
|
config_dir = os.environ.get("CONFIG_DIR", str(Path.home() / ".config" / "youtube_cli"))
|
||||||
Path.home() / ".config" / "youtube_cli" / "downloaded_videos.json"
|
self.archive_file = Path(config_dir) / "downloaded_videos.json"
|
||||||
)
|
|
||||||
self.downloaded_videos = self.load_archive()
|
self.downloaded_videos = self.load_archive()
|
||||||
|
|
||||||
def get_yt_dlp_version(self):
|
def get_yt_dlp_version(self):
|
||||||
@ -166,41 +166,38 @@ class YouTubeCLI:
|
|||||||
def load_config(self, config_path=None):
|
def load_config(self, config_path=None):
|
||||||
"""Load configuration from file or use defaults."""
|
"""Load configuration from file or use defaults."""
|
||||||
default_config = {
|
default_config = {
|
||||||
"download_dir": "/Volumes/MediaServer/Youtube/",
|
"download_dir": os.environ.get("DOWNLOAD_DIR", str(Path.home() / "Downloads" / "YouTube")),
|
||||||
"default_locations": [
|
"default_locations": [
|
||||||
"/Volumes/MediaServer/Youtube/",
|
"General",
|
||||||
"/Volumes/MediaServer/Youtube/Tech",
|
"Music",
|
||||||
"/Volumes/MediaServer/Youtube/AI",
|
"Music Videos",
|
||||||
"/Volumes/MediaServer/Youtube/Art",
|
"Podcasts",
|
||||||
"/Volumes/MediaServer/Youtube/Homes",
|
"Educational",
|
||||||
"/Volumes/MediaServer/Youtube/Cooking",
|
"Tutorials",
|
||||||
"/Volumes/MediaServer/Youtube/Fitness",
|
"Gaming",
|
||||||
"/Volumes/MediaServer/Youtube/Music",
|
"Shorts",
|
||||||
"/Volumes/MediaServer/Youtube/Gaming",
|
"Vlogs",
|
||||||
"/Volumes/MediaServer/Youtube/Education",
|
"Documentaries",
|
||||||
"/Volumes/MediaServer/Youtube/Travel",
|
"Comedy",
|
||||||
"/Volumes/MediaServer/Youtube/Business",
|
"News",
|
||||||
"/Volumes/MediaServer/Youtube/Science",
|
"Sports",
|
||||||
"/Volumes/MediaServer/Youtube/History",
|
"Cooking",
|
||||||
"/Volumes/MediaServer/Youtube/Comedy",
|
"Fitness",
|
||||||
"/Volumes/MediaServer/Youtube/News",
|
"Tech Reviews",
|
||||||
"/Volumes/MediaServer/Youtube/Sports",
|
|
||||||
"/Volumes/MediaServer/Youtube/Nature",
|
|
||||||
"/Volumes/MediaServer/Youtube/Photography",
|
|
||||||
"/Volumes/MediaServer/Youtube/Language",
|
|
||||||
"/Volumes/MediaServer/Youtube/Automotive",
|
|
||||||
"/Volumes/MediaServer/Youtube/Anime",
|
|
||||||
],
|
],
|
||||||
"max_videos_per_page": 15,
|
"max_videos_per_page": 15,
|
||||||
"yt_dlp_args": {
|
"yt_dlp_args": {
|
||||||
"format": "bestvideo[height=1080]+bestaudio/bestvideo[height<=1080]+bestaudio",
|
"format": "bestvideo[height<=1080]+bestaudio/best",
|
||||||
"write_thumbnail": True,
|
"write_thumbnail": True,
|
||||||
"extractor_args": "youtube:player-client=default,-tv_simply",
|
|
||||||
},
|
},
|
||||||
"network_share_path": "/Volumes/MediaServer/Youtube/",
|
"network_share_path": "",
|
||||||
"default_network_subfolder": "General",
|
"default_network_subfolder": "General",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if config_path is None:
|
||||||
|
config_dir = os.environ.get("CONFIG_DIR", str(Path.home() / ".config" / "youtube_cli"))
|
||||||
|
config_path = str(Path(config_dir) / "config.json")
|
||||||
|
|
||||||
if config_path and os.path.exists(config_path):
|
if config_path and os.path.exists(config_path):
|
||||||
try:
|
try:
|
||||||
with open(config_path, "r") as f:
|
with open(config_path, "r") as f:
|
||||||
@ -364,8 +361,6 @@ class YouTubeCLI:
|
|||||||
"--no-warnings",
|
"--no-warnings",
|
||||||
"--no-progress",
|
"--no-progress",
|
||||||
"--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
"--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||||
"--remote-components",
|
|
||||||
"ejs:github",
|
|
||||||
f"ytsearch{15 * page}:{sanitized_query}",
|
f"ytsearch{15 * page}:{sanitized_query}",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -803,38 +798,19 @@ class YouTubeCLI:
|
|||||||
download_dir.mkdir(parents=True, exist_ok=True)
|
download_dir.mkdir(parents=True, exist_ok=True)
|
||||||
logger.debug(f"Download directory: {download_dir}")
|
logger.debug(f"Download directory: {download_dir}")
|
||||||
|
|
||||||
# Prepare yt-dlp command with better handling for JS challenges
|
# Prepare yt-dlp command (minimal - nightly handles JS/challenges)
|
||||||
cmd = [
|
cmd = [
|
||||||
"yt-dlp",
|
"yt-dlp",
|
||||||
"--no-warnings",
|
"-o", str(download_dir / "%(title)s.%(ext)s"),
|
||||||
"-o",
|
|
||||||
str(download_dir / "%(title)s.%(ext)s"),
|
|
||||||
"--write-thumbnail",
|
"--write-thumbnail",
|
||||||
"--remote-components",
|
"--download-archive", str(download_dir / ".yt-dlp-archive.txt"),
|
||||||
"ejs:github",
|
"--js-runtimes", "deno",
|
||||||
|
"--remote-components", "ejs:github",
|
||||||
|
"--extractor-args", "youtube:pot_provider=deno,player_client=web,ios,android",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Add custom args from config if they exist
|
# Add retries
|
||||||
ytdlp_args = config.get("yt_dlp_args", {})
|
cmd.extend(["--retries", "3", "--fragment-retries", "3"])
|
||||||
if "format" in ytdlp_args:
|
|
||||||
cmd.extend(["--format", ytdlp_args["format"]])
|
|
||||||
if ytdlp_args.get("write_thumbnail", False):
|
|
||||||
cmd.append("--write-thumbnail")
|
|
||||||
|
|
||||||
# Add extractor args
|
|
||||||
if "extractor_args" in ytdlp_args:
|
|
||||||
cmd.extend(["--extractor-args", ytdlp_args["extractor_args"]])
|
|
||||||
|
|
||||||
# For problematic videos, also add retries and better error handling
|
|
||||||
cmd.extend(
|
|
||||||
[
|
|
||||||
"--no-check-certificates",
|
|
||||||
"--retries",
|
|
||||||
"3",
|
|
||||||
"--fragment-retries",
|
|
||||||
"3",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add URL
|
# Add URL
|
||||||
cmd.append(url)
|
cmd.append(url)
|
||||||
@ -912,8 +888,8 @@ class YouTubeCLI:
|
|||||||
logger.warning(
|
logger.warning(
|
||||||
"Note: This video requires JavaScript challenge solving."
|
"Note: This video requires JavaScript challenge solving."
|
||||||
)
|
)
|
||||||
logger.warning("Install required components with:")
|
logger.warning("Update yt-dlp with:")
|
||||||
logger.warning("yt-dlp --remote-components ejs:github")
|
logger.warning("pip install --upgrade --pre yt-dlp")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error during download: {str(e)}")
|
logger.error(f"Error during download: {str(e)}")
|
||||||
@ -998,27 +974,19 @@ class YouTubeCLI:
|
|||||||
playlist_dir = download_dir / playlist_title
|
playlist_dir = download_dir / playlist_title
|
||||||
playlist_dir.mkdir(parents=True, exist_ok=True)
|
playlist_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Prepare yt-dlp command for playlist
|
# Prepare yt-dlp command for playlist (minimal)
|
||||||
cmd = [
|
cmd = [
|
||||||
"yt-dlp",
|
"yt-dlp",
|
||||||
"-o",
|
"-o", str(playlist_dir / "%(title)s.%(ext)s"),
|
||||||
str(playlist_dir / "%(title)s.%(ext)s"),
|
|
||||||
"--write-thumbnail",
|
"--write-thumbnail",
|
||||||
"--remote-components",
|
"--download-archive", str(playlist_dir / ".yt-dlp-archive.txt"),
|
||||||
"ejs:github",
|
"--js-runtimes", "deno",
|
||||||
|
"--remote-components", "ejs:github",
|
||||||
|
"--extractor-args", "youtube:pot_provider=deno,player_client=web,ios,android",
|
||||||
|
"--retries", "3",
|
||||||
|
"--fragment-retries", "3",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Use best available format that includes both video and audio
|
|
||||||
# cmd.extend(["--format", "best"])
|
|
||||||
|
|
||||||
# Add extractor args from config if they exist
|
|
||||||
ytdlp_args = config.get("yt_dlp_args", {})
|
|
||||||
if "extractor_args" in ytdlp_args:
|
|
||||||
cmd.extend(["--extractor-args", ytdlp_args["extractor_args"]])
|
|
||||||
|
|
||||||
# Add retries for playlist downloads
|
|
||||||
cmd.extend(["--retries", "3", "--fragment-retries", "3"])
|
|
||||||
|
|
||||||
# Add URL
|
# Add URL
|
||||||
cmd.append(url)
|
cmd.append(url)
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user