Initial commit: youtube-web (extracted from youtube-cli)

This commit is contained in:
Jarian Cottingham 2026-08-21 17:08:28 +00:00
commit 9af2f71922
50 changed files with 10143 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules/
dist/
playwright-report/
test-results/
__pycache__/

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 jarianc
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

203
PLAN.md Normal file
View File

@ -0,0 +1,203 @@
youtube-cli/
├── youtube_cli/ # Core CLI application (existing)
├── youtube_tui/ # Textual TUI (existing)
├── web/ # React web interface (NEW) - COMPLETE ✓
│ ├── server/ # Backend API server (Flask) - COMPLETE
│ │ ├── app.py # Flask application with 15 endpoints
│ │ ├── models/
│ │ └── routes/
│ ├── web-app/ # React frontend - COMPLETE
│ │ ├── public/
│ │ └── src/
│ │ ├── api/ # API client functions
│ │ ├── components/# UI components
│ │ └── pages/ # Page components
│ ├── tests/ # E2E tests (Playwright)
│ ├── package.json
│ ├── requirements-web.txt
│ └── PLAN.md
└── ...
```
## Project Status: COMPLETE ✓
### What's Built
A full-stack web application with:
- **Backend**: Flask API server exposing YouTubeCLI functionality
- **Frontend**: React + TypeScript application
- **Tests**: Playwright E2E tests
---
## Implementation Phases
### Phase 1: Backend API Server (Flask) - COMPLETE ✓
**Goal**: Create a REST API that exposes YouTubeCLI functionality
**Files created**:
- `web/server/app.py` - Main Flask application with 15 endpoints
- `web/server/models/__init__.py` - Data models package
- `web/server/routes/__init__.py` - Routes package
- `web/requirements-web.txt` - Python dependencies
**API Endpoints**:
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/health` | GET | Health check |
| `/api/config` | GET | Get configuration |
| `/api/categories` | GET | Get download categories |
| `/api/search?q=query&page=1` | GET | Search for videos |
| `/api/download` | POST | Download a video |
| `/api/download/playlist` | POST | Download a playlist |
| `/api/archive` | GET | Get download archive |
| `/api/archive/<video_id>` | DELETE | Remove from archive |
| `/api/queue` | GET | Get all queue items |
| `/api/queue/<id>` | DELETE | Remove from queue |
| `/api/queue/<id>/retry` | POST | Retry download |
| `/api/queue/<id>/cancel` | POST | Cancel download |
| `/api/queue/<id>/status` | GET | Get download status |
| `/api/queue/clear/completed` | POST | Clear completed items |
| `/api/queue/clear/failed` | POST | Clear failed items |
---
### Phase 2: React Application Setup - COMPLETE ✓
**Goal**: Create a modern React app with TypeScript
**Files created**:
- `web/web-app/package.json` - Dependencies (React, Vite, Tailwind, Playwright)
- `web/web-app/vite.config.ts` - Vite with API proxy to Flask backend
- `web/web-app/tsconfig.json` - TypeScript configuration
- `web/web-app/tailwind.config.js` - Tailwind CSS configuration
- `web/web-app/index.html` - HTML entry point
- `web/web-app/src/main.tsx` - React entry point
- `web/web-app/src/App.tsx` - Main app with routing
- `web/web-app/src/index.css` - Global CSS with Tailwind imports
---
### Phase 3: Core Features - COMPLETE ✓
#### API Layer (`src/api/`)
- `client.ts` - Axios instance pointing to `http://localhost:4096`
- `search.ts` - Search API functions
- `download.ts` - Download API functions
- `queue.ts` - Queue management API functions
- `archive.ts` - Archive API functions
#### Components (`src/components/`)
- `Navbar.tsx` - Navigation bar with links to Search, Queue, Archive
#### Pages (`src/pages/`)
**SearchPage.tsx** (`/`)
- Search input with YouTube URL or query support
- Recent searches history
- Search button and Enter key support
**SearchResults.tsx** (`/results`)
- Video grid display with thumbnails
- Category selection modal
- Download with progress tracking
**Queue.tsx** (`/queue`)
- Queue table with status tracking (pending/downloading/completed/cancelled/failed)
- Progress bars with polling updates (every 2 seconds)
- Action buttons: Cancel, Retry, Remove, Clear Completed, Clear Failed
- Queue statistics display
**Archive.tsx** (`/archive`)
- List of downloaded videos
- Search/filter archive
- View/download date and metadata
- Remove from archive functionality
---
### Phase 4: Testing - COMPLETE ✓
**Test Framework**: Playwright
**Tests**: `web/web-app/tests/e2e/app.spec.ts`
**Test Coverage**:
- Visit home page
- Search for a video
- Display search results
- Navigate to queue page
- Navigate to archive page
---
### Phase 5: Documentation - COMPLETE ✓
**Files**:
- `web/README.md` - Setup and usage instructions
- `web/requirements-web.txt` - Python dependencies
---
## Build Status
```
✓ Build completed successfully
✓ No TypeScript errors
✓ E2E tests created (ready to run)
✓ Production bundle: 227.93 kB (73.65 kB gzipped)
```
---
## Usage
### Development
```bash
# Terminal 1: Start Flask backend
cd web/server
pip install -r requirements-web.txt
python app.py
# Terminal 2: Start React frontend
cd web/web-app
npm install
npm run dev
```
The frontend will be available at `http://localhost:5173` and proxy API requests to the Flask backend on port 4096.
### Production
```bash
# Build the React app
cd web/web-app
npm run build
# Serve static files with Flask
cd web/server
python app.py
```
---
## Configuration
The application uses the same configuration as the CLI/TUI:
- **Config location**: `~/.config/youtube_cli/config.json`
- **Archive location**: `~/.config/youtube_cli/downloaded_videos.json`
---
## Success Metrics
- [x] All TUI features implemented in web interface
- [x] Downloads work reliably
- [x] Queue management functional
- [x] Responsive on desktop and mobile
- [x] Error messages user-friendly
- [x] Build successful
- [x] Tests cover core functionality (E2E tests in place)
```

192
README.md Normal file
View File

@ -0,0 +1,192 @@
# YouTube Web Interface
A modern React-based web interface for the YouTube CLI application. Provides a browser-based UI for searching, downloading, and managing YouTube videos with the same functionality as the TUI, but accessible from any device on your network.
## Features
- **Search YouTube** - Search for videos with autocomplete and history
- **Browse Results** - View search results with thumbnails, duration, and view counts
- **Download Videos** - Download videos with category selection
- **Download Playlists** - Download entire playlists
- **Queue Management** - Manage download queue with progress tracking
- **Download Archive** - View and manage downloaded videos
- **Real-time Updates** - Polling-based progress updates
- **Responsive Design** - Works on desktop and mobile
## Architecture
```
youtube-cli/
├── youtube_cli/ # Core CLI application (existing)
├── youtube_tui/ # Textual TUI (existing)
├── web/ # React web interface
│ ├── server/ # Flask API server
│ │ ├── app.py # Main Flask application
│ │ └── requirements-web.txt
│ └── web-app/ # React frontend
│ ├── src/
│ │ ├── api/ # API client functions
│ │ ├── pages/ # Page components
│ │ └── components/# UI components
│ └── package.json
```
## Setup
### Prerequisites
- Python 3.8+ with pip
- yt-dlp installed: `pip install yt-dlp`
- Node.js 18+ with npm
### Installation
1. **Install Python dependencies**:
```bash
cd web
pip install -r requirements-web.txt
```
2. **Install Node.js dependencies**:
```bash
cd web-app
npm install
```
### Configuration
The application uses the same configuration as the CLI/TUI:
- **Config location**: `~/.config/youtube_cli/config.json`
- **Archive location**: `~/.config/youtube_cli/downloaded_videos.json`
Make sure your config has the necessary settings:
```json
{
"download_dir": "/path/to/downloads",
"default_locations": ["/path/to/downloads/Music", "/path/to/downloads/Videos"],
"max_videos_per_page": 15,
"yt_dlp_args": {
"format": "bestvideo[height<=1080]+bestaudio/best"
}
}
```
## Usage
### Development Mode
Run both backend and frontend:
```bash
# Terminal 1: Start Flask backend
cd web/server
python app.py
# Terminal 2: Start React frontend
cd web/web-app
npm run dev
```
The frontend will be available at `http://localhost:3000` and will proxy API requests to the Flask backend on port 4096.
### Production Mode
```bash
# Build the React app
cd web/web-app
npm run build
# Serve static files with Flask
cd web/server
python app.py
```
The frontend will be served from the `dist/` folder at `http://localhost:4096`.
## API Endpoints
### Health & Configuration
- `GET /api/health` - Health check
- `GET /api/config` - Get configuration
- `GET /api/categories` - Get available download categories
### Search
- `GET /api/search?q=query&page=1` - Search for videos
### Download
- `POST /api/download` - Download a video
- `POST /api/download/playlist` - Download a playlist
### Queue
- `GET /api/queue` - Get all queue items
- `DELETE /api/queue/:id` - Remove item from queue
- `POST /api/queue/:id/retry` - Retry a failed download
- `POST /api/queue/:id/cancel` - Cancel a download
- `GET /api/queue/:id/status` - Get download progress
- `POST /api/queue/clear/completed` - Clear completed items
- `POST /api/queue/clear/failed` - Clear failed items
### Archive
- `GET /api/archive` - Get download archive
- `DELETE /api/archive/:videoId` - Remove from archive
## Project Structure
### Backend (`web/server/`)
- `app.py` - Flask application with all API endpoints
- `requirements-web.txt` - Python dependencies
### Frontend (`web/web-app/`)
- `src/api/` - API client functions
- `client.ts` - Axios instance with interceptors
- `search.ts` - Search API calls
- `download.ts` - Download API calls
- `queue.ts` - Queue management API calls
- `archive.ts` - Archive API calls
- `src/pages/` - Page components
- `SearchPage.tsx` - Search interface
- `SearchResults.tsx` - Search results grid
- `Queue.tsx` - Download queue management
- `Archive.tsx` - Download history
- `src/components/` - UI components
- `Navbar.tsx` - Navigation bar
- `tailwind.config.js` - Tailwind CSS configuration
## Design Decisions
1. **Flask Backend**: Used Flask for simplicity and integration with existing YouTubeCLI
2. **Polling for Updates**: Implemented polling instead of WebSockets for simpler deployment
3. **In-memory Queue**: Queue stored in memory (can be extended to persist to file)
4. **CORS Enabled**: For development; should be restricted in production
5. **API Proxy**: Vite configured to proxy `/api` requests to Flask backend
## Future Enhancements
- [ ] Persist queue to file
- [ ] WebSocket support for real-time updates
- [ ] Authentication system
- [ ] Settings page for configuration
- [ ] Download history with filtering
- [ ] Batch download functionality
- [ ] Email notifications for downloads
- [ ] Scheduled downloads
## Troubleshooting
### Backend won't start
- Ensure yt-dlp is installed: `yt-dlp --version`
- Check Python dependencies: `pip install -r requirements-web.txt`
### Frontend won't compile
- Clear node_modules: `rm -rf node_modules && npm install`
- Check Node version: `node --version` (should be 18+)
### API requests fail
- Ensure backend is running on port 4096
- Check CORS settings in `app.py`
- Verify API base URL in `src/api/client.ts`
## License
MIT License - same as the main YouTube CLI project

7
requirements-web.txt Normal file
View File

@ -0,0 +1,7 @@
flask>=2.0.0
flask-cors>=3.0.0
flask-socketio>=5.0.0
gunicorn>=21.0.0
yt-dlp
sqlalchemy>=2.0.0
eventlet>=0.33.0

170
server/app.py Normal file
View File

@ -0,0 +1,170 @@
#!/usr/bin/env python3
"""
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 logging
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
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 download_engine import DownloadEngine
from models.archive import ArchiveDB
from models.queue_store import QueueStore
from routes import archive_bp, download_bp, queue_bp, search_bp
from youtube_cli.main import YouTubeCLI
# 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)
# Initialize SocketIO - use threading mode (compatible with gunicorn gthread worker)
socketio = SocketIO(app, cors_allowed_origins="*", async_mode='threading')
# 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)
# 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_error_response, make_response
@app.route('/api/health', methods=['GET'])
def health_check():
"""Health check endpoint."""
try:
yt_dlp_version = yt_cli.get_yt_dlp_version()
return make_response({
"status": "healthy",
"timestamp": datetime.now(timezone.utc).isoformat(),
"yt_dlp_version": yt_dlp_version
})
except Exception as e:
return make_error_response(f"Health check failed: {str(e)}", 500)
@app.route('/api/config', methods=['GET'])
def get_config():
"""Get current configuration."""
try:
config = yt_cli.config
return make_response({
"download_dir": config.get("download_dir"),
"default_locations": config.get("default_locations", []),
"max_videos_per_page": config.get("max_videos_per_page", 15),
"network_share_path": config.get("network_share_path"),
"default_network_subfolder": config.get("default_network_subfolder")
})
except Exception as e:
return make_error_response(f"Failed to get config: {str(e)}", 500)
@app.route('/api/categories', methods=['GET'])
def get_categories():
"""Get available download categories."""
try:
config = yt_cli.config
categories = yt_cli.get_categories(config)
return make_response({
"categories": categories,
"default_download_dir": config.get("download_dir")
})
except Exception as e:
return make_error_response(f"Failed to get categories: {str(e)}", 500)
# ==================== Register Blueprints ====================
app.register_blueprint(search_bp)
app.register_blueprint(download_bp)
app.register_blueprint(queue_bp)
app.register_blueprint(archive_bp)
# ==================== WebSocket Events ====================
@socketio.on('connect')
def handle_connect():
"""Handle client WebSocket connection."""
from flask_socketio import emit
emit('connected', {'message': 'Connected to server'})
@socketio.on('disconnect')
def handle_disconnect():
"""Handle client WebSocket disconnection."""
pass
# ==================== 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 - 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)
@app.errorhandler(500)
def internal_error(error):
"""Handle 500 errors."""
return make_error_response("Internal server error", 500)
# ==================== Run Server ====================
if __name__ == '__main__':
port = int(os.environ.get('PORT', 4096))
debug = os.environ.get('FLASK_DEBUG', 'false').lower() == 'true'
# 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)

132
server/banned_terms.txt Normal file
View File

@ -0,0 +1,132 @@
# 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
countdown
# ASMR
asmr
# Instructions
instructions
# Prone
prone
# Massage
massage

526
server/download_engine.py Normal file
View File

@ -0,0 +1,526 @@
"""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 ArchiveItem, QueueItem
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}"

36
server/gunicorn.conf.py Normal file
View File

@ -0,0 +1,36 @@
"""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:
log_dir = os.environ.get("GUNICORN_LOG_DIR", os.path.dirname(os.path.abspath(__file__)))
accesslog = os.path.join(log_dir, "gunicorn-access.log")
errorlog = os.path.join(log_dir, "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

122
server/models/__init__.py Normal file
View File

@ -0,0 +1,122 @@
"""Data models for the web application."""
from dataclasses import dataclass
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()

245
server/models/archive.py Normal file
View File

@ -0,0 +1,245 @@
"""SQLAlchemy models for the archive database."""
from datetime import datetime, timezone
from pathlib import Path
from sqlalchemy import (
BigInteger,
Column,
DateTime,
Integer,
String,
create_engine,
)
from sqlalchemy.orm import DeclarativeBase, sessionmaker
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."""
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,204 @@
"""JSON-backed queue store with file locking for thread safety."""
import json
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
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.archive import archive_bp
from routes.download import download_bp
from routes.queue import queue_bp
from routes.search import search_bp
__all__ = ['search_bp', 'download_bp', 'queue_bp', 'archive_bp']

234
server/routes/archive.py Normal file
View File

@ -0,0 +1,234 @@
"""Archive API endpoints with SQLite backend."""
import os
from flask import Blueprint, Response, request, send_file
from models import ArchiveItem
from utils import make_error_response, make_response
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)

214
server/routes/download.py Normal file
View File

@ -0,0 +1,214 @@
"""Download-related API endpoints."""
import uuid
from flask import Blueprint, request
from models import QueueItem
from utils import make_error_response, make_response
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
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 download_engine, 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
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_error_response, make_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 download_engine
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": "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 download_engine, queue_store, 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 download_engine, 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)
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)

330
server/routes/search.py Normal file
View File

@ -0,0 +1,330 @@
"""Search-related API endpoints."""
import json
import os
import re
from pathlib import Path
import yt_dlp
from flask import Blueprint, request
from utils import make_error_response, make_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 (word-boundary aware to avoid "hero" matching "ero", etc.)
for term in _banned_terms:
if re.search(r'\b' + re.escape(term) + r'\b', query_lower):
return True
# Multi-word exact match (e.g. "no nut november") — substring OK for phrases
for term in _banned_terms:
if ' ' in term and 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 short words (5 chars or less) to avoid false positives (e.g. "hero" matching "ero")
if len(word) <= 5:
continue
for term in _banned_terms:
# Skip fuzzy matching for short banned terms (too many false positives)
if len(term) <= 5:
continue
# Skip fuzzy matching for terms that cause false positives
if term in ("strip", "gooning"):
continue
# 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("Unable to query — banned search term detected.", 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,118 @@
"""Tests for download recovery after server crash."""
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
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,223 @@
"""Tests for QueueStore - JSON persistence, corruption recovery, and CRUD operations."""
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
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-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

13
web-app/index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>YouTube Web Interface</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3089
web-app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
web-app/package.json Normal file
View File

@ -0,0 +1,30 @@
{
"name": "youtube-web-app",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"test:e2e": "playwright test"
},
"dependencies": {
"axios": "^1.6.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"socket.io-client": "^4.7.0"
},
"devDependencies": {
"@playwright/test": "^1.40.0",
"@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.31",
"tailwindcss": "^3.3.5",
"typescript": "^5.3.3",
"vite": "^5.0.0"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@ -0,0 +1,7 @@
{
"plugins": [
{
"postcss-plugin": true
}
]
}

15
web-app/public/index.html Normal file
View File

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>YouTube CLI - Video Manager</title>
<meta name="description" content="Search, download, and manage YouTube videos" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

26
web-app/src/App.tsx Normal file
View File

@ -0,0 +1,26 @@
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";
import Archive from "./pages/Archive";
import Navbar from "./components/Navbar";
function App() {
return (
<div className="min-h-screen bg-slate-900 text-slate-100">
<Navbar />
<main className="container mx-auto px-4 py-6">
<Routes>
<Route path="/" element={<SearchPage />} />
<Route path="/results" element={<SearchResults />} />
<Route path="/queue" element={<Queue />} />
<Route path="/archive" element={<Archive />} />
<Route path="/direct" element={<DirectPage />} />
</Routes>
</main>
</div>
);
}
export default App;

103
web-app/src/api/archive.ts Normal file
View File

@ -0,0 +1,103 @@
import { apiClient } from "./client";
export interface ArchiveItem {
id: string;
videoId: string;
title: string;
description: string;
thumbnail: string;
url: string;
category: string;
downloadPath: string;
networkSharePath?: string;
downloadDate: string;
duration: string;
views: string;
channel: string;
size?: string;
}
export interface ArchiveResponse {
items: ArchiveItem[];
total: number;
page: number;
hasMore: boolean;
}
export interface ArchiveFilters {
page?: number;
limit?: number;
category?: string;
search?: string;
startDate?: string;
endDate?: string;
}
export async function getArchive(
filters: ArchiveFilters = {},
): Promise<ArchiveResponse> {
const response = await apiClient.get<any>("/archive", {
params: filters,
});
// Handle both server response formats (archive vs items)
const archiveData = response.data.archive || response.data.items || [];
return {
items: archiveData,
total: response.data.total || archiveData.length,
page: filters.page || 1,
hasMore: archiveData.length >= (filters.limit || 24),
};
}
export async function getArchiveItem(videoId: string): Promise<ArchiveItem> {
const response = await apiClient.get<ArchiveItem>(`/archive/${videoId}`);
return response.data;
}
export async function deleteFromArchive(videoId: string): Promise<void> {
await apiClient.delete(`/archive/${videoId}`);
}
export async function clearArchive(): Promise<void> {
await apiClient.delete("/archive");
}
export async function getArchiveStats(): Promise<{
total: number;
totalSize: string;
categories: Record<string, number>;
}> {
const response = await apiClient.get<{
total: number;
totalSize: string;
categories: Record<string, number>;
}>("/archive/stats");
return response.data;
}
export async function getCategoryList(): Promise<string[]> {
const response = await apiClient.get<string[]>("/archive/categories");
return response.data;
}
export async function exportArchive(
format: "json" | "csv" = "json",
): Promise<Blob> {
const response = await apiClient.get("/archive/export", {
params: { format },
responseType: "blob",
});
return response.data;
}
export async function importArchive(
file: File,
): Promise<{ success: boolean; imported: number }> {
const formData = new FormData();
formData.append("file", file);
const response = await apiClient.post<{ success: boolean; imported: number }>(
"/archive/import",
formData,
);
return response.data;
}

34
web-app/src/api/client.ts Normal file
View File

@ -0,0 +1,34 @@
import axios from "axios";
const apiBaseURL = import.meta.env.VITE_API_BASE_URL || "/api";
export const apiClient = axios.create({
baseURL: apiBaseURL,
timeout: 30000,
headers: {
"Content-Type": "application/json",
},
});
apiClient.interceptors.request.use(
(config) => {
return config;
},
(error) => {
return Promise.reject(error);
},
);
apiClient.interceptors.response.use(
(response) => {
return response;
},
(error) => {
if (error.response?.status === 404) {
console.error("API endpoint not found");
} else if (error.response?.status === 500) {
console.error("Server error occurred");
}
return Promise.reject(error);
},
);

123
web-app/src/api/queue.ts Normal file
View File

@ -0,0 +1,123 @@
import { apiClient } from "./client";
export interface QueueItem {
id: string;
videoId: string;
title: string;
thumbnail: string;
status: "pending" | "downloading" | "completed" | "failed";
progress: number;
category: string;
addedAt: string;
errorMessage?: string;
quality?: string;
}
export interface QueueResponse {
items: QueueItem[];
total: number;
pendingCount: number;
downloadingCount: number;
}
export interface AddToQueueRequest {
videoId: string;
title: string;
thumbnail: string;
category: string;
url: string;
quality?: string;
}
export async function getQueue(): Promise<QueueResponse> {
const response = await apiClient.get<any>("/queue");
// Handle both server response formats (queue vs items)
const queueData = response.data.queue || response.data.items || [];
const total = response.data.total || 0;
return {
items: queueData,
total: total,
pendingCount: queueData.filter((item: any) => item.status === "pending")
.length,
downloadingCount: queueData.filter(
(item: any) => item.status === "downloading",
).length,
};
}
export async function addToQueue(
request: AddToQueueRequest,
): Promise<QueueItem> {
const response = await apiClient.post<QueueItem>("/queue", request);
return response.data;
}
export async function removeFromQueue(queueId: string): Promise<void> {
await apiClient.delete(`/queue/${queueId}`);
}
export async function clearQueue(): Promise<void> {
await apiClient.delete("/queue");
}
export async function moveQueueItem(
queueId: string,
direction: "up" | "down",
): Promise<void> {
await apiClient.post(`/queue/${queueId}/move`, { direction });
}
export async function updateQueueItem(
queueId: string,
updates: Partial<QueueItem>,
): Promise<QueueItem> {
const response = await apiClient.put<QueueItem>(`/queue/${queueId}`, updates);
return response.data;
}
export async function getQueueStats(): Promise<{
total: number;
pending: number;
downloading: number;
completed: number;
failed: number;
}> {
const response = await apiClient.get<{
total: number;
pending: number;
downloading: number;
completed: number;
failed: number;
}>("/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`);
}

98
web-app/src/api/search.ts Normal file
View File

@ -0,0 +1,98 @@
import { apiClient } from "./client";
export interface SearchResult {
id: string;
title: string;
description: string;
thumbnail: string;
url: string;
duration: string;
views: string;
channel: string;
isShort: boolean;
published: string;
}
export interface SearchResponse {
results: SearchResult[];
total: number;
page: number;
hasMore: boolean;
}
export interface SearchParams {
query: string;
page?: number;
limit?: number;
}
export async function searchVideos(
params: SearchParams,
): Promise<SearchResponse> {
const response = await apiClient.get<SearchResponse>("/search", {
params: {
q: params.query,
page: params.page || 1,
limit: params.limit || 15,
},
});
// 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.results || [];
return {
results: videos.map((v: any) => ({
id: v.id,
videoId: v.videoId || v.id,
title: v.title,
description: v.description || "",
thumbnail: v.thumbnail || `https://i.ytimg.com/vi/${v.id}/hqdefault.jpg`,
url: v.url,
category: "General",
duration: v.duration || "0:00",
views: v.views || "0",
channel: v.channel || "Unknown",
isShort: v.isShort || false,
published: v.published || "",
})),
total: videos.length,
page: actualData.page || params.page || 1,
hasMore: actualData.hasMore || videos.length >= 15,
};
}
export async function getVideoDetails(videoId: string): Promise<SearchResult> {
const response = await apiClient.get<SearchResult>(`/video/${videoId}`);
return response.data;
}
export async function getVideoInfo(url: string): Promise<SearchResult> {
const response = await apiClient.get<SearchResult>("/info", {
params: { url },
});
return response.data;
}
export async function getRecentSearches(): Promise<string[]> {
const response = await apiClient.get<string[]>("/recent-searches");
return response.data;
}
export async function clearRecentSearches(): Promise<void> {
await apiClient.delete("/recent-searches");
}
export async function removeRecentSearch(query: string): Promise<void> {
await apiClient.delete(`/recent-searches/${encodeURIComponent(query)}`);
}
export async function saveRecentSearch(query: string): Promise<void> {
await apiClient.post("/recent-searches", { query });
}

60
web-app/src/api/socket.ts Normal file
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

@ -0,0 +1,97 @@
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" },
];
return (
<nav className="bg-slate-800 border-b border-slate-700">
<div className="container mx-auto px-4">
<div className="flex items-center justify-between h-16">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-red-600 rounded-lg flex items-center justify-center">
<svg
className="w-5 h-5 text-white"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.376.545a3.017 3.017 0 0 0-2.122 2.136C1.997 8.268 1.997 12 1.997 12s0 3.732 1.997 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.545 9.376.545 9.376.545s7.505 0 9.376-.545a3.015 3.015 0 0 0 2.122-2.136c1.997-2.082 1.997-5.814 1.997-5.814s0-3.732-1.997-5.814zM9.525 12.428V7.75l9.147 4.678-9.147 4.678V12.428c0-1.546-1.235-2.8-2.76-2.8-1.526 0-2.76 1.254-2.76 2.8s1.235 2.8 2.76 2.8c1.525 0 2.76-1.254 2.76-2.8" />
</svg>
</div>
<span className="text-xl font-bold text-white">YouTube CLI</span>
</div>
<div className="flex items-center gap-6">
{navLinks.map((link) => {
const isActive = location.pathname === link.path;
return (
<Link
key={link.path}
to={link.path}
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${
isActive
? "bg-red-600 text-white"
: "text-slate-300 hover:bg-slate-700 hover:text-white"
}`}
>
<span className="text-lg">{link.label}</span>
</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>
);
}

22
web-app/src/index.css Normal file
View File

@ -0,0 +1,22 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
#root {
width: 100%;
min-height: 100vh;
}

13
web-app/src/main.tsx Normal file
View File

@ -0,0 +1,13 @@
import React from "react"
import ReactDOM from "react-dom/client"
import { BrowserRouter } from "react-router-dom"
import App from "./App.tsx"
import "./index.css"
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
)

View File

@ -0,0 +1,306 @@
import { useState, useEffect } from "react";
import VideoPlayerModal from "../components/VideoPlayerModal";
import {
getArchive,
deleteFromArchive,
getArchiveStats,
getCategoryList,
} from "../api/archive";
interface ArchiveItem {
id: string;
videoId: string;
title: string;
description: string;
thumbnail: string;
url: string;
category: string;
downloadPath: string;
networkSharePath?: string;
downloadDate: string;
duration: string;
views: string;
channel: string;
size?: string;
}
export default function Archive() {
const [archiveItems, setArchiveItems] = useState<ArchiveItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [stats, setStats] = useState({
total: 0,
totalSize: "0 MB",
categories: {} as Record<string, number>,
});
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();
fetchCategories();
fetchStats();
}, [currentPage, selectedCategory, searchQuery]);
const fetchArchive = async () => {
try {
const filters: any = { page: currentPage, limit: 24 };
if (selectedCategory) filters.category = selectedCategory;
if (searchQuery) filters.search = searchQuery;
const response = await getArchive(filters);
setArchiveItems(response.items);
setTotalPages(Math.ceil(response.total / 24));
} catch (err) {
console.error("Failed to fetch archive:", err);
} finally {
setIsLoading(false);
}
};
const fetchStats = async () => {
try {
const response = await getArchiveStats();
setStats(response);
} catch (err) {
console.error("Failed to fetch archive stats:", err);
}
};
const fetchCategories = async () => {
try {
const response = await getCategoryList();
setCategories(response);
} catch (err) {
console.error("Failed to fetch categories:", err);
}
};
const handleDelete = async (videoId: string) => {
if (
window.confirm(
"Are you sure you want to delete this video from the archive?",
)
) {
try {
await deleteFromArchive(videoId);
fetchArchive();
fetchStats();
} catch (err) {
console.error("Failed to delete from archive:", err);
alert("Failed to delete video from archive");
}
}
};
const formatDateString = (dateString: string) => {
const date = new Date(dateString);
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(date);
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-red-600"></div>
</div>
);
}
return (
<div className="max-w-7xl mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold text-white mb-2">Video Archive</h1>
<p className="text-slate-400">Manage your downloaded videos</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mb-6">
<div className="bg-slate-800 p-4 rounded-xl">
<div className="text-sm text-slate-400">Total Videos</div>
<div className="text-2xl font-bold text-white">{stats.total}</div>
</div>
<div className="bg-slate-800 p-4 rounded-xl">
<div className="text-sm text-slate-400">Total Storage</div>
<div className="text-2xl font-bold text-white">{stats.totalSize}</div>
</div>
<div className="bg-slate-800 p-4 rounded-xl md:col-span-2">
<div className="text-sm text-slate-400">Categories</div>
<div className="flex flex-wrap gap-2 mt-2">
{Object.entries(stats.categories).map(([category, count]) => (
<span
key={category}
className="px-3 py-1 bg-slate-700 text-slate-300 rounded-full text-sm"
>
{category}: {count}
</span>
))}
</div>
</div>
</div>
<div className="flex flex-wrap gap-4 mb-6">
<div className="flex-1 min-w-[200px]">
<input
type="text"
placeholder="Search archive..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full px-4 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-red-600"
/>
</div>
<select
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
className="px-4 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-red-600"
>
<option value="">All Categories</option>
{categories.map((cat) => (
<option key={cat} value={cat}>
{cat}
</option>
))}
</select>
</div>
{archiveItems.length === 0 ? (
<div className="text-center py-12 bg-slate-800 rounded-xl">
<svg
className="w-16 h-16 mx-auto text-slate-600 mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
/>
</svg>
<h3 className="text-xl font-semibold text-white mb-2">
Archive is empty
</h3>
<p className="text-slate-400">Download videos to see them here</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{archiveItems.map((video) => (
<div
key={video.videoId}
className="bg-slate-800 rounded-xl overflow-hidden group hover:shadow-2xl hover:shadow-red-900/20 transition-all duration-300 hover:scale-[1.02]"
>
<div className="relative aspect-video">
<img
src={video.thumbnail}
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>
<div className="absolute top-2 right-2">
<span className="bg-slate-900/80 text-white text-xs px-2 py-1 rounded">
{video.category}
</span>
</div>
<button
onClick={() => handleDelete(video.videoId)}
className="absolute top-2 left-2 p-1 bg-red-600/80 hover:bg-red-700 text-white rounded transition-colors"
title="Delete from archive"
>
<svg
className="w-4 h-4"
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 className="p-4">
<h3 className="font-semibold text-white mb-2 line-clamp-2 group-hover:text-red-500 transition-colors">
{video.title}
</h3>
<div className="flex items-center text-sm text-slate-400 mb-2">
<span className="mr-2">{video.channel}</span>
<span> {video.views} views</span>
</div>
<div className="text-xs text-slate-500 mb-2">
Downloaded: {formatDateString(video.downloadDate)}
</div>
{video.size && (
<div className="text-xs text-slate-500">
Size: {video.size}
</div>
)}
{video.networkSharePath && (
<div className="text-xs text-green-500 mt-1">
Copied to network share
</div>
)}
</div>
</div>
))}
</div>
)}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 mt-8">
<button
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
disabled={currentPage === 1}
className="px-4 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white disabled:opacity-50 disabled:cursor-not-allowed hover:bg-slate-700 transition-colors"
>
Previous
</button>
<span className="text-slate-300">
Page {currentPage} of {totalPages}
</span>
<button
onClick={() =>
setCurrentPage((prev) => Math.min(totalPages, prev + 1))
}
disabled={currentPage === totalPages}
className="px-4 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white disabled:opacity-50 disabled:cursor-not-allowed hover:bg-slate-700 transition-colors"
>
Next
</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>
);
}

369
web-app/src/pages/Queue.tsx Normal file
View File

@ -0,0 +1,369 @@
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" | "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,
downloading: 0,
completed: 0,
failed: 0
})
const fetchQueue = useCallback(async () => {
try {
const response = await getQueue()
setQueueItems(response.items)
setStats({
total: response.total,
pending: response.pendingCount,
downloading: response.downloadingCount,
completed: response.items.filter(item => item.status === "completed").length,
failed: response.items.filter(item => item.status === "failed").length
})
} catch (err) {
console.error("Failed to fetch queue:", err)
} 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 {
await removeFromQueue(queueId)
fetchQueue()
} catch (err) {
console.error("Failed to remove from queue:", err)
alert("Failed to remove item from 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 {
await clearQueue()
fetchQueue()
} catch (err) {
console.error("Failed to clear queue:", err)
alert("Failed to clear queue")
}
}
}
const getStatusColor = (status: string) => {
switch (status) {
case "pending": return "text-yellow-500"
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"
}
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-red-600"></div>
</div>
)
}
return (
<div className="max-w-6xl mx-auto">
<div className="flex items-center justify-between mb-8">
<h1 className="text-3xl font-bold text-white">Download Queue</h1>
{queueItems.length > 0 && (
<button onClick={handleClearQueue} className="px-4 py-2 bg-red-600/20 text-red-500 border border-red-500/50 rounded-lg hover:bg-red-600/30 transition-colors">
Clear Queue
</button>
)}
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
<div className="bg-slate-800 p-4 rounded-xl">
<div className="text-sm text-slate-400">Total</div>
<div className="text-2xl font-bold text-white">{stats.total}</div>
</div>
<div className="bg-slate-800 p-4 rounded-xl">
<div className="text-sm text-slate-400">Pending</div>
<div className={`text-2xl font-bold ${stats.pending > 0 ? "text-yellow-500" : "text-slate-300"}`}>{stats.pending}</div>
</div>
<div className="bg-slate-800 p-4 rounded-xl">
<div className="text-sm text-slate-400">Downloading</div>
<div className={`text-2xl font-bold ${stats.downloading > 0 ? "text-blue-500" : "text-slate-300"}`}>{stats.downloading}</div>
</div>
<div className="bg-slate-800 p-4 rounded-xl">
<div className="text-sm text-slate-400">Completed</div>
<div className="text-2xl font-bold text-green-500">{stats.completed}</div>
</div>
<div className="bg-slate-800 p-4 rounded-xl">
<div className="text-sm text-slate-400">Failed</div>
<div className={`text-2xl font-bold ${stats.failed > 0 ? "text-red-500" : "text-slate-300"}`}>{stats.failed}</div>
</div>
</div>
{queueItems.length === 0 ? (
<div className="text-center py-12 bg-slate-800 rounded-xl">
<svg className="w-16 h-16 mx-auto text-slate-600 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
<h3 className="text-xl font-semibold text-white mb-2">Queue is empty</h3>
<p className="text-slate-400">Add videos from search results to start downloading</p>
</div>
) : (
<div className="space-y-4">
{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 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">
<div className="flex items-start justify-between mb-2">
<h3 className="font-semibold text-white line-clamp-2 max-w-2xl">{item.title}</h3>
<div className="flex items-center gap-2">
<span className={`text-sm ${getStatusColor(item.status)} font-medium`}>
{item.status.charAt(0).toUpperCase() + item.status.slice(1)}
</span>
{item.status === "pending" && (
<button onClick={() => handleStartDownload(item.id)} className="text-slate-500 hover:text-green-500 transition-colors" title="Start download">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-3.197a.75.75 0 011.06-1.06l3.75 3.75a.75.75 0 010 1.06l-3.75 3.75a.75.75 0 11-1.06-1.06l3.197-3.197z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
)}
{item.status === "failed" && (
<button onClick={() => handleRetry(item.id)} className="text-slate-500 hover:text-yellow-500 transition-colors" title="Retry download">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
)}
{item.status === "downloading" && (
<button onClick={() => handleCancel(item.id)} className="text-slate-500 hover:text-orange-500 transition-colors" title="Cancel download">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
<button onClick={() => handleRemove(item.id)} className="text-slate-500 hover:text-red-500 transition-colors" title="Remove from queue">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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>
<div className="flex flex-wrap gap-4 text-sm text-slate-400 mb-4">
<span>Category: {item.category}</span>
<span>Added: {new Date(item.addedAt).toLocaleDateString()}</span>
{item.errorMessage && <span className="text-red-500">Error: {item.errorMessage}</span>}
</div>
<div className="relative pt-1">
<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" : 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>
</div>
</div>
{item.status === "downloading" && (
<div className="flex items-center gap-2 text-sm text-slate-400">
<svg className="animate-spin h-4 w-4 text-blue-500" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.82 3 7.938l3-2.647z"></path>
</svg>
Downloading in progress...
</div>
)}
</div>
</div>
</div>
))}
</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,376 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useNavigate } from "react-router-dom";
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 [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(() => {
loadRecentSearches();
}, []);
const loadRecentSearches = async () => {
try {
const searches = await getRecentSearches();
setRecentSearches(searches);
} catch (err) {
console.error("Failed to load recent searches:", err);
}
};
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.response?.data?.error || 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 performSearch(searchQuery, 1, false);
if (response) {
setHasSearched(true);
if (!recentSearches.includes(searchQuery)) {
const newSearches = [searchQuery, ...recentSearches].slice(0, 10);
setRecentSearches(newSearches);
}
}
} finally {
setIsLoading(false);
}
};
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);
setCurrentQuery(query);
setCurrentPage(1);
setIsLoading(true);
performSearch(query, 1, false)
.then((response) => {
if (response) {
setHasSearched(true);
}
})
.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">
<h1 className="text-4xl md:text-5xl font-bold text-white mb-4">
Search and Download YouTube Videos
</h1>
<p className="text-slate-400 text-lg">
Find, download, and manage your favorite YouTube content
</p>
</div>
<form onSubmit={handleSearch} className="mb-8">
<div className="relative">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Enter YouTube URL or search query..."
className="w-full px-6 py-4 pl-14 bg-slate-800 border border-slate-700 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-red-600 focus:border-transparent text-lg"
/>
<svg
className="w-6 h-6 text-slate-500 absolute left-4 top-1/2 -translate-y-1/2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<button
type="submit"
disabled={isLoading}
className="absolute right-2 top-2 bottom-2 bg-red-600 text-white px-6 rounded-lg font-semibold hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isLoading ? "Searching..." : "Search"}
</button>
</div>
</form>
{recentSearches.length > 0 && !hasSearched && (
<div className="bg-slate-800 rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-white">
Recent Searches
</h2>
<button
onClick={clearRecentSearches}
className="text-sm text-slate-400 hover:text-white transition-colors"
>
Clear History
</button>
</div>
<div className="flex flex-wrap gap-2">
{recentSearches.map((search, index) => (
<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 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>
)}
{error && (
<div className="bg-red-500/10 border border-red-500/50 text-red-500 p-4 rounded-lg mt-6 text-center">
{error}
</div>
)}
{searchResults.length > 0 && (
<div className="mt-8">
<h2 className="text-2xl font-bold mb-6 text-white">
Search Results ({searchResults.length})
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{searchResults.map((video) => (
<div
key={video.id}
onClick={() => handleVideoClick(video)}
className="bg-slate-800 rounded-xl overflow-hidden cursor-pointer hover:shadow-2xl hover:shadow-red-900/20 transition-all duration-300 hover:scale-[1.02] group"
>
<div className="relative aspect-video">
<img
src={video.thumbnail}
alt={video.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
<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">
{video.title}
</h3>
<div className="flex items-center text-sm text-slate-400">
<span className="mr-2">{video.channel}</span>
<span> {video.views} views</span>
</div>
<div className="mt-2 text-xs text-slate-500">
{video.isShort && (
<span className="inline-block bg-red-600 text-white text-[10px] px-1.5 py-0.5 rounded mr-2">
SHORT
</span>
)}
<span>{video.published}</span>
</div>
</div>
</div>
))}
</div>
{hasMore && (
<div ref={loadMoreRef} className="flex justify-center py-8">
{isLoadingMore && (
<div className="flex items-center gap-2 text-slate-400">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-red-600"></div>
<span>Loading more results...</span>
</div>
)}
</div>
)}
{!hasMore && searchResults.length > 0 && (
<div className="text-center py-6 text-slate-500">
No more results
</div>
)}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,298 @@
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;
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 SearchResults() {
const [video, setVideo] = useState<Video | null>(null);
const [_, setSearchResults] = useState<Video[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const navigate = useNavigate();
useEffect(() => {
const storedResults = sessionStorage.getItem("searchResults");
const storedVideo = sessionStorage.getItem("currentVideo");
if (storedResults && storedVideo) {
try {
const results: Video[] = JSON.parse(storedResults);
const currentVideo: Video = JSON.parse(storedVideo);
setSearchResults(results);
setVideo(currentVideo);
setIsLoading(false);
} catch (err) {
console.error("Failed to parse session storage:", err);
setError("Failed to load video details.");
setIsLoading(false);
}
} else {
setError("No video selected. Please search for videos first.");
setIsLoading(false);
}
}, []);
const handleDownload = async () => {
if (!video) return;
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: selectedCategory,
url: video.url,
quality: getQuality(),
});
alert("Video added to download queue!");
navigate("/queue");
} catch (err) {
console.error("Failed to download video:", err);
setError("Failed to add video to queue. Please try again.");
} finally {
setIsLoading(false);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-red-600"></div>
</div>
);
}
if (error) {
return (
<div className="max-w-4xl mx-auto">
<div className="bg-red-500/10 border border-red-500/50 text-red-500 p-4 rounded-lg text-center mt-8">
{error}
</div>
<button
onClick={() => navigate("/")}
className="mt-4 px-6 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
>
Go Back to Search
</button>
</div>
);
}
if (!video) {
return null;
}
return (
<div className="max-w-6xl mx-auto">
<button
onClick={() => navigate("/")}
className="mb-6 flex items-center text-slate-400 hover:text-white transition-colors"
>
<svg
className="w-4 h-4 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
Back to Search
</button>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<div className="bg-slate-800 rounded-xl overflow-hidden shadow-2xl">
<div className="aspect-video bg-black">
<img
src={video.thumbnail}
alt={video.title}
className="w-full h-full object-cover"
/>
</div>
<div className="p-6">
<h1 className="text-2xl md:text-3xl font-bold text-white mb-4 line-clamp-2">
{video.title}
</h1>
<div className="flex flex-wrap items-center gap-4 text-sm text-slate-400 mb-4">
<span className="flex items-center">
<svg
className="w-5 h-5 mr-2 text-red-600"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.376.545a3.017 3.017 0 0 0-2.122 2.136C1.997 8.268 1.997 12 1.997 12s0 3.732 1.997 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.545 9.376.545 9.376.545s7.505 0 9.376-.545a3.015 3.015 0 0 0 2.122-2.136c1.997-2.082 1.997-5.814 1.997-5.814s0-3.732-1.997-5.814zM9.525 12.428V7.75l9.147 4.678-9.147 4.678V12.428c0-1.546-1.235-2.8-2.76-2.8-1.526 0-2.76 1.254-2.76 2.8s1.235 2.8 2.76 2.8c1.525 0 2.76-1.254 2.76-2.8" />
</svg>
{video.views}
</span>
<span>{video.published}</span>
{video.isShort && (
<span className="inline-block bg-red-600 text-white text-[10px] px-1.5 py-0.5 rounded">
SHORT
</span>
)}
</div>
<div className="mb-4">
<h3 className="text-sm font-semibold text-slate-400 mb-2">
Channel
</h3>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-slate-700 rounded-full flex items-center justify-center text-white">
{video.channel.charAt(0).toUpperCase()}
</div>
<span className="text-white">{video.channel}</span>
</div>
</div>
<div className="mb-4">
<h3 className="text-sm font-semibold text-slate-400 mb-2">
Description
</h3>
<p className="text-slate-300 text-sm line-clamp-3">
{video.description}
</p>
</div>
</div>
</div>
</div>
<div className="lg:col-span-1">
<div className="bg-slate-800 rounded-xl p-6 shadow-xl">
<h2 className="text-xl font-bold text-white mb-4">
Download Options
</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-400 mb-2">
Category
</label>
<select
id="downloadCategory"
defaultValue={video.category || "General"}
className="w-full px-4 py-2 bg-slate-900 border border-slate-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-red-600"
>
<option value="General">General</option>
<option value="Music">Music</option>
<option value="Videos">Videos</option>
<option value="Podcasts">Podcasts</option>
<option value="Educational">Educational</option>
<option value="Gaming">Gaming</option>
</select>
</div>
<button
onClick={handleDownload}
disabled={isLoading}
className="w-full py-3 px-4 bg-red-600 text-white rounded-lg font-semibold hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{isLoading ? (
<>
<svg
className="animate-spin h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Downloading...
</>
) : (
<>
<svg
className="w-5 h-5"
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>
Download Now
</>
)}
</button>
<button
onClick={() => navigate("/queue")}
className="w-full py-2 px-4 bg-slate-700 text-slate-300 rounded-lg font-medium hover:bg-slate-600 transition-colors"
>
View Queue
</button>
</div>
</div>
<div className="bg-slate-800 rounded-xl p-6 mt-6">
<h3 className="text-sm font-semibold text-slate-400 mb-3">
Video Details
</h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-slate-500">Duration:</span>
<span className="text-white">{video.duration}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">Views:</span>
<span className="text-white">{video.views}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">Category:</span>
<span className="text-white">
{video.category || "General"}
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">Source:</span>
<a
href={video.url}
target="_blank"
rel="noopener noreferrer"
className="text-red-600 hover:underline"
>
YouTube
</a>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

10
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

@ -0,0 +1,8 @@
/** @type {import("tailwindcss").Config} */
export default {
content: ["./src/**/*.{js,jsx,ts,tsx}"],
theme: {
extend: {},
},
plugins: [],
}

View File

@ -0,0 +1,152 @@
import { test, expect } from "@playwright/test";
test.describe("YouTube Web App E2E Tests", () => {
test.beforeEach(async ({ page }) => {
// Go to home page for tests that need it
await page.goto("http://localhost:5173");
});
test("should visit the home page", async ({ page }) => {
await expect(page).toHaveTitle(/YouTube/);
const searchInput = page.locator(
'input[placeholder="Enter YouTube URL or search query..."]',
);
await expect(searchInput).toBeVisible();
});
test("should search for a video", async ({ page }) => {
const searchInput = page.locator(
'input[placeholder="Enter YouTube URL or search query..."]',
);
await searchInput.fill("test video");
await page.keyboard.press("Enter");
await page.waitForTimeout(2000);
const searchForm = page.locator(
'form:has(input[placeholder="Enter YouTube URL or search query..."])',
);
await expect(searchForm).toBeVisible();
});
test("should display search results", async ({ page }) => {
const searchInput = page.locator(
'input[placeholder="Enter YouTube URL or search query..."]',
);
await searchInput.fill("test");
await page.keyboard.press("Enter");
// Wait for search button to be disabled (search in progress)
await page.waitForSelector('button[disabled]:has-text("Searching...")', {
timeout: 10000,
});
// Wait for search to complete (button reappears enabled)
await page.waitForSelector('button:has-text("Search")', {
timeout: 30000,
});
// Wait for search results to appear
await page.waitForSelector("h2:has-text('Search Results')", {
state: "visible",
timeout: 10000,
});
await expect(page.locator("h2:has-text('Search Results')")).toBeVisible();
});
test("should navigate to queue page", async ({ page }) => {
// Mock queue API call
await page.route("**/api/queue", (route) => {
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ queue: [], total: 0 }),
});
});
await page.goto("http://localhost:5173/queue");
await expect(page).toHaveURL("http://localhost:5173/queue");
await expect(page.locator("text=Download Queue")).toBeVisible();
});
test("should navigate to archive page", async ({ page }) => {
// Go to archive page - the API is mocked by the running Flask server
await page.goto("http://localhost:5173/archive");
await expect(page).toHaveURL("http://localhost:5173/archive");
// Wait for the main container to appear
await page.waitForSelector(".max-w-7xl", { timeout: 15000 });
// Verify the page has the archive heading
await expect(page.locator("h1:has-text('Video Archive')")).toBeVisible({
timeout: 10000,
});
});
test("should click on a search result card and display video information", async ({
page,
}) => {
// Search for "test video"
const searchInput = page.locator(
'input[placeholder="Enter YouTube URL or search query..."]',
);
await searchInput.fill("test video");
await page.keyboard.press("Enter");
// Wait for search button to be disabled (search in progress)
await page.waitForSelector('button[disabled]:has-text("Searching...")', {
timeout: 10000,
});
// Wait for search to complete (button reappears enabled)
await page.waitForSelector('button:has-text("Search")', {
timeout: 30000,
});
// Wait for search results to appear
await page.waitForSelector("h2:has-text('Search Results')", {
state: "visible",
timeout: 10000,
});
// Wait for search result cards to appear
await page.waitForSelector(".bg-slate-800.rounded-xl", {
timeout: 10000,
});
// Get the first search result card and click it
// Use a more specific selector that targets search result cards by their structure
// Cards contain thumbnail (aspect-video), title, channel, and views
await page.waitForSelector(".aspect-video img", { timeout: 10000 });
// Count only the result cards (not other elements with similar classes)
const resultCards = page.locator(".grid.grid-cols-1 .bg-slate-800");
const cardCount = await resultCards.count();
if (cardCount > 0) {
await resultCards.first().click();
// Wait for navigation to complete by checking URL change
await page.waitForFunction(
() => {
const url = window.location.href;
return url.includes("/results");
},
{ timeout: 10000 },
);
// Wait for video information to be displayed
await page.waitForSelector("h1.text-2xl", { timeout: 10000 });
// Verify video information elements are visible
await expect(page.locator("h1.text-2xl")).toBeVisible({
timeout: 10000,
});
await expect(page.locator("text=Channel")).toBeVisible({
timeout: 10000,
});
await expect(page.locator("text=Download Options")).toBeVisible({
timeout: 10000,
});
} else {
console.log("No search results found - test skipped clicking");
}
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

28
web-app/tsconfig.json Normal file
View File

@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"allowJs": true,
"checkJs": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"exclude": ["node_modules"]
}

15
web-app/vite.config.ts Normal file
View File

@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
},
proxy: {
"/api": {
target: "http://localhost:4096",
changeOrigin: true,
},
},
})