Initial commit: youtube-web (extracted from youtube-cli)
This commit is contained in:
commit
9af2f71922
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
playwright-report/
|
||||
test-results/
|
||||
__pycache__/
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
203
PLAN.md
Normal 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
192
README.md
Normal 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
7
requirements-web.txt
Normal 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
170
server/app.py
Normal 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
132
server/banned_terms.txt
Normal 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
526
server/download_engine.py
Normal 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
36
server/gunicorn.conf.py
Normal 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
122
server/models/__init__.py
Normal 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
245
server/models/archive.py
Normal 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()
|
||||
204
server/models/queue_store.py
Normal file
204
server/models/queue_store.py
Normal 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
1142
server/nohup.out
Normal file
File diff suppressed because one or more lines are too long
8
server/routes/__init__.py
Normal file
8
server/routes/__init__.py
Normal 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
234
server/routes/archive.py
Normal 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
214
server/routes/download.py
Normal 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
281
server/routes/queue.py
Normal file
@ -0,0 +1,281 @@
|
||||
"""Queue management API endpoints."""
|
||||
|
||||
import logging
|
||||
|
||||
from flask import Blueprint, request
|
||||
from utils import make_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
330
server/routes/search.py
Normal 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)
|
||||
118
server/tests/test_download_recovery.py
Normal file
118
server/tests/test_download_recovery.py
Normal 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!")
|
||||
177
server/tests/test_queue_api.py
Normal file
177
server/tests/test_queue_api.py
Normal file
@ -0,0 +1,177 @@
|
||||
"""Tests for queue API endpoints - verify the Flask routes work correctly."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
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!")
|
||||
223
server/tests/test_queue_store.py
Normal file
223
server/tests/test_queue_store.py
Normal 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
20
server/utils.py
Normal file
@ -0,0 +1,20 @@
|
||||
"""Shared utility functions for the web server."""
|
||||
|
||||
from flask import jsonify
|
||||
|
||||
|
||||
def make_response(data, status=200):
|
||||
"""Create a JSON response. Returns data directly (no wrapper)."""
|
||||
resp = jsonify(data)
|
||||
resp.status_code = status
|
||||
return resp
|
||||
|
||||
|
||||
def make_error_response(message, status=400):
|
||||
"""Create an error response."""
|
||||
resp = jsonify({
|
||||
"success": False,
|
||||
"error": message
|
||||
})
|
||||
resp.status_code = status
|
||||
return resp
|
||||
5
web-app/.env.example
Normal file
5
web-app/.env.example
Normal file
@ -0,0 +1,5 @@
|
||||
# API base URL (default: /api for same-origin, or full URL for cross-origin)
|
||||
# VITE_API_BASE_URL=http://localhost:4096/api
|
||||
|
||||
# WebSocket URL (default: window.location.origin for same-origin)
|
||||
# VITE_WS_URL=http://localhost:4096
|
||||
13
web-app/index.html
Normal file
13
web-app/index.html
Normal 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
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
30
web-app/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
web-app/postcss.config.js
Normal file
6
web-app/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
7
web-app/postcss.config.json
Normal file
7
web-app/postcss.config.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"plugins": [
|
||||
{
|
||||
"postcss-plugin": true
|
||||
}
|
||||
]
|
||||
}
|
||||
15
web-app/public/index.html
Normal file
15
web-app/public/index.html
Normal 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
26
web-app/src/App.tsx
Normal 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
103
web-app/src/api/archive.ts
Normal 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
34
web-app/src/api/client.ts
Normal 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
123
web-app/src/api/queue.ts
Normal 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
98
web-app/src/api/search.ts
Normal 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
60
web-app/src/api/socket.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { io, Socket } from "socket.io-client";
|
||||
|
||||
let socket: Socket | null = null;
|
||||
|
||||
export function getSocket(): Socket {
|
||||
if (!socket) {
|
||||
const wsURL = import.meta.env.VITE_WS_URL || window.location.origin;
|
||||
socket = io(wsURL, {
|
||||
transports: ["websocket", "polling"],
|
||||
reconnection: true,
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionAttempts: 5,
|
||||
});
|
||||
|
||||
socket.on("connect", () => {
|
||||
console.log("[WS] Connected");
|
||||
});
|
||||
|
||||
socket.on("disconnect", () => {
|
||||
console.log("[WS] Disconnected");
|
||||
});
|
||||
|
||||
socket.on("connect_error", (err) => {
|
||||
console.error("[WS] Connection error:", err.message);
|
||||
});
|
||||
}
|
||||
return socket;
|
||||
}
|
||||
|
||||
export function disconnectSocket(): void {
|
||||
if (socket) {
|
||||
socket.disconnect();
|
||||
socket = null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ProgressUpdate {
|
||||
queueId: string;
|
||||
progress: number;
|
||||
speed?: string | null;
|
||||
eta?: string | null;
|
||||
}
|
||||
|
||||
export interface StatusUpdate {
|
||||
queueId: string;
|
||||
status: string;
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
export interface CompleteUpdate {
|
||||
queueId: string;
|
||||
downloadPath?: string;
|
||||
fileSize?: number;
|
||||
videoCount?: number;
|
||||
}
|
||||
|
||||
export interface FailedUpdate {
|
||||
queueId: string;
|
||||
error: string;
|
||||
}
|
||||
97
web-app/src/components/Navbar.tsx
Normal file
97
web-app/src/components/Navbar.tsx
Normal 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();
|
||||
}
|
||||
73
web-app/src/components/VideoPlayerModal.tsx
Normal file
73
web-app/src/components/VideoPlayerModal.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface VideoPlayerModalProps {
|
||||
videoId: string;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
localVideoPath?: string | null;
|
||||
}
|
||||
|
||||
export default function VideoPlayerModal({ videoId, title, onClose, localVideoPath }: VideoPlayerModalProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handleKey);
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKey);
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (videoRef.current && localVideoPath) {
|
||||
videoRef.current.play().catch(() => {});
|
||||
}
|
||||
}, [localVideoPath]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-slate-900 rounded-xl overflow-hidden w-full max-w-4xl mx-4 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-700">
|
||||
<h2 className="text-white font-semibold truncate pr-4">{title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-slate-400 hover:text-white transition-colors flex-shrink-0"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="aspect-video bg-black">
|
||||
{localVideoPath ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={localVideoPath}
|
||||
controls
|
||||
className="w-full h-full"
|
||||
autoPlay
|
||||
/>
|
||||
) : (
|
||||
<iframe
|
||||
src={`https://www.youtube.com/embed/${videoId}?autoplay=1&rel=0`}
|
||||
title={title}
|
||||
className="w-full h-full"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen"
|
||||
allowFullScreen
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
web-app/src/index.css
Normal file
22
web-app/src/index.css
Normal 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
13
web-app/src/main.tsx
Normal 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>,
|
||||
)
|
||||
306
web-app/src/pages/Archive.tsx
Normal file
306
web-app/src/pages/Archive.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
157
web-app/src/pages/DirectPage.tsx
Normal file
157
web-app/src/pages/DirectPage.tsx
Normal file
@ -0,0 +1,157 @@
|
||||
import { useState } from "react";
|
||||
import { addToQueue } from "../api/queue";
|
||||
import { getQuality } from "../components/Navbar";
|
||||
|
||||
interface VideoInfo {
|
||||
id: string;
|
||||
videoId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
thumbnail: string;
|
||||
url: string;
|
||||
duration: string;
|
||||
views: string;
|
||||
channel: string;
|
||||
isShort: boolean;
|
||||
published: string;
|
||||
}
|
||||
|
||||
export default function DirectPage() {
|
||||
const [url, setUrl] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [videoInfo, setVideoInfo] = useState<VideoInfo | null>(null);
|
||||
const [added, setAdded] = useState(false);
|
||||
|
||||
const handleFetchInfo = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!url.trim()) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setVideoInfo(null);
|
||||
setAdded(false);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/info?url=${encodeURIComponent(url.trim())}`);
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
throw new Error(err.error || "Failed to fetch video info");
|
||||
}
|
||||
const data = await response.json();
|
||||
setVideoInfo(data);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Could not fetch video info. Check the URL and try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!videoInfo) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await addToQueue({
|
||||
videoId: videoInfo.videoId,
|
||||
title: videoInfo.title,
|
||||
thumbnail: videoInfo.thumbnail,
|
||||
category: "General",
|
||||
url: videoInfo.url,
|
||||
quality: getQuality(),
|
||||
});
|
||||
setAdded(true);
|
||||
} catch (err) {
|
||||
setError("Failed to add video to queue.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-900 text-white">
|
||||
<div className="container mx-auto px-4 py-12">
|
||||
<h1 className="text-3xl font-bold mb-8 text-center">Download by Link</h1>
|
||||
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<form onSubmit={handleFetchInfo} className="mb-8">
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="Paste YouTube URL (e.g., https://youtube.com/watch?v=...)"
|
||||
className="flex-1 px-4 py-3 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-red-600"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !url.trim()}
|
||||
className="px-6 py-3 bg-red-600 hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg font-semibold transition-colors"
|
||||
>
|
||||
{loading ? "..." : "Fetch"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-600/20 border border-red-500/50 rounded-lg text-red-500">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{added && (
|
||||
<div className="mb-6 p-4 bg-green-500/20 border border-green-500/50 rounded-lg text-green-500">
|
||||
Added to queue! You can track progress on the Queue page.
|
||||
<button
|
||||
onClick={() => {
|
||||
setUrl("");
|
||||
setVideoInfo(null);
|
||||
setAdded(false);
|
||||
}}
|
||||
className="ml-4 underline hover:text-green-400"
|
||||
>
|
||||
Download another
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{videoInfo && !added && (
|
||||
<div className="bg-slate-800 rounded-xl overflow-hidden">
|
||||
<div className="flex flex-col md:flex-row gap-4 p-4">
|
||||
<div className="w-full md:w-64 aspect-video md:aspect-auto flex-shrink-0">
|
||||
<img
|
||||
src={videoInfo.thumbnail}
|
||||
alt={videoInfo.title}
|
||||
className="w-full h-full object-cover rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-semibold mb-2">{videoInfo.title}</h2>
|
||||
<div className="text-sm text-slate-400 mb-1">
|
||||
{videoInfo.channel}
|
||||
</div>
|
||||
<div className="flex gap-4 text-sm text-slate-500">
|
||||
<span>Duration: {videoInfo.duration}s</span>
|
||||
<span>Views: {videoInfo.views}</span>
|
||||
{videoInfo.isShort && (
|
||||
<span className="inline-block bg-red-600 text-white text-[10px] px-1.5 py-0.5 rounded">
|
||||
SHORT
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={loading}
|
||||
className="mt-4 px-6 py-2 bg-red-600 hover:bg-red-700 disabled:opacity-50 rounded-lg font-semibold transition-colors"
|
||||
>
|
||||
{loading ? "Adding..." : "Add to Queue"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
369
web-app/src/pages/Queue.tsx
Normal file
369
web-app/src/pages/Queue.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
376
web-app/src/pages/SearchPage.tsx
Normal file
376
web-app/src/pages/SearchPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
298
web-app/src/pages/SearchResults.tsx
Normal file
298
web-app/src/pages/SearchResults.tsx
Normal 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
10
web-app/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL?: string;
|
||||
readonly VITE_WS_URL?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
8
web-app/tailwind.config.js
Normal file
8
web-app/tailwind.config.js
Normal file
@ -0,0 +1,8 @@
|
||||
/** @type {import("tailwindcss").Config} */
|
||||
export default {
|
||||
content: ["./src/**/*.{js,jsx,ts,tsx}"],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
152
web-app/tests/e2e/app.spec.ts
Normal file
152
web-app/tests/e2e/app.spec.ts
Normal 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
28
web-app/tsconfig.json
Normal 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
15
web-app/vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user