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