Update web server, UI components, and add new features (archive, queue, download engine, socket support)

This commit is contained in:
Jarian Cottingham 2026-07-02 18:29:40 +00:00
parent 405767a562
commit 415fe2324b
44 changed files with 5484 additions and 656 deletions

77
.dockerignore Normal file
View 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

View File

@ -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

View File

@ -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
View File

@ -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"

View File

@ -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

View File

@ -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"]

View File

@ -1,5 +1,5 @@
# Core dependencies
yt-dlp>=2023.12.0
yt-dlp
rich>=13.0.0
requests>=2.28.0

View File

@ -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
View File

@ -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" },
]

View File

@ -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

View File

@ -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
View 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

View 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}"

View 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

View File

@ -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()

View 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()

View 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

File diff suppressed because one or more lines are too long

View File

@ -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']

View 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)

View 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
View 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
View 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)

View 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!")

View 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!")

View 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
View 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
View 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

View File

@ -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",

View File

@ -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",

View File

@ -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>

View File

@ -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",

View File

@ -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`);
}

View File

@ -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 });
}

View 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;
}

View File

@ -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();
}

View 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>
);
}

View File

@ -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>
);
}

View 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>
);
}

View File

@ -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,11 +270,33 @@ export default function Queue() {
<span className={`text-sm ${getStatusColor(item.status)} font-medium`}>
{item.status.charAt(0).toUpperCase() + item.status.slice(1)}
</span>
<button onClick={() => handleRemove(item.id)} className="text-slate-500 hover:text-red-500 transition-colors" title="Remove from queue">
{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="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>
</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>
)
}

View File

@ -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);
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);
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}
className="group relative"
>
<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"
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>

View File

@ -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
View 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;
}

View File

@ -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)