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
|
||||
```
|
||||
|
||||
**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
|
||||
|
||||
**Core dependencies (requirements.txt):**
|
||||
@ -129,6 +143,11 @@ docker-compose up --build
|
||||
# Run the API test script
|
||||
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)
|
||||
pytest
|
||||
```
|
||||
@ -142,6 +161,22 @@ python test_api.py
|
||||
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
|
||||
|
||||
### 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 /app
|
||||
WORKDIR /build
|
||||
|
||||
# Copy requirements first (for better caching)
|
||||
COPY requirements-api.txt .
|
||||
# Copy frontend package files
|
||||
COPY web/web-app/package.json web/web-app/package-lock.json ./
|
||||
|
||||
# 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 . .
|
||||
|
||||
# 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 4096
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:4096/health || exit 1
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
||||
CMD curl -f http://localhost:4096/api/health || exit 1
|
||||
|
||||
# Run the application with timeout protection
|
||||
CMD ["timeout", "3600", "python", "app.py"]
|
||||
# Run the application
|
||||
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
|
||||
|
||||
# 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_FILE = LOG_DIR / "app.log"
|
||||
|
||||
|
||||
@ -1,10 +1,36 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
youtube-api:
|
||||
build: .
|
||||
youtube-web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: youtube-web
|
||||
ports:
|
||||
- "4096:4096"
|
||||
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
|
||||
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",
|
||||
]
|
||||
dependencies = [
|
||||
"yt-dlp==2026.3.3",
|
||||
"yt-dlp",
|
||||
"rich>=13.0.0",
|
||||
"requests>=2.28.0",
|
||||
]
|
||||
@ -63,6 +63,9 @@ tui = [
|
||||
"textual>=0.40.0",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
prerelease = "allow"
|
||||
|
||||
[tool.setuptools]
|
||||
packages = ["youtube_cli", "youtube_tui", "youtube_tui.screens", "youtube_tui.widgets", "youtube_tui.models", "youtube_tui.services"]
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
# Core dependencies
|
||||
yt-dlp>=2023.12.0
|
||||
yt-dlp
|
||||
rich>=13.0.0
|
||||
requests>=2.28.0
|
||||
|
||||
|
||||
2
setup.py
2
setup.py
@ -37,7 +37,7 @@ setup(
|
||||
],
|
||||
python_requires=">=3.7",
|
||||
install_requires=[
|
||||
"yt-dlp>=2023.12.0",
|
||||
"yt-dlp",
|
||||
"rich>=13.0.0",
|
||||
"requests>=2.28.0",
|
||||
],
|
||||
|
||||
13
uv.lock
generated
13
uv.lock
generated
@ -1,7 +1,10 @@
|
||||
version = 1
|
||||
revision = 2
|
||||
revision = 3
|
||||
requires-python = ">=3.10"
|
||||
|
||||
[options]
|
||||
prerelease-mode = "allow"
|
||||
|
||||
[[package]]
|
||||
name = "backports-asyncio-runner"
|
||||
version = "1.2.0"
|
||||
@ -988,15 +991,15 @@ requires-dist = [
|
||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" },
|
||||
{ name = "textual", marker = "extra == 'tui'", specifier = ">=0.40.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"]
|
||||
|
||||
[[package]]
|
||||
name = "yt-dlp"
|
||||
version = "2026.3.3"
|
||||
version = "2026.5.16.233954.dev0"
|
||||
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 = [
|
||||
{ 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-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
|
||||
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 sys
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, jsonify, request
|
||||
from flask import Flask, send_from_directory
|
||||
from flask_cors import CORS
|
||||
from flask_socketio import SocketIO
|
||||
|
||||
# Add parent directory to path to import YouTubeCLI
|
||||
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 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
|
||||
app = Flask(__name__)
|
||||
CORS(app, resources={r"/api/*": {"origins": "*"}})
|
||||
|
||||
# Create YouTubeCLI instance
|
||||
# Initialize 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():
|
||||
"""Get current configuration from YouTubeCLI instance."""
|
||||
return yt_cli.config
|
||||
# Persistent config dir from env var
|
||||
config_dir = os.environ.get('CONFIG_DIR', str(Path.home() / '.config' / 'youtube_cli'))
|
||||
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):
|
||||
"""Create a JSON response with proper structure."""
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": data
|
||||
}), status
|
||||
|
||||
|
||||
def make_error_response(message, status=400):
|
||||
"""Create an error response."""
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": message
|
||||
}), status
|
||||
# Initialize components with persistent paths
|
||||
queue_store = QueueStore(store_path=str(Path(config_dir) / 'queue.json'))
|
||||
archive_db = ArchiveDB(db_path=str(Path(config_dir) / 'archive.db'))
|
||||
download_engine = DownloadEngine(queue_store, archive_db, yt_cli, socketio)
|
||||
|
||||
|
||||
# ==================== Health & Config ====================
|
||||
|
||||
from utils import make_response, make_error_response
|
||||
|
||||
|
||||
@app.route('/api/health', methods=['GET'])
|
||||
def health_check():
|
||||
"""Health check endpoint."""
|
||||
@ -59,7 +71,7 @@ def health_check():
|
||||
yt_dlp_version = yt_cli.get_yt_dlp_version()
|
||||
return make_response({
|
||||
"status": "healthy",
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"yt_dlp_version": yt_dlp_version
|
||||
})
|
||||
except Exception as e:
|
||||
@ -67,10 +79,10 @@ def health_check():
|
||||
|
||||
|
||||
@app.route('/api/config', methods=['GET'])
|
||||
def get_config_endpoint():
|
||||
def get_config():
|
||||
"""Get current configuration."""
|
||||
try:
|
||||
config = get_config()
|
||||
config = yt_cli.config
|
||||
return make_response({
|
||||
"download_dir": config.get("download_dir"),
|
||||
"default_locations": config.get("default_locations", []),
|
||||
@ -86,7 +98,7 @@ def get_config_endpoint():
|
||||
def get_categories():
|
||||
"""Get available download categories."""
|
||||
try:
|
||||
config = get_config()
|
||||
config = yt_cli.config
|
||||
categories = yt_cli.get_categories(config)
|
||||
return make_response({
|
||||
"categories": categories,
|
||||
@ -96,433 +108,48 @@ def get_categories():
|
||||
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:
|
||||
return make_error_response("Search query is required", 400)
|
||||
# ==================== WebSocket Events ====================
|
||||
|
||||
if page < 1:
|
||||
return make_error_response("Page must be greater than 0", 400)
|
||||
@socketio.on('connect')
|
||||
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({
|
||||
"query": query,
|
||||
"page": page,
|
||||
"videos": videos
|
||||
})
|
||||
except Exception as e:
|
||||
return make_error_response(f"Search failed: {str(e)}", 500)
|
||||
@socketio.on('disconnect')
|
||||
def handle_disconnect():
|
||||
"""Handle client WebSocket disconnection."""
|
||||
pass
|
||||
|
||||
|
||||
# ==================== Download ====================
|
||||
|
||||
# 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)
|
||||
# ==================== Static File Serving ====================
|
||||
|
||||
@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 ====================
|
||||
|
||||
@app.errorhandler(404)
|
||||
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)
|
||||
|
||||
|
||||
@ -535,8 +162,8 @@ def internal_error(error):
|
||||
# ==================== Run Server ====================
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Default to port 4096
|
||||
port = int(os.environ.get('PORT', 4096))
|
||||
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",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
"react": "^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": {
|
||||
"@playwright/test": "^1.40.0",
|
||||
@ -922,9 +924,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -939,9 +938,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -956,9 +952,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -973,9 +966,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -990,9 +980,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -1007,9 +994,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -1024,9 +1008,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -1041,9 +1022,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -1058,9 +1036,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -1075,9 +1050,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -1092,9 +1064,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -1109,9 +1078,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -1126,9 +1092,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -1219,6 +1182,11 @@
|
||||
"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": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@ -1348,6 +1316,11 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "10.4.27",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz",
|
||||
@ -1385,6 +1358,16 @@
|
||||
"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": {
|
||||
"version": "2.10.13",
|
||||
"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_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": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
||||
@ -1527,6 +1522,17 @@
|
||||
"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": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
|
||||
@ -1568,7 +1574,6 @@
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"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": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
||||
@ -1596,6 +1609,19 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.5.329",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.329.tgz",
|
||||
@ -1603,6 +1629,67 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||
@ -1705,6 +1792,40 @@
|
||||
"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": {
|
||||
"version": "5.3.4",
|
||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||
@ -1738,7 +1859,6 @@
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
@ -1754,6 +1874,41 @@
|
||||
"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": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
@ -1767,11 +1922,46 @@
|
||||
"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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
@ -1926,6 +2116,14 @@
|
||||
"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": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
@ -1950,11 +2148,29 @@
|
||||
"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": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mz": {
|
||||
@ -2267,6 +2483,14 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@ -2498,6 +2722,32 @@
|
||||
"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": {
|
||||
"version": "1.2.1",
|
||||
"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_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": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
@ -10,9 +10,11 @@
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
"react": "^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": {
|
||||
"@playwright/test": "^1.40.0",
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import DirectPage from "./pages/DirectPage";
|
||||
import SearchPage from "./pages/SearchPage";
|
||||
import SearchResults from "./pages/SearchResults";
|
||||
import Queue from "./pages/Queue";
|
||||
@ -15,6 +16,7 @@ function App() {
|
||||
<Route path="/results" element={<SearchResults />} />
|
||||
<Route path="/queue" element={<Queue />} />
|
||||
<Route path="/archive" element={<Archive />} />
|
||||
<Route path="/direct" element={<DirectPage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import axios from "axios";
|
||||
|
||||
const apiBaseURL = import.meta.env.VITE_API_BASE_URL || "/api";
|
||||
|
||||
export const apiClient = axios.create({
|
||||
baseURL: "http://localhost:4096/api",
|
||||
baseURL: apiBaseURL,
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
||||
@ -10,6 +10,7 @@ export interface QueueItem {
|
||||
category: string;
|
||||
addedAt: string;
|
||||
errorMessage?: string;
|
||||
quality?: string;
|
||||
}
|
||||
|
||||
export interface QueueResponse {
|
||||
@ -25,6 +26,7 @@ export interface AddToQueueRequest {
|
||||
thumbnail: string;
|
||||
category: string;
|
||||
url: string;
|
||||
quality?: string;
|
||||
}
|
||||
|
||||
export async function getQueue(): Promise<QueueResponse> {
|
||||
@ -89,3 +91,33 @@ export async function getQueueStats(): Promise<{
|
||||
}>("/queue/stats");
|
||||
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
|
||||
// Server wraps response in {success: true, data: {...}}
|
||||
// Handle server errors that return 200 but have error field
|
||||
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 videos = actualData.videos || [];
|
||||
const videos = actualData.results || [];
|
||||
|
||||
return {
|
||||
results: videos.map((v: any) => ({
|
||||
id: v.id,
|
||||
videoId: v.id,
|
||||
videoId: v.videoId || v.id,
|
||||
title: v.title,
|
||||
description: "",
|
||||
thumbnail: v.thumbnail,
|
||||
description: v.description || "",
|
||||
thumbnail: v.thumbnail || `https://i.ytimg.com/vi/${v.id}/hqdefault.jpg`,
|
||||
url: v.url,
|
||||
category: "General",
|
||||
duration: v.length || "0:00",
|
||||
views: v.view_count ? String(v.view_count) : "0",
|
||||
channel: v.author || v.channel || "Unknown",
|
||||
isShort: v.is_short || false,
|
||||
published: "2024-01-01",
|
||||
duration: v.duration || "0:00",
|
||||
views: v.views || "0",
|
||||
channel: v.channel || "Unknown",
|
||||
isShort: v.isShort || false,
|
||||
published: v.published || "",
|
||||
})),
|
||||
total: videos.length,
|
||||
page: params.page || 1,
|
||||
hasMore: videos.length >= 15,
|
||||
page: actualData.page || params.page || 1,
|
||||
hasMore: actualData.hasMore || videos.length >= 15,
|
||||
};
|
||||
}
|
||||
|
||||
@ -85,6 +89,10 @@ export async function clearRecentSearches(): Promise<void> {
|
||||
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> {
|
||||
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";
|
||||
|
||||
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() {
|
||||
const location = useLocation();
|
||||
const [quality, setQuality] = useState(getStoredQuality);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(STORAGE_KEY, quality);
|
||||
}, [quality]);
|
||||
|
||||
const navLinks = [
|
||||
{ path: "/", label: "Search", icon: "search" },
|
||||
{ path: "/direct", label: "Direct", icon: "link" },
|
||||
{ path: "/queue", label: "Queue", icon: "queue" },
|
||||
{ path: "/archive", label: "Archive", icon: "archive" },
|
||||
];
|
||||
@ -43,9 +64,34 @@ export default function Navbar() {
|
||||
</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>
|
||||
</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 VideoPlayerModal from "../components/VideoPlayerModal";
|
||||
import {
|
||||
getArchive,
|
||||
deleteFromArchive,
|
||||
@ -36,6 +37,7 @@ export default function Archive() {
|
||||
const [selectedCategory, setSelectedCategory] = useState("");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [categories, setCategories] = useState<string[]>([]);
|
||||
const [playingVideo, setPlayingVideo] = useState<{ videoId: string; title: string; downloadPath?: string | null } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchArchive();
|
||||
@ -201,6 +203,17 @@ export default function Archive() {
|
||||
alt={video.title}
|
||||
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">
|
||||
{video.duration}
|
||||
</div>
|
||||
@ -279,6 +292,15 @@ export default function Archive() {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{playingVideo && (
|
||||
<VideoPlayerModal
|
||||
videoId={playingVideo.videoId}
|
||||
title={playingVideo.title}
|
||||
onClose={() => setPlayingVideo(null)}
|
||||
localVideoPath={playingVideo.downloadPath ? `/api/archive/${playingVideo.videoId}/stream` : undefined}
|
||||
/>
|
||||
)}
|
||||
</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 { getQueue, removeFromQueue, clearQueue } from "../api/queue"
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import VideoPlayerModal from "../components/VideoPlayerModal"
|
||||
import { getQueue, removeFromQueue, clearQueue, startDownload, retryQueueItem, cancelQueueItem } from "../api/queue"
|
||||
import { getSocket } from "../api/socket"
|
||||
|
||||
interface QueueItem {
|
||||
id: string
|
||||
videoId: string
|
||||
title: string
|
||||
thumbnail: string
|
||||
status: "pending" | "downloading" | "completed" | "failed"
|
||||
status: "pending" | "downloading" | "completed" | "failed" | "cancelled"
|
||||
progress: number
|
||||
category: string
|
||||
addedAt: string
|
||||
errorMessage?: string
|
||||
downloadPath?: string
|
||||
fileSize?: string
|
||||
speed?: string
|
||||
eta?: string
|
||||
}
|
||||
|
||||
export default function Queue() {
|
||||
const [queueItems, setQueueItems] = useState<QueueItem[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [playingVideo, setPlayingVideo] = useState<{ videoId: string; title: string; downloadPath?: string | null } | null>(null)
|
||||
const [stats, setStats] = useState({
|
||||
total: 0,
|
||||
pending: 0,
|
||||
@ -24,13 +31,7 @@ export default function Queue() {
|
||||
failed: 0
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
fetchQueue()
|
||||
const interval = setInterval(fetchQueue, 5000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
const fetchQueue = async () => {
|
||||
const fetchQueue = useCallback(async () => {
|
||||
try {
|
||||
const response = await getQueue()
|
||||
setQueueItems(response.items)
|
||||
@ -46,7 +47,87 @@ export default function Queue() {
|
||||
} finally {
|
||||
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) => {
|
||||
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 () => {
|
||||
if (window.confirm("Are you sure you want to clear the entire queue?")) {
|
||||
try {
|
||||
@ -76,6 +187,7 @@ export default function Queue() {
|
||||
case "downloading": return "text-blue-500"
|
||||
case "completed": return "text-green-500"
|
||||
case "failed": return "text-red-500"
|
||||
case "cancelled": return "text-gray-500"
|
||||
default: return "text-slate-400"
|
||||
}
|
||||
}
|
||||
@ -135,9 +247,20 @@ export default function Queue() {
|
||||
{queueItems.map((item) => (
|
||||
<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-shrink-0">
|
||||
<div className="aspect-video w-32 rounded-lg overflow-hidden bg-slate-700">
|
||||
<div className="flex-shrink-0 relative">
|
||||
<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" />
|
||||
<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 className="flex-1">
|
||||
@ -147,9 +270,31 @@ export default function Queue() {
|
||||
<span className={`text-sm ${getStatusColor(item.status)} font-medium`}>
|
||||
{item.status.charAt(0).toUpperCase() + item.status.slice(1)}
|
||||
</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">
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
@ -165,19 +310,30 @@ export default function Queue() {
|
||||
<div className="flex mb-2 items-center justify-between">
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-xs font-semibold inline-block text-blue-500">
|
||||
{Math.round(item.progress)}%
|
||||
</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 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 ${
|
||||
item.status === "failed" ? "bg-red-500" :
|
||||
item.status === "completed" ? "bg-green-500" :
|
||||
item.status === "cancelled" ? "bg-gray-500" :
|
||||
item.status === "downloading" ? "bg-blue-500" :
|
||||
"bg-yellow-500"
|
||||
}`}></div>
|
||||
@ -199,6 +355,15 @@ export default function Queue() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{playingVideo && (
|
||||
<VideoPlayerModal
|
||||
videoId={playingVideo.videoId}
|
||||
title={playingVideo.title}
|
||||
onClose={() => setPlayingVideo(null)}
|
||||
localVideoPath={playingVideo.downloadPath ? `/api/archive/${playingVideo.videoId}/stream` : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,15 +1,36 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
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() {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [recentSearches, setRecentSearches] = useState<string[]>([]);
|
||||
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 [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
if (!searchQuery.trim()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setCurrentPage(1);
|
||||
setCurrentQuery(searchQuery);
|
||||
|
||||
try {
|
||||
const response = await searchVideos({
|
||||
query: searchQuery,
|
||||
page: 1,
|
||||
limit: 15,
|
||||
});
|
||||
setSearchResults(response.results);
|
||||
setHasSearched(true);
|
||||
|
||||
if (!recentSearches.includes(searchQuery)) {
|
||||
const newSearches = [searchQuery, ...recentSearches].slice(0, 10);
|
||||
setRecentSearches(newSearches);
|
||||
const response = await performSearch(searchQuery, 1, false);
|
||||
if (response) {
|
||||
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 {
|
||||
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("currentVideo", JSON.stringify(video));
|
||||
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) => {
|
||||
setSearchQuery(query);
|
||||
searchVideos({ query, page: 1, limit: 15 })
|
||||
setCurrentQuery(query);
|
||||
setCurrentPage(1);
|
||||
setIsLoading(true);
|
||||
performSearch(query, 1, false)
|
||||
.then((response) => {
|
||||
setSearchResults(response.results);
|
||||
setHasSearched(true);
|
||||
if (response) {
|
||||
setHasSearched(true);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
setError("Failed to search videos. Please try again.");
|
||||
console.error("Search error:", err);
|
||||
});
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
const clearRecentSearches = async () => {
|
||||
try {
|
||||
await apiClearRecentSearches();
|
||||
setRecentSearches([]);
|
||||
} catch (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 (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="text-center py-12">
|
||||
@ -138,13 +260,25 @@ export default function SearchPage() {
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{recentSearches.map((search, index) => (
|
||||
<button
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => handleRecentSearch(search)}
|
||||
className="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-slate-200 rounded-lg transition-colors text-sm"
|
||||
className="group relative"
|
||||
>
|
||||
{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>
|
||||
@ -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">
|
||||
{video.duration}
|
||||
</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 className="p-4">
|
||||
<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>
|
||||
|
||||
{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>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { addToQueue } from "../api/queue";
|
||||
import { getQuality } from "../components/Navbar";
|
||||
|
||||
interface Video {
|
||||
id: string;
|
||||
@ -52,12 +53,16 @@ export default function SearchResults() {
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const selectedCategory =
|
||||
(document.getElementById("downloadCategory") as HTMLSelectElement)
|
||||
?.value || video.category || "General";
|
||||
await addToQueue({
|
||||
videoId: video.videoId,
|
||||
title: video.title,
|
||||
thumbnail: video.thumbnail,
|
||||
category: video.category || "General",
|
||||
category: selectedCategory,
|
||||
url: video.url,
|
||||
quality: getQuality(),
|
||||
});
|
||||
alert("Video added to download 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()
|
||||
|
||||
# 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)
|
||||
|
||||
# Use RotatingFileHandler for log rotation (10MB, 5 backups)
|
||||
@ -58,9 +59,8 @@ class YouTubeCLI:
|
||||
self.original_query = None
|
||||
self.current_page = 1
|
||||
# Use a proper user directory for the archive file
|
||||
self.archive_file = (
|
||||
Path.home() / ".config" / "youtube_cli" / "downloaded_videos.json"
|
||||
)
|
||||
config_dir = os.environ.get("CONFIG_DIR", str(Path.home() / ".config" / "youtube_cli"))
|
||||
self.archive_file = Path(config_dir) / "downloaded_videos.json"
|
||||
self.downloaded_videos = self.load_archive()
|
||||
|
||||
def get_yt_dlp_version(self):
|
||||
@ -166,41 +166,38 @@ class YouTubeCLI:
|
||||
def load_config(self, config_path=None):
|
||||
"""Load configuration from file or use defaults."""
|
||||
default_config = {
|
||||
"download_dir": "/Volumes/MediaServer/Youtube/",
|
||||
"download_dir": os.environ.get("DOWNLOAD_DIR", str(Path.home() / "Downloads" / "YouTube")),
|
||||
"default_locations": [
|
||||
"/Volumes/MediaServer/Youtube/",
|
||||
"/Volumes/MediaServer/Youtube/Tech",
|
||||
"/Volumes/MediaServer/Youtube/AI",
|
||||
"/Volumes/MediaServer/Youtube/Art",
|
||||
"/Volumes/MediaServer/Youtube/Homes",
|
||||
"/Volumes/MediaServer/Youtube/Cooking",
|
||||
"/Volumes/MediaServer/Youtube/Fitness",
|
||||
"/Volumes/MediaServer/Youtube/Music",
|
||||
"/Volumes/MediaServer/Youtube/Gaming",
|
||||
"/Volumes/MediaServer/Youtube/Education",
|
||||
"/Volumes/MediaServer/Youtube/Travel",
|
||||
"/Volumes/MediaServer/Youtube/Business",
|
||||
"/Volumes/MediaServer/Youtube/Science",
|
||||
"/Volumes/MediaServer/Youtube/History",
|
||||
"/Volumes/MediaServer/Youtube/Comedy",
|
||||
"/Volumes/MediaServer/Youtube/News",
|
||||
"/Volumes/MediaServer/Youtube/Sports",
|
||||
"/Volumes/MediaServer/Youtube/Nature",
|
||||
"/Volumes/MediaServer/Youtube/Photography",
|
||||
"/Volumes/MediaServer/Youtube/Language",
|
||||
"/Volumes/MediaServer/Youtube/Automotive",
|
||||
"/Volumes/MediaServer/Youtube/Anime",
|
||||
"General",
|
||||
"Music",
|
||||
"Music Videos",
|
||||
"Podcasts",
|
||||
"Educational",
|
||||
"Tutorials",
|
||||
"Gaming",
|
||||
"Shorts",
|
||||
"Vlogs",
|
||||
"Documentaries",
|
||||
"Comedy",
|
||||
"News",
|
||||
"Sports",
|
||||
"Cooking",
|
||||
"Fitness",
|
||||
"Tech Reviews",
|
||||
],
|
||||
"max_videos_per_page": 15,
|
||||
"yt_dlp_args": {
|
||||
"format": "bestvideo[height=1080]+bestaudio/bestvideo[height<=1080]+bestaudio",
|
||||
"format": "bestvideo[height<=1080]+bestaudio/best",
|
||||
"write_thumbnail": True,
|
||||
"extractor_args": "youtube:player-client=default,-tv_simply",
|
||||
},
|
||||
"network_share_path": "/Volumes/MediaServer/Youtube/",
|
||||
"network_share_path": "",
|
||||
"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):
|
||||
try:
|
||||
with open(config_path, "r") as f:
|
||||
@ -364,8 +361,6 @@ class YouTubeCLI:
|
||||
"--no-warnings",
|
||||
"--no-progress",
|
||||
"--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
"--remote-components",
|
||||
"ejs:github",
|
||||
f"ytsearch{15 * page}:{sanitized_query}",
|
||||
]
|
||||
|
||||
@ -803,38 +798,19 @@ class YouTubeCLI:
|
||||
download_dir.mkdir(parents=True, exist_ok=True)
|
||||
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 = [
|
||||
"yt-dlp",
|
||||
"--no-warnings",
|
||||
"-o",
|
||||
str(download_dir / "%(title)s.%(ext)s"),
|
||||
"-o", str(download_dir / "%(title)s.%(ext)s"),
|
||||
"--write-thumbnail",
|
||||
"--remote-components",
|
||||
"ejs:github",
|
||||
"--download-archive", str(download_dir / ".yt-dlp-archive.txt"),
|
||||
"--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
|
||||
ytdlp_args = config.get("yt_dlp_args", {})
|
||||
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 retries
|
||||
cmd.extend(["--retries", "3", "--fragment-retries", "3"])
|
||||
|
||||
# Add URL
|
||||
cmd.append(url)
|
||||
@ -912,8 +888,8 @@ class YouTubeCLI:
|
||||
logger.warning(
|
||||
"Note: This video requires JavaScript challenge solving."
|
||||
)
|
||||
logger.warning("Install required components with:")
|
||||
logger.warning("yt-dlp --remote-components ejs:github")
|
||||
logger.warning("Update yt-dlp with:")
|
||||
logger.warning("pip install --upgrade --pre yt-dlp")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during download: {str(e)}")
|
||||
@ -998,27 +974,19 @@ class YouTubeCLI:
|
||||
playlist_dir = download_dir / playlist_title
|
||||
playlist_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Prepare yt-dlp command for playlist
|
||||
# Prepare yt-dlp command for playlist (minimal)
|
||||
cmd = [
|
||||
"yt-dlp",
|
||||
"-o",
|
||||
str(playlist_dir / "%(title)s.%(ext)s"),
|
||||
"-o", str(playlist_dir / "%(title)s.%(ext)s"),
|
||||
"--write-thumbnail",
|
||||
"--remote-components",
|
||||
"ejs:github",
|
||||
"--download-archive", str(playlist_dir / ".yt-dlp-archive.txt"),
|
||||
"--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
|
||||
cmd.append(url)
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user