refactor: extract TUI and web UI into separate projects; fix placeholder URLs
This commit is contained in:
parent
c58943f6b8
commit
03c9173a8b
@ -3,7 +3,6 @@ include LICENSE
|
|||||||
include pyproject.toml
|
include pyproject.toml
|
||||||
include requirements.txt
|
include requirements.txt
|
||||||
recursive-include youtube_cli *.py
|
recursive-include youtube_cli *.py
|
||||||
recursive-include youtube_tui *.py
|
|
||||||
recursive-include tests *.py
|
recursive-include tests *.py
|
||||||
global-exclude __pycache__
|
global-exclude __pycache__
|
||||||
global-exclude *.py[cod]
|
global-exclude *.py[cod]
|
||||||
|
|||||||
46
README.md
46
README.md
@ -15,7 +15,6 @@ A command-line interface for browsing and downloading YouTube videos with advanc
|
|||||||
- **Network share copying** - automatically copy downloads to network shares
|
- **Network share copying** - automatically copy downloads to network shares
|
||||||
- **Download archive tracking** - track already downloaded videos
|
- **Download archive tracking** - track already downloaded videos
|
||||||
- **Update checking** - automatically check for yt-dlp updates
|
- **Update checking** - automatically check for yt-dlp updates
|
||||||
- **TUI (Textual-based Interface)** - Modern terminal-based user interface with keyboard navigation
|
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@ -24,7 +23,6 @@ A command-line interface for browsing and downloading YouTube videos with advanc
|
|||||||
- rich
|
- rich
|
||||||
- requests
|
- requests
|
||||||
- Flask (for API functionality)
|
- Flask (for API functionality)
|
||||||
- Textual (for TUI functionality)
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@ -309,45 +307,9 @@ The application follows a modular architecture with:
|
|||||||
- Better integration with AI tools and agents
|
- Better integration with AI tools and agents
|
||||||
- Enhanced logging and monitoring
|
- Enhanced logging and monitoring
|
||||||
|
|
||||||
## YouTube TUI
|
## Related Projects
|
||||||
|
|
||||||
A modern, terminal-based user interface for browsing and downloading YouTube videos built with Textual.
|
The TUI and web interfaces were extracted into their own projects:
|
||||||
|
|
||||||
### Features
|
- **[youtube-tui](https://git.jarianc.com/jarianc/youtube-tui)** — modern terminal-based interface built with Textual
|
||||||
|
- **[youtube-web](https://git.jarianc.com/jarianc/youtube-web)** — React-based web interface with download queue management
|
||||||
- Modern terminal interface with keyboard navigation
|
|
||||||
- Search and browse YouTube videos
|
|
||||||
- Download videos with progress indication
|
|
||||||
- Playlist support
|
|
||||||
- Pagination through results
|
|
||||||
- Category selection for downloads
|
|
||||||
- Network share integration
|
|
||||||
- Download archive tracking
|
|
||||||
|
|
||||||
### Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install youtube-cli[tui]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Usage
|
|
||||||
|
|
||||||
```bash
|
|
||||||
youtube-tui
|
|
||||||
```
|
|
||||||
|
|
||||||
### Keyboard Shortcuts
|
|
||||||
|
|
||||||
| Key | Action |
|
|
||||||
|-----|--------|
|
|
||||||
| `Enter` | Search / Download |
|
|
||||||
| `n` | Next Page |
|
|
||||||
| `p` | Previous Page |
|
|
||||||
| `q` | Quit / Back |
|
|
||||||
| `Escape` | Cancel / Back |
|
|
||||||
| `Ctrl+F` | Search from anywhere |
|
|
||||||
| `Ctrl+R` | Refresh |
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
See [TUI.md](docs/TUI.md) for complete TUI documentation.
|
|
||||||
418
docs/TUI.md
418
docs/TUI.md
@ -1,418 +0,0 @@
|
|||||||
# YouTube TUI Documentation
|
|
||||||
|
|
||||||
A modern, terminal-based user interface for browsing and downloading YouTube videos using Textual and yt-dlp.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- **Modern Terminal Interface** - Built with Textual for a rich, interactive experience
|
|
||||||
- **Search Videos** - Search YouTube with keyword queries and view results in a table
|
|
||||||
- **Download Videos** - Download videos directly with progress indication
|
|
||||||
- **Playlist Support** - Download entire YouTube playlists
|
|
||||||
- **Pagination** - Navigate through search results with keyboard shortcuts
|
|
||||||
- **Category Selection** - Choose download locations from configured categories
|
|
||||||
- **Network Share Integration** - Automatically copy downloads to network shares
|
|
||||||
- **Download Archive** - Track already downloaded videos
|
|
||||||
- **Short Video Detection** - Automatically detect and mark short videos
|
|
||||||
- **Keyboard Navigation** - Full keyboard control with intuitive shortcuts
|
|
||||||
- **Responsive Design** - Works in any terminal size with adaptive layout
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
- Python 3.7+
|
|
||||||
- Textual (TUI framework)
|
|
||||||
- yt-dlp
|
|
||||||
- rich
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
### From Source
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/yourusername/youtube-cli.git
|
|
||||||
cd youtube-cli
|
|
||||||
pip install -e .[tui,dev]
|
|
||||||
```
|
|
||||||
|
|
||||||
### With pip
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install youtube-cli[tui]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Using pip with all extras
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install youtube-cli[all]
|
|
||||||
```
|
|
||||||
|
|
||||||
This will install:
|
|
||||||
- Core dependencies (yt-dlp, rich, requests)
|
|
||||||
- TUI dependencies (textual)
|
|
||||||
- Development tools (pytest, black, ruff, mypy)
|
|
||||||
|
|
||||||
### Docker Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker build -t youtube-tui -f docker/Dockerfile.tui .
|
|
||||||
docker run -it --rm -v ~/Downloads:/root/Downloads youtube-tui
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### Basic Usage
|
|
||||||
|
|
||||||
```bash
|
|
||||||
youtube-tui
|
|
||||||
```
|
|
||||||
|
|
||||||
### Running with Arguments
|
|
||||||
|
|
||||||
```bash
|
|
||||||
youtube-tui "python tutorial"
|
|
||||||
```
|
|
||||||
|
|
||||||
This will open the TUI and immediately search for "python tutorial".
|
|
||||||
|
|
||||||
## Keyboard Shortcuts
|
|
||||||
|
|
||||||
### Global Shortcuts
|
|
||||||
|
|
||||||
| Key | Action | Description |
|
|
||||||
|-----|--------|-------------|
|
|
||||||
| `Ctrl+C` | Quit | Exit the application |
|
|
||||||
| `Ctrl+Q` | Quit | Exit the application |
|
|
||||||
| `F1` | Help | Open help screen |
|
|
||||||
| `Esc` | Back | Go back to previous screen |
|
|
||||||
| `Ctrl+R` | Refresh | Refresh current screen |
|
|
||||||
|
|
||||||
### Search Screen
|
|
||||||
|
|
||||||
| Key | Action | Description |
|
|
||||||
|-----|--------|-------------|
|
|
||||||
| `Enter` | Search | Execute search with current input |
|
|
||||||
| `Ctrl+F` | Search | Open search from anywhere |
|
|
||||||
| `Escape` | Cancel | Go back to previous screen |
|
|
||||||
|
|
||||||
### Results Screen
|
|
||||||
|
|
||||||
| Key | Action | Description |
|
|
||||||
|-----|--------|-------------|
|
|
||||||
| `Enter` | Download | Download selected video |
|
|
||||||
| `n` | Next Page | Go to next page of results |
|
|
||||||
| `p` | Previous Page | Go to previous page of results |
|
|
||||||
| `q` | Quit | Go back to search screen |
|
|
||||||
| `Escape` | Quit | Go back to search screen |
|
|
||||||
| `Ctrl+R` | Refresh | Refresh current results |
|
|
||||||
| `Ctrl+F` | Search | Open search from anywhere |
|
|
||||||
|
|
||||||
### Download Screen
|
|
||||||
|
|
||||||
| Key | Action | Description |
|
|
||||||
|-----|--------|-------------|
|
|
||||||
| `Escape` | Cancel | Cancel current download |
|
|
||||||
| `Ctrl+R` | Refresh | Refresh screen |
|
|
||||||
|
|
||||||
## Navigation
|
|
||||||
|
|
||||||
### Moving Between Screens
|
|
||||||
|
|
||||||
1. **From Search to Results**: Enter a search term and press Enter
|
|
||||||
2. **From Results to Download**: Select a video and press Enter
|
|
||||||
3. **From Download to Results**: Wait for download to complete or press Escape to cancel
|
|
||||||
4. **From Any Screen to Search**: Press Escape or use the navigation menu
|
|
||||||
|
|
||||||
### Page Navigation
|
|
||||||
|
|
||||||
- **Next Page**: Press `n` or click the "Next" button
|
|
||||||
- **Previous Page**: Press `p` or click the "Previous" button
|
|
||||||
- **Page Indicator**: Shows current page and total pages
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
The TUI reads configuration from the same config file as the CLI:
|
|
||||||
|
|
||||||
**Location**: `~/.config/youtube_cli/config.json`
|
|
||||||
|
|
||||||
**Example Configuration**:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"download_dir": "~/Downloads/youtube",
|
|
||||||
"default_locations": [
|
|
||||||
"~/Downloads/youtube",
|
|
||||||
"~/Movies/youtube",
|
|
||||||
"/tmp/youtube"
|
|
||||||
],
|
|
||||||
"max_videos_per_page": 15,
|
|
||||||
"yt_dlp_args": {
|
|
||||||
"format": "bestvideo[height=1080]+bestaudio/bestvideo[height<=1080]+bestaudio",
|
|
||||||
"write_thumbnail": true,
|
|
||||||
"extractor_args": "youtube:player-client=default,-tv_simply"
|
|
||||||
},
|
|
||||||
"network_share_path": "/Volumes/MediaServer/Youtube/",
|
|
||||||
"default_network_subfolder": "General"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Configuration Options
|
|
||||||
|
|
||||||
| Option | Description | Default |
|
|
||||||
|--------|-------------|---------|
|
|
||||||
| `download_dir` | Default download directory | `~/Downloads/youtube` |
|
|
||||||
| `default_locations` | List of category folders | `["~/Downloads/youtube"]` |
|
|
||||||
| `max_videos_per_page` | Number of videos per page | `15` |
|
|
||||||
| `yt_dlp_args` | Custom yt-dlp arguments | `{"format": "bestvideo[height=1080]+bestaudio"}` |
|
|
||||||
| `network_share_path` | Network share path | `/Volumes/MediaServer/Youtube/` |
|
|
||||||
| `default_network_subfolder` | Default network subfolder | `General` |
|
|
||||||
|
|
||||||
## Screens
|
|
||||||
|
|
||||||
### Search Screen
|
|
||||||
|
|
||||||
The search screen is the main entry point. Enter a search term and press Enter to search YouTube.
|
|
||||||
|
|
||||||
**Features**:
|
|
||||||
- Search input field with placeholder text
|
|
||||||
- Search and Cancel buttons
|
|
||||||
- Status bar showing current screen and mode
|
|
||||||
|
|
||||||
### Results Screen
|
|
||||||
|
|
||||||
Displays search results in a table format with:
|
|
||||||
|
|
||||||
- **Video Title**: Click to select for download
|
|
||||||
- **Author/Channel**: Shows the video creator
|
|
||||||
- **Duration**: Video length (MM:SS format)
|
|
||||||
- **Type**: Video, Short, or Playlist indicator
|
|
||||||
|
|
||||||
**Table Features**:
|
|
||||||
- Keyboard navigation (arrow keys)
|
|
||||||
- Selection highlighting
|
|
||||||
- Auto-truncation of long text
|
|
||||||
- Short video detection with "(short)" prefix
|
|
||||||
|
|
||||||
### Download Screen
|
|
||||||
|
|
||||||
Shows download progress with:
|
|
||||||
|
|
||||||
- **Video Title**: Current video being downloaded
|
|
||||||
- **Progress Bar**: Visual progress indicator
|
|
||||||
- **Status Message**: Current download state
|
|
||||||
- **Cancel Button**: Cancel ongoing download
|
|
||||||
|
|
||||||
**States**:
|
|
||||||
- Preparing: Initial setup
|
|
||||||
- Downloading: Active download in progress
|
|
||||||
- Complete: Download finished successfully
|
|
||||||
- Failed: Download encountered an error
|
|
||||||
|
|
||||||
### Help Screen
|
|
||||||
|
|
||||||
Displays help information with:
|
|
||||||
|
|
||||||
- Keyboard shortcuts reference
|
|
||||||
- Configuration options
|
|
||||||
- Troubleshooting tips
|
|
||||||
- Version information
|
|
||||||
|
|
||||||
## Status Bar
|
|
||||||
|
|
||||||
The status bar appears at the bottom of each screen and shows:
|
|
||||||
|
|
||||||
- Current screen name
|
|
||||||
- Application version
|
|
||||||
- yt-dlp version
|
|
||||||
- Status message
|
|
||||||
- Current time
|
|
||||||
- Active theme
|
|
||||||
|
|
||||||
## Theming
|
|
||||||
|
|
||||||
The TUI supports theming through Textual's CSS system. You can customize colors and styles by creating a custom CSS file.
|
|
||||||
|
|
||||||
### Default Theme
|
|
||||||
|
|
||||||
The default theme uses a modern dark color scheme with:
|
|
||||||
|
|
||||||
- Dark background colors
|
|
||||||
- Bright text for readability
|
|
||||||
- Primary color accents
|
|
||||||
- Muted colors for secondary elements
|
|
||||||
|
|
||||||
### Custom Themes
|
|
||||||
|
|
||||||
Create a custom theme by editing the TUI's CSS file or using Textual's theme system.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Application Won't Start
|
|
||||||
|
|
||||||
**Issue**: `ModuleNotFoundError: No module named 'textual'`
|
|
||||||
|
|
||||||
**Solution**:
|
|
||||||
```bash
|
|
||||||
pip install textual
|
|
||||||
```
|
|
||||||
|
|
||||||
### Search Returns No Results
|
|
||||||
|
|
||||||
**Issue**: Search completes but no videos are found
|
|
||||||
|
|
||||||
**Possible Causes**:
|
|
||||||
1. Network connectivity issues
|
|
||||||
2. YouTube API restrictions
|
|
||||||
3. Invalid search term
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
1. Check internet connection
|
|
||||||
2. Try a different search term
|
|
||||||
3. Check yt-dlp installation: `yt-dlp --version`
|
|
||||||
|
|
||||||
### Download Fails
|
|
||||||
|
|
||||||
**Issue**: Download starts but fails partway through
|
|
||||||
|
|
||||||
**Possible Causes**:
|
|
||||||
1. Network interruption
|
|
||||||
2. Video unavailable
|
|
||||||
3. Storage space issues
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
1. Check network connection
|
|
||||||
2. Verify video URL works in browser
|
|
||||||
3. Check available disk space
|
|
||||||
|
|
||||||
### Keyboard Shortcuts Not Working
|
|
||||||
|
|
||||||
**Issue**: Keyboard shortcuts don't respond
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
1. Ensure terminal supports keyboard events
|
|
||||||
2. Check for terminal-specific key bindings
|
|
||||||
3. Try different terminal application
|
|
||||||
|
|
||||||
### Screen Displays Incorrectly
|
|
||||||
|
|
||||||
**Issue**: Text appears garbled or misaligned
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
1. Resize terminal window
|
|
||||||
2. Check terminal encoding (UTF-8 recommended)
|
|
||||||
3. Update Textual: `pip install --upgrade textual`
|
|
||||||
|
|
||||||
### Configuration Changes Not Applied
|
|
||||||
|
|
||||||
**Issue**: Changes to config file don't take effect
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
1. Restart the application
|
|
||||||
2. Check config file permissions
|
|
||||||
3. Verify JSON syntax is valid
|
|
||||||
|
|
||||||
## Advanced Usage
|
|
||||||
|
|
||||||
### Download Multiple Videos
|
|
||||||
|
|
||||||
1. Search for videos
|
|
||||||
2. Select multiple videos by navigating and using the download function
|
|
||||||
3. The TUI will queue downloads sequentially
|
|
||||||
|
|
||||||
### Download Playlists
|
|
||||||
|
|
||||||
1. Search for a playlist URL
|
|
||||||
2. Select the playlist from results
|
|
||||||
3. The TUI will download all videos in the playlist
|
|
||||||
|
|
||||||
### Custom Download Locations
|
|
||||||
|
|
||||||
1. Press `Ctrl+F` to open search from anywhere
|
|
||||||
2. Search for a video
|
|
||||||
3. When downloading, select a custom category
|
|
||||||
4. The video will be saved to that category's folder
|
|
||||||
|
|
||||||
## API Integration
|
|
||||||
|
|
||||||
The TUI can also be used with the REST API:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Start the API server
|
|
||||||
python app.py
|
|
||||||
|
|
||||||
# Use the API
|
|
||||||
curl "http://localhost:4096/search?q=python&page=1"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
### Running Tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run all tests
|
|
||||||
python test_tui.py
|
|
||||||
|
|
||||||
# Run with coverage
|
|
||||||
pytest --cov=youtube_tui tests/
|
|
||||||
|
|
||||||
# Run specific test file
|
|
||||||
pytest tests/unit/test_models.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### Building the TUI
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install development dependencies
|
|
||||||
pip install -e .[dev]
|
|
||||||
|
|
||||||
# Run linter
|
|
||||||
ruff check .
|
|
||||||
|
|
||||||
# Format code
|
|
||||||
black .
|
|
||||||
|
|
||||||
# Type checking
|
|
||||||
mypy .
|
|
||||||
```
|
|
||||||
|
|
||||||
### Directory Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
youtube_tui/
|
|
||||||
├── app.py # Main application class
|
|
||||||
├── __main__.py # Entry point
|
|
||||||
├── models/ # Data models
|
|
||||||
│ ├── __init__.py
|
|
||||||
│ └── video.py
|
|
||||||
├── services/ # Service layer
|
|
||||||
│ ├── __init__.py
|
|
||||||
│ └── youtube.py
|
|
||||||
├── screens/ # Textual screens
|
|
||||||
│ ├── __init__.py
|
|
||||||
│ ├── search.py
|
|
||||||
│ ├── results.py
|
|
||||||
│ ├── download.py
|
|
||||||
│ └── help.py
|
|
||||||
└── widgets/ # Custom widgets
|
|
||||||
├── __init__.py
|
|
||||||
├── status_bar.py
|
|
||||||
└── command_palette.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
1. Fork the repository
|
|
||||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
|
||||||
3. Make your changes
|
|
||||||
4. Run tests (`python test_tui.py`)
|
|
||||||
5. Commit your changes (`git commit -m 'Add some amazing feature'`)
|
|
||||||
6. Push to the branch (`git push origin feature/amazing-feature`)
|
|
||||||
7. Open a Pull Request
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
MIT License
|
|
||||||
|
|
||||||
## Acknowledgments
|
|
||||||
|
|
||||||
- Built with [Textual](https://textual.textualize.io/) - A TUI framework for Python
|
|
||||||
- Uses [yt-dlp](https://github.com/yt-dlp/yt-dlp) for YouTube video handling
|
|
||||||
- Inspired by [Rich](https://github.com/Textualize/rich) for terminal formatting
|
|
||||||
@ -1,480 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Manual test script for YouTube TUI
|
|
||||||
Tests all required functionality
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parent
|
|
||||||
|
|
||||||
|
|
||||||
def print_section(title):
|
|
||||||
"""Print a section header"""
|
|
||||||
print(f"\n{'=' * 60}")
|
|
||||||
print(f" {title}")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
|
|
||||||
def print_test(name, result, details=""):
|
|
||||||
"""Print test result"""
|
|
||||||
status = "✓ PASS" if result else "✗ FAIL"
|
|
||||||
print(f"{status}: {name}")
|
|
||||||
if details:
|
|
||||||
print(f" {details}")
|
|
||||||
|
|
||||||
|
|
||||||
def test_dependencies():
|
|
||||||
"""Test that all required dependencies are installed"""
|
|
||||||
print_section("1. Dependency Check")
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("textual", "textual>=8.0"),
|
|
||||||
("yt_dlp", "yt-dlp"),
|
|
||||||
("rich", "rich"),
|
|
||||||
("requests", "requests"),
|
|
||||||
]
|
|
||||||
|
|
||||||
results = []
|
|
||||||
for package, version_spec in dependencies:
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
[
|
|
||||||
sys.executable,
|
|
||||||
"-c",
|
|
||||||
f"import {package}; print(getattr({package}, '__version__', 'unknown'))",
|
|
||||||
],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=5,
|
|
||||||
)
|
|
||||||
if result.returncode == 0:
|
|
||||||
version = result.stdout.strip()
|
|
||||||
print_test(
|
|
||||||
package.replace("_", "-"), True, f"Version {version}"
|
|
||||||
)
|
|
||||||
results.append((package.replace("_", "-"), True))
|
|
||||||
else:
|
|
||||||
print_test(
|
|
||||||
package.replace("_", "-"), False, "Could not get version"
|
|
||||||
)
|
|
||||||
results.append((package.replace("_", "-"), False))
|
|
||||||
except Exception as e:
|
|
||||||
print_test(package.replace("_", "-"), False, str(e))
|
|
||||||
results.append((package.replace("_", "-"), False))
|
|
||||||
|
|
||||||
return all(r[1] for r in results)
|
|
||||||
|
|
||||||
|
|
||||||
def test_startup():
|
|
||||||
"""Test basic startup of the application"""
|
|
||||||
print_section("2. Basic Startup Test")
|
|
||||||
|
|
||||||
# Test import
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
[
|
|
||||||
sys.executable,
|
|
||||||
"-c",
|
|
||||||
"from youtube_tui.app import YouTubeTUI; print('OK')",
|
|
||||||
],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
import_ok = result.returncode == 0 and result.stdout.strip() == "OK"
|
|
||||||
print_test("Import successful", import_ok)
|
|
||||||
|
|
||||||
if not import_ok:
|
|
||||||
print(f" Error: {result.stderr}")
|
|
||||||
|
|
||||||
return import_ok
|
|
||||||
except Exception as e:
|
|
||||||
print_test("Import successful", False, str(e))
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def test_app_creation():
|
|
||||||
"""Test that the app can be instantiated"""
|
|
||||||
print_section("3. App Creation Test")
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
[
|
|
||||||
sys.executable,
|
|
||||||
"-c",
|
|
||||||
"""
|
|
||||||
from youtube_tui.app import YouTubeTUI
|
|
||||||
app = YouTubeTUI()
|
|
||||||
print(f"Version: {app.VERSION}")
|
|
||||||
print(f"yt-dlp: {app.yt_dlp_version}")
|
|
||||||
print(f"Search history: {len(app.search_history)} items")
|
|
||||||
print("OK")
|
|
||||||
""",
|
|
||||||
],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.returncode == 0:
|
|
||||||
lines = result.stdout.strip().split("\n")
|
|
||||||
for line in lines:
|
|
||||||
if (
|
|
||||||
line.startswith("Version:")
|
|
||||||
or line.startswith("yt-dlp:")
|
|
||||||
or line.startswith("Search history:")
|
|
||||||
):
|
|
||||||
print(f" {line}")
|
|
||||||
print_test("App creation successful", True)
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
print_test("App creation successful", False, result.stderr)
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
print_test("App creation successful", False, str(e))
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def test_unit_tests():
|
|
||||||
"""Run the built-in unit tests"""
|
|
||||||
print_section("4. Unit Tests")
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
[sys.executable, "youtube_tui/test_tui.py"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=30,
|
|
||||||
cwd=str(REPO_ROOT),
|
|
||||||
)
|
|
||||||
|
|
||||||
print(result.stdout)
|
|
||||||
if result.stderr:
|
|
||||||
print("STDERR:", result.stderr)
|
|
||||||
|
|
||||||
# Parse test results
|
|
||||||
if "Total:" in result.stdout:
|
|
||||||
total_line = [
|
|
||||||
line for line in result.stdout.split("\n") if "Total:" in line
|
|
||||||
][0]
|
|
||||||
print(f" {total_line}")
|
|
||||||
|
|
||||||
passed = "✓ PASS" in result.stdout
|
|
||||||
print_test("Unit tests", passed)
|
|
||||||
return passed
|
|
||||||
else:
|
|
||||||
print_test("Unit tests", False, "Could not parse test results")
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
print_test("Unit tests", False, str(e))
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def test_keyboard_shortcuts():
|
|
||||||
"""Test keyboard shortcut handling"""
|
|
||||||
print_section("5. Keyboard Shortcuts")
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
[
|
|
||||||
sys.executable,
|
|
||||||
"-c",
|
|
||||||
"""
|
|
||||||
from youtube_tui.app import YouTubeTUI
|
|
||||||
|
|
||||||
app = YouTubeTUI()
|
|
||||||
|
|
||||||
# Test that shortcuts are registered
|
|
||||||
shortcuts = [
|
|
||||||
('escape', 'quit'),
|
|
||||||
('ctrl+f', 'search'),
|
|
||||||
('ctrl+r', 'refresh'),
|
|
||||||
('ctrl+p', 'command_palette'),
|
|
||||||
('ctrl+h', 'help'),
|
|
||||||
('ctrl+t', 'toggle_theme'),
|
|
||||||
]
|
|
||||||
|
|
||||||
print("Keyboard shortcuts registered:")
|
|
||||||
for key, action in shortcuts:
|
|
||||||
print(f" {key}: {action}")
|
|
||||||
|
|
||||||
print("OK")
|
|
||||||
""",
|
|
||||||
],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.returncode == 0:
|
|
||||||
print(result.stdout)
|
|
||||||
print_test("Keyboard shortcuts", True)
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
print_test("Keyboard shortcuts", False, result.stderr)
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
print_test("Keyboard shortcuts", False, str(e))
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def test_video_model():
|
|
||||||
"""Test Video model functionality"""
|
|
||||||
print_section("6. Video Model Test")
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
[
|
|
||||||
sys.executable,
|
|
||||||
"-c",
|
|
||||||
"""
|
|
||||||
from youtube_tui.models.video import Video
|
|
||||||
|
|
||||||
# Test basic video creation
|
|
||||||
video = Video(
|
|
||||||
video_id="dQw4w9WgXcQ",
|
|
||||||
title="Test Video",
|
|
||||||
channel="Test Channel",
|
|
||||||
channel_id="UC123",
|
|
||||||
duration="3:45",
|
|
||||||
view_count="1000000",
|
|
||||||
upload_date="20230101",
|
|
||||||
description="Test description",
|
|
||||||
)
|
|
||||||
|
|
||||||
print(f"Video ID: {video.video_id}")
|
|
||||||
print(f"Title: {video.title}")
|
|
||||||
print(f"Display Title: {video.display_title}")
|
|
||||||
print(f"URL: {video.url}")
|
|
||||||
print(f"Is Short: {video.is_short}")
|
|
||||||
print(f"Duration: {video.duration}")
|
|
||||||
print(f"View Count: {video.view_count}")
|
|
||||||
|
|
||||||
# Test from_dict
|
|
||||||
video2 = Video.from_dict({
|
|
||||||
"video_id": "abc123",
|
|
||||||
"title": "Another Video",
|
|
||||||
"channel": "Another Channel",
|
|
||||||
"channel_id": "UC456",
|
|
||||||
"duration": "5:30",
|
|
||||||
"view_count": "500000",
|
|
||||||
"upload_date": "20230201",
|
|
||||||
"description": "Another description",
|
|
||||||
})
|
|
||||||
|
|
||||||
print(f"from_dict() works: {video2.title}")
|
|
||||||
|
|
||||||
# Test to_dict
|
|
||||||
video_dict = video.to_dict()
|
|
||||||
print(f"to_dict() keys: {list(video_dict.keys())}")
|
|
||||||
|
|
||||||
print("OK")
|
|
||||||
""",
|
|
||||||
],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.returncode == 0:
|
|
||||||
print(result.stdout)
|
|
||||||
print_test("Video model", True)
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
print_test("Video model", False, result.stderr)
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
print_test("Video model", False, str(e))
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def test_search_history():
|
|
||||||
"""Test search history functionality"""
|
|
||||||
print_section("7. Search History Test")
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
[
|
|
||||||
sys.executable,
|
|
||||||
"-c",
|
|
||||||
"""
|
|
||||||
from youtube_tui.app import YouTubeTUI
|
|
||||||
|
|
||||||
app = YouTubeTUI()
|
|
||||||
|
|
||||||
# Test adding to history
|
|
||||||
app.add_to_search_history("test search 1")
|
|
||||||
app.add_to_search_history("test search 2")
|
|
||||||
app.add_to_search_history("test search 1") # Duplicate
|
|
||||||
|
|
||||||
history = app.search_history
|
|
||||||
|
|
||||||
print(f"History items: {len(history)}")
|
|
||||||
print(f"History: {history}")
|
|
||||||
|
|
||||||
# Test that duplicates are removed
|
|
||||||
if len(history) == 2:
|
|
||||||
print("Duplicate removal: OK")
|
|
||||||
else:
|
|
||||||
print(f"Duplicate removal: FAIL (expected 2, got {len(history)})")
|
|
||||||
|
|
||||||
print("OK")
|
|
||||||
""",
|
|
||||||
],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.returncode == 0:
|
|
||||||
print(result.stdout)
|
|
||||||
print_test("Search history", True)
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
print_test("Search history", False, result.stderr)
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
print_test("Search history", False, str(e))
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def test_services():
|
|
||||||
"""Test service layer components"""
|
|
||||||
print_section("8. Services Test")
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
[
|
|
||||||
sys.executable,
|
|
||||||
"-c",
|
|
||||||
"""
|
|
||||||
from youtube_tui.services.youtube import YouTubeService
|
|
||||||
|
|
||||||
# Test YouTube service
|
|
||||||
yt_service = YouTubeService()
|
|
||||||
print(f"YouTube service created: {yt_service is not None}")
|
|
||||||
print(f"YouTube service: OK")
|
|
||||||
|
|
||||||
print("OK")
|
|
||||||
""",
|
|
||||||
],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.returncode == 0:
|
|
||||||
print(result.stdout)
|
|
||||||
print_test("Services", True)
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
print_test("Services", False, result.stderr)
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
print_test("Services", False, str(e))
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def test_screens():
|
|
||||||
"""Test screen components"""
|
|
||||||
print_section("9. Screens Test")
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
[
|
|
||||||
sys.executable,
|
|
||||||
"-c",
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
os.environ['TERM'] = 'dumb'
|
|
||||||
|
|
||||||
from youtube_tui.screens.search import SearchScreen
|
|
||||||
from youtube_tui.screens.results import ResultsScreen
|
|
||||||
from youtube_tui.screens.download import DownloadScreen
|
|
||||||
from youtube_tui.screens.help import HelpScreen
|
|
||||||
from youtube_tui.screens.modal import CategorySelectionModal
|
|
||||||
|
|
||||||
screens = [
|
|
||||||
('SearchScreen', SearchScreen),
|
|
||||||
('ResultsScreen', ResultsScreen),
|
|
||||||
('DownloadScreen', DownloadScreen),
|
|
||||||
('HelpScreen', HelpScreen),
|
|
||||||
('CategorySelectionModal', CategorySelectionModal),
|
|
||||||
]
|
|
||||||
|
|
||||||
print("Screens:")
|
|
||||||
for name, screen_class in screens:
|
|
||||||
try:
|
|
||||||
instance = screen_class()
|
|
||||||
print(f" {name}: OK")
|
|
||||||
except Exception as e:
|
|
||||||
print(f" {name}: FAIL - {e}")
|
|
||||||
|
|
||||||
print("OK")
|
|
||||||
""",
|
|
||||||
],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.returncode == 0:
|
|
||||||
print(result.stdout)
|
|
||||||
print_test("Screens", True)
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
print_test("Screens", False, result.stderr)
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
print_test("Screens", False, str(e))
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
os.chdir(REPO_ROOT)
|
|
||||||
"""Run all tests"""
|
|
||||||
print("=" * 60)
|
|
||||||
print(" YouTube TUI Manual Test Suite")
|
|
||||||
print("=" * 60)
|
|
||||||
print(f"\nPython: {sys.version}")
|
|
||||||
print(f"Working directory: {REPO_ROOT}")
|
|
||||||
|
|
||||||
results = []
|
|
||||||
|
|
||||||
# Run tests
|
|
||||||
results.append(("Dependencies", test_dependencies()))
|
|
||||||
results.append(("Startup", test_startup()))
|
|
||||||
results.append(("App Creation", test_app_creation()))
|
|
||||||
results.append(("Unit Tests", test_unit_tests()))
|
|
||||||
results.append(("Keyboard Shortcuts", test_keyboard_shortcuts()))
|
|
||||||
results.append(("Video Model", test_video_model()))
|
|
||||||
results.append(("Search History", test_search_history()))
|
|
||||||
results.append(("Services", test_services()))
|
|
||||||
results.append(("Screens", test_screens()))
|
|
||||||
|
|
||||||
# Summary
|
|
||||||
print_section("Test Summary")
|
|
||||||
|
|
||||||
passed = sum(1 for _, r in results if r)
|
|
||||||
total = len(results)
|
|
||||||
|
|
||||||
for name, result in results:
|
|
||||||
status = "✓ PASS" if result else "✗ FAIL"
|
|
||||||
print(f"{status}: {name}")
|
|
||||||
|
|
||||||
print(f"\nTotal: {passed}/{total} tests passed")
|
|
||||||
|
|
||||||
if passed == total:
|
|
||||||
print("\n✓ All tests passed!")
|
|
||||||
return 0
|
|
||||||
else:
|
|
||||||
print(f"\n✗ {total - passed} test(s) failed")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@ -40,13 +40,12 @@ dependencies = [
|
|||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
youtube-cli = "youtube_cli.main:main"
|
youtube-cli = "youtube_cli.main:main"
|
||||||
youtube-tui = "youtube_tui.__main__:main"
|
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Homepage = "https://git.example.com/jarianc/youtube-cli"
|
Homepage = "https://git.jarianc.com/jarianc/youtube-cli"
|
||||||
Issues = "https://git.example.com/jarianc/youtube-cli/issues"
|
Issues = "https://git.jarianc.com/jarianc/youtube-cli/issues"
|
||||||
Documentation = "https://git.example.com/jarianc/youtube-cli"
|
Documentation = "https://git.jarianc.com/jarianc/youtube-cli"
|
||||||
Source = "https://git.example.com/jarianc/youtube-cli"
|
Source = "https://git.jarianc.com/jarianc/youtube-cli"
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
@ -61,18 +60,12 @@ dev = [
|
|||||||
api = [
|
api = [
|
||||||
"Flask==2.3.3",
|
"Flask==2.3.3",
|
||||||
]
|
]
|
||||||
tui = [
|
|
||||||
"textual>=0.40.0,<2.0.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
prerelease = "allow"
|
prerelease = "allow"
|
||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
packages = ["youtube_cli", "youtube_tui", "youtube_tui.screens", "youtube_tui.widgets", "youtube_tui.models", "youtube_tui.services"]
|
packages = ["youtube_cli"]
|
||||||
|
|
||||||
[tool.setuptools.package-data]
|
|
||||||
youtube_tui = ["py.typed"]
|
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
@ -90,7 +83,6 @@ select = ["E", "F", "W", "I"]
|
|||||||
ignore = ["E501", "E402"]
|
ignore = ["E501", "E402"]
|
||||||
|
|
||||||
[tool.ruff.lint.per-file-ignores]
|
[tool.ruff.lint.per-file-ignores]
|
||||||
"web/server/models/archive.py" = ["F821"]
|
|
||||||
"**/tests/**" = ["F401", "F841"]
|
"**/tests/**" = ["F401", "F841"]
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
@ -113,10 +105,6 @@ exclude = [
|
|||||||
"tests/",
|
"tests/",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[tool.mypy.overrides]]
|
|
||||||
module = "textual.*"
|
|
||||||
ignore_missing_imports = true
|
|
||||||
|
|
||||||
[[tool.mypy.overrides]]
|
[[tool.mypy.overrides]]
|
||||||
module = "rich.*"
|
module = "rich.*"
|
||||||
ignore_missing_imports = true
|
ignore_missing_imports = true
|
||||||
@ -125,7 +113,4 @@ ignore_missing_imports = true
|
|||||||
module = "youtube_cli.*"
|
module = "youtube_cli.*"
|
||||||
ignore_missing_imports = true
|
ignore_missing_imports = true
|
||||||
|
|
||||||
[[tool.mypy.overrides]]
|
|
||||||
module = "youtube_tui.*"
|
|
||||||
check_untyped_defs = true
|
|
||||||
disallow_untyped_defs = true
|
|
||||||
|
|||||||
@ -1,4 +0,0 @@
|
|||||||
textual>=8.0
|
|
||||||
yt-dlp
|
|
||||||
rich
|
|
||||||
requests
|
|
||||||
22
setup.py
22
setup.py
@ -9,14 +9,14 @@ setup(
|
|||||||
author="Jarian Cottingham",
|
author="Jarian Cottingham",
|
||||||
author_email="jarianc@proton.me",
|
author_email="jarianc@proton.me",
|
||||||
description="A command-line interface for browsing and downloading YouTube videos",
|
description="A command-line interface for browsing and downloading YouTube videos",
|
||||||
keywords="youtube, cli, download, video, yt-dlp, tui, terminal",
|
keywords="youtube, cli, download, video, yt-dlp, terminal",
|
||||||
long_description=long_description,
|
long_description=long_description,
|
||||||
long_description_content_type="text/markdown",
|
long_description_content_type="text/markdown",
|
||||||
url="https://git.example.com/jarianc/youtube-cli",
|
url="https://git.jarianc.com/jarianc/youtube-cli",
|
||||||
project_urls={
|
project_urls={
|
||||||
"Documentation": "https://git.example.com/jarianc/youtube-cli",
|
"Documentation": "https://git.jarianc.com/jarianc/youtube-cli",
|
||||||
"Issue Tracker": "https://git.example.com/jarianc/youtube-cli/issues",
|
"Issue Tracker": "https://git.jarianc.com/jarianc/youtube-cli/issues",
|
||||||
"Source": "https://git.example.com/jarianc/youtube-cli",
|
"Source": "https://git.jarianc.com/jarianc/youtube-cli",
|
||||||
},
|
},
|
||||||
packages=find_packages(),
|
packages=find_packages(),
|
||||||
classifiers=[
|
classifiers=[
|
||||||
@ -56,14 +56,10 @@ setup(
|
|||||||
"api": [
|
"api": [
|
||||||
"Flask==2.3.3",
|
"Flask==2.3.3",
|
||||||
],
|
],
|
||||||
"tui": [
|
|
||||||
"textual>=0.40.0",
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
entry_points={
|
entry_points={
|
||||||
"console_scripts": [
|
"console_scripts": [
|
||||||
"youtube-cli=youtube_cli.main:main",
|
"youtube-cli=youtube_cli.main:main",
|
||||||
"youtube-tui=youtube_tui.__main__:main",
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
test_suite="tests",
|
test_suite="tests",
|
||||||
@ -73,12 +69,4 @@ setup(
|
|||||||
"pytest-cov>=4.0.0",
|
"pytest-cov>=4.0.0",
|
||||||
],
|
],
|
||||||
include_package_data=True,
|
include_package_data=True,
|
||||||
package_data={
|
|
||||||
"youtube_tui": [
|
|
||||||
"screens/*.py",
|
|
||||||
"services/*.py",
|
|
||||||
"widgets/*.py",
|
|
||||||
"models/*.py",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|||||||
166
test_tui.py
166
test_tui.py
@ -1,166 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Main test runner for YouTube TUI tests
|
|
||||||
|
|
||||||
This script runs all TUI tests with detailed output and coverage reporting.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
|
|
||||||
|
|
||||||
def print_header(text):
|
|
||||||
"""Print a formatted header"""
|
|
||||||
print("\n" + "=" * 70)
|
|
||||||
print(f" {text}")
|
|
||||||
print("=" * 70)
|
|
||||||
|
|
||||||
|
|
||||||
def print_success(text):
|
|
||||||
"""Print success message"""
|
|
||||||
print(f"\033[92m✓ {text}\033[0m")
|
|
||||||
|
|
||||||
|
|
||||||
def print_error(text):
|
|
||||||
"""Print error message"""
|
|
||||||
print(f"\033[91m✗ {text}\033[0m")
|
|
||||||
|
|
||||||
|
|
||||||
def print_info(text):
|
|
||||||
"""Print info message"""
|
|
||||||
print(f"\033[94mℹ {text}\033[0m")
|
|
||||||
|
|
||||||
|
|
||||||
def run_pytest():
|
|
||||||
"""Run pytest with coverage"""
|
|
||||||
print_header("Running TUI Tests")
|
|
||||||
|
|
||||||
# Build pytest command
|
|
||||||
pytest_cmd = [
|
|
||||||
sys.executable,
|
|
||||||
"-m",
|
|
||||||
"pytest",
|
|
||||||
"tests/",
|
|
||||||
"-v", # Verbose output
|
|
||||||
"--tb=short", # Short traceback
|
|
||||||
"--strict-markers", # Require markers
|
|
||||||
]
|
|
||||||
|
|
||||||
# Add coverage if available
|
|
||||||
try:
|
|
||||||
import importlib.util
|
|
||||||
|
|
||||||
if importlib.util.find_spec("pytest_cov") is not None:
|
|
||||||
pytest_cmd.extend(
|
|
||||||
[
|
|
||||||
"--cov=youtube_tui",
|
|
||||||
"--cov-report=term-missing",
|
|
||||||
"--cov-report=html:htmlcov",
|
|
||||||
"--cov-config=.coveragerc",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
print_info("Coverage reporting enabled")
|
|
||||||
else:
|
|
||||||
print_info("Coverage not available, skipping coverage reporting")
|
|
||||||
except ImportError:
|
|
||||||
print_info("Coverage not available, skipping coverage reporting")
|
|
||||||
|
|
||||||
print_info(f"Running: {' '.join(pytest_cmd)}")
|
|
||||||
|
|
||||||
# Run pytest
|
|
||||||
result = subprocess.run(
|
|
||||||
pytest_cmd, cwd=os.path.dirname(os.path.abspath(__file__))
|
|
||||||
)
|
|
||||||
|
|
||||||
return result.returncode
|
|
||||||
|
|
||||||
|
|
||||||
def run_tui_tests_directly():
|
|
||||||
"""Run TUI tests directly without pytest"""
|
|
||||||
print_header("Running TUI Tests (Direct Mode)")
|
|
||||||
|
|
||||||
# Import test modules
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
# Discover tests
|
|
||||||
loader = unittest.TestLoader()
|
|
||||||
start_dir = "tests"
|
|
||||||
suite = loader.discover(start_dir, pattern="test_*.py")
|
|
||||||
|
|
||||||
# Run tests
|
|
||||||
runner = unittest.TextTestRunner(verbosity=2)
|
|
||||||
result = runner.run(suite)
|
|
||||||
|
|
||||||
return 0 if result.wasSuccessful() else 1
|
|
||||||
|
|
||||||
|
|
||||||
def check_dependencies():
|
|
||||||
"""Check if required dependencies are installed"""
|
|
||||||
print_header("Checking Dependencies")
|
|
||||||
|
|
||||||
required_packages = [
|
|
||||||
("textual", "Textual TUI framework"),
|
|
||||||
("pytest", "pytest testing framework"),
|
|
||||||
]
|
|
||||||
|
|
||||||
missing = []
|
|
||||||
|
|
||||||
for package, name in required_packages:
|
|
||||||
try:
|
|
||||||
__import__(package)
|
|
||||||
print_success(f"{name} ({package})")
|
|
||||||
except ImportError:
|
|
||||||
print_error(f"{name} ({package}) - NOT INSTALLED")
|
|
||||||
missing.append(package)
|
|
||||||
|
|
||||||
if missing:
|
|
||||||
print_info("\nInstall missing packages with:")
|
|
||||||
print(f" pip install {' '.join(missing)}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""Main entry point"""
|
|
||||||
print_header("YouTube TUI Test Suite")
|
|
||||||
|
|
||||||
# Check dependencies
|
|
||||||
if not check_dependencies():
|
|
||||||
print_error("Missing dependencies. Please install required packages.")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Check if pytest is available
|
|
||||||
try:
|
|
||||||
import importlib.util
|
|
||||||
|
|
||||||
if importlib.util.find_spec("pytest") is not None:
|
|
||||||
print_success("pytest is installed")
|
|
||||||
use_pytest = True
|
|
||||||
else:
|
|
||||||
print_info("pytest not found, using unittest")
|
|
||||||
use_pytest = False
|
|
||||||
except ImportError:
|
|
||||||
print_info("pytest not found, using unittest")
|
|
||||||
use_pytest = False
|
|
||||||
|
|
||||||
# Run tests
|
|
||||||
if use_pytest:
|
|
||||||
exit_code = run_pytest()
|
|
||||||
else:
|
|
||||||
exit_code = run_tui_tests_directly()
|
|
||||||
|
|
||||||
# Print summary
|
|
||||||
print_header("Test Summary")
|
|
||||||
|
|
||||||
if exit_code == 0:
|
|
||||||
print_success("All tests passed!")
|
|
||||||
else:
|
|
||||||
print_error("Some tests failed. Please review the output above.")
|
|
||||||
|
|
||||||
return exit_code
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@ -1,235 +0,0 @@
|
|||||||
"""
|
|
||||||
Test fixtures and configuration for YouTube TUI tests
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
# Test fixtures
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def sample_video_data():
|
|
||||||
"""Sample video data for testing"""
|
|
||||||
return {
|
|
||||||
"video_id": "dQw4w9WgXcQ",
|
|
||||||
"title": "Rick Astley - Never Gonna Give You Up",
|
|
||||||
"channel": "RickAstleyVEVO",
|
|
||||||
"channel_id": "UCuZqHn2U8f4o7bVlY8v8w",
|
|
||||||
"duration": "3:33",
|
|
||||||
"view_count": "1000000",
|
|
||||||
"upload_date": "20091025",
|
|
||||||
"description": "The official video for Rick Astley's hit song.",
|
|
||||||
"thumbnail_url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg",
|
|
||||||
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
|
||||||
"is_short": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def sample_video_object(sample_video_data):
|
|
||||||
"""Sample Video object for testing"""
|
|
||||||
from youtube_tui.models.video import Video
|
|
||||||
|
|
||||||
return Video(**sample_video_data)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_yt_dlp_result():
|
|
||||||
"""Mock yt-dlp search result"""
|
|
||||||
return {
|
|
||||||
"id": "abc123",
|
|
||||||
"title": "Sample Video",
|
|
||||||
"author": "Sample Channel",
|
|
||||||
"channel": "Sample Channel",
|
|
||||||
"channel_id": "channel123",
|
|
||||||
"length": "10:30",
|
|
||||||
"view_count": 150000,
|
|
||||||
"upload_date": "20240115",
|
|
||||||
"description": "A sample video description",
|
|
||||||
"thumbnail": "https://i.ytimg.com/vi/abc123/hqdefault.jpg",
|
|
||||||
"url": "https://www.youtube.com/watch?v=abc123",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_youtube_service():
|
|
||||||
"""Mock YouTubeService for testing"""
|
|
||||||
from youtube_cli.main import YouTubeCLI
|
|
||||||
from youtube_tui.services.youtube import YouTubeService
|
|
||||||
|
|
||||||
# Create a real service with mocked cli
|
|
||||||
with patch.object(YouTubeCLI, "__init__", return_value=None):
|
|
||||||
service = YouTubeService.__new__(YouTubeService)
|
|
||||||
service.cli = MagicMock()
|
|
||||||
service.console = MagicMock()
|
|
||||||
|
|
||||||
# Mock async methods
|
|
||||||
service.search_videos = AsyncMock()
|
|
||||||
service.download_video = AsyncMock()
|
|
||||||
service.download_playlist = AsyncMock()
|
|
||||||
service.get_categories = AsyncMock()
|
|
||||||
service.is_video_downloaded = AsyncMock()
|
|
||||||
service.add_to_archive = AsyncMock()
|
|
||||||
service.get_archive = AsyncMock()
|
|
||||||
service.get_downloaded_video_ids = AsyncMock()
|
|
||||||
service.remove_from_archive = AsyncMock()
|
|
||||||
service._create_video_from_result = MagicMock()
|
|
||||||
|
|
||||||
return service
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_video():
|
|
||||||
"""Mock Video object"""
|
|
||||||
from youtube_tui.models.video import Video
|
|
||||||
|
|
||||||
video = Video(
|
|
||||||
video_id="test123",
|
|
||||||
title="Test Video",
|
|
||||||
channel="Test Channel",
|
|
||||||
channel_id="channel123",
|
|
||||||
duration="5:00",
|
|
||||||
view_count="1000",
|
|
||||||
upload_date="20240101",
|
|
||||||
description="Test description",
|
|
||||||
thumbnail_url="https://example.com/thumb.jpg",
|
|
||||||
url="https://www.youtube.com/watch?v=test123",
|
|
||||||
is_short=False,
|
|
||||||
)
|
|
||||||
return video
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_search_results():
|
|
||||||
"""Mock search results"""
|
|
||||||
from youtube_tui.models.video import Video
|
|
||||||
|
|
||||||
videos = [
|
|
||||||
Video(
|
|
||||||
video_id=f"video{i}",
|
|
||||||
title=f"Video {i}",
|
|
||||||
channel=f"Channel {i}",
|
|
||||||
channel_id=f"channel{i}",
|
|
||||||
duration=f"{i}:00",
|
|
||||||
view_count=str(i * 1000),
|
|
||||||
upload_date="20240101",
|
|
||||||
description=f"Description {i}",
|
|
||||||
is_short=(i % 3 == 0),
|
|
||||||
url=f"https://www.youtube.com/watch?v=video{i}",
|
|
||||||
)
|
|
||||||
for i in range(1, 16)
|
|
||||||
]
|
|
||||||
return videos
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def app_config(tmp_path):
|
|
||||||
"""Temporary app configuration"""
|
|
||||||
config = {
|
|
||||||
"download_dir": str(tmp_path / "downloads"),
|
|
||||||
"default_locations": [
|
|
||||||
str(tmp_path / "downloads"),
|
|
||||||
str(tmp_path / "movies"),
|
|
||||||
],
|
|
||||||
"max_videos_per_page": 15,
|
|
||||||
"yt_dlp_args": {
|
|
||||||
"format": "bestvideo[height=1080]+bestaudio",
|
|
||||||
},
|
|
||||||
"network_share_path": str(tmp_path / "network"),
|
|
||||||
"default_network_subfolder": "General",
|
|
||||||
}
|
|
||||||
return config
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_app():
|
|
||||||
"""Mock Textual App for testing screens"""
|
|
||||||
from textual.app import App
|
|
||||||
|
|
||||||
mock_app = MagicMock(spec=App)
|
|
||||||
mock_app.screen_stack = [None, None, None] # Simulate screen stack
|
|
||||||
mock_app.notify = MagicMock()
|
|
||||||
mock_app.exit = MagicMock()
|
|
||||||
|
|
||||||
# Mock push_screen and pop_screen
|
|
||||||
mock_app.push_screen = MagicMock()
|
|
||||||
mock_app.push_results_screen = MagicMock()
|
|
||||||
mock_app.pop_screen = MagicMock()
|
|
||||||
mock_app.action_open_search = MagicMock()
|
|
||||||
mock_app.run_background = MagicMock()
|
|
||||||
|
|
||||||
return mock_app
|
|
||||||
|
|
||||||
|
|
||||||
# Async test utilities
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def event_loop():
|
|
||||||
"""Create an event loop for async tests"""
|
|
||||||
loop = asyncio.new_event_loop()
|
|
||||||
yield loop
|
|
||||||
loop.close()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
async def async_mock_search_results():
|
|
||||||
"""Async mock search results"""
|
|
||||||
from youtube_tui.models.video import Video
|
|
||||||
|
|
||||||
videos = [
|
|
||||||
Video(
|
|
||||||
video_id=f"video{i}",
|
|
||||||
title=f"Video {i}",
|
|
||||||
channel=f"Channel {i}",
|
|
||||||
channel_id=f"channel{i}",
|
|
||||||
duration=f"{i}:00",
|
|
||||||
view_count=str(i * 1000),
|
|
||||||
upload_date="20240101",
|
|
||||||
description=f"Description {i}",
|
|
||||||
is_short=(i % 3 == 0),
|
|
||||||
url=f"https://www.youtube.com/watch?v=video{i}",
|
|
||||||
)
|
|
||||||
for i in range(1, 16)
|
|
||||||
]
|
|
||||||
return videos
|
|
||||||
|
|
||||||
|
|
||||||
# Mock patches
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_yt_dlp():
|
|
||||||
"""Patch yt-dlp for testing"""
|
|
||||||
with patch("youtube_tui.services.youtube.subprocess") as mock_subprocess:
|
|
||||||
mock_result = MagicMock()
|
|
||||||
mock_result.returncode = 0
|
|
||||||
mock_result.stdout = "2024.01.01"
|
|
||||||
mock_subprocess.run.return_value = mock_result
|
|
||||||
yield mock_subprocess
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_archive():
|
|
||||||
"""Mock archive data"""
|
|
||||||
return {
|
|
||||||
"video123": {
|
|
||||||
"url": "https://www.youtube.com/watch?v=video123",
|
|
||||||
"id": "video123",
|
|
||||||
"title": "Downloaded Video",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_archive_file(tmp_path, mock_archive):
|
|
||||||
"""Create a mock archive file"""
|
|
||||||
archive_path = tmp_path / "archive.json"
|
|
||||||
import json
|
|
||||||
|
|
||||||
with open(archive_path, "w") as f:
|
|
||||||
json.dump(mock_archive, f)
|
|
||||||
return archive_path
|
|
||||||
@ -1,324 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Integration test for the download queue system
|
|
||||||
"""
|
|
||||||
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from youtube_tui.models.queue_item import QueueStatus
|
|
||||||
from youtube_tui.models.video import Video
|
|
||||||
from youtube_tui.services.queue import DownloadQueue
|
|
||||||
|
|
||||||
|
|
||||||
class TestDownloadQueue:
|
|
||||||
"""Tests for the DownloadQueue service"""
|
|
||||||
|
|
||||||
def test_queue_initialization(self):
|
|
||||||
"""Test queue initialization"""
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
|
||||||
# Create a queue without loading from file
|
|
||||||
queue = DownloadQueue.__new__(DownloadQueue)
|
|
||||||
queue._queue = []
|
|
||||||
queue._archive_file = Path(tmpdir) / "download_queue.json"
|
|
||||||
|
|
||||||
assert queue.get_stats()["total"] == 0
|
|
||||||
|
|
||||||
def test_add_video_to_queue(self):
|
|
||||||
"""Test adding a video to the queue"""
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
|
||||||
with patch.object(
|
|
||||||
DownloadQueue,
|
|
||||||
"_archive_file",
|
|
||||||
Path(tmpdir) / "download_queue.json",
|
|
||||||
):
|
|
||||||
queue = DownloadQueue()
|
|
||||||
|
|
||||||
# Create a test video
|
|
||||||
video = Video(
|
|
||||||
video_id="test123",
|
|
||||||
title="Test Video",
|
|
||||||
channel="Test Channel",
|
|
||||||
channel_id="channel123",
|
|
||||||
duration="10:00",
|
|
||||||
view_count="1000",
|
|
||||||
upload_date="20240101",
|
|
||||||
description="Test description",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add to queue
|
|
||||||
item = queue.add_video(video, category="Tech")
|
|
||||||
assert item.status == QueueStatus.PENDING
|
|
||||||
assert item.category == "Tech"
|
|
||||||
assert item.video.video_id == "test123"
|
|
||||||
|
|
||||||
# Check stats
|
|
||||||
stats = queue.get_stats()
|
|
||||||
assert stats["total"] == 1
|
|
||||||
assert stats["pending"] == 1
|
|
||||||
|
|
||||||
def test_remove_video_from_queue(self):
|
|
||||||
"""Test removing a video from the queue"""
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
|
||||||
with patch.object(
|
|
||||||
DownloadQueue,
|
|
||||||
"_archive_file",
|
|
||||||
Path(tmpdir) / "download_queue.json",
|
|
||||||
):
|
|
||||||
queue = DownloadQueue()
|
|
||||||
|
|
||||||
# Create a test video
|
|
||||||
video = Video(
|
|
||||||
video_id="test123",
|
|
||||||
title="Test Video",
|
|
||||||
channel="Test Channel",
|
|
||||||
channel_id="channel123",
|
|
||||||
duration="10:00",
|
|
||||||
view_count="1000",
|
|
||||||
upload_date="20240101",
|
|
||||||
description="Test description",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add to queue
|
|
||||||
queue.add_video(video)
|
|
||||||
|
|
||||||
# Remove from queue
|
|
||||||
removed = queue.remove_video("test123")
|
|
||||||
assert removed is True
|
|
||||||
|
|
||||||
# Check stats
|
|
||||||
stats = queue.get_stats()
|
|
||||||
assert stats["total"] == 0
|
|
||||||
|
|
||||||
def test_update_status_and_progress(self):
|
|
||||||
"""Test updating status and progress"""
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
|
||||||
with patch.object(
|
|
||||||
DownloadQueue,
|
|
||||||
"_archive_file",
|
|
||||||
Path(tmpdir) / "download_queue.json",
|
|
||||||
):
|
|
||||||
queue = DownloadQueue()
|
|
||||||
|
|
||||||
# Create a test video
|
|
||||||
video = Video(
|
|
||||||
video_id="test123",
|
|
||||||
title="Test Video",
|
|
||||||
channel="Test Channel",
|
|
||||||
channel_id="channel123",
|
|
||||||
duration="10:00",
|
|
||||||
view_count="1000",
|
|
||||||
upload_date="20240101",
|
|
||||||
description="Test description",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add to queue
|
|
||||||
queue.add_video(video)
|
|
||||||
|
|
||||||
# Update status to downloading
|
|
||||||
queue.update_status(
|
|
||||||
"test123", QueueStatus.DOWNLOADING, progress=50
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get the item and check status
|
|
||||||
items = queue.get_queue()
|
|
||||||
assert len(items) == 1
|
|
||||||
assert items[0].status == QueueStatus.DOWNLOADING
|
|
||||||
assert items[0].progress == 50
|
|
||||||
|
|
||||||
# Update status to completed
|
|
||||||
queue.update_status(
|
|
||||||
"test123", QueueStatus.COMPLETED, progress=100
|
|
||||||
)
|
|
||||||
|
|
||||||
items = queue.get_queue()
|
|
||||||
assert items[0].status == QueueStatus.COMPLETED
|
|
||||||
assert items[0].progress == 100
|
|
||||||
|
|
||||||
def test_cancel_video(self):
|
|
||||||
"""Test cancelling a video in the queue"""
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
|
||||||
with patch.object(
|
|
||||||
DownloadQueue,
|
|
||||||
"_archive_file",
|
|
||||||
Path(tmpdir) / "download_queue.json",
|
|
||||||
):
|
|
||||||
queue = DownloadQueue()
|
|
||||||
|
|
||||||
# Create a test video
|
|
||||||
video = Video(
|
|
||||||
video_id="test123",
|
|
||||||
title="Test Video",
|
|
||||||
channel="Test Channel",
|
|
||||||
channel_id="channel123",
|
|
||||||
duration="10:00",
|
|
||||||
view_count="1000",
|
|
||||||
upload_date="20240101",
|
|
||||||
description="Test description",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add to queue
|
|
||||||
queue.add_video(video)
|
|
||||||
|
|
||||||
# Cancel the video
|
|
||||||
cancelled = queue.cancel_video("test123")
|
|
||||||
assert cancelled is True
|
|
||||||
|
|
||||||
# Check status
|
|
||||||
items = queue.get_queue()
|
|
||||||
assert items[0].status == QueueStatus.CANCELLED
|
|
||||||
|
|
||||||
def test_get_next_pending(self):
|
|
||||||
"""Test getting the next pending item"""
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
|
||||||
with patch.object(
|
|
||||||
DownloadQueue,
|
|
||||||
"_archive_file",
|
|
||||||
Path(tmpdir) / "download_queue.json",
|
|
||||||
):
|
|
||||||
queue = DownloadQueue()
|
|
||||||
|
|
||||||
# Create test videos
|
|
||||||
video1 = Video(
|
|
||||||
video_id="test1",
|
|
||||||
title="Test Video 1",
|
|
||||||
channel="Test Channel",
|
|
||||||
channel_id="channel1",
|
|
||||||
duration="10:00",
|
|
||||||
view_count="1000",
|
|
||||||
upload_date="20240101",
|
|
||||||
description="Test",
|
|
||||||
)
|
|
||||||
video2 = Video(
|
|
||||||
video_id="test2",
|
|
||||||
title="Test Video 2",
|
|
||||||
channel="Test Channel",
|
|
||||||
channel_id="channel2",
|
|
||||||
duration="10:00",
|
|
||||||
view_count="1000",
|
|
||||||
upload_date="20240101",
|
|
||||||
description="Test",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add to queue
|
|
||||||
queue.add_video(video1)
|
|
||||||
queue.add_video(video2)
|
|
||||||
|
|
||||||
# Get next pending
|
|
||||||
next_item = queue.get_next_pending()
|
|
||||||
assert next_item is not None
|
|
||||||
assert next_item.video.video_id == "test1"
|
|
||||||
|
|
||||||
# Update first item to downloading
|
|
||||||
queue.update_status("test1", QueueStatus.DOWNLOADING)
|
|
||||||
|
|
||||||
# Get next pending - should be test2
|
|
||||||
next_item = queue.get_next_pending()
|
|
||||||
assert next_item.video.video_id == "test2"
|
|
||||||
|
|
||||||
# Update test2 to downloading
|
|
||||||
queue.update_status("test2", QueueStatus.DOWNLOADING)
|
|
||||||
|
|
||||||
# No more pending items
|
|
||||||
next_item = queue.get_next_pending()
|
|
||||||
assert next_item is None
|
|
||||||
|
|
||||||
def test_clear_completed(self):
|
|
||||||
"""Test clearing completed and cancelled items"""
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
|
||||||
with patch.object(
|
|
||||||
DownloadQueue,
|
|
||||||
"_archive_file",
|
|
||||||
Path(tmpdir) / "download_queue.json",
|
|
||||||
):
|
|
||||||
queue = DownloadQueue()
|
|
||||||
|
|
||||||
# Create test videos
|
|
||||||
video1 = Video(
|
|
||||||
video_id="test1",
|
|
||||||
title="Test Video 1",
|
|
||||||
channel="Test Channel",
|
|
||||||
channel_id="channel1",
|
|
||||||
duration="10:00",
|
|
||||||
view_count="1000",
|
|
||||||
upload_date="20240101",
|
|
||||||
description="Test",
|
|
||||||
)
|
|
||||||
video2 = Video(
|
|
||||||
video_id="test2",
|
|
||||||
title="Test Video 2",
|
|
||||||
channel="Test Channel",
|
|
||||||
channel_id="channel2",
|
|
||||||
duration="10:00",
|
|
||||||
view_count="1000",
|
|
||||||
upload_date="20240101",
|
|
||||||
description="Test",
|
|
||||||
)
|
|
||||||
video3 = Video(
|
|
||||||
video_id="test3",
|
|
||||||
title="Test Video 3",
|
|
||||||
channel="Test Channel",
|
|
||||||
channel_id="channel3",
|
|
||||||
duration="10:00",
|
|
||||||
view_count="1000",
|
|
||||||
upload_date="20240101",
|
|
||||||
description="Test",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add to queue
|
|
||||||
queue.add_video(video1) # pending
|
|
||||||
queue.add_video(video2) # pending
|
|
||||||
|
|
||||||
# Mark test1 as completed
|
|
||||||
queue.update_status("test1", QueueStatus.COMPLETED)
|
|
||||||
|
|
||||||
# Mark test2 as cancelled
|
|
||||||
queue.cancel_video("test2")
|
|
||||||
|
|
||||||
# Mark test3 as completed
|
|
||||||
queue.add_video(video3)
|
|
||||||
queue.update_status("test3", QueueStatus.COMPLETED)
|
|
||||||
|
|
||||||
# Clear completed and cancelled
|
|
||||||
removed = queue.clear_completed()
|
|
||||||
assert removed == 3 # All three should be removed
|
|
||||||
|
|
||||||
# Check stats
|
|
||||||
stats = queue.get_stats()
|
|
||||||
assert stats["total"] == 0
|
|
||||||
|
|
||||||
def test_clear_failed(self):
|
|
||||||
"""Test clearing failed items"""
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
|
||||||
with patch.object(
|
|
||||||
DownloadQueue,
|
|
||||||
"_archive_file",
|
|
||||||
Path(tmpdir) / "download_queue.json",
|
|
||||||
):
|
|
||||||
queue = DownloadQueue()
|
|
||||||
|
|
||||||
# Create test videos
|
|
||||||
video1 = Video(
|
|
||||||
video_id="test1",
|
|
||||||
title="Test Video 1",
|
|
||||||
channel="Test Channel",
|
|
||||||
channel_id="channel1",
|
|
||||||
duration="10:00",
|
|
||||||
view_count="1000",
|
|
||||||
upload_date="20240101",
|
|
||||||
description="Test",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add to queue and fail it
|
|
||||||
queue.add_video(video1)
|
|
||||||
queue.update_status("test1", QueueStatus.FAILED)
|
|
||||||
|
|
||||||
# Clear failed
|
|
||||||
removed = queue.clear_failed()
|
|
||||||
assert removed == 1
|
|
||||||
|
|
||||||
# Check stats
|
|
||||||
stats = queue.get_stats()
|
|
||||||
assert stats["total"] == 0
|
|
||||||
assert stats["failed"] == 0
|
|
||||||
@ -1,664 +0,0 @@
|
|||||||
"""
|
|
||||||
Integration tests for TUI screens using Textual's testing framework
|
|
||||||
|
|
||||||
Tests are structured to use Textual's App.run_test() which provides:
|
|
||||||
- A running App instance with proper screen stack
|
|
||||||
- Async test context
|
|
||||||
- Headless mode for non-interactive testing
|
|
||||||
- Pilot object for simulating user interactions
|
|
||||||
"""
|
|
||||||
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
class TestSearchScreen:
|
|
||||||
"""Integration tests for SearchScreen using Textual's testing framework"""
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def app(self):
|
|
||||||
"""Create a test app with mocked YouTube service"""
|
|
||||||
from youtube_tui.app import YouTubeTUI
|
|
||||||
|
|
||||||
# Create app without actually running it
|
|
||||||
with patch.object(YouTubeTUI, "_check_yt_dlp"):
|
|
||||||
with patch.object(YouTubeTUI, "load_search_history"):
|
|
||||||
app = YouTubeTUI()
|
|
||||||
app._testing = True
|
|
||||||
yield app
|
|
||||||
|
|
||||||
def test_search_screen_compose(self, app):
|
|
||||||
"""Test search screen composition"""
|
|
||||||
from youtube_tui.screens.search import SearchScreen
|
|
||||||
|
|
||||||
screen = SearchScreen()
|
|
||||||
# Compose should yield widgets
|
|
||||||
widgets = list(screen.compose())
|
|
||||||
assert len(widgets) > 0
|
|
||||||
assert screen is not None
|
|
||||||
|
|
||||||
async def test_search_action_with_empty_input(self, app):
|
|
||||||
"""Test search action with empty input"""
|
|
||||||
from youtube_tui.screens.search import SearchScreen
|
|
||||||
|
|
||||||
screen = SearchScreen()
|
|
||||||
# Use app.run_test() to ensure proper screen mounting
|
|
||||||
async with app.run_test() as pilot:
|
|
||||||
app.push_screen(screen)
|
|
||||||
# Wait for the screen to be fully mounted
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
# Mock the update_status method to track calls
|
|
||||||
update_calls = []
|
|
||||||
screen.update_status = lambda message: update_calls.append(message)
|
|
||||||
|
|
||||||
# Simulate pressing Enter with empty input
|
|
||||||
await pilot.press("enter")
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
# Verify that update_status was called (error message)
|
|
||||||
assert len(update_calls) > 0
|
|
||||||
|
|
||||||
async def test_search_action_with_valid_input(self, app):
|
|
||||||
"""Test search action with valid input"""
|
|
||||||
|
|
||||||
search_term = "python tutorial"
|
|
||||||
from youtube_tui.screens.search import SearchScreen
|
|
||||||
|
|
||||||
screen = SearchScreen()
|
|
||||||
async with app.run_test() as pilot:
|
|
||||||
app.push_screen(screen)
|
|
||||||
# Wait for the screen to be fully mounted
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
# Type the search term
|
|
||||||
await pilot.press(*search_term)
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
# Press Enter to search
|
|
||||||
await pilot.press("enter")
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
# Get the screen and verify search term was set
|
|
||||||
screen = app.screen
|
|
||||||
assert screen.search_term == search_term
|
|
||||||
|
|
||||||
async def test_search_action_from_anywhere(self, app):
|
|
||||||
"""Test search from anywhere action"""
|
|
||||||
|
|
||||||
search_term = "music"
|
|
||||||
from youtube_tui.screens.search import SearchScreen
|
|
||||||
|
|
||||||
screen = SearchScreen()
|
|
||||||
async with app.run_test() as pilot:
|
|
||||||
app.push_screen(screen)
|
|
||||||
# Wait for the screen to be fully mounted
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
# Type and press enter
|
|
||||||
await pilot.press(*search_term)
|
|
||||||
await pilot.pause()
|
|
||||||
await pilot.press("enter")
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
# Get the screen and verify search term was set
|
|
||||||
screen = app.screen
|
|
||||||
assert screen.search_term == search_term
|
|
||||||
|
|
||||||
async def test_cancel_action(self, app):
|
|
||||||
"""Test cancel action"""
|
|
||||||
|
|
||||||
async with app.run_test() as pilot:
|
|
||||||
# Wait for the screen to be fully mounted
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
# Press Escape to trigger cancel
|
|
||||||
await pilot.press("escape")
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
async def test_go_back_action(self, app):
|
|
||||||
"""Test go back action"""
|
|
||||||
|
|
||||||
async with app.run_test() as pilot:
|
|
||||||
# Wait for the screen to be fully mounted
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
# Press Escape to trigger go_back
|
|
||||||
await pilot.press("escape")
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
async def test_quit_action(self, app):
|
|
||||||
"""Test quit action"""
|
|
||||||
|
|
||||||
async with app.run_test() as pilot:
|
|
||||||
# Wait for the screen to be fully mounted
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
# Press q to quit
|
|
||||||
await pilot.press("q")
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
async def test_button_pressed_search(self, app):
|
|
||||||
"""Test button press for search"""
|
|
||||||
|
|
||||||
async with app.run_test() as pilot:
|
|
||||||
# Wait for the screen to be fully mounted
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
# Press Enter to trigger search
|
|
||||||
await pilot.press("enter")
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
async def test_button_pressed_cancel(self, app):
|
|
||||||
"""Test button press for cancel"""
|
|
||||||
|
|
||||||
async with app.run_test() as pilot:
|
|
||||||
# Wait for the screen to be fully mounted
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
# Press Escape to cancel
|
|
||||||
await pilot.press("escape")
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
async def test_input_submitted(self, app):
|
|
||||||
"""Test input submitted event"""
|
|
||||||
|
|
||||||
async with app.run_test() as pilot:
|
|
||||||
# Wait for the screen to be fully mounted
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
# Type something and press Enter
|
|
||||||
await pilot.press("t", "e", "s", "t")
|
|
||||||
await pilot.pause()
|
|
||||||
await pilot.press("enter")
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
|
|
||||||
class TestResultsScreen:
|
|
||||||
"""Integration tests for ResultsScreen"""
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def app(self):
|
|
||||||
"""Create a test app with mocked YouTube service"""
|
|
||||||
from youtube_tui.app import YouTubeTUI
|
|
||||||
|
|
||||||
with patch.object(YouTubeTUI, "_check_yt_dlp"):
|
|
||||||
with patch.object(YouTubeTUI, "load_search_history"):
|
|
||||||
app = YouTubeTUI()
|
|
||||||
app._testing = True
|
|
||||||
yield app
|
|
||||||
|
|
||||||
def test_results_screen_compose(self, app):
|
|
||||||
"""Test results screen composition"""
|
|
||||||
from youtube_tui.screens.results import ResultsScreen
|
|
||||||
|
|
||||||
screen = ResultsScreen("test query")
|
|
||||||
# Compose should yield widgets
|
|
||||||
widgets = list(screen.compose())
|
|
||||||
assert len(widgets) > 0
|
|
||||||
assert screen is not None
|
|
||||||
|
|
||||||
async def test_load_results_success(self, app, mock_search_results):
|
|
||||||
"""Test loading results successfully"""
|
|
||||||
from youtube_tui.screens.results import ResultsScreen
|
|
||||||
|
|
||||||
async with app.run_test() as pilot:
|
|
||||||
# Wait for the screen to be fully mounted
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
# Get the current screen (should be SearchScreen)
|
|
||||||
# We need to push a ResultsScreen
|
|
||||||
screen = ResultsScreen("test query")
|
|
||||||
app.push_screen(screen)
|
|
||||||
|
|
||||||
# Mock the YouTube service to return results
|
|
||||||
with patch.object(
|
|
||||||
screen.youtube_service,
|
|
||||||
"search_videos",
|
|
||||||
return_value=mock_search_results,
|
|
||||||
):
|
|
||||||
# Wait for the screen to load results
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
async def test_load_results_error(self, app):
|
|
||||||
"""Test loading results with error"""
|
|
||||||
from youtube_tui.screens.results import ResultsScreen
|
|
||||||
|
|
||||||
async with app.run_test() as pilot:
|
|
||||||
# Wait for the screen to be fully mounted
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
# Push a ResultsScreen
|
|
||||||
screen = ResultsScreen("test query")
|
|
||||||
app.push_screen(screen)
|
|
||||||
|
|
||||||
# Mock the YouTube service to raise an error
|
|
||||||
with patch.object(
|
|
||||||
screen.youtube_service,
|
|
||||||
"search_videos",
|
|
||||||
side_effect=Exception("Network error"),
|
|
||||||
):
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
async def test_update_table(self, app, mock_search_results):
|
|
||||||
"""Test updating the results table"""
|
|
||||||
|
|
||||||
from youtube_tui.screens.results import ResultsScreen
|
|
||||||
|
|
||||||
async with app.run_test() as pilot:
|
|
||||||
# Wait for the screen to be fully mounted
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
# Push a ResultsScreen
|
|
||||||
screen = ResultsScreen("test query")
|
|
||||||
app.push_screen(screen)
|
|
||||||
|
|
||||||
# Set videos directly
|
|
||||||
screen.videos = mock_search_results[:5]
|
|
||||||
|
|
||||||
# Wait for the screen to be fully mounted
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
# Now we can update the table
|
|
||||||
screen.update_table()
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
async def test_update_pagination(self, app, mock_search_results):
|
|
||||||
"""Test pagination update"""
|
|
||||||
from youtube_tui.screens.results import ResultsScreen
|
|
||||||
|
|
||||||
async with app.run_test() as pilot:
|
|
||||||
# Wait for the screen to be fully mounted
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
# Push a ResultsScreen
|
|
||||||
screen = ResultsScreen("test query")
|
|
||||||
app.push_screen(screen)
|
|
||||||
|
|
||||||
# Set pagination values
|
|
||||||
screen.videos = mock_search_results[:10]
|
|
||||||
screen.page = 1
|
|
||||||
screen.total_pages = 2
|
|
||||||
|
|
||||||
# Wait for the screen to be fully mounted
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
# Now we can update pagination
|
|
||||||
screen.update_pagination()
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
async def test_download_action_no_selection(self, app, mock_search_results):
|
|
||||||
"""Test download with no selection"""
|
|
||||||
from youtube_tui.screens.results import ResultsScreen
|
|
||||||
|
|
||||||
async with app.run_test() as pilot:
|
|
||||||
# Wait for the screen to be fully mounted
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
# Push a ResultsScreen
|
|
||||||
screen = ResultsScreen("test query")
|
|
||||||
app.push_screen(screen)
|
|
||||||
screen.videos = mock_search_results
|
|
||||||
|
|
||||||
# Wait for the screen to be fully mounted
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
# Try to download without selecting a row
|
|
||||||
await pilot.press("enter")
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
async def test_download_action_with_selection(self, app, mock_video):
|
|
||||||
"""Test download with video selection"""
|
|
||||||
from youtube_tui.screens.results import ResultsScreen
|
|
||||||
|
|
||||||
async with app.run_test() as pilot:
|
|
||||||
# Wait for the screen to be fully mounted
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
# Push a ResultsScreen
|
|
||||||
screen = ResultsScreen("test query")
|
|
||||||
app.push_screen(screen)
|
|
||||||
screen.videos = [mock_video]
|
|
||||||
|
|
||||||
# Wait for the screen to be fully mounted
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
# Select a row first
|
|
||||||
await pilot.press("down")
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
# Then press enter to download
|
|
||||||
await pilot.press("enter")
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
async def test_next_page_action(self, app, mock_search_results):
|
|
||||||
"""Test next page navigation"""
|
|
||||||
from youtube_tui.screens.results import ResultsScreen
|
|
||||||
|
|
||||||
async with app.run_test() as pilot:
|
|
||||||
# Wait for the screen to be fully mounted
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
# Push a ResultsScreen
|
|
||||||
screen = ResultsScreen("test query")
|
|
||||||
screen.page = 1
|
|
||||||
screen.total_pages = 2
|
|
||||||
app.push_screen(screen)
|
|
||||||
|
|
||||||
# Mock the service to return different results for each page
|
|
||||||
def mock_search(search_term, page=1, per_page=15):
|
|
||||||
if page == 1:
|
|
||||||
return mock_search_results[:15]
|
|
||||||
else:
|
|
||||||
return []
|
|
||||||
|
|
||||||
with patch.object(
|
|
||||||
screen.youtube_service, "search_videos", side_effect=mock_search
|
|
||||||
):
|
|
||||||
# Wait for the screen to be fully mounted
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
# Press 'n' for next page
|
|
||||||
await pilot.press("n")
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
# Page should have incremented
|
|
||||||
assert screen.page == 2
|
|
||||||
|
|
||||||
async def test_previous_page_action(self, app):
|
|
||||||
"""Test previous page navigation"""
|
|
||||||
from youtube_tui.screens.results import ResultsScreen
|
|
||||||
|
|
||||||
async with app.run_test() as pilot:
|
|
||||||
# Wait for the screen to be fully mounted
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
# Push a ResultsScreen
|
|
||||||
screen = ResultsScreen("test query")
|
|
||||||
screen.page = 2
|
|
||||||
screen.total_pages = 2
|
|
||||||
app.push_screen(screen)
|
|
||||||
|
|
||||||
# Wait for the screen to be fully mounted
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
# Press 'p' for previous page
|
|
||||||
await pilot.press("p")
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
# Page should have decremented
|
|
||||||
assert screen.page == 1
|
|
||||||
|
|
||||||
async def test_button_pressed_previous(self, app, mock_search_results):
|
|
||||||
"""Test previous button press"""
|
|
||||||
from youtube_tui.screens.results import ResultsScreen
|
|
||||||
|
|
||||||
async with app.run_test() as pilot:
|
|
||||||
# Wait for the screen to be fully mounted
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
# Push a ResultsScreen
|
|
||||||
screen = ResultsScreen("test query")
|
|
||||||
screen.page = 2
|
|
||||||
screen.total_pages = 2
|
|
||||||
app.push_screen(screen)
|
|
||||||
|
|
||||||
# Wait for the screen to be fully mounted
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
# Press 'p' key to go to previous page
|
|
||||||
await pilot.press("p")
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
async def test_button_pressed_next(self, app, mock_search_results):
|
|
||||||
"""Test next button press"""
|
|
||||||
from youtube_tui.screens.results import ResultsScreen
|
|
||||||
|
|
||||||
async with app.run_test() as pilot:
|
|
||||||
# Wait for the screen to be fully mounted
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
# Push a ResultsScreen
|
|
||||||
screen = ResultsScreen("test query")
|
|
||||||
screen.page = 1
|
|
||||||
screen.total_pages = 2
|
|
||||||
app.push_screen(screen)
|
|
||||||
|
|
||||||
# Wait for the screen to be fully mounted
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
# Press 'n' key to go to next page
|
|
||||||
await pilot.press("n")
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
async def test_data_table_row_selected(self, app, mock_video):
|
|
||||||
"""Test data table row selection"""
|
|
||||||
from youtube_tui.screens.results import ResultsScreen
|
|
||||||
|
|
||||||
async with app.run_test() as pilot:
|
|
||||||
# Wait for the screen to be fully mounted
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
# Push a ResultsScreen
|
|
||||||
screen = ResultsScreen("test query")
|
|
||||||
screen.videos = [mock_video]
|
|
||||||
app.push_screen(screen)
|
|
||||||
|
|
||||||
# Wait for the screen to be fully mounted
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
# Select a row
|
|
||||||
await pilot.press("down")
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
# Press enter to trigger row selection
|
|
||||||
await pilot.press("enter")
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
|
|
||||||
class TestDownloadScreen:
|
|
||||||
"""Integration tests for DownloadScreen"""
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def app(self):
|
|
||||||
"""Create a test app with mocked YouTube service"""
|
|
||||||
from youtube_tui.app import YouTubeTUI
|
|
||||||
|
|
||||||
with patch.object(YouTubeTUI, "_check_yt_dlp"):
|
|
||||||
with patch.object(YouTubeTUI, "load_search_history"):
|
|
||||||
app = YouTubeTUI()
|
|
||||||
app._testing = True
|
|
||||||
yield app
|
|
||||||
|
|
||||||
def test_download_screen_compose(self, app, mock_video):
|
|
||||||
"""Test download screen composition"""
|
|
||||||
from youtube_tui.screens.download import DownloadScreen
|
|
||||||
|
|
||||||
screen = DownloadScreen(mock_video)
|
|
||||||
# Compose should yield widgets
|
|
||||||
widgets = list(screen.compose())
|
|
||||||
assert len(widgets) > 0
|
|
||||||
assert screen is not None
|
|
||||||
|
|
||||||
async def test_start_download_success(self, app, mock_video):
|
|
||||||
"""Test successful download"""
|
|
||||||
from youtube_tui.screens.download import DownloadScreen
|
|
||||||
|
|
||||||
async with app.run_test() as pilot:
|
|
||||||
# Wait for the screen to be fully mounted
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
# Push a DownloadScreen
|
|
||||||
screen = DownloadScreen(mock_video)
|
|
||||||
app.push_screen(screen)
|
|
||||||
|
|
||||||
# Mock successful download
|
|
||||||
async def mock_download(video, category):
|
|
||||||
return True
|
|
||||||
|
|
||||||
with patch.object(
|
|
||||||
screen.youtube_service,
|
|
||||||
"download_video",
|
|
||||||
side_effect=mock_download,
|
|
||||||
):
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
async def test_start_download_failure(self, app, mock_video):
|
|
||||||
"""Test download failure"""
|
|
||||||
from youtube_tui.screens.download import DownloadScreen
|
|
||||||
|
|
||||||
async with app.run_test() as pilot:
|
|
||||||
# Wait for the screen to be fully mounted
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
# Push a DownloadScreen
|
|
||||||
screen = DownloadScreen(mock_video)
|
|
||||||
app.push_screen(screen)
|
|
||||||
|
|
||||||
# Mock failed download
|
|
||||||
async def mock_download(video, category):
|
|
||||||
return False
|
|
||||||
|
|
||||||
with patch.object(
|
|
||||||
screen.youtube_service,
|
|
||||||
"download_video",
|
|
||||||
side_effect=mock_download,
|
|
||||||
):
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
async def test_start_download_exception(self, app, mock_video):
|
|
||||||
"""Test download with exception"""
|
|
||||||
from youtube_tui.screens.download import DownloadScreen
|
|
||||||
|
|
||||||
async with app.run_test() as pilot:
|
|
||||||
# Wait for the screen to be fully mounted
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
# Push a DownloadScreen
|
|
||||||
screen = DownloadScreen(mock_video)
|
|
||||||
app.push_screen(screen)
|
|
||||||
|
|
||||||
# Mock exception during download
|
|
||||||
async def mock_download(video, category):
|
|
||||||
raise Exception("Download failed")
|
|
||||||
|
|
||||||
with patch.object(
|
|
||||||
screen.youtube_service,
|
|
||||||
"download_video",
|
|
||||||
side_effect=mock_download,
|
|
||||||
):
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
async def test_cancel_download(self, app, mock_video):
|
|
||||||
"""Test download cancellation"""
|
|
||||||
from youtube_tui.screens.download import DownloadScreen
|
|
||||||
|
|
||||||
async with app.run_test() as pilot:
|
|
||||||
# Wait for the screen to be fully mounted
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
# Push a DownloadScreen
|
|
||||||
screen = DownloadScreen(mock_video)
|
|
||||||
app.push_screen(screen)
|
|
||||||
|
|
||||||
# Wait for the screen to be fully mounted
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
# Press escape to cancel
|
|
||||||
await pilot.press("escape")
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
async def test_refresh_screen(self, app, mock_video):
|
|
||||||
"""Test refresh screen action"""
|
|
||||||
from youtube_tui.screens.download import DownloadScreen
|
|
||||||
|
|
||||||
async with app.run_test() as pilot:
|
|
||||||
# Wait for the screen to be fully mounted
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
# Push a DownloadScreen
|
|
||||||
screen = DownloadScreen(mock_video)
|
|
||||||
app.push_screen(screen)
|
|
||||||
|
|
||||||
# Wait for the screen to be fully mounted
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
# Press ctrl+r to refresh
|
|
||||||
await pilot.press("ctrl+r")
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
async def test_unload_success(self, app, mock_video):
|
|
||||||
"""Test screen unload with success"""
|
|
||||||
from youtube_tui.screens.download import DownloadScreen
|
|
||||||
|
|
||||||
async with app.run_test() as pilot:
|
|
||||||
# Wait for the screen to be fully mounted
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
# Push a DownloadScreen
|
|
||||||
screen = DownloadScreen(mock_video)
|
|
||||||
screen.download_complete = True
|
|
||||||
app.push_screen(screen)
|
|
||||||
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
async def test_unload_error(self, app, mock_video):
|
|
||||||
"""Test screen unload with error"""
|
|
||||||
from youtube_tui.screens.download import DownloadScreen
|
|
||||||
|
|
||||||
async with app.run_test() as pilot:
|
|
||||||
# Wait for the screen to be fully mounted
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
# Push a DownloadScreen
|
|
||||||
screen = DownloadScreen(mock_video)
|
|
||||||
screen.download_error = True
|
|
||||||
app.push_screen(screen)
|
|
||||||
|
|
||||||
await pilot.pause()
|
|
||||||
|
|
||||||
|
|
||||||
# Fixtures for test data
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_search_results():
|
|
||||||
"""Mock search results"""
|
|
||||||
from youtube_tui.models.video import Video
|
|
||||||
|
|
||||||
videos = [
|
|
||||||
Video(
|
|
||||||
video_id=f"video{i}",
|
|
||||||
title=f"Video {i}",
|
|
||||||
channel=f"Channel {i}",
|
|
||||||
channel_id=f"channel{i}",
|
|
||||||
duration=f"{i}:00",
|
|
||||||
view_count=str(i * 1000),
|
|
||||||
upload_date="20240101",
|
|
||||||
description=f"Description {i}",
|
|
||||||
is_short=(i % 3 == 0),
|
|
||||||
url=f"https://www.youtube.com/watch?v=video{i}",
|
|
||||||
)
|
|
||||||
for i in range(1, 16)
|
|
||||||
]
|
|
||||||
return videos
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_video():
|
|
||||||
"""Mock Video object"""
|
|
||||||
from youtube_tui.models.video import Video
|
|
||||||
|
|
||||||
video = Video(
|
|
||||||
video_id="test123",
|
|
||||||
title="Test Video",
|
|
||||||
channel="Test Channel",
|
|
||||||
channel_id="channel123",
|
|
||||||
duration="5:00",
|
|
||||||
view_count="1000",
|
|
||||||
upload_date="20240101",
|
|
||||||
description="Test description",
|
|
||||||
thumbnail_url="https://example.com/thumb.jpg",
|
|
||||||
url="https://www.youtube.com/watch?v=test123",
|
|
||||||
is_short=False,
|
|
||||||
)
|
|
||||||
return video
|
|
||||||
@ -1,219 +0,0 @@
|
|||||||
"""
|
|
||||||
Unit tests for Video model
|
|
||||||
"""
|
|
||||||
|
|
||||||
from youtube_tui.models.video import Video
|
|
||||||
|
|
||||||
|
|
||||||
class TestVideoModel:
|
|
||||||
"""Tests for the Video dataclass"""
|
|
||||||
|
|
||||||
def test_video_creation(self, sample_video_data):
|
|
||||||
"""Test creating a Video object from data"""
|
|
||||||
video = Video(**sample_video_data)
|
|
||||||
|
|
||||||
assert video.video_id == "dQw4w9WgXcQ"
|
|
||||||
assert video.title == "Rick Astley - Never Gonna Give You Up"
|
|
||||||
assert video.channel == "RickAstleyVEVO"
|
|
||||||
assert video.duration == "3:33"
|
|
||||||
assert video.view_count == "1000000"
|
|
||||||
assert video.upload_date == "20091025"
|
|
||||||
assert video.is_short is False
|
|
||||||
|
|
||||||
def test_video_url_generation(self, sample_video_data):
|
|
||||||
"""Test URL is generated from video_id"""
|
|
||||||
video = Video(**sample_video_data)
|
|
||||||
|
|
||||||
assert video.url == "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
|
|
||||||
|
|
||||||
def test_video_short_detection(self, sample_video_data):
|
|
||||||
"""Test short video detection"""
|
|
||||||
# Test normal video
|
|
||||||
video = Video(**sample_video_data)
|
|
||||||
assert video.is_short is False
|
|
||||||
|
|
||||||
# Test short video via URL
|
|
||||||
short_data = sample_video_data.copy()
|
|
||||||
short_data["url"] = "https://www.youtube.com/shorts/dQw4w9WgXcQ"
|
|
||||||
short_data["is_short"] = False # Reset to test detection
|
|
||||||
video = Video(**short_data)
|
|
||||||
assert video.is_short is True
|
|
||||||
|
|
||||||
def test_video_short_detection_duration(self, sample_video_data):
|
|
||||||
"""Test short video detection via duration"""
|
|
||||||
short_data = sample_video_data.copy()
|
|
||||||
short_data["duration"] = "0:00" # Shorts have 0:00 duration
|
|
||||||
video = Video(**short_data)
|
|
||||||
assert video.is_short is True
|
|
||||||
|
|
||||||
def test_display_title_with_short(self, sample_video_data):
|
|
||||||
"""Test display title for short videos"""
|
|
||||||
short_data = sample_video_data.copy()
|
|
||||||
short_data["is_short"] = True
|
|
||||||
video = Video(**short_data)
|
|
||||||
|
|
||||||
assert (
|
|
||||||
video.display_title
|
|
||||||
== "(short) Rick Astley - Never Gonna Give You Up"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_display_title_without_short(self, sample_video_data):
|
|
||||||
"""Test display title for normal videos"""
|
|
||||||
video = Video(**sample_video_data)
|
|
||||||
|
|
||||||
assert video.display_title == "Rick Astley - Never Gonna Give You Up"
|
|
||||||
|
|
||||||
def test_display_duration_short(self, sample_video_data):
|
|
||||||
"""Test display duration for short videos"""
|
|
||||||
short_data = sample_video_data.copy()
|
|
||||||
short_data["is_short"] = True
|
|
||||||
video = Video(**short_data)
|
|
||||||
|
|
||||||
assert video.display_duration == "Short"
|
|
||||||
|
|
||||||
def test_display_duration_normal(self, sample_video_data):
|
|
||||||
"""Test display duration for normal videos"""
|
|
||||||
video = Video(**sample_video_data)
|
|
||||||
|
|
||||||
assert video.display_duration == "3:33"
|
|
||||||
|
|
||||||
def test_to_dict(self, sample_video_data):
|
|
||||||
"""Test Video to_dict conversion"""
|
|
||||||
video = Video(**sample_video_data)
|
|
||||||
result = video.to_dict()
|
|
||||||
|
|
||||||
assert result["video_id"] == "dQw4w9WgXcQ"
|
|
||||||
assert result["title"] == "Rick Astley - Never Gonna Give You Up"
|
|
||||||
assert result["is_short"] is False
|
|
||||||
assert result["url"] == "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
|
|
||||||
|
|
||||||
def test_to_dict_default_thumbnail(self, sample_video_data):
|
|
||||||
"""Test to_dict with None thumbnail"""
|
|
||||||
video = Video(**sample_video_data)
|
|
||||||
video.thumbnail_url = None
|
|
||||||
result = video.to_dict()
|
|
||||||
|
|
||||||
assert result["thumbnail_url"] is None
|
|
||||||
|
|
||||||
def test_from_dict(self, sample_video_data):
|
|
||||||
"""Test Video from_dict creation"""
|
|
||||||
video_dict = {
|
|
||||||
"video_id": "abc123",
|
|
||||||
"title": "Test Video",
|
|
||||||
"channel": "Test Channel",
|
|
||||||
"channel_id": "channel123",
|
|
||||||
"duration": "5:00",
|
|
||||||
"view_count": "1000",
|
|
||||||
"upload_date": "20240101",
|
|
||||||
"description": "Test description",
|
|
||||||
"thumbnail_url": "https://example.com/thumb.jpg",
|
|
||||||
"url": "https://www.youtube.com/watch?v=abc123",
|
|
||||||
}
|
|
||||||
|
|
||||||
video = Video.from_dict(video_dict)
|
|
||||||
|
|
||||||
assert video.video_id == "abc123"
|
|
||||||
assert video.title == "Test Video"
|
|
||||||
assert video.channel == "Test Channel"
|
|
||||||
assert video.description == "Test description"
|
|
||||||
|
|
||||||
def test_from_dict_optional_fields(self, sample_video_data):
|
|
||||||
"""Test from_dict with optional fields"""
|
|
||||||
video_dict = {
|
|
||||||
"video_id": "abc123",
|
|
||||||
"title": "Test Video",
|
|
||||||
"channel": "Test Channel",
|
|
||||||
"channel_id": "channel123",
|
|
||||||
"duration": "5:00",
|
|
||||||
"view_count": "1000",
|
|
||||||
"upload_date": "20240101",
|
|
||||||
# description is optional
|
|
||||||
}
|
|
||||||
|
|
||||||
video = Video.from_dict(video_dict)
|
|
||||||
|
|
||||||
assert video.description == ""
|
|
||||||
assert video.thumbnail_url is None
|
|
||||||
|
|
||||||
def test_video_equality(self, sample_video_data):
|
|
||||||
"""Test Video equality comparison"""
|
|
||||||
video1 = Video(**sample_video_data)
|
|
||||||
video2 = Video(**sample_video_data)
|
|
||||||
video3 = Video(**{**sample_video_data, "title": "Different Title"})
|
|
||||||
|
|
||||||
# Dataclass should have automatic equality
|
|
||||||
assert video1 == video2
|
|
||||||
assert video1 != video3
|
|
||||||
|
|
||||||
def test_video_repr(self, sample_video_data):
|
|
||||||
"""Test Video string representation"""
|
|
||||||
video = Video(**sample_video_data)
|
|
||||||
repr_str = repr(video)
|
|
||||||
|
|
||||||
assert "Video" in repr_str
|
|
||||||
assert "dQw4w9WgXcQ" in repr_str
|
|
||||||
|
|
||||||
def test_video_hash(self, sample_video_data):
|
|
||||||
"""Test Video hash (for set usage)"""
|
|
||||||
video = Video(**sample_video_data)
|
|
||||||
|
|
||||||
# Dataclass should be hashable (unless frozen=True, then not)
|
|
||||||
try:
|
|
||||||
video_hash = hash(video)
|
|
||||||
assert isinstance(video_hash, int)
|
|
||||||
except TypeError:
|
|
||||||
# If not hashable, that's also acceptable for mutable dataclasses
|
|
||||||
pass
|
|
||||||
|
|
||||||
def test_video_with_custom_url(self, sample_video_data):
|
|
||||||
"""Test Video with custom URL"""
|
|
||||||
custom_url = "https://youtu.be/dQw4w9WgXcQ"
|
|
||||||
video_data = sample_video_data.copy()
|
|
||||||
video_data["url"] = custom_url
|
|
||||||
|
|
||||||
video = Video(**video_data)
|
|
||||||
|
|
||||||
assert video.url == custom_url
|
|
||||||
# is_short should be detected from URL
|
|
||||||
assert video.is_short is False # youtu.be doesn't have /shorts/
|
|
||||||
|
|
||||||
def test_video_short_youtu_be(self, sample_video_data):
|
|
||||||
"""Test short detection with youtu.be URL"""
|
|
||||||
short_url = "https://youtu.be/dQw4w9WgXcQ?t=0"
|
|
||||||
video_data = sample_video_data.copy()
|
|
||||||
video_data["url"] = short_url
|
|
||||||
video_data["is_short"] = False # Reset to test detection
|
|
||||||
|
|
||||||
# Note: Our detection only checks for /shorts/ in URL, not youtu.be shorts
|
|
||||||
video = Video(**video_data)
|
|
||||||
# This should be False since we don't detect youtu.be shorts URLs
|
|
||||||
assert video.is_short is False
|
|
||||||
|
|
||||||
def test_video_empty_description(self, sample_video_data):
|
|
||||||
"""Test Video with empty description"""
|
|
||||||
video_data = sample_video_data.copy()
|
|
||||||
video_data["description"] = ""
|
|
||||||
|
|
||||||
video = Video(**video_data)
|
|
||||||
|
|
||||||
assert video.description == ""
|
|
||||||
|
|
||||||
def test_video_none_values(self, sample_video_data):
|
|
||||||
"""Test Video with None values"""
|
|
||||||
video_data = {
|
|
||||||
"video_id": "test123",
|
|
||||||
"title": "Test",
|
|
||||||
"channel": "Channel",
|
|
||||||
"channel_id": "channel123",
|
|
||||||
"duration": "1:00",
|
|
||||||
"view_count": "0",
|
|
||||||
"upload_date": "20240101",
|
|
||||||
"description": "",
|
|
||||||
"thumbnail_url": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
video = Video(**video_data)
|
|
||||||
|
|
||||||
assert video.thumbnail_url is None
|
|
||||||
assert video.description == ""
|
|
||||||
assert video.url == "https://www.youtube.com/watch?v=test123"
|
|
||||||
@ -1,340 +0,0 @@
|
|||||||
"""
|
|
||||||
Unit tests for YouTubeService
|
|
||||||
"""
|
|
||||||
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
class TestYouTubeService:
|
|
||||||
"""Tests for YouTubeService class"""
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def service(self):
|
|
||||||
"""Create YouTubeService instance"""
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
from youtube_tui.services.youtube import YouTubeService
|
|
||||||
|
|
||||||
with patch("youtube_cli.main.YouTubeCLI"):
|
|
||||||
service = YouTubeService.__new__(YouTubeService)
|
|
||||||
service.cli = MagicMock()
|
|
||||||
|
|
||||||
# Set up format_duration to use the real implementation
|
|
||||||
def real_format_duration(seconds):
|
|
||||||
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}"
|
|
||||||
else:
|
|
||||||
return f"{minutes}:{secs:02d}"
|
|
||||||
|
|
||||||
service.cli.format_duration = real_format_duration
|
|
||||||
service.console = MagicMock()
|
|
||||||
return service
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_yt_dlp_result(self):
|
|
||||||
"""Mock yt-dlp search result"""
|
|
||||||
return {
|
|
||||||
"id": "abc123",
|
|
||||||
"title": "Sample Video",
|
|
||||||
"author": "Sample Channel",
|
|
||||||
"channel": "Sample Channel",
|
|
||||||
"channel_id": "channel123",
|
|
||||||
"length": "10:30",
|
|
||||||
"view_count": 150000,
|
|
||||||
"upload_date": "20240115",
|
|
||||||
"description": "A sample video description",
|
|
||||||
"thumbnail": "https://i.ytimg.com/vi/abc123/hqdefault.jpg",
|
|
||||||
"url": "https://www.youtube.com/watch?v=abc123",
|
|
||||||
}
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_search_videos_success(self, service, mock_yt_dlp_result):
|
|
||||||
"""Test successful video search"""
|
|
||||||
from youtube_tui.models.video import Video
|
|
||||||
|
|
||||||
# Setup mock
|
|
||||||
service.cli.search_videos.return_value = [mock_yt_dlp_result]
|
|
||||||
service._create_video_from_result = MagicMock(
|
|
||||||
return_value=MagicMock(spec=Video)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Call the method - patch asyncio.to_thread since that's how it's imported
|
|
||||||
with patch("asyncio.to_thread") as mock_to_thread:
|
|
||||||
# The _search function returns a list of Video objects
|
|
||||||
mock_to_thread.return_value = [mock_yt_dlp_result]
|
|
||||||
results = await service.search_videos("test query")
|
|
||||||
|
|
||||||
# Verify
|
|
||||||
assert isinstance(results, list)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_search_videos_empty_result(self, service):
|
|
||||||
"""Test search with no results"""
|
|
||||||
service.cli.search_videos.return_value = []
|
|
||||||
|
|
||||||
results = await service.search_videos("test query")
|
|
||||||
|
|
||||||
assert results == []
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_search_videos_error(self, service):
|
|
||||||
"""Test search error handling"""
|
|
||||||
service.cli.search_videos.side_effect = Exception("Network error")
|
|
||||||
service.console.print = MagicMock()
|
|
||||||
|
|
||||||
with pytest.raises(Exception): # SearchError wrapped in asyncio
|
|
||||||
await service.search_videos("test query")
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_search_videos_custom_page(self, service, mock_yt_dlp_result):
|
|
||||||
"""Test search with custom page parameter"""
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
from youtube_tui.models.video import Video
|
|
||||||
|
|
||||||
service.cli.search_videos.return_value = [mock_yt_dlp_result]
|
|
||||||
# Mock _create_video_from_result to return proper Video objects
|
|
||||||
service._create_video_from_result = MagicMock(
|
|
||||||
return_value=MagicMock(
|
|
||||||
spec=Video,
|
|
||||||
video_id="abc123",
|
|
||||||
title="Sample Video",
|
|
||||||
channel="Sample Channel",
|
|
||||||
channel_id="channel123",
|
|
||||||
duration="10:30",
|
|
||||||
view_count="150000",
|
|
||||||
upload_date="20240115",
|
|
||||||
description="A sample video description",
|
|
||||||
thumbnail_url="https://i.ytimg.com/vi/abc123/hqdefault.jpg",
|
|
||||||
url="https://www.youtube.com/watch?v=abc123",
|
|
||||||
is_short=False,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Mock asyncio.to_thread to execute the actual _search function
|
|
||||||
async def mock_to_thread(func, *args, **kwargs):
|
|
||||||
# Execute the function synchronously
|
|
||||||
result = func()
|
|
||||||
return result
|
|
||||||
|
|
||||||
with patch("asyncio.to_thread", mock_to_thread):
|
|
||||||
await service.search_videos("test query", page=2, per_page=10)
|
|
||||||
|
|
||||||
# Note: per_page is ignored per the implementation
|
|
||||||
service.cli.search_videos.assert_called_once_with(
|
|
||||||
"test query", service.cli.config, 2, return_results=True
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_download_video_success(self, service, mock_video):
|
|
||||||
"""Test successful video download"""
|
|
||||||
service.cli.download_video.return_value = True
|
|
||||||
|
|
||||||
result = await service.download_video(mock_video, "Music")
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
service.cli.download_video.assert_called_once()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_download_video_failure(self, service, mock_video):
|
|
||||||
"""Test video download failure"""
|
|
||||||
service.cli.download_video.return_value = False
|
|
||||||
service.console.print = MagicMock()
|
|
||||||
|
|
||||||
result = await service.download_video(mock_video, "Music")
|
|
||||||
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_download_video_error(self, service, mock_video):
|
|
||||||
"""Test download error handling"""
|
|
||||||
service.cli.download_video.side_effect = Exception("Download failed")
|
|
||||||
service.console.print = MagicMock()
|
|
||||||
|
|
||||||
with pytest.raises(Exception): # DownloadError wrapped in asyncio
|
|
||||||
await service.download_video(mock_video, "Music")
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_download_playlist_success(self, service, mock_video):
|
|
||||||
"""Test successful playlist download"""
|
|
||||||
service.cli.download_playlist.return_value = True
|
|
||||||
|
|
||||||
result = await service.download_playlist(mock_video, "Music")
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
service.cli.download_playlist.assert_called_once()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_download_playlist_error(self, service, mock_video):
|
|
||||||
"""Test playlist download error handling"""
|
|
||||||
service.cli.download_playlist.side_effect = Exception("Playlist error")
|
|
||||||
service.console.print = MagicMock()
|
|
||||||
|
|
||||||
with pytest.raises(Exception): # DownloadError wrapped in asyncio
|
|
||||||
await service.download_playlist(mock_video, "Music")
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_categories_success(self, service):
|
|
||||||
"""Test getting categories"""
|
|
||||||
service.cli.get_categories.return_value = ["Music", "Videos", "Movies"]
|
|
||||||
|
|
||||||
categories = await service.get_categories()
|
|
||||||
|
|
||||||
assert categories == ["Music", "Videos", "Movies"]
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_is_video_downloaded_true(self, service):
|
|
||||||
"""Test checking downloaded video (exists)"""
|
|
||||||
service.cli.is_video_downloaded.return_value = True
|
|
||||||
|
|
||||||
result = await service.is_video_downloaded("video123")
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_is_video_downloaded_false(self, service):
|
|
||||||
"""Test checking downloaded video (not exists)"""
|
|
||||||
service.cli.is_video_downloaded.return_value = False
|
|
||||||
|
|
||||||
result = await service.is_video_downloaded("video123")
|
|
||||||
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_add_to_archive_success(self, service, mock_video):
|
|
||||||
"""Test adding video to archive"""
|
|
||||||
service.cli.add_to_archive = MagicMock()
|
|
||||||
|
|
||||||
await service.add_to_archive(mock_video)
|
|
||||||
|
|
||||||
service.cli.add_to_archive.assert_called_once_with(
|
|
||||||
{
|
|
||||||
"url": mock_video.url,
|
|
||||||
"id": mock_video.video_id,
|
|
||||||
"title": mock_video.title,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_add_to_archive_error(self, service, mock_video):
|
|
||||||
"""Test archive error handling"""
|
|
||||||
service.cli.add_to_archive.side_effect = Exception("Archive error")
|
|
||||||
service.console.print = MagicMock()
|
|
||||||
|
|
||||||
with pytest.raises(Exception): # ArchiveError wrapped in asyncio
|
|
||||||
await service.add_to_archive(mock_video)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_archive_success(self, service):
|
|
||||||
"""Test loading archive"""
|
|
||||||
mock_archive = {
|
|
||||||
"video123": {
|
|
||||||
"url": "https://youtube.com/watch?v=video123",
|
|
||||||
"id": "video123",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
service.cli.load_archive.return_value = mock_archive
|
|
||||||
|
|
||||||
result = await service.get_archive()
|
|
||||||
|
|
||||||
assert result == mock_archive
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_downloaded_video_ids(self, service):
|
|
||||||
"""Test getting downloaded video IDs"""
|
|
||||||
mock_archive = {
|
|
||||||
"video1": {"url": "https://youtube.com/watch?v=video1"},
|
|
||||||
"video2": {"url": "https://youtube.com/watch?v=video2"},
|
|
||||||
}
|
|
||||||
service.get_archive = AsyncMock(return_value=mock_archive)
|
|
||||||
|
|
||||||
result = await service.get_downloaded_video_ids()
|
|
||||||
|
|
||||||
assert result == {"video1", "video2"}
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_remove_from_archive_success(self, service):
|
|
||||||
"""Test removing video from archive"""
|
|
||||||
service.cli.load_archive.return_value = {"video123": {"url": "test"}}
|
|
||||||
service.cli.save_archive = MagicMock()
|
|
||||||
|
|
||||||
result = await service.remove_from_archive("video123")
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_remove_from_archive_not_found(self, service):
|
|
||||||
"""Test removing non-existent video from archive"""
|
|
||||||
service.cli.load_archive.return_value = {"video123": {"url": "test"}}
|
|
||||||
service.cli.save_archive = MagicMock()
|
|
||||||
|
|
||||||
result = await service.remove_from_archive("video999")
|
|
||||||
|
|
||||||
assert result is False
|
|
||||||
service.cli.save_archive.assert_not_called()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_remove_from_archive_error(self, service):
|
|
||||||
"""Test archive removal error handling"""
|
|
||||||
service.cli.load_archive.side_effect = Exception("Archive error")
|
|
||||||
service.cli.save_archive = MagicMock()
|
|
||||||
|
|
||||||
result = await service.remove_from_archive("video123")
|
|
||||||
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
def test_create_video_from_result(self, service, mock_yt_dlp_result):
|
|
||||||
"""Test creating Video from yt-dlp result"""
|
|
||||||
result = service._create_video_from_result(mock_yt_dlp_result)
|
|
||||||
|
|
||||||
assert result.video_id == "abc123"
|
|
||||||
assert result.title == "Sample Video"
|
|
||||||
assert result.channel == "Sample Channel"
|
|
||||||
assert result.duration == "10:30"
|
|
||||||
assert result.view_count == "150000"
|
|
||||||
|
|
||||||
def test_create_video_from_result_channel_field(self, service):
|
|
||||||
"""Test creating Video with channel field instead of author"""
|
|
||||||
result_data = {
|
|
||||||
"id": "abc123",
|
|
||||||
"title": "Sample Video",
|
|
||||||
"channel": "Sample Channel",
|
|
||||||
"channel_id": "channel123",
|
|
||||||
"length": "5:00",
|
|
||||||
"view_count": 1000,
|
|
||||||
"upload_date": "20240101",
|
|
||||||
"description": "Test",
|
|
||||||
"thumbnail": "https://example.com/thumb.jpg",
|
|
||||||
"url": "https://youtube.com/watch?v=abc123",
|
|
||||||
}
|
|
||||||
|
|
||||||
result = service._create_video_from_result(result_data)
|
|
||||||
|
|
||||||
assert result.channel == "Sample Channel"
|
|
||||||
|
|
||||||
def test_format_duration(self, service):
|
|
||||||
"""Test duration formatting"""
|
|
||||||
result = service.format_duration(3665)
|
|
||||||
|
|
||||||
# 3665 seconds = 1 hour, 1 minute, 5 seconds
|
|
||||||
# Format should be HH:MM:SS or MM:SS
|
|
||||||
assert result in ["1:01:05", "01:01:05"]
|
|
||||||
|
|
||||||
def test_format_duration_minutes(self, service):
|
|
||||||
"""Test duration formatting for minutes"""
|
|
||||||
result = service.format_duration(125)
|
|
||||||
|
|
||||||
assert result == "2:05"
|
|
||||||
|
|
||||||
def test_format_duration_seconds(self, service):
|
|
||||||
"""Test duration formatting for seconds only"""
|
|
||||||
result = service.format_duration(30)
|
|
||||||
|
|
||||||
assert result == "0:30"
|
|
||||||
@ -1,231 +0,0 @@
|
|||||||
"""
|
|
||||||
Unit tests for TUI widgets
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
class TestStatusBar:
|
|
||||||
"""Tests for StatusBar widget"""
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_app(self):
|
|
||||||
"""Mock Textual app"""
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
from textual.app import App
|
|
||||||
|
|
||||||
# Create a minimal mock app that works with Textual
|
|
||||||
app = MagicMock(spec=App)
|
|
||||||
app.theme = "default"
|
|
||||||
app.VERSION = "1.0.0"
|
|
||||||
return app
|
|
||||||
|
|
||||||
def test_statusbar_initialization(self, mock_app):
|
|
||||||
"""Test StatusBar initialization"""
|
|
||||||
from youtube_tui.widgets.status_bar import StatusBar
|
|
||||||
|
|
||||||
# Mock subprocess.run
|
|
||||||
with patch("subprocess.run") as mock_run:
|
|
||||||
mock_result = MagicMock()
|
|
||||||
mock_result.returncode = 0
|
|
||||||
mock_result.stdout = "2024.01.01"
|
|
||||||
mock_run.return_value = mock_result
|
|
||||||
|
|
||||||
status_bar = StatusBar(mock_app)
|
|
||||||
|
|
||||||
assert status_bar.current_screen == "Search"
|
|
||||||
assert status_bar.status_message == "Ready"
|
|
||||||
assert status_bar.downloading is False
|
|
||||||
assert status_bar.yt_dlp_version == "2024.01.01"
|
|
||||||
|
|
||||||
def test_statusbar_update_version_success(self, mock_app):
|
|
||||||
"""Test version update with successful yt-dlp call"""
|
|
||||||
from youtube_tui.widgets.status_bar import StatusBar
|
|
||||||
|
|
||||||
with patch("subprocess.run") as mock_run:
|
|
||||||
mock_result = MagicMock()
|
|
||||||
mock_result.returncode = 0
|
|
||||||
mock_result.stdout = "2024.01.15"
|
|
||||||
mock_run.return_value = mock_result
|
|
||||||
|
|
||||||
status_bar = StatusBar(mock_app)
|
|
||||||
status_bar.update_version()
|
|
||||||
|
|
||||||
assert status_bar.yt_dlp_version == "2024.01.15"
|
|
||||||
|
|
||||||
def test_statusbar_update_version_not_installed(self, mock_app):
|
|
||||||
"""Test version update when yt-dlp not installed"""
|
|
||||||
from youtube_tui.widgets.status_bar import StatusBar
|
|
||||||
|
|
||||||
with patch("subprocess.run") as mock_run:
|
|
||||||
mock_result = MagicMock()
|
|
||||||
mock_result.returncode = 1
|
|
||||||
mock_run.return_value = mock_result
|
|
||||||
|
|
||||||
status_bar = StatusBar(mock_app)
|
|
||||||
status_bar.update_version()
|
|
||||||
|
|
||||||
assert status_bar.yt_dlp_version == "not installed"
|
|
||||||
|
|
||||||
def test_statusbar_update_version_error(self, mock_app):
|
|
||||||
"""Test version update with error"""
|
|
||||||
from youtube_tui.widgets.status_bar import StatusBar
|
|
||||||
|
|
||||||
with patch("subprocess.run") as mock_run:
|
|
||||||
mock_run.side_effect = Exception("Command failed")
|
|
||||||
|
|
||||||
status_bar = StatusBar(mock_app)
|
|
||||||
status_bar.update_version()
|
|
||||||
|
|
||||||
assert status_bar.yt_dlp_version == "unknown"
|
|
||||||
|
|
||||||
def test_statusbar_set_screen(self, mock_app):
|
|
||||||
"""Test setting screen name"""
|
|
||||||
from youtube_tui.widgets.status_bar import StatusBar
|
|
||||||
|
|
||||||
with patch("subprocess.run"):
|
|
||||||
status_bar = StatusBar(mock_app)
|
|
||||||
|
|
||||||
status_bar.set_screen("Results")
|
|
||||||
assert status_bar.current_screen == "Results"
|
|
||||||
|
|
||||||
def test_statusbar_set_downloading(self, mock_app):
|
|
||||||
"""Test setting downloading state"""
|
|
||||||
from youtube_tui.widgets.status_bar import StatusBar
|
|
||||||
|
|
||||||
with patch("subprocess.run"):
|
|
||||||
status_bar = StatusBar(mock_app)
|
|
||||||
|
|
||||||
status_bar.set_downloading(True)
|
|
||||||
assert status_bar.downloading is True
|
|
||||||
|
|
||||||
status_bar.set_downloading(False)
|
|
||||||
assert status_bar.downloading is False
|
|
||||||
|
|
||||||
def test_statusbar_set_status(self, mock_app):
|
|
||||||
"""Test setting status message"""
|
|
||||||
from youtube_tui.widgets.status_bar import StatusBar
|
|
||||||
|
|
||||||
with patch("subprocess.run"):
|
|
||||||
status_bar = StatusBar(mock_app)
|
|
||||||
|
|
||||||
status_bar.set_status("Downloading video...")
|
|
||||||
assert status_bar.status_message == "Downloading video..."
|
|
||||||
|
|
||||||
def test_statusbar_render(self, mock_app):
|
|
||||||
"""Test status bar rendering"""
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from youtube_tui.widgets.status_bar import StatusBar
|
|
||||||
|
|
||||||
with patch("subprocess.run") as mock_run:
|
|
||||||
mock_result = MagicMock()
|
|
||||||
mock_result.returncode = 0
|
|
||||||
mock_result.stdout = "2024.01.01"
|
|
||||||
mock_run.return_value = mock_result
|
|
||||||
|
|
||||||
status_bar = StatusBar(mock_app)
|
|
||||||
status_bar.set_status("Ready")
|
|
||||||
|
|
||||||
# Mock datetime for consistent testing
|
|
||||||
with patch(
|
|
||||||
"youtube_tui.widgets.status_bar.datetime"
|
|
||||||
) as mock_datetime:
|
|
||||||
mock_datetime.now.return_value = datetime(
|
|
||||||
2024, 1, 15, 12, 30, 0
|
|
||||||
)
|
|
||||||
render_result = status_bar.render()
|
|
||||||
|
|
||||||
render_str = str(render_result)
|
|
||||||
assert "Search" in render_str
|
|
||||||
assert "2024.01.01" in render_str
|
|
||||||
assert "Ready" in render_str
|
|
||||||
assert "12:30:00" in render_str
|
|
||||||
|
|
||||||
def test_statusbar_render_downloading(self, mock_app):
|
|
||||||
"""Test status bar rendering with downloading indicator"""
|
|
||||||
from youtube_tui.widgets.status_bar import StatusBar
|
|
||||||
|
|
||||||
with patch("subprocess.run") as mock_run:
|
|
||||||
mock_result = MagicMock()
|
|
||||||
mock_result.returncode = 0
|
|
||||||
mock_result.stdout = "2024.01.01"
|
|
||||||
mock_run.return_value = mock_result
|
|
||||||
|
|
||||||
status_bar = StatusBar(mock_app)
|
|
||||||
status_bar.set_downloading(True)
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"youtube_tui.widgets.status_bar.datetime"
|
|
||||||
) as mock_datetime:
|
|
||||||
mock_datetime.now.return_value = datetime(
|
|
||||||
2024, 1, 15, 12, 30, 0
|
|
||||||
)
|
|
||||||
render_result = status_bar.render()
|
|
||||||
|
|
||||||
render_str = str(render_result)
|
|
||||||
assert "↓" in render_str # Download indicator
|
|
||||||
|
|
||||||
|
|
||||||
class TestCommandPalette:
|
|
||||||
"""Tests for CommandPalette widget"""
|
|
||||||
|
|
||||||
def test_command_palette_creation(self):
|
|
||||||
"""Test CommandPalette initialization"""
|
|
||||||
from youtube_tui.widgets.command_palette import CommandPalette
|
|
||||||
|
|
||||||
palette = CommandPalette()
|
|
||||||
assert palette is not None
|
|
||||||
|
|
||||||
|
|
||||||
class TestCustomWidgets:
|
|
||||||
"""Tests for custom widget implementations"""
|
|
||||||
|
|
||||||
def test_static_widget_creation(self):
|
|
||||||
"""Test basic Static widget"""
|
|
||||||
from textual.widgets import Static
|
|
||||||
|
|
||||||
widget = Static("Test content")
|
|
||||||
assert widget.renderable == "Test content"
|
|
||||||
|
|
||||||
def test_static_widget_with_rich_markup(self):
|
|
||||||
"""Test Static widget with Rich markup"""
|
|
||||||
from textual.widgets import Static
|
|
||||||
|
|
||||||
widget = Static("[bold]Test[/bold] [red]content[/red]")
|
|
||||||
# The content should contain the markup
|
|
||||||
assert "[bold]" in str(widget.renderable)
|
|
||||||
|
|
||||||
def test_button_widget_creation(self):
|
|
||||||
"""Test Button widget"""
|
|
||||||
from textual.widgets import Button
|
|
||||||
|
|
||||||
button = Button("Click me")
|
|
||||||
assert str(button.label) == "Click me"
|
|
||||||
|
|
||||||
def test_input_widget_creation(self):
|
|
||||||
"""Test Input widget"""
|
|
||||||
from textual.widgets import Input
|
|
||||||
|
|
||||||
input_widget = Input(placeholder="Enter text...")
|
|
||||||
assert input_widget.placeholder == "Enter text..."
|
|
||||||
|
|
||||||
def test_data_table_creation(self):
|
|
||||||
"""Test DataTable widget"""
|
|
||||||
from textual.widgets import DataTable
|
|
||||||
|
|
||||||
table = DataTable()
|
|
||||||
assert table is not None
|
|
||||||
|
|
||||||
def test_progress_bar_creation(self):
|
|
||||||
"""Test ProgressBar widget"""
|
|
||||||
from textual.widgets import ProgressBar
|
|
||||||
|
|
||||||
progress = ProgressBar(total=100)
|
|
||||||
progress.progress = 50
|
|
||||||
assert progress.total == 100
|
|
||||||
assert progress.progress == 50
|
|
||||||
203
web/PLAN.md
203
web/PLAN.md
@ -1,203 +0,0 @@
|
|||||||
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
web/README.md
192
web/README.md
@ -1,192 +0,0 @@
|
|||||||
# 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
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,170 +0,0 @@
|
|||||||
#!/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)
|
|
||||||
@ -1,132 +0,0 @@
|
|||||||
# 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
|
|
||||||
@ -1,526 +0,0 @@
|
|||||||
"""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}"
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
"""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
|
|
||||||
@ -1,122 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@ -1,245 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@ -1,204 +0,0 @@
|
|||||||
"""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
web/server/nohup.out
1142
web/server/nohup.out
File diff suppressed because one or more lines are too long
@ -1,8 +0,0 @@
|
|||||||
"""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']
|
|
||||||
@ -1,234 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@ -1,214 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@ -1,281 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@ -1,330 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@ -1,118 +0,0 @@
|
|||||||
"""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!")
|
|
||||||
@ -1,177 +0,0 @@
|
|||||||
"""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!")
|
|
||||||
@ -1,223 +0,0 @@
|
|||||||
"""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!")
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
"""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
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
# 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
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
<!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/web-app/package-lock.json
generated
3089
web/web-app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,30 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"plugins": [
|
|
||||||
{
|
|
||||||
"postcss-plugin": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,103 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
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);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@ -1,123 +0,0 @@
|
|||||||
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`);
|
|
||||||
}
|
|
||||||
@ -1,98 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
@ -1,60 +0,0 @@
|
|||||||
import { io, Socket } from "socket.io-client";
|
|
||||||
|
|
||||||
let socket: Socket | null = null;
|
|
||||||
|
|
||||||
export function getSocket(): Socket {
|
|
||||||
if (!socket) {
|
|
||||||
const wsURL = import.meta.env.VITE_WS_URL || window.location.origin;
|
|
||||||
socket = io(wsURL, {
|
|
||||||
transports: ["websocket", "polling"],
|
|
||||||
reconnection: true,
|
|
||||||
reconnectionDelay: 1000,
|
|
||||||
reconnectionAttempts: 5,
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on("connect", () => {
|
|
||||||
console.log("[WS] Connected");
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on("disconnect", () => {
|
|
||||||
console.log("[WS] Disconnected");
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on("connect_error", (err) => {
|
|
||||||
console.error("[WS] Connection error:", err.message);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return socket;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function disconnectSocket(): void {
|
|
||||||
if (socket) {
|
|
||||||
socket.disconnect();
|
|
||||||
socket = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProgressUpdate {
|
|
||||||
queueId: string;
|
|
||||||
progress: number;
|
|
||||||
speed?: string | null;
|
|
||||||
eta?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StatusUpdate {
|
|
||||||
queueId: string;
|
|
||||||
status: string;
|
|
||||||
progress?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CompleteUpdate {
|
|
||||||
queueId: string;
|
|
||||||
downloadPath?: string;
|
|
||||||
fileSize?: number;
|
|
||||||
videoCount?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FailedUpdate {
|
|
||||||
queueId: string;
|
|
||||||
error: string;
|
|
||||||
}
|
|
||||||
@ -1,97 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
@ -1,73 +0,0 @@
|
|||||||
import { useEffect, useRef } from "react";
|
|
||||||
|
|
||||||
interface VideoPlayerModalProps {
|
|
||||||
videoId: string;
|
|
||||||
title: string;
|
|
||||||
onClose: () => void;
|
|
||||||
localVideoPath?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function VideoPlayerModal({ videoId, title, onClose, localVideoPath }: VideoPlayerModalProps) {
|
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") onClose();
|
|
||||||
};
|
|
||||||
document.addEventListener("keydown", handleKey);
|
|
||||||
document.body.style.overflow = "hidden";
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("keydown", handleKey);
|
|
||||||
document.body.style.overflow = "";
|
|
||||||
};
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (videoRef.current && localVideoPath) {
|
|
||||||
videoRef.current.play().catch(() => {});
|
|
||||||
}
|
|
||||||
}, [localVideoPath]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="bg-slate-900 rounded-xl overflow-hidden w-full max-w-4xl mx-4 shadow-2xl"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-700">
|
|
||||||
<h2 className="text-white font-semibold truncate pr-4">{title}</h2>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="text-slate-400 hover:text-white transition-colors flex-shrink-0"
|
|
||||||
>
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="aspect-video bg-black">
|
|
||||||
{localVideoPath ? (
|
|
||||||
<video
|
|
||||||
ref={videoRef}
|
|
||||||
src={localVideoPath}
|
|
||||||
controls
|
|
||||||
className="w-full h-full"
|
|
||||||
autoPlay
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<iframe
|
|
||||||
src={`https://www.youtube.com/embed/${videoId}?autoplay=1&rel=0`}
|
|
||||||
title={title}
|
|
||||||
className="w-full h-full"
|
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen"
|
|
||||||
allowFullScreen
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
@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;
|
|
||||||
}
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
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>,
|
|
||||||
)
|
|
||||||
@ -1,306 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,157 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { addToQueue } from "../api/queue";
|
|
||||||
import { getQuality } from "../components/Navbar";
|
|
||||||
|
|
||||||
interface VideoInfo {
|
|
||||||
id: string;
|
|
||||||
videoId: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
thumbnail: string;
|
|
||||||
url: string;
|
|
||||||
duration: string;
|
|
||||||
views: string;
|
|
||||||
channel: string;
|
|
||||||
isShort: boolean;
|
|
||||||
published: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DirectPage() {
|
|
||||||
const [url, setUrl] = useState("");
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [videoInfo, setVideoInfo] = useState<VideoInfo | null>(null);
|
|
||||||
const [added, setAdded] = useState(false);
|
|
||||||
|
|
||||||
const handleFetchInfo = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!url.trim()) return;
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
setVideoInfo(null);
|
|
||||||
setAdded(false);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/info?url=${encodeURIComponent(url.trim())}`);
|
|
||||||
if (!response.ok) {
|
|
||||||
const err = await response.json();
|
|
||||||
throw new Error(err.error || "Failed to fetch video info");
|
|
||||||
}
|
|
||||||
const data = await response.json();
|
|
||||||
setVideoInfo(data);
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.message || "Could not fetch video info. Check the URL and try again.");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDownload = async () => {
|
|
||||||
if (!videoInfo) return;
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
await addToQueue({
|
|
||||||
videoId: videoInfo.videoId,
|
|
||||||
title: videoInfo.title,
|
|
||||||
thumbnail: videoInfo.thumbnail,
|
|
||||||
category: "General",
|
|
||||||
url: videoInfo.url,
|
|
||||||
quality: getQuality(),
|
|
||||||
});
|
|
||||||
setAdded(true);
|
|
||||||
} catch (err) {
|
|
||||||
setError("Failed to add video to queue.");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-slate-900 text-white">
|
|
||||||
<div className="container mx-auto px-4 py-12">
|
|
||||||
<h1 className="text-3xl font-bold mb-8 text-center">Download by Link</h1>
|
|
||||||
|
|
||||||
<div className="max-w-2xl mx-auto">
|
|
||||||
<form onSubmit={handleFetchInfo} className="mb-8">
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={url}
|
|
||||||
onChange={(e) => setUrl(e.target.value)}
|
|
||||||
placeholder="Paste YouTube URL (e.g., https://youtube.com/watch?v=...)"
|
|
||||||
className="flex-1 px-4 py-3 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-red-600"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading || !url.trim()}
|
|
||||||
className="px-6 py-3 bg-red-600 hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg font-semibold transition-colors"
|
|
||||||
>
|
|
||||||
{loading ? "..." : "Fetch"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="mb-6 p-4 bg-red-600/20 border border-red-500/50 rounded-lg text-red-500">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{added && (
|
|
||||||
<div className="mb-6 p-4 bg-green-500/20 border border-green-500/50 rounded-lg text-green-500">
|
|
||||||
Added to queue! You can track progress on the Queue page.
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setUrl("");
|
|
||||||
setVideoInfo(null);
|
|
||||||
setAdded(false);
|
|
||||||
}}
|
|
||||||
className="ml-4 underline hover:text-green-400"
|
|
||||||
>
|
|
||||||
Download another
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{videoInfo && !added && (
|
|
||||||
<div className="bg-slate-800 rounded-xl overflow-hidden">
|
|
||||||
<div className="flex flex-col md:flex-row gap-4 p-4">
|
|
||||||
<div className="w-full md:w-64 aspect-video md:aspect-auto flex-shrink-0">
|
|
||||||
<img
|
|
||||||
src={videoInfo.thumbnail}
|
|
||||||
alt={videoInfo.title}
|
|
||||||
className="w-full h-full object-cover rounded-lg"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h2 className="text-xl font-semibold mb-2">{videoInfo.title}</h2>
|
|
||||||
<div className="text-sm text-slate-400 mb-1">
|
|
||||||
{videoInfo.channel}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-4 text-sm text-slate-500">
|
|
||||||
<span>Duration: {videoInfo.duration}s</span>
|
|
||||||
<span>Views: {videoInfo.views}</span>
|
|
||||||
{videoInfo.isShort && (
|
|
||||||
<span className="inline-block bg-red-600 text-white text-[10px] px-1.5 py-0.5 rounded">
|
|
||||||
SHORT
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={handleDownload}
|
|
||||||
disabled={loading}
|
|
||||||
className="mt-4 px-6 py-2 bg-red-600 hover:bg-red-700 disabled:opacity-50 rounded-lg font-semibold transition-colors"
|
|
||||||
>
|
|
||||||
{loading ? "Adding..." : "Add to Queue"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,369 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,376 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,298 +0,0 @@
|
|||||||
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/web-app/src/vite-env.d.ts
vendored
10
web/web-app/src/vite-env.d.ts
vendored
@ -1,10 +0,0 @@
|
|||||||
/// <reference types="vite/client" />
|
|
||||||
|
|
||||||
interface ImportMetaEnv {
|
|
||||||
readonly VITE_API_BASE_URL?: string;
|
|
||||||
readonly VITE_WS_URL?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ImportMeta {
|
|
||||||
readonly env: ImportMetaEnv;
|
|
||||||
}
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
/** @type {import("tailwindcss").Config} */
|
|
||||||
export default {
|
|
||||||
content: ["./src/**/*.{js,jsx,ts,tsx}"],
|
|
||||||
theme: {
|
|
||||||
extend: {},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
}
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"status": "passed",
|
|
||||||
"failedTests": []
|
|
||||||
}
|
|
||||||
@ -1,152 +0,0 @@
|
|||||||
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.
|
Before Width: | Height: | Size: 40 KiB |
@ -1,28 +0,0 @@
|
|||||||
{
|
|
||||||
"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"]
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
# YouTube TUI
|
|
||||||
|
|
||||||
Text-based User Interface (TUI) for YouTube CLI
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install -e .
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```bash
|
|
||||||
youtube-tui
|
|
||||||
```
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install dependencies
|
|
||||||
pip install textual>=8.0
|
|
||||||
|
|
||||||
# Run the TUI
|
|
||||||
python -m youtube_tui
|
|
||||||
```
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
- `__init__.py` - Package initialization
|
|
||||||
- `__main__.py` - Entry point for `python -m youtube_tui`
|
|
||||||
- `app.py` - Main Textual application
|
|
||||||
- `models/` - Data models (Video, etc.)
|
|
||||||
- `services/` - Business logic services
|
|
||||||
- `widgets/` - Textual widgets
|
|
||||||
- `screens/` - TUI screens
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
"""
|
|
||||||
YouTube TUI - A Text-based User Interface for browsing and downloading YouTube videos
|
|
||||||
"""
|
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Entry point for the YouTube TUI application
|
|
||||||
"""
|
|
||||||
|
|
||||||
from youtube_tui.app import main
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@ -1,296 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Main Textual Application class for YouTube TUI
|
|
||||||
Enhanced with command palette, help screen, status bar, and theme support
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import subprocess
|
|
||||||
from datetime import datetime
|
|
||||||
from logging.handlers import RotatingFileHandler
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Optional
|
|
||||||
|
|
||||||
from textual.app import App, ComposeResult
|
|
||||||
from textual.widgets import Header, Static
|
|
||||||
|
|
||||||
# Configure logging
|
|
||||||
LOG_DIR = Path.home() / ".config" / "youtube_cli" / "logs"
|
|
||||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
LOG_FILE = LOG_DIR / "app.log"
|
|
||||||
|
|
||||||
# Use RotatingFileHandler for log rotation (10MB, 5 backups)
|
|
||||||
file_handler = RotatingFileHandler(LOG_FILE, maxBytes=10 * 1024 * 1024, backupCount=5)
|
|
||||||
file_handler.setLevel(logging.DEBUG)
|
|
||||||
file_handler.setFormatter(
|
|
||||||
logging.Formatter(
|
|
||||||
"%(asctime)s | %(name)s | %(levelname)s | %(message)s",
|
|
||||||
"%Y-%m-%d %H:%M:%S",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create console handler
|
|
||||||
console_handler = logging.StreamHandler()
|
|
||||||
console_handler.setLevel(logging.INFO)
|
|
||||||
console_handler.setFormatter(logging.Formatter("%(message)s"))
|
|
||||||
|
|
||||||
# Configure root logger
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.DEBUG,
|
|
||||||
handlers=[
|
|
||||||
file_handler,
|
|
||||||
console_handler,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
from youtube_tui.models.video import Video
|
|
||||||
from youtube_tui.services.youtube import YouTubeService
|
|
||||||
from youtube_tui.services.queue import DownloadQueue
|
|
||||||
from youtube_tui.services.download_manager import DownloadManager
|
|
||||||
from youtube_tui.widgets.footer import CustomFooter
|
|
||||||
|
|
||||||
|
|
||||||
class YouTubeTUI(App):
|
|
||||||
"""Main application class for YouTube TUI"""
|
|
||||||
|
|
||||||
VERSION = "0.1.0"
|
|
||||||
|
|
||||||
CSS = """
|
|
||||||
Screen {
|
|
||||||
align: center middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#header {
|
|
||||||
dock: top;
|
|
||||||
}
|
|
||||||
|
|
||||||
#footer {
|
|
||||||
dock: bottom;
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
BINDINGS = [
|
|
||||||
("q", "quit", "Quit"),
|
|
||||||
("escape", "cancel", "Cancel"),
|
|
||||||
("ctrl+p", "command_palette", "Command Palette"),
|
|
||||||
("ctrl+h", "show_help", "Help"),
|
|
||||||
("ctrl+t", "toggle_theme", "Toggle Theme"),
|
|
||||||
("ctrl+r", "refresh_screen", "Refresh"),
|
|
||||||
("ctrl+f", "open_search", "Search"),
|
|
||||||
("ctrl+l", "open_queue", "Queue"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
||||||
# Set _theme_name directly to avoid property issues during init
|
|
||||||
object.__setattr__(self, "_theme_name", "textual-dark")
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
self.current_screen: Optional[object] = None
|
|
||||||
self.current_search_term = ""
|
|
||||||
self.search_history: list = []
|
|
||||||
self.load_search_history()
|
|
||||||
self.yt_dlp_version = "unknown"
|
|
||||||
self._check_yt_dlp()
|
|
||||||
self.downloading = False
|
|
||||||
# Initialize queue and download manager
|
|
||||||
self.download_queue: Optional[DownloadQueue] = None
|
|
||||||
self.youtube_service: Optional[YouTubeService] = None
|
|
||||||
self.download_manager: Optional[DownloadManager] = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def theme(self) -> str:
|
|
||||||
return getattr(self, "_theme_name", "textual-dark")
|
|
||||||
|
|
||||||
@theme.setter
|
|
||||||
def theme(self, value: str) -> None:
|
|
||||||
self._theme_name = value
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
"""Compose the UI layout with enhanced components"""
|
|
||||||
yield Header()
|
|
||||||
yield Static("YouTube TUI - Browse and download videos", id="main-content")
|
|
||||||
yield CustomFooter(self)
|
|
||||||
|
|
||||||
async def action_quit(self) -> None:
|
|
||||||
"""Quit the application"""
|
|
||||||
self.exit()
|
|
||||||
|
|
||||||
def action_cancel(self) -> None:
|
|
||||||
"""Handle escape key"""
|
|
||||||
if hasattr(self.screen, "action_cancel"):
|
|
||||||
self.screen.action_cancel()
|
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
|
||||||
"""Called when the app is mounted"""
|
|
||||||
# Initialize download queue and manager
|
|
||||||
self.youtube_service = YouTubeService()
|
|
||||||
self.download_queue = DownloadQueue()
|
|
||||||
self.download_manager = DownloadManager(
|
|
||||||
self.download_queue, self.youtube_service
|
|
||||||
)
|
|
||||||
self.download_manager.start_processing()
|
|
||||||
|
|
||||||
self.push_home_screen()
|
|
||||||
self.current_screen = self.screen
|
|
||||||
|
|
||||||
def on_screen_stack_changed(self) -> None:
|
|
||||||
"""Called when the screen stack changes"""
|
|
||||||
current_screen = self.screen
|
|
||||||
if hasattr(current_screen, "search_term"):
|
|
||||||
self.current_search_term = current_screen.search_term
|
|
||||||
self.current_screen = current_screen
|
|
||||||
|
|
||||||
def push_home_screen(self) -> None:
|
|
||||||
"""Push the home screen"""
|
|
||||||
from youtube_tui.screens.home import HomeScreen
|
|
||||||
|
|
||||||
self.push_screen(HomeScreen())
|
|
||||||
|
|
||||||
def push_search_screen(self) -> None:
|
|
||||||
"""Push the search screen"""
|
|
||||||
from youtube_tui.screens.search import SearchScreen
|
|
||||||
|
|
||||||
self.push_screen(SearchScreen())
|
|
||||||
|
|
||||||
def push_results_screen(self, search_term: str, page: int = 1) -> None:
|
|
||||||
"""Push the results screen"""
|
|
||||||
from youtube_tui.screens.results import ResultsScreen
|
|
||||||
|
|
||||||
self.push_screen(ResultsScreen(search_term, page))
|
|
||||||
|
|
||||||
def push_download_screen(self, video: Video) -> None:
|
|
||||||
"""Push the download screen"""
|
|
||||||
from youtube_tui.screens.download import DownloadScreen
|
|
||||||
|
|
||||||
self.push_screen(DownloadScreen(video))
|
|
||||||
|
|
||||||
def push_category_modal(self) -> str:
|
|
||||||
"""Push the category selection modal and return selected category"""
|
|
||||||
from youtube_tui.screens.modal import CategorySelectionModal
|
|
||||||
|
|
||||||
# ModalScreen doesn't have request_screen in textual
|
|
||||||
self.push_screen(CategorySelectionModal())
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def _check_yt_dlp(self) -> None:
|
|
||||||
"""Check yt-dlp installation and version"""
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["yt-dlp", "--version"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=5,
|
|
||||||
)
|
|
||||||
if result.returncode == 0:
|
|
||||||
self.yt_dlp_version = result.stdout.strip()
|
|
||||||
except Exception:
|
|
||||||
self.yt_dlp_version = "not installed"
|
|
||||||
|
|
||||||
def load_search_history(self) -> None:
|
|
||||||
"""Load search history from config file"""
|
|
||||||
config_dir = Path.home() / ".config" / "youtube_cli"
|
|
||||||
history_file = config_dir / "search_history.json"
|
|
||||||
|
|
||||||
if history_file.exists():
|
|
||||||
try:
|
|
||||||
with open(history_file, "r") as f:
|
|
||||||
self.search_history = json.load(f)
|
|
||||||
except Exception:
|
|
||||||
self.search_history = []
|
|
||||||
|
|
||||||
def save_search_history(self) -> None:
|
|
||||||
"""Save search history to config file"""
|
|
||||||
config_dir = Path.home() / ".config" / "youtube_cli"
|
|
||||||
config_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
history_file = config_dir / "search_history.json"
|
|
||||||
try:
|
|
||||||
with open(history_file, "w") as f:
|
|
||||||
json.dump(self.search_history, f, indent=2)
|
|
||||||
except Exception:
|
|
||||||
pass # Silently fail if we can't save
|
|
||||||
|
|
||||||
def add_to_search_history(self, search_term: str) -> None:
|
|
||||||
"""Add a search term to history"""
|
|
||||||
# Remove duplicates
|
|
||||||
self.search_history = [
|
|
||||||
item
|
|
||||||
for item in self.search_history
|
|
||||||
if item.get("search_term") != search_term
|
|
||||||
]
|
|
||||||
|
|
||||||
# Add new entry
|
|
||||||
self.search_history.insert(
|
|
||||||
0,
|
|
||||||
{
|
|
||||||
"search_term": search_term,
|
|
||||||
"timestamp": datetime.now().isoformat(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Keep only last 50 searches
|
|
||||||
self.search_history = self.search_history[:50]
|
|
||||||
|
|
||||||
self.save_search_history()
|
|
||||||
|
|
||||||
def action_command_palette(self) -> None:
|
|
||||||
"""Open the command palette"""
|
|
||||||
from youtube_tui.widgets.command_palette import CommandPalette
|
|
||||||
|
|
||||||
self.push_screen(CommandPalette())
|
|
||||||
|
|
||||||
def action_show_help(self) -> None:
|
|
||||||
"""Show the help screen"""
|
|
||||||
from youtube_tui.screens.help import HelpScreen
|
|
||||||
|
|
||||||
self.push_screen(HelpScreen())
|
|
||||||
|
|
||||||
def action_toggle_theme(self) -> None:
|
|
||||||
"""Toggle between dark and light themes"""
|
|
||||||
# Textual 0.43+ has built-in dark/light theme support
|
|
||||||
# We'll cycle through available themes
|
|
||||||
if self.theme == "css":
|
|
||||||
self.theme = "textual-dark"
|
|
||||||
elif self.theme == "textual-dark":
|
|
||||||
self.theme = "textual-light"
|
|
||||||
else:
|
|
||||||
self.theme = "css"
|
|
||||||
self.refresh()
|
|
||||||
|
|
||||||
def action_refresh_screen(self) -> None:
|
|
||||||
"""Refresh the current screen"""
|
|
||||||
current_screen = self.screen
|
|
||||||
if hasattr(current_screen, "refresh"):
|
|
||||||
current_screen.refresh()
|
|
||||||
|
|
||||||
def action_open_search(self) -> None:
|
|
||||||
"""Open the search screen from anywhere"""
|
|
||||||
from youtube_tui.screens.search import SearchScreen
|
|
||||||
|
|
||||||
self.push_screen(SearchScreen())
|
|
||||||
|
|
||||||
def action_open_queue(self) -> None:
|
|
||||||
"""Open the queue screen from anywhere"""
|
|
||||||
from youtube_tui.screens.queue import QueueScreen
|
|
||||||
|
|
||||||
self.push_screen(QueueScreen())
|
|
||||||
|
|
||||||
def on_search_complete(self, search_term: str) -> None:
|
|
||||||
"""Handle search completion"""
|
|
||||||
self.current_search_term = search_term
|
|
||||||
self.add_to_search_history(search_term)
|
|
||||||
|
|
||||||
def set_downloading(self, downloading: bool) -> None:
|
|
||||||
"""Set downloading state for status bar"""
|
|
||||||
self.downloading = downloading
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
"""Main entry point for the TUI application"""
|
|
||||||
app = YouTubeTUI()
|
|
||||||
app.run()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
"""
|
|
||||||
Models package for YouTube TUI
|
|
||||||
"""
|
|
||||||
|
|
||||||
from youtube_tui.models.video import Video
|
|
||||||
|
|
||||||
__all__ = ["Video"]
|
|
||||||
@ -1,144 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Download Queue System for YouTube TUI
|
|
||||||
"""
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
from enum import Enum
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from youtube_tui.models.video import Video
|
|
||||||
|
|
||||||
|
|
||||||
class QueueStatus(Enum):
|
|
||||||
"""Status of a queue item"""
|
|
||||||
|
|
||||||
PENDING = "pending"
|
|
||||||
DOWNLOADING = "downloading"
|
|
||||||
COMPLETED = "completed"
|
|
||||||
CANCELLED = "cancelled"
|
|
||||||
FAILED = "failed"
|
|
||||||
|
|
||||||
|
|
||||||
class QueueItem:
|
|
||||||
"""Represents an item in the download queue"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
video: Optional[Video],
|
|
||||||
category: Optional[str] = None,
|
|
||||||
network_folder: Optional[str] = None,
|
|
||||||
):
|
|
||||||
self._id = uuid.uuid4() # Unique identifier for tracking
|
|
||||||
self.video = video
|
|
||||||
self.category = category
|
|
||||||
self.network_folder = network_folder
|
|
||||||
self.status = QueueStatus.PENDING
|
|
||||||
self.progress = 0
|
|
||||||
self.started_at: Optional[str] = None
|
|
||||||
self.completed_at: Optional[str] = None
|
|
||||||
self.error_message: Optional[str] = None # Error details when failed
|
|
||||||
|
|
||||||
@property
|
|
||||||
def id(self) -> uuid.UUID:
|
|
||||||
"""Get the unique ID of this queue item"""
|
|
||||||
return self._id
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_active(self) -> bool:
|
|
||||||
"""Check if this item is currently active (downloading or pending)"""
|
|
||||||
return self.status in (
|
|
||||||
QueueStatus.PENDING,
|
|
||||||
QueueStatus.DOWNLOADING,
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_complete(self) -> bool:
|
|
||||||
"""Check if this item has completed (successfully or not)"""
|
|
||||||
return self.status in (
|
|
||||||
QueueStatus.COMPLETED,
|
|
||||||
QueueStatus.CANCELLED,
|
|
||||||
QueueStatus.FAILED,
|
|
||||||
)
|
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
|
||||||
"""Convert to dictionary for JSON serialization"""
|
|
||||||
return {
|
|
||||||
"id": str(self._id),
|
|
||||||
"video": self.video.to_dict() if self.video else None,
|
|
||||||
"category": self.category,
|
|
||||||
"network_folder": self.network_folder,
|
|
||||||
"status": self.status.value,
|
|
||||||
"progress": self.progress,
|
|
||||||
"started_at": self.started_at,
|
|
||||||
"completed_at": self.completed_at,
|
|
||||||
"error_message": self.error_message,
|
|
||||||
}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, data: dict) -> "QueueItem":
|
|
||||||
"""Create QueueItem instance from dictionary"""
|
|
||||||
video_data = data.get("video")
|
|
||||||
video: Video = (
|
|
||||||
Video.from_dict(video_data)
|
|
||||||
if video_data
|
|
||||||
else Video(
|
|
||||||
video_id="",
|
|
||||||
title="Unknown Video",
|
|
||||||
channel="Unknown Channel",
|
|
||||||
channel_id="",
|
|
||||||
duration="0:00",
|
|
||||||
view_count="0",
|
|
||||||
upload_date="",
|
|
||||||
description="",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
item = cls(
|
|
||||||
video=video,
|
|
||||||
category=data.get("category"),
|
|
||||||
network_folder=data.get("network_folder"),
|
|
||||||
)
|
|
||||||
# Parse UUID from string if present
|
|
||||||
item_id = data.get("id")
|
|
||||||
if item_id:
|
|
||||||
try:
|
|
||||||
item._id = uuid.UUID(item_id)
|
|
||||||
except ValueError:
|
|
||||||
pass # Keep auto-generated UUID if parsing fails
|
|
||||||
item.status = QueueStatus(data.get("status", "pending"))
|
|
||||||
item.progress = data.get("progress", 0)
|
|
||||||
item.started_at = data.get("started_at")
|
|
||||||
item.completed_at = data.get("completed_at")
|
|
||||||
item.error_message = data.get("error_message")
|
|
||||||
return item
|
|
||||||
|
|
||||||
def start_download(self) -> None:
|
|
||||||
"""Mark item as downloading"""
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
self.status = QueueStatus.DOWNLOADING
|
|
||||||
self.started_at = datetime.now().isoformat()
|
|
||||||
|
|
||||||
def update_progress(self, progress: int) -> None:
|
|
||||||
"""Update download progress"""
|
|
||||||
self.progress = max(0, min(100, progress))
|
|
||||||
|
|
||||||
def complete(self) -> None:
|
|
||||||
"""Mark item as completed"""
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
self.status = QueueStatus.COMPLETED
|
|
||||||
self.progress = 100
|
|
||||||
self.completed_at = datetime.now().isoformat()
|
|
||||||
|
|
||||||
def cancel(self) -> None:
|
|
||||||
"""Mark item as cancelled"""
|
|
||||||
self.status = QueueStatus.CANCELLED
|
|
||||||
self.completed_at = None
|
|
||||||
|
|
||||||
def fail(self, error_message: Optional[str] = None) -> None:
|
|
||||||
"""Mark item as failed with optional error message"""
|
|
||||||
self.status = QueueStatus.FAILED
|
|
||||||
self.completed_at = None
|
|
||||||
self.error_message = error_message
|
|
||||||
@ -1,77 +0,0 @@
|
|||||||
"""
|
|
||||||
Video data model for YouTube TUI
|
|
||||||
"""
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Video:
|
|
||||||
"""Represents a YouTube video"""
|
|
||||||
|
|
||||||
video_id: str
|
|
||||||
title: str
|
|
||||||
channel: str
|
|
||||||
channel_id: str
|
|
||||||
duration: str
|
|
||||||
view_count: str
|
|
||||||
upload_date: str
|
|
||||||
description: str
|
|
||||||
thumbnail_url: Optional[str] = None
|
|
||||||
is_short: bool = False
|
|
||||||
url: str = ""
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
"""Post-initialization to set URL and detect shorts"""
|
|
||||||
if not self.url:
|
|
||||||
self.url = f"https://www.youtube.com/watch?v={self.video_id}"
|
|
||||||
|
|
||||||
if "/shorts/" in self.url or self.duration == "0:00":
|
|
||||||
self.is_short = True
|
|
||||||
|
|
||||||
@property
|
|
||||||
def display_title(self) -> str:
|
|
||||||
"""Get title with short indicator"""
|
|
||||||
if self.is_short:
|
|
||||||
return f"(short) {self.title}"
|
|
||||||
return self.title
|
|
||||||
|
|
||||||
@property
|
|
||||||
def display_duration(self) -> str:
|
|
||||||
"""Get formatted duration"""
|
|
||||||
if self.is_short:
|
|
||||||
return "Short"
|
|
||||||
return self.duration
|
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
|
||||||
"""Convert to dictionary for JSON serialization"""
|
|
||||||
return {
|
|
||||||
"video_id": self.video_id,
|
|
||||||
"title": self.title,
|
|
||||||
"channel": self.channel,
|
|
||||||
"channel_id": self.channel_id,
|
|
||||||
"duration": self.duration,
|
|
||||||
"view_count": self.view_count,
|
|
||||||
"upload_date": self.upload_date,
|
|
||||||
"description": self.description,
|
|
||||||
"thumbnail_url": self.thumbnail_url,
|
|
||||||
"is_short": self.is_short,
|
|
||||||
"url": self.url,
|
|
||||||
}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, data: dict) -> "Video":
|
|
||||||
"""Create Video instance from dictionary"""
|
|
||||||
return cls(
|
|
||||||
video_id=data["video_id"],
|
|
||||||
title=data["title"],
|
|
||||||
channel=data["channel"],
|
|
||||||
channel_id=data["channel_id"],
|
|
||||||
duration=data["duration"],
|
|
||||||
view_count=data["view_count"],
|
|
||||||
upload_date=data["upload_date"],
|
|
||||||
description=data.get("description", ""),
|
|
||||||
thumbnail_url=data.get("thumbnail_url"),
|
|
||||||
url=data.get("url", ""),
|
|
||||||
)
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
[build-system]
|
|
||||||
requires = ["setuptools>=61.0"]
|
|
||||||
build-backend = "setuptools.build_meta"
|
|
||||||
|
|
||||||
[project]
|
|
||||||
name = "youtube-tui"
|
|
||||||
version = "0.1.0"
|
|
||||||
description = "Text-based User Interface (TUI) for YouTube CLI"
|
|
||||||
readme = "README.md"
|
|
||||||
requires-python = ">=3.9"
|
|
||||||
authors = [
|
|
||||||
{name = "Your Name", email = "your.email@example.com"},
|
|
||||||
]
|
|
||||||
license = {text = "MIT"}
|
|
||||||
classifiers = [
|
|
||||||
"Development Status :: 3 - Alpha",
|
|
||||||
"Environment :: Console",
|
|
||||||
"Intended Audience :: End Users/Desktop",
|
|
||||||
"License :: OSI Approved :: MIT License",
|
|
||||||
"Operating System :: OS Independent",
|
|
||||||
"Programming Language :: Python :: 3",
|
|
||||||
"Programming Language :: Python :: 3.9",
|
|
||||||
"Programming Language :: Python :: 3.10",
|
|
||||||
"Programming Language :: Python :: 3.11",
|
|
||||||
"Programming Language :: Python :: 3.12",
|
|
||||||
"Topic :: Multimedia :: Video",
|
|
||||||
]
|
|
||||||
dependencies = [
|
|
||||||
"textual>=8.0",
|
|
||||||
"yt-dlp",
|
|
||||||
"rich",
|
|
||||||
"requests",
|
|
||||||
]
|
|
||||||
|
|
||||||
[project.scripts]
|
|
||||||
youtube-tui = "youtube_tui.__main__:main"
|
|
||||||
|
|
||||||
[project.urls]
|
|
||||||
Homepage = "https://github.com/yourusername/youtube-cli"
|
|
||||||
Issues = "https://github.com/yourusername/youtube-cli/issues"
|
|
||||||
|
|
||||||
[tool.setuptools]
|
|
||||||
packages = ["youtube_tui", "youtube_tui.screens", "youtube_tui.widgets", "youtube_tui.models", "youtube_tui.services"]
|
|
||||||
|
|
||||||
[tool.setuptools.package-data]
|
|
||||||
youtube_tui = ["py.typed"]
|
|
||||||
|
|
||||||
[tool.ruff]
|
|
||||||
# Skip whitespace checks in CSS strings (Textual styling)
|
|
||||||
# These are intentional blank lines in CSS
|
|
||||||
lint.ignore = ["W293", "W291", "E402"]
|
|
||||||
|
|
||||||
[tool.mypy]
|
|
||||||
python_version = "3.11"
|
|
||||||
warn_return_any = false
|
|
||||||
warn_unused_ignores = false
|
|
||||||
disallow_untyped_defs = false
|
|
||||||
check_untyped_defs = false
|
|
||||||
follow_imports = "skip"
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
"""
|
|
||||||
Screens package for YouTube TUI
|
|
||||||
"""
|
|
||||||
|
|
||||||
from youtube_tui.screens.download import DownloadScreen
|
|
||||||
from youtube_tui.screens.help import HelpScreen
|
|
||||||
from youtube_tui.screens.history import SearchHistoryScreen
|
|
||||||
from youtube_tui.screens.modal import CategorySelectionModal
|
|
||||||
from youtube_tui.screens.results import ResultsScreen
|
|
||||||
from youtube_tui.screens.search import SearchScreen
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"SearchScreen",
|
|
||||||
"ResultsScreen",
|
|
||||||
"DownloadScreen",
|
|
||||||
"CategorySelectionModal",
|
|
||||||
"HelpScreen",
|
|
||||||
"SearchHistoryScreen",
|
|
||||||
]
|
|
||||||
@ -1,204 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Download Screen for YouTube TUI
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from textual.app import ComposeResult
|
|
||||||
from textual.containers import Container
|
|
||||||
from textual.screen import Screen
|
|
||||||
from textual.widgets import (
|
|
||||||
Footer,
|
|
||||||
Header,
|
|
||||||
ProgressBar,
|
|
||||||
Static,
|
|
||||||
)
|
|
||||||
|
|
||||||
from youtube_tui.models.video import Video
|
|
||||||
from youtube_tui.services.youtube import YouTubeService
|
|
||||||
|
|
||||||
|
|
||||||
class DownloadScreen(Screen):
|
|
||||||
"""Screen for displaying download progress"""
|
|
||||||
|
|
||||||
CSS = """
|
|
||||||
DownloadScreen {
|
|
||||||
align: center middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#download-container {
|
|
||||||
width: 70%;
|
|
||||||
height: auto;
|
|
||||||
border: double #555555;
|
|
||||||
padding: 2 3;
|
|
||||||
margin: 2 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#video-title {
|
|
||||||
width: 100%;
|
|
||||||
height: 3;
|
|
||||||
content-align: center middle;
|
|
||||||
background: $surface;
|
|
||||||
margin-bottom: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#progress-container {
|
|
||||||
width: 100%;
|
|
||||||
height: 5;
|
|
||||||
margin: 2 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#status-message {
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
content-align: center middle;
|
|
||||||
margin: 1 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#actions {
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
dock: bottom;
|
|
||||||
margin-top: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
width: 15;
|
|
||||||
margin: 1 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#status-bar {
|
|
||||||
dock: bottom;
|
|
||||||
height: 1;
|
|
||||||
background: $surface;
|
|
||||||
color: $text-muted;
|
|
||||||
padding: 0 1;
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
BINDINGS = [
|
|
||||||
("escape", "cancel", "Cancel"),
|
|
||||||
("ctrl+r", "refresh_screen", "Refresh"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __init__(self, video: Video, category: Optional[str] = None):
|
|
||||||
super().__init__()
|
|
||||||
self.youtube_service = YouTubeService()
|
|
||||||
self.video = video
|
|
||||||
self.category = category
|
|
||||||
self.download_complete = False
|
|
||||||
self.download_error = False
|
|
||||||
self.download_task: Optional[asyncio.Task] = None
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
"""Compose the download screen"""
|
|
||||||
yield Header()
|
|
||||||
yield Container(
|
|
||||||
Static(f"[bold]{self.video.display_title}[/bold]", id="video-title"),
|
|
||||||
Static("Preparing download...", id="status-message"),
|
|
||||||
Container(
|
|
||||||
ProgressBar(total=100, id="progress-bar"),
|
|
||||||
id="progress-container",
|
|
||||||
),
|
|
||||||
id="download-container",
|
|
||||||
)
|
|
||||||
yield Static(id="status-bar")
|
|
||||||
yield Footer()
|
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
|
||||||
"""Called when screen is mounted"""
|
|
||||||
self.update_status("[blue]Starting download...[/blue]")
|
|
||||||
# Use asyncio.create_task instead of app.run_background
|
|
||||||
self.download_task = asyncio.create_task(self.start_download())
|
|
||||||
|
|
||||||
def action_refresh_screen(self) -> None:
|
|
||||||
"""Refresh the screen"""
|
|
||||||
# For download screen, refresh just updates the status
|
|
||||||
self.update_status("[blue]Status: Download in progress...[/blue]")
|
|
||||||
|
|
||||||
async def start_download(self) -> None:
|
|
||||||
"""Start the download process"""
|
|
||||||
try:
|
|
||||||
async def progress_callback(percentage: int) -> bool:
|
|
||||||
"""Update progress bar during download"""
|
|
||||||
self.update_progress(percentage)
|
|
||||||
self.update_status(
|
|
||||||
f"[blue]Downloading... {percentage}%[/blue]"
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
|
|
||||||
success = await self.youtube_service.download_video(
|
|
||||||
self.video, self.category, progress_callback=progress_callback
|
|
||||||
)
|
|
||||||
|
|
||||||
if success:
|
|
||||||
self.download_complete = True
|
|
||||||
self.update_status("[green]Download completed![/green]")
|
|
||||||
self.update_progress(100)
|
|
||||||
# Wait briefly before returning
|
|
||||||
await asyncio.sleep(2)
|
|
||||||
self.app.pop_screen()
|
|
||||||
else:
|
|
||||||
self.download_error = True
|
|
||||||
self.update_status("[red]Download failed![/red]")
|
|
||||||
# Wait briefly before returning
|
|
||||||
await asyncio.sleep(2)
|
|
||||||
self.app.pop_screen()
|
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
# Task was cancelled
|
|
||||||
self.download_error = True
|
|
||||||
self.update_status("[yellow]Download cancelled[/yellow]")
|
|
||||||
self.app.pop_screen()
|
|
||||||
except Exception as e:
|
|
||||||
self.download_error = True
|
|
||||||
self.update_status(f"[red]Error: {e}[/red]")
|
|
||||||
# Wait briefly before returning
|
|
||||||
await asyncio.sleep(2)
|
|
||||||
self.app.pop_screen()
|
|
||||||
|
|
||||||
def update_progress(self, percentage: int) -> None:
|
|
||||||
"""Update the progress bar"""
|
|
||||||
progress_bar = self.query_one("#progress-bar", ProgressBar)
|
|
||||||
progress_bar.progress = percentage
|
|
||||||
|
|
||||||
def update_status(self, message: str) -> None:
|
|
||||||
"""Update the status message"""
|
|
||||||
status_message = self.query_one("#status-message", Static)
|
|
||||||
status_message.update(message)
|
|
||||||
|
|
||||||
status_bar = self.query_one("#status-bar", Static)
|
|
||||||
status_bar.update(f"[bold white]{message}[/bold white]")
|
|
||||||
|
|
||||||
def action_cancel(self) -> None:
|
|
||||||
"""Cancel the download"""
|
|
||||||
# Check if there's a download manager and queue to cancel
|
|
||||||
if hasattr(self.app, "download_manager") and self.app.download_manager:
|
|
||||||
# Cancel via the download manager
|
|
||||||
self.app.download_manager.cancel_active_download()
|
|
||||||
|
|
||||||
if self.download_task:
|
|
||||||
self.download_task.cancel()
|
|
||||||
self.download_error = True
|
|
||||||
self.update_status("[yellow]Download cancelled[/yellow]")
|
|
||||||
self.app.pop_screen()
|
|
||||||
|
|
||||||
def on_unload(self) -> None:
|
|
||||||
"""Called when screen is unloaded"""
|
|
||||||
if self.download_complete:
|
|
||||||
# Show success message briefly before returning
|
|
||||||
self.app.notify(
|
|
||||||
f"Downloaded: {self.video.display_title}",
|
|
||||||
title="Success",
|
|
||||||
severity="information",
|
|
||||||
timeout=3,
|
|
||||||
)
|
|
||||||
elif self.download_error:
|
|
||||||
self.app.notify(
|
|
||||||
f"Failed to download: {self.video.display_title}",
|
|
||||||
title="Error",
|
|
||||||
severity="error",
|
|
||||||
timeout=3,
|
|
||||||
)
|
|
||||||
@ -1,183 +0,0 @@
|
|||||||
"""
|
|
||||||
Help Screen for YouTube TUI
|
|
||||||
"""
|
|
||||||
|
|
||||||
from textual.app import ComposeResult
|
|
||||||
from textual.containers import Container, VerticalScroll
|
|
||||||
from textual.screen import ModalScreen
|
|
||||||
from textual.widgets import Footer, Header, Static
|
|
||||||
|
|
||||||
|
|
||||||
class HelpScreen(ModalScreen):
|
|
||||||
"""Help screen with keyboard shortcuts documentation"""
|
|
||||||
|
|
||||||
CSS = """
|
|
||||||
HelpScreen {
|
|
||||||
align: center middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#help-container {
|
|
||||||
width: 80%;
|
|
||||||
height: 80%;
|
|
||||||
border: solid #555555;
|
|
||||||
background: $surface;
|
|
||||||
padding: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#help-title {
|
|
||||||
width: 100%;
|
|
||||||
height: 3;
|
|
||||||
dock: top;
|
|
||||||
background: $primary;
|
|
||||||
content-align: center middle;
|
|
||||||
color: $text;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-title {
|
|
||||||
width: 100%;
|
|
||||||
height: 2;
|
|
||||||
margin: 1 0;
|
|
||||||
color: $primary;
|
|
||||||
text-style: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shortcut-row {
|
|
||||||
height: 2;
|
|
||||||
padding: 0 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shortcut-key {
|
|
||||||
width: 20;
|
|
||||||
color: $accent;
|
|
||||||
text-style: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shortcut-desc {
|
|
||||||
width: 100%;
|
|
||||||
color: $text;
|
|
||||||
}
|
|
||||||
|
|
||||||
#app-description {
|
|
||||||
width: 100%;
|
|
||||||
height: 6;
|
|
||||||
margin: 1 0;
|
|
||||||
color: $text;
|
|
||||||
}
|
|
||||||
|
|
||||||
#config-info {
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
margin: 1 0;
|
|
||||||
color: $text-muted;
|
|
||||||
}
|
|
||||||
|
|
||||||
#close-hint {
|
|
||||||
width: 100%;
|
|
||||||
height: 2;
|
|
||||||
dock: bottom;
|
|
||||||
text-align: center;
|
|
||||||
color: $text-muted;
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
BINDINGS = [
|
|
||||||
("escape", "close_help", "Close"),
|
|
||||||
("q", "close_help", "Close"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
"""Compose the help screen"""
|
|
||||||
yield Header()
|
|
||||||
yield Container(
|
|
||||||
Static("YouTube TUI Help", id="help-title"),
|
|
||||||
VerticalScroll(
|
|
||||||
Static(
|
|
||||||
"A Text-based User Interface for browsing and downloading YouTube videos",
|
|
||||||
id="app-description",
|
|
||||||
),
|
|
||||||
Static("Global Keyboard Shortcuts", classes="section-title"),
|
|
||||||
Static(
|
|
||||||
"[key]q[/key] [desc]Quit application[/desc]",
|
|
||||||
classes="shortcut-row",
|
|
||||||
),
|
|
||||||
Static(
|
|
||||||
"[key]escape[/key] [desc]Cancel current operation / go back[/desc]",
|
|
||||||
classes="shortcut-row",
|
|
||||||
),
|
|
||||||
Static(
|
|
||||||
"[key]ctrl+p[/key] [desc]Open command palette[/desc]",
|
|
||||||
classes="shortcut-row",
|
|
||||||
),
|
|
||||||
Static(
|
|
||||||
"[key]ctrl+h[/key] [desc]Show this help screen[/desc]",
|
|
||||||
classes="shortcut-row",
|
|
||||||
),
|
|
||||||
Static(
|
|
||||||
"[key]ctrl+t[/key] [desc]Toggle theme (dark/light)[/desc]",
|
|
||||||
classes="shortcut-row",
|
|
||||||
),
|
|
||||||
Static(
|
|
||||||
"[key]ctrl+r[/key] [desc]Refresh current screen[/desc]",
|
|
||||||
classes="shortcut-row",
|
|
||||||
),
|
|
||||||
Static(
|
|
||||||
"[key]ctrl+f[/key] [desc]Open search from any screen[/desc]",
|
|
||||||
classes="shortcut-row",
|
|
||||||
),
|
|
||||||
Static("Search Screen Shortcuts", classes="section-title"),
|
|
||||||
Static(
|
|
||||||
"[key]enter[/key] [desc]Perform search[/desc]",
|
|
||||||
classes="shortcut-row",
|
|
||||||
),
|
|
||||||
Static("Results Screen Shortcuts", classes="section-title"),
|
|
||||||
Static(
|
|
||||||
"[key]n[/key] [desc]Next page[/desc]",
|
|
||||||
classes="shortcut-row",
|
|
||||||
),
|
|
||||||
Static(
|
|
||||||
"[key]p[/key] [desc]Previous page[/desc]",
|
|
||||||
classes="shortcut-row",
|
|
||||||
),
|
|
||||||
Static(
|
|
||||||
"[key]enter[/key] [desc]Download selected video[/desc]",
|
|
||||||
classes="shortcut-row",
|
|
||||||
),
|
|
||||||
Static(
|
|
||||||
"[key]escape[/key] [desc]Go back to search[/desc]",
|
|
||||||
classes="shortcut-row",
|
|
||||||
),
|
|
||||||
Static(
|
|
||||||
"Category Selection Modal Shortcuts",
|
|
||||||
classes="section-title",
|
|
||||||
),
|
|
||||||
Static(
|
|
||||||
"[key]arrow keys[/key] [desc]Navigate options[/desc]",
|
|
||||||
classes="shortcut-row",
|
|
||||||
),
|
|
||||||
Static(
|
|
||||||
"[key]enter[/key] [desc]Select category[/desc]",
|
|
||||||
classes="shortcut-row",
|
|
||||||
),
|
|
||||||
Static(
|
|
||||||
"[key]escape[/key] [desc]Cancel[/desc]",
|
|
||||||
classes="shortcut-row",
|
|
||||||
),
|
|
||||||
Static("Configuration", classes="section-title"),
|
|
||||||
Static(
|
|
||||||
"Configuration file: [path]~/.config/youtube_cli/config.json[/path]",
|
|
||||||
classes="config-info",
|
|
||||||
),
|
|
||||||
Static(
|
|
||||||
"Archive file: [path]~/.config/youtube_cli/downloaded_videos.json[/path]",
|
|
||||||
classes="config-info",
|
|
||||||
),
|
|
||||||
id="help-content",
|
|
||||||
),
|
|
||||||
Static("Press [key]ESC[/key] or [key]Q[/key] to close", id="close-hint"),
|
|
||||||
id="help-container",
|
|
||||||
)
|
|
||||||
yield Footer()
|
|
||||||
|
|
||||||
def action_close_help(self) -> None:
|
|
||||||
"""Close the help screen"""
|
|
||||||
self.app.pop_screen()
|
|
||||||
@ -1,186 +0,0 @@
|
|||||||
"""
|
|
||||||
Search History Screen for YouTube TUI
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from textual.app import ComposeResult
|
|
||||||
from textual.containers import Container
|
|
||||||
from textual.screen import ModalScreen
|
|
||||||
from textual.widgets import Footer, Header, ListItem, ListView, Static
|
|
||||||
|
|
||||||
|
|
||||||
class SearchHistoryScreen(ModalScreen):
|
|
||||||
"""Screen showing search history"""
|
|
||||||
|
|
||||||
CSS = """
|
|
||||||
SearchHistoryScreen {
|
|
||||||
align: center middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#history-container {
|
|
||||||
width: 70%;
|
|
||||||
height: 70%;
|
|
||||||
border: solid #555555;
|
|
||||||
background: $surface;
|
|
||||||
padding: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#history-title {
|
|
||||||
width: 100%;
|
|
||||||
height: 3;
|
|
||||||
dock: top;
|
|
||||||
background: $primary;
|
|
||||||
content-align: center middle;
|
|
||||||
color: $text;
|
|
||||||
}
|
|
||||||
|
|
||||||
ListView {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
ListItem {
|
|
||||||
height: 3;
|
|
||||||
padding: 0 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
ListItem:hover {
|
|
||||||
background: $primary-darken-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
ListItem.--highlight {
|
|
||||||
background: $primary;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-item {
|
|
||||||
height: 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-time {
|
|
||||||
color: $text-muted;
|
|
||||||
}
|
|
||||||
|
|
||||||
#clear-btn {
|
|
||||||
margin: 1 1;
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
BINDINGS = [
|
|
||||||
("escape", "close_history", "Close"),
|
|
||||||
("q", "close_history", "Close"),
|
|
||||||
("d", "delete_selected", "Delete Selected"),
|
|
||||||
("c", "clear_all", "Clear All"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
self.history_file = (
|
|
||||||
Path.home() / ".config" / "youtube_cli" / "search_history.json"
|
|
||||||
)
|
|
||||||
self.search_history = []
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
"""Compose the history screen"""
|
|
||||||
yield Header()
|
|
||||||
yield Container(
|
|
||||||
Static("Search History", id="history-title"),
|
|
||||||
ListView(id="history-list"),
|
|
||||||
id="history-container",
|
|
||||||
)
|
|
||||||
yield Footer()
|
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
|
||||||
"""Load history on mount"""
|
|
||||||
self.load_history()
|
|
||||||
|
|
||||||
def load_history(self) -> None:
|
|
||||||
"""Load search history from file"""
|
|
||||||
self.search_history = []
|
|
||||||
|
|
||||||
if self.history_file.exists():
|
|
||||||
try:
|
|
||||||
with open(self.history_file, "r") as f:
|
|
||||||
self.search_history = json.load(f)
|
|
||||||
except Exception:
|
|
||||||
self.search_history = []
|
|
||||||
|
|
||||||
# Reverse to show newest first
|
|
||||||
self.search_history = list(reversed(self.search_history))
|
|
||||||
|
|
||||||
list_view = self.query_one("#history-list", ListView)
|
|
||||||
list_view.clear()
|
|
||||||
|
|
||||||
for item in self.search_history:
|
|
||||||
search_term = item.get("search_term", "")
|
|
||||||
timestamp = item.get("timestamp", "")
|
|
||||||
|
|
||||||
# Format timestamp
|
|
||||||
if timestamp:
|
|
||||||
try:
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
dt = datetime.fromisoformat(timestamp)
|
|
||||||
time_str = dt.strftime("%Y-%m-%d %H:%M")
|
|
||||||
except Exception:
|
|
||||||
time_str = timestamp
|
|
||||||
else:
|
|
||||||
time_str = "Unknown time"
|
|
||||||
|
|
||||||
list_view.append(
|
|
||||||
ListItem(Static(f"[bold]{search_term}[/bold]\n[dim]{time_str}[/dim]"))
|
|
||||||
)
|
|
||||||
|
|
||||||
def action_delete_selected(self) -> None:
|
|
||||||
"""Delete selected history item"""
|
|
||||||
list_view = self.query_one("#history-list", ListView)
|
|
||||||
if list_view.children:
|
|
||||||
# Get the selected item
|
|
||||||
selected_index = list_view.index
|
|
||||||
if (
|
|
||||||
selected_index is not None
|
|
||||||
and selected_index >= 0
|
|
||||||
and selected_index < len(self.search_history)
|
|
||||||
):
|
|
||||||
# Remove from history
|
|
||||||
del self.search_history[selected_index]
|
|
||||||
self.save_history()
|
|
||||||
self.load_history()
|
|
||||||
|
|
||||||
def action_clear_all(self) -> None:
|
|
||||||
"""Clear all history"""
|
|
||||||
self.search_history = []
|
|
||||||
self.save_history()
|
|
||||||
self.load_history()
|
|
||||||
self.notify("Search history cleared", timeout=2)
|
|
||||||
|
|
||||||
def save_history(self) -> None:
|
|
||||||
"""Save history to file"""
|
|
||||||
try:
|
|
||||||
# Ensure directory exists
|
|
||||||
self.history_file.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
with open(self.history_file, "w") as f:
|
|
||||||
json.dump(list(reversed(self.search_history)), f, indent=2)
|
|
||||||
except Exception as e:
|
|
||||||
self.notify(f"Error saving history: {e}", severity="error", timeout=3)
|
|
||||||
|
|
||||||
def action_close_history(self) -> None:
|
|
||||||
"""Close the history screen"""
|
|
||||||
self.app.pop_screen()
|
|
||||||
|
|
||||||
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
|
||||||
"""Handle item selection"""
|
|
||||||
# Use event.index directly, ignore list_view
|
|
||||||
item_index = event.index
|
|
||||||
|
|
||||||
if 0 <= item_index < len(self.search_history):
|
|
||||||
# Get the search term
|
|
||||||
search_term = self.search_history[item_index].get("search_term", "")
|
|
||||||
|
|
||||||
# Push search screen with the term
|
|
||||||
self.app.push_screen("search")
|
|
||||||
# Note: We'd need to expose the search screen to set the term
|
|
||||||
# For now, just notify
|
|
||||||
self.notify(f"Selected: {search_term}", timeout=2)
|
|
||||||
@ -1,192 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Home Screen for YouTube TUI
|
|
||||||
Main dashboard with quick actions
|
|
||||||
"""
|
|
||||||
|
|
||||||
from textual.app import ComposeResult
|
|
||||||
from textual.containers import Container
|
|
||||||
from textual.screen import Screen
|
|
||||||
from textual.widgets import (
|
|
||||||
Button,
|
|
||||||
Footer,
|
|
||||||
Header,
|
|
||||||
Static,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class HomeScreen(Screen):
|
|
||||||
"""Main dashboard screen with quick actions"""
|
|
||||||
|
|
||||||
CSS = """
|
|
||||||
HomeScreen {
|
|
||||||
align: center middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#welcome-container {
|
|
||||||
width: 60%;
|
|
||||||
height: auto;
|
|
||||||
border: double #555555;
|
|
||||||
padding: 2 3;
|
|
||||||
margin: 2 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#welcome-title {
|
|
||||||
width: 100%;
|
|
||||||
height: 3;
|
|
||||||
content-align: center middle;
|
|
||||||
margin-bottom: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#welcome-info {
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
margin-bottom: 2;
|
|
||||||
color: $text-muted;
|
|
||||||
}
|
|
||||||
|
|
||||||
#quick-actions {
|
|
||||||
width: 100%;
|
|
||||||
layout: grid;
|
|
||||||
grid-gutter: 1;
|
|
||||||
grid-columns: 2;
|
|
||||||
margin: 1 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
width: 100%;
|
|
||||||
margin: 0 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#status-info {
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
margin-top: 2;
|
|
||||||
color: $text-muted;
|
|
||||||
}
|
|
||||||
|
|
||||||
#status-bar {
|
|
||||||
dock: bottom;
|
|
||||||
height: 1;
|
|
||||||
background: $surface;
|
|
||||||
color: $text-muted;
|
|
||||||
padding: 0 1;
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
BINDINGS = [
|
|
||||||
("s", "open_search", "Search"),
|
|
||||||
("q", "open_queue", "Queue"),
|
|
||||||
("h", "open_history", "History"),
|
|
||||||
("ctrl+h", "show_help", "Help"),
|
|
||||||
("q", "quit", "Quit"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
self.download_queue = None
|
|
||||||
self.download_manager = None
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
"""Compose the home screen"""
|
|
||||||
yield Header()
|
|
||||||
yield Container(
|
|
||||||
Static(
|
|
||||||
"[bold cyan]YouTube CLI[/bold cyan] - Browse and download videos",
|
|
||||||
id="welcome-title",
|
|
||||||
),
|
|
||||||
Static(
|
|
||||||
"Use keyboard shortcuts or buttons to navigate",
|
|
||||||
id="welcome-info",
|
|
||||||
),
|
|
||||||
Container(
|
|
||||||
Button("Search Videos", id="search-btn"),
|
|
||||||
Button("Download Queue", id="queue-btn"),
|
|
||||||
Button("Search History", id="history-btn"),
|
|
||||||
Button("Help", id="help-btn"),
|
|
||||||
id="quick-actions",
|
|
||||||
),
|
|
||||||
Static(id="status-info"),
|
|
||||||
id="welcome-container",
|
|
||||||
)
|
|
||||||
yield Static(id="status-bar")
|
|
||||||
yield Footer()
|
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
|
||||||
"""Called when screen is mounted"""
|
|
||||||
if hasattr(self.app, "download_queue"):
|
|
||||||
self.download_queue = self.app.download_queue
|
|
||||||
if hasattr(self.app, "download_manager"):
|
|
||||||
self.download_manager = self.app.download_manager
|
|
||||||
|
|
||||||
self.update_queue_status()
|
|
||||||
self.update_status("[green]Welcome to YouTube TUI - Press 's' to search[/green]")
|
|
||||||
|
|
||||||
def update_queue_status(self) -> None:
|
|
||||||
"""Update queue status info"""
|
|
||||||
if self.download_manager:
|
|
||||||
status = self.download_manager.get_queue_status()
|
|
||||||
status_text = (
|
|
||||||
f"Queue: {status['pending_count']} pending, "
|
|
||||||
f"{status['downloading_count']} downloading, "
|
|
||||||
f"{status['total_count']} total"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
status_text = "Queue: No active downloads"
|
|
||||||
|
|
||||||
status_info = self.query_one("#status-info", Static)
|
|
||||||
status_info.update(f"[dim]{status_text}[/dim]")
|
|
||||||
|
|
||||||
def update_status(self, message: str) -> None:
|
|
||||||
"""Update the status bar message"""
|
|
||||||
status_bar = self.query_one("#status-bar", Static)
|
|
||||||
status_bar.update(f"[bold white]{message}[/bold white]")
|
|
||||||
|
|
||||||
def action_open_search(self) -> None:
|
|
||||||
"""Open search screen"""
|
|
||||||
if hasattr(self.app, "push_search_screen"):
|
|
||||||
self.app.push_search_screen()
|
|
||||||
else:
|
|
||||||
from youtube_tui.screens.search import SearchScreen
|
|
||||||
|
|
||||||
self.app.push_screen(SearchScreen())
|
|
||||||
|
|
||||||
def action_open_queue(self) -> None:
|
|
||||||
"""Open queue screen"""
|
|
||||||
if hasattr(self.app, "action_open_queue"):
|
|
||||||
self.app.action_open_queue()
|
|
||||||
else:
|
|
||||||
from youtube_tui.screens.queue import QueueScreen
|
|
||||||
|
|
||||||
self.app.push_screen(QueueScreen())
|
|
||||||
|
|
||||||
def action_open_history(self) -> None:
|
|
||||||
"""Open search history"""
|
|
||||||
from youtube_tui.screens.history import SearchHistoryScreen
|
|
||||||
|
|
||||||
self.app.push_screen(SearchHistoryScreen())
|
|
||||||
|
|
||||||
def action_show_help(self) -> None:
|
|
||||||
"""Show help screen"""
|
|
||||||
from youtube_tui.screens.help import HelpScreen
|
|
||||||
|
|
||||||
self.app.push_screen(HelpScreen())
|
|
||||||
|
|
||||||
def action_quit(self) -> None:
|
|
||||||
"""Quit the application"""
|
|
||||||
self.app.exit()
|
|
||||||
|
|
||||||
def action_cancel(self) -> None:
|
|
||||||
"""Handle escape key"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
||||||
"""Handle button presses"""
|
|
||||||
if event.button.id == "search-btn":
|
|
||||||
self.action_open_search()
|
|
||||||
elif event.button.id == "queue-btn":
|
|
||||||
self.action_open_queue()
|
|
||||||
elif event.button.id == "history-btn":
|
|
||||||
self.action_open_history()
|
|
||||||
elif event.button.id == "help-btn":
|
|
||||||
self.action_show_help()
|
|
||||||
@ -1,253 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Category Selection Modal for YouTube TUI
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from textual.app import ComposeResult
|
|
||||||
from textual.containers import Container, Vertical
|
|
||||||
from textual.screen import ModalScreen
|
|
||||||
from textual.widgets import (
|
|
||||||
Button,
|
|
||||||
Footer,
|
|
||||||
Header,
|
|
||||||
Input,
|
|
||||||
ListItem,
|
|
||||||
ListView,
|
|
||||||
Static,
|
|
||||||
)
|
|
||||||
|
|
||||||
from youtube_tui.services.youtube import YouTubeService
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class CategorySelectionModal(ModalScreen):
|
|
||||||
"""Modal for selecting a download category"""
|
|
||||||
|
|
||||||
CSS = """
|
|
||||||
CategorySelectionModal {
|
|
||||||
align: center middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#modal-container {
|
|
||||||
width: 60%;
|
|
||||||
height: auto;
|
|
||||||
border: solid #555555;
|
|
||||||
background: $surface;
|
|
||||||
padding: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#modal-title {
|
|
||||||
width: 100%;
|
|
||||||
height: 3;
|
|
||||||
dock: top;
|
|
||||||
background: $primary;
|
|
||||||
content-align: center middle;
|
|
||||||
color: $text;
|
|
||||||
}
|
|
||||||
|
|
||||||
#categories-container {
|
|
||||||
width: 100%;
|
|
||||||
height: 20;
|
|
||||||
margin: 1 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#custom-input {
|
|
||||||
width: 100%;
|
|
||||||
margin: 1 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#modal-actions {
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
dock: bottom;
|
|
||||||
margin-top: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
width: 15;
|
|
||||||
margin: 1 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
ListItem {
|
|
||||||
height: 3;
|
|
||||||
padding: 0 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
ListItem:hover {
|
|
||||||
background: $primary-darken-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
ListItem.--highlight {
|
|
||||||
background: $primary;
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
BINDINGS = [
|
|
||||||
("escape", "close_modal", "Cancel"),
|
|
||||||
("enter", "select_category", "Select"),
|
|
||||||
("up", "cursor_up", "Cursor Up"),
|
|
||||||
("down", "cursor_down", "Cursor Down"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
super().__init__()
|
|
||||||
self.youtube_service = YouTubeService()
|
|
||||||
self.selected_category: Optional[str] = None
|
|
||||||
self.selected_index = 0
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
"""Compose the modal"""
|
|
||||||
yield Header()
|
|
||||||
yield Container(
|
|
||||||
Static("Select Download Category", id="modal-title"),
|
|
||||||
Vertical(
|
|
||||||
Static("Available Categories:", id="categories-label"),
|
|
||||||
ListView(id="categories-list"),
|
|
||||||
Static("Or type custom folder name:", id="custom-label"),
|
|
||||||
Input(placeholder="Enter custom folder name...", id="custom-input"),
|
|
||||||
id="categories-container",
|
|
||||||
),
|
|
||||||
Container(
|
|
||||||
Button("Select", id="select-btn"),
|
|
||||||
Button("Cancel", id="cancel-btn"),
|
|
||||||
id="modal-actions",
|
|
||||||
),
|
|
||||||
id="modal-container",
|
|
||||||
)
|
|
||||||
yield Footer()
|
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
|
||||||
"""Called when modal is mounted"""
|
|
||||||
self.load_categories()
|
|
||||||
self.update_status("Use arrow keys to select, Enter to confirm")
|
|
||||||
|
|
||||||
def load_categories(self) -> None:
|
|
||||||
"""Load available categories into the list"""
|
|
||||||
list_view = self.query_one("#categories-list", ListView)
|
|
||||||
list_view.clear()
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Note: This is called from on_mount which is sync
|
|
||||||
# In a real async context, this should be awaited
|
|
||||||
categories: list = self.youtube_service.cli.get_categories(
|
|
||||||
self.youtube_service.cli.config
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.debug(f"load_categories: categories={categories}")
|
|
||||||
|
|
||||||
for category in categories:
|
|
||||||
# Extract folder name for display
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
folder_name = Path(category).name if Path(category).name else "Root"
|
|
||||||
category_id = Path(category).name.replace(" ", "-").replace("/", "-")
|
|
||||||
logger.debug(
|
|
||||||
f"load_categories: category={category}, folder_name={folder_name}, category_id={category_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create a custom widget for the item
|
|
||||||
item = ListItem(
|
|
||||||
Static(f" {folder_name}"), id=f"category-{category_id}"
|
|
||||||
)
|
|
||||||
list_view.append(item)
|
|
||||||
|
|
||||||
# Highlight first item
|
|
||||||
if list_view.children:
|
|
||||||
list_view.children[0].add_class("--highlight")
|
|
||||||
self.selected_index = 0
|
|
||||||
logger.debug(
|
|
||||||
f"load_categories: first item highlighted, selected_index={self.selected_index}"
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
list_view.append(
|
|
||||||
ListItem(Static(f"[red]Error loading categories: {e}[/red]"))
|
|
||||||
)
|
|
||||||
|
|
||||||
def update_status(self, message: str) -> None:
|
|
||||||
"""Update the modal status"""
|
|
||||||
# We could add a status line if needed
|
|
||||||
pass
|
|
||||||
|
|
||||||
def action_select_category(self) -> None:
|
|
||||||
"""Select the current category"""
|
|
||||||
list_view = self.query_one("#categories-list", ListView)
|
|
||||||
|
|
||||||
# Debug logging
|
|
||||||
logger.debug(
|
|
||||||
f"action_select_category: selected_index={self.selected_index}, children_count={len(list_view.children)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if list_view.children and 0 <= self.selected_index < len(list_view.children):
|
|
||||||
# Get the selected item
|
|
||||||
item = list_view.children[self.selected_index]
|
|
||||||
category_id = item.id
|
|
||||||
logger.debug(f"action_select_category: item.id={category_id}")
|
|
||||||
if category_id and category_id.startswith("category-"):
|
|
||||||
self.selected_category = category_id.replace("category-", "")
|
|
||||||
logger.debug(
|
|
||||||
f"action_select_category: selected_category={self.selected_category}"
|
|
||||||
)
|
|
||||||
self.dismiss(self.selected_category)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check custom input
|
|
||||||
custom_input = self.query_one("#custom-input", Input)
|
|
||||||
custom_name = custom_input.value.strip()
|
|
||||||
if custom_name:
|
|
||||||
self.selected_category = custom_name
|
|
||||||
self.dismiss(self.selected_category)
|
|
||||||
return
|
|
||||||
|
|
||||||
# No valid selection
|
|
||||||
self.update_status("[red]Please select a category or enter a custom name[/red]")
|
|
||||||
|
|
||||||
def action_cursor_up(self) -> None:
|
|
||||||
"""Move cursor up"""
|
|
||||||
list_view = self.query_one("#categories-list", ListView)
|
|
||||||
if list_view.children:
|
|
||||||
# Remove highlight from current item
|
|
||||||
if 0 <= self.selected_index < len(list_view.children):
|
|
||||||
list_view.children[self.selected_index].remove_class("--highlight")
|
|
||||||
|
|
||||||
# Move up
|
|
||||||
self.selected_index = max(0, self.selected_index - 1)
|
|
||||||
|
|
||||||
# Highlight new item
|
|
||||||
list_view.children[self.selected_index].add_class("--highlight")
|
|
||||||
|
|
||||||
def action_cursor_down(self) -> None:
|
|
||||||
"""Move cursor down"""
|
|
||||||
list_view = self.query_one("#categories-list", ListView)
|
|
||||||
if list_view.children:
|
|
||||||
# Remove highlight from current item
|
|
||||||
if 0 <= self.selected_index < len(list_view.children):
|
|
||||||
list_view.children[self.selected_index].remove_class("--highlight")
|
|
||||||
|
|
||||||
# Move down
|
|
||||||
self.selected_index = min(
|
|
||||||
len(list_view.children) - 1, self.selected_index + 1
|
|
||||||
)
|
|
||||||
|
|
||||||
# Highlight new item
|
|
||||||
list_view.children[self.selected_index].add_class("--highlight")
|
|
||||||
|
|
||||||
def action_close_modal(self) -> None:
|
|
||||||
"""Close modal without selecting"""
|
|
||||||
self.selected_category = None
|
|
||||||
self.dismiss(None)
|
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
||||||
"""Handle button presses"""
|
|
||||||
if event.button.id == "select-btn":
|
|
||||||
self.action_select_category()
|
|
||||||
elif event.button.id == "cancel-btn":
|
|
||||||
self.action_close_modal()
|
|
||||||
|
|
||||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
||||||
"""Handle enter key in custom input"""
|
|
||||||
self.action_select_category()
|
|
||||||
@ -1,433 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Queue Screen for YouTube TUI
|
|
||||||
Displays and manages the download queue
|
|
||||||
"""
|
|
||||||
|
|
||||||
from textual.app import ComposeResult
|
|
||||||
from textual.containers import Container
|
|
||||||
from textual.screen import Screen
|
|
||||||
from textual.widgets import (
|
|
||||||
Button,
|
|
||||||
DataTable,
|
|
||||||
Footer,
|
|
||||||
Header,
|
|
||||||
Static,
|
|
||||||
)
|
|
||||||
|
|
||||||
from youtube_tui.models.queue_item import QueueStatus
|
|
||||||
|
|
||||||
|
|
||||||
class QueueScreen(Screen):
|
|
||||||
"""Screen for displaying and managing the download queue"""
|
|
||||||
|
|
||||||
CSS = """
|
|
||||||
QueueScreen {
|
|
||||||
align: center middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#queue-container {
|
|
||||||
width: 95%;
|
|
||||||
height: 70%;
|
|
||||||
border: solid #555555;
|
|
||||||
margin: 1 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#queue-title {
|
|
||||||
width: 100%;
|
|
||||||
height: 3;
|
|
||||||
dock: top;
|
|
||||||
background: $surface;
|
|
||||||
content-align: center middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#stats-container {
|
|
||||||
width: 100%;
|
|
||||||
height: 3;
|
|
||||||
dock: top;
|
|
||||||
background: $surface;
|
|
||||||
content-align: center middle;
|
|
||||||
margin-bottom: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#status-bar {
|
|
||||||
dock: bottom;
|
|
||||||
height: 1;
|
|
||||||
background: $surface;
|
|
||||||
color: $text-muted;
|
|
||||||
padding: 0 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
width: 15;
|
|
||||||
margin: 0 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
DataTable {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
DataTable .datatable-row-highlight {
|
|
||||||
background: $primary;
|
|
||||||
}
|
|
||||||
|
|
||||||
DataTable .datatable-header {
|
|
||||||
background: $primary-darken-2;
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
BINDINGS = [
|
|
||||||
("q", "go_back", "Back"),
|
|
||||||
("escape", "go_back", "Back"),
|
|
||||||
("ctrl+r", "refresh_screen", "Refresh"),
|
|
||||||
("d", "download_selected", "Download Now"),
|
|
||||||
("r", "remove_selected", "Remove"),
|
|
||||||
("y", "retry_selected", "Retry"),
|
|
||||||
("c", "clear_completed", "Clear Completed"),
|
|
||||||
("f", "clear_failed", "Clear Failed"),
|
|
||||||
("ctrl+f", "search_from_anywhere", "Search"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
self.youtube_service = None
|
|
||||||
self.download_queue = None
|
|
||||||
self.download_manager = None
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
"""Compose the queue screen"""
|
|
||||||
yield Header()
|
|
||||||
yield Static("Download Queue", id="queue-title")
|
|
||||||
yield Container(
|
|
||||||
Static(id="stats-container"),
|
|
||||||
DataTable(id="queue-table", show_cursor=False),
|
|
||||||
id="queue-container",
|
|
||||||
)
|
|
||||||
yield Container(
|
|
||||||
Button("← Back", id="back-btn"),
|
|
||||||
Button("Refresh", id="refresh-btn"),
|
|
||||||
Button("Remove", id="remove-btn"),
|
|
||||||
Button("Retry", id="retry-btn"),
|
|
||||||
Button("Clear Done", id="clear-done-btn"),
|
|
||||||
Button("Clear Failed", id="clear-failed-btn"),
|
|
||||||
id="queue-controls",
|
|
||||||
)
|
|
||||||
yield Static(id="status-bar")
|
|
||||||
yield Footer()
|
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
|
||||||
"""Called when screen is mounted"""
|
|
||||||
# Get references from app
|
|
||||||
if hasattr(self.app, "youtube_service"):
|
|
||||||
self.youtube_service = self.app.youtube_service
|
|
||||||
if hasattr(self.app, "download_queue"):
|
|
||||||
self.download_queue = self.app.download_queue
|
|
||||||
if hasattr(self.app, "download_manager"):
|
|
||||||
self.download_manager = self.app.download_manager
|
|
||||||
|
|
||||||
self.update_table()
|
|
||||||
self.update_stats()
|
|
||||||
self.update_status("[green]Queue loaded[/green]")
|
|
||||||
|
|
||||||
# Start auto-refresh if downloads active
|
|
||||||
if self.download_manager and self.download_manager.is_processing():
|
|
||||||
self.set_interval(2, self._auto_refresh)
|
|
||||||
|
|
||||||
def _auto_refresh(self) -> None:
|
|
||||||
"""Auto-refresh queue table every 2 seconds"""
|
|
||||||
self.update_table()
|
|
||||||
self.update_stats()
|
|
||||||
|
|
||||||
# Stop auto-refresh when no downloads active
|
|
||||||
if self.download_manager:
|
|
||||||
status = self.download_manager.get_queue_status()
|
|
||||||
if not status.get("has_active_download") and not status.get("pending_count"):
|
|
||||||
self.clear_interval(self._auto_refresh)
|
|
||||||
|
|
||||||
def action_refresh_screen(self) -> None:
|
|
||||||
"""Refresh the screen"""
|
|
||||||
self.update_table()
|
|
||||||
self.update_stats()
|
|
||||||
self.update_status("[blue]Refreshed queue[/blue]")
|
|
||||||
|
|
||||||
def action_search_from_anywhere(self) -> None:
|
|
||||||
"""Open search from anywhere"""
|
|
||||||
if hasattr(self.app, "action_open_search"):
|
|
||||||
self.app.action_open_search()
|
|
||||||
else:
|
|
||||||
self.app.pop_screen()
|
|
||||||
|
|
||||||
def update_table(self) -> None:
|
|
||||||
"""Update the DataTable with queue items"""
|
|
||||||
table = self.query_one("#queue-table", DataTable)
|
|
||||||
|
|
||||||
# Get queue items
|
|
||||||
if self.download_queue:
|
|
||||||
items = self.download_queue.get_queue()
|
|
||||||
|
|
||||||
# Clear and rebuild columns
|
|
||||||
table.clear()
|
|
||||||
|
|
||||||
# Set up columns
|
|
||||||
table.add_columns("Status", "Title", "Category", "Progress")
|
|
||||||
table.add_columns("Started", "Completed")
|
|
||||||
table.add_columns("Actions")
|
|
||||||
|
|
||||||
for item in items:
|
|
||||||
# Get status text with color
|
|
||||||
status = item.status.value
|
|
||||||
status_color = {
|
|
||||||
QueueStatus.PENDING: "yellow",
|
|
||||||
QueueStatus.DOWNLOADING: "blue",
|
|
||||||
QueueStatus.COMPLETED: "green",
|
|
||||||
QueueStatus.CANCELLED: "yellow",
|
|
||||||
QueueStatus.FAILED: "red",
|
|
||||||
}.get(item.status, "white")
|
|
||||||
|
|
||||||
# Truncate long titles
|
|
||||||
title = item.video.display_title if item.video else "Unknown"
|
|
||||||
if len(title) > 40:
|
|
||||||
title = title[:37] + "..."
|
|
||||||
|
|
||||||
# Get category
|
|
||||||
category = item.category or "Default"
|
|
||||||
|
|
||||||
# Format progress
|
|
||||||
progress = f"{item.progress}%"
|
|
||||||
if item.status == QueueStatus.DOWNLOADING:
|
|
||||||
progress = f"[blue]{progress}[/blue]"
|
|
||||||
|
|
||||||
# Format timestamps
|
|
||||||
started_at = item.started_at or "-"
|
|
||||||
completed_at = item.completed_at or "-"
|
|
||||||
|
|
||||||
row_key = item.video.video_id if item.video else ""
|
|
||||||
|
|
||||||
# Determine actions for this row
|
|
||||||
actions = ""
|
|
||||||
if item.status == QueueStatus.FAILED:
|
|
||||||
actions = "[yellow]Retry[/yellow]"
|
|
||||||
|
|
||||||
# Use add_row with check for duplicate
|
|
||||||
try:
|
|
||||||
table.add_row(
|
|
||||||
f"[{status_color}]{status}[/{status_color}]",
|
|
||||||
title,
|
|
||||||
category,
|
|
||||||
progress,
|
|
||||||
started_at,
|
|
||||||
completed_at,
|
|
||||||
actions,
|
|
||||||
key=row_key,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
# Row already exists, skip it
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Focus the table
|
|
||||||
table.focus()
|
|
||||||
|
|
||||||
def update_stats(self) -> None:
|
|
||||||
"""Update the queue statistics"""
|
|
||||||
if self.download_queue:
|
|
||||||
stats = self.download_queue.get_stats()
|
|
||||||
stats_text = (
|
|
||||||
f"Total: {stats['total']} | "
|
|
||||||
f"Pending: {stats['pending']} | "
|
|
||||||
f"Downloading: {stats['downloading']} | "
|
|
||||||
f"Completed: {stats['completed']} | "
|
|
||||||
f"Cancelled: {stats['cancelled']} | "
|
|
||||||
f"Failed: {stats['failed']}"
|
|
||||||
)
|
|
||||||
|
|
||||||
stats_container = self.query_one("#stats-container", Static)
|
|
||||||
stats_container.update(f"[bold]{stats_text}[/bold]")
|
|
||||||
|
|
||||||
def update_status(self, message: str) -> None:
|
|
||||||
"""Update the status bar message"""
|
|
||||||
status_bar = self.query_one("#status-bar", Static)
|
|
||||||
status_bar.update(f"[bold white]{message}[/bold white]")
|
|
||||||
|
|
||||||
def action_download_selected(self) -> None:
|
|
||||||
"""Download selected item immediately"""
|
|
||||||
table = self.query_one("#queue-table", DataTable)
|
|
||||||
selected_row = table.cursor_row
|
|
||||||
|
|
||||||
if selected_row < 0:
|
|
||||||
self.update_status("[yellow]Select an item to download[/yellow]")
|
|
||||||
return
|
|
||||||
|
|
||||||
items = self.download_queue.get_queue() if self.download_queue else []
|
|
||||||
if selected_row >= len(items):
|
|
||||||
self.update_status("[yellow]Invalid selection[/yellow]")
|
|
||||||
return
|
|
||||||
|
|
||||||
item = items[selected_row]
|
|
||||||
|
|
||||||
if item.status == QueueStatus.PENDING:
|
|
||||||
# Start the download manager if not running
|
|
||||||
if self.download_manager and not self.download_manager.is_processing():
|
|
||||||
self.download_manager.start()
|
|
||||||
|
|
||||||
# Force immediate download by moving item to top
|
|
||||||
# In a real implementation, we'd have a priority queue
|
|
||||||
self.update_status(
|
|
||||||
f"[blue]Downloading: {item.video.display_title if item.video else 'Unknown'}[/blue]"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.update_status("[yellow]Only pending items can be downloaded[/yellow]")
|
|
||||||
|
|
||||||
def action_retry_selected(self) -> None:
|
|
||||||
"""Retry selected failed item"""
|
|
||||||
table = self.query_one("#queue-table", DataTable)
|
|
||||||
selected_row = table.cursor_row
|
|
||||||
|
|
||||||
if selected_row < 0:
|
|
||||||
self.update_status("[yellow]Select an item to retry[/yellow]")
|
|
||||||
return
|
|
||||||
|
|
||||||
items = self.download_queue.get_queue() if self.download_queue else []
|
|
||||||
if selected_row >= len(items):
|
|
||||||
return
|
|
||||||
|
|
||||||
item = items[selected_row]
|
|
||||||
|
|
||||||
# Only allow retrying failed items
|
|
||||||
if item.status != QueueStatus.FAILED:
|
|
||||||
self.update_status("[yellow]Only failed items can be retried[/yellow]")
|
|
||||||
return
|
|
||||||
|
|
||||||
if item.video:
|
|
||||||
# Reset the item status to PENDING
|
|
||||||
item.status = QueueStatus.PENDING
|
|
||||||
item.started_at = None
|
|
||||||
item.completed_at = None
|
|
||||||
item.progress = 0
|
|
||||||
|
|
||||||
# Re-add to download queue
|
|
||||||
if self.download_queue.add_video(item.video, item.category):
|
|
||||||
self.update_status(
|
|
||||||
f"[green]Retrying: {item.video.display_title}[/green]"
|
|
||||||
)
|
|
||||||
self.app.notify(
|
|
||||||
f"Retrying: {item.video.display_title}",
|
|
||||||
title="Queue",
|
|
||||||
severity="information",
|
|
||||||
timeout=3,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.update_status("[red]Failed to retry item[/red]")
|
|
||||||
else:
|
|
||||||
self.update_status("[red]Invalid item for retry[/red]")
|
|
||||||
|
|
||||||
self.update_table()
|
|
||||||
self.update_stats()
|
|
||||||
|
|
||||||
def action_remove_selected(self) -> None:
|
|
||||||
"""Remove selected item from queue"""
|
|
||||||
table = self.query_one("#queue-table", DataTable)
|
|
||||||
selected_row = table.cursor_row
|
|
||||||
|
|
||||||
if selected_row < 0:
|
|
||||||
self.update_status("[yellow]Select an item to remove[/yellow]")
|
|
||||||
return
|
|
||||||
|
|
||||||
items = self.download_queue.get_queue() if self.download_queue else []
|
|
||||||
if selected_row >= len(items):
|
|
||||||
return
|
|
||||||
|
|
||||||
item = items[selected_row]
|
|
||||||
|
|
||||||
if item.video:
|
|
||||||
if self.download_queue.remove_video(item.video.video_id):
|
|
||||||
self.update_status(
|
|
||||||
f"[green]Removed: {item.video.display_title}[/green]"
|
|
||||||
)
|
|
||||||
self.app.notify(
|
|
||||||
f"Removed: {item.video.display_title}",
|
|
||||||
title="Queue",
|
|
||||||
severity="information",
|
|
||||||
timeout=3,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.update_status("[red]Failed to remove item[/red]")
|
|
||||||
|
|
||||||
self.update_table()
|
|
||||||
self.update_stats()
|
|
||||||
|
|
||||||
def action_clear_completed(self) -> None:
|
|
||||||
"""Clear completed and cancelled items from queue"""
|
|
||||||
if self.download_queue:
|
|
||||||
removed = self.download_queue.clear_completed()
|
|
||||||
if removed > 0:
|
|
||||||
self.update_status(
|
|
||||||
f"[green]Cleared {removed} completed/cancelled items[/green]"
|
|
||||||
)
|
|
||||||
self.app.notify(
|
|
||||||
f"Cleared {removed} items",
|
|
||||||
title="Queue",
|
|
||||||
severity="information",
|
|
||||||
timeout=3,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.update_status(
|
|
||||||
"[yellow]No completed/cancelled items to clear[/yellow]"
|
|
||||||
)
|
|
||||||
|
|
||||||
self.update_table()
|
|
||||||
self.update_stats()
|
|
||||||
|
|
||||||
def action_clear_failed(self) -> None:
|
|
||||||
"""Clear failed items from queue"""
|
|
||||||
if self.download_queue:
|
|
||||||
removed = self.download_queue.clear_failed()
|
|
||||||
if removed > 0:
|
|
||||||
self.update_status(f"[green]Cleared {removed} failed items[/green]")
|
|
||||||
self.app.notify(
|
|
||||||
f"Cleared {removed} items",
|
|
||||||
title="Queue",
|
|
||||||
severity="information",
|
|
||||||
timeout=3,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.update_status("[yellow]No failed items to clear[/yellow]")
|
|
||||||
|
|
||||||
self.update_table()
|
|
||||||
self.update_stats()
|
|
||||||
|
|
||||||
def action_go_back(self) -> None:
|
|
||||||
"""Go back to results screen"""
|
|
||||||
self.app.pop_screen()
|
|
||||||
|
|
||||||
def action_quit(self) -> None:
|
|
||||||
"""Quit to search screen"""
|
|
||||||
self.app.pop_screen()
|
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
||||||
"""Handle button presses"""
|
|
||||||
if event.button.id == "back-btn":
|
|
||||||
self.action_go_back()
|
|
||||||
elif event.button.id == "refresh-btn":
|
|
||||||
self.action_refresh_screen()
|
|
||||||
elif event.button.id == "remove-btn":
|
|
||||||
self.action_remove_selected()
|
|
||||||
elif event.button.id == "retry-btn":
|
|
||||||
self.action_retry_selected()
|
|
||||||
elif event.button.id == "clear-done-btn":
|
|
||||||
self.action_clear_completed()
|
|
||||||
elif event.button.id == "clear-failed-btn":
|
|
||||||
self.action_clear_failed()
|
|
||||||
|
|
||||||
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
|
||||||
"""Handle row selection"""
|
|
||||||
# Get the queue item that was selected
|
|
||||||
row_key = event.row_key
|
|
||||||
row_index = int(row_key.value) - 1 if row_key else -1 # type: ignore[arg-type]
|
|
||||||
|
|
||||||
if self.download_queue:
|
|
||||||
items = self.download_queue.get_queue()
|
|
||||||
if 0 <= row_index < len(items):
|
|
||||||
item = items[row_index]
|
|
||||||
if item.video:
|
|
||||||
self.update_status(f"Selected: {item.video.display_title}")
|
|
||||||
@ -1,364 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Results Screen for YouTube TUI
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from textual.app import ComposeResult
|
|
||||||
from textual.containers import Container
|
|
||||||
from textual.screen import Screen
|
|
||||||
from textual.widgets import (
|
|
||||||
Button,
|
|
||||||
DataTable,
|
|
||||||
Footer,
|
|
||||||
Header,
|
|
||||||
Static,
|
|
||||||
)
|
|
||||||
|
|
||||||
from youtube_tui.models.video import Video
|
|
||||||
from youtube_tui.screens.modal import CategorySelectionModal
|
|
||||||
from youtube_tui.services.youtube import YouTubeService
|
|
||||||
|
|
||||||
|
|
||||||
class ResultsScreen(Screen):
|
|
||||||
"""Screen for displaying search results"""
|
|
||||||
|
|
||||||
ALLOW_SELECT = True
|
|
||||||
|
|
||||||
CSS = """
|
|
||||||
ResultsScreen {
|
|
||||||
align: center middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#results-container {
|
|
||||||
width: 95%;
|
|
||||||
height: 70%;
|
|
||||||
border: solid #555555;
|
|
||||||
margin: 1 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#results-title {
|
|
||||||
width: 100%;
|
|
||||||
height: 3;
|
|
||||||
dock: top;
|
|
||||||
background: $surface;
|
|
||||||
content-align: center middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#pagination-controls {
|
|
||||||
width: 100%;
|
|
||||||
height: 3;
|
|
||||||
dock: bottom;
|
|
||||||
background: $surface;
|
|
||||||
content-align: center middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#status-bar {
|
|
||||||
dock: bottom;
|
|
||||||
height: 1;
|
|
||||||
background: $surface;
|
|
||||||
color: $text-muted;
|
|
||||||
padding: 0 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
width: 15;
|
|
||||||
margin: 0 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
DataTable {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
DataTable .datatable-row-highlight {
|
|
||||||
background: $primary;
|
|
||||||
}
|
|
||||||
|
|
||||||
DataTable .datatable-header {
|
|
||||||
background: $primary-darken-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.results-table {
|
|
||||||
background: $surface;
|
|
||||||
border: round #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.results-table .datatable-row:hover {
|
|
||||||
background: $primary-lighten-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.results-table .datatable-row-selected {
|
|
||||||
background: $primary;
|
|
||||||
}
|
|
||||||
|
|
||||||
.results-table .datatable-row-active {
|
|
||||||
background: $primary-darken-2;
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
BINDINGS = [
|
|
||||||
("n", "next_page", "Next Page"),
|
|
||||||
("p", "previous_page", "Previous Page"),
|
|
||||||
("q", "go_back", "Back"),
|
|
||||||
("enter", "download", "Download"),
|
|
||||||
("escape", "go_back", "Back"),
|
|
||||||
("ctrl+r", "refresh_screen", "Refresh"),
|
|
||||||
("ctrl+f", "search_from_anywhere", "Search"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __init__(self, search_term: str, page: int = 1):
|
|
||||||
super().__init__()
|
|
||||||
self.youtube_service = YouTubeService()
|
|
||||||
self.search_term = search_term
|
|
||||||
self.page = page
|
|
||||||
self.videos: List[Video] = []
|
|
||||||
self.total_pages: int = 1
|
|
||||||
self.max_per_page: int = 15
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
"""Compose the results screen"""
|
|
||||||
yield Header()
|
|
||||||
yield Static(
|
|
||||||
f"Results for: [bold cyan]{self.search_term}[/bold cyan] (Page {self.page})",
|
|
||||||
id="results-title",
|
|
||||||
)
|
|
||||||
table = DataTable(
|
|
||||||
id="results-table",
|
|
||||||
show_cursor=True,
|
|
||||||
cursor_type="row",
|
|
||||||
show_row_labels=False,
|
|
||||||
classes="results-table",
|
|
||||||
)
|
|
||||||
yield Container(
|
|
||||||
table,
|
|
||||||
id="results-container",
|
|
||||||
)
|
|
||||||
yield Container(
|
|
||||||
Button("← Prev", id="prev-btn"),
|
|
||||||
Static(id="page-indicator"),
|
|
||||||
Button("Next →", id="next-btn"),
|
|
||||||
id="pagination-controls",
|
|
||||||
)
|
|
||||||
yield Static(id="status-bar")
|
|
||||||
yield Footer()
|
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
|
||||||
"""Called when screen is mounted"""
|
|
||||||
self.query_one("#results-table", DataTable).focus()
|
|
||||||
# Use asyncio.create_task to run the async load_results method
|
|
||||||
# since on_mount is synchronous but we need to fetch data asynchronously
|
|
||||||
self.load_task = asyncio.create_task(self.load_results())
|
|
||||||
self.update_status("[blue]Loading search results...[/blue]")
|
|
||||||
|
|
||||||
def action_refresh_screen(self) -> None:
|
|
||||||
"""Refresh the screen"""
|
|
||||||
# Cancel any existing load task and start a new one
|
|
||||||
if hasattr(self, "load_task") and self.load_task:
|
|
||||||
self.load_task.cancel()
|
|
||||||
self.load_task = asyncio.create_task(self.load_results())
|
|
||||||
self.update_status("[blue]Refreshing results...[/blue]")
|
|
||||||
|
|
||||||
def action_search_from_anywhere(self) -> None:
|
|
||||||
"""Open search from anywhere"""
|
|
||||||
# Use the app's action_open_search if available, otherwise go back
|
|
||||||
if hasattr(self.app, "action_open_search"):
|
|
||||||
self.app.action_open_search()
|
|
||||||
else:
|
|
||||||
self.app.pop_screen()
|
|
||||||
|
|
||||||
async def load_results(self) -> None:
|
|
||||||
"""Load search results from YouTube"""
|
|
||||||
# Clear stale results immediately to prevent mixing with new search
|
|
||||||
self.videos = []
|
|
||||||
self.total_pages = 1
|
|
||||||
self.update_table()
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.videos = await self.youtube_service.search_videos(
|
|
||||||
self.search_term, page=self.page, per_page=self.max_per_page
|
|
||||||
)
|
|
||||||
|
|
||||||
# Calculate total pages (simplified - yt-dlp returns 15 per page)
|
|
||||||
if len(self.videos) == self.max_per_page:
|
|
||||||
self.total_pages = self.page + 1 # There might be more pages
|
|
||||||
else:
|
|
||||||
self.total_pages = self.page
|
|
||||||
|
|
||||||
# Update the table
|
|
||||||
self.update_table()
|
|
||||||
|
|
||||||
# Update pagination controls
|
|
||||||
self.update_pagination()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.update_status(f"[red]Error loading results: {e}[/red]")
|
|
||||||
self.videos = []
|
|
||||||
|
|
||||||
def update_table(self) -> None:
|
|
||||||
"""Update the DataTable with videos"""
|
|
||||||
table = self.query_one("#results-table", DataTable)
|
|
||||||
|
|
||||||
# Clear existing data
|
|
||||||
table.clear(columns=True)
|
|
||||||
|
|
||||||
# Set up columns
|
|
||||||
table.add_columns("#", "Title", "Author", "Duration", "Type")
|
|
||||||
|
|
||||||
# Add rows
|
|
||||||
for i, video in enumerate(self.videos, 1):
|
|
||||||
# Determine video type
|
|
||||||
if video.is_short:
|
|
||||||
video_type = "Short"
|
|
||||||
elif "/playlist" in video.url:
|
|
||||||
video_type = "Playlist"
|
|
||||||
else:
|
|
||||||
video_type = "Video"
|
|
||||||
|
|
||||||
# Truncate long titles
|
|
||||||
title = video.display_title
|
|
||||||
if len(title) > 50:
|
|
||||||
title = title[:47] + "..."
|
|
||||||
|
|
||||||
author = video.channel
|
|
||||||
if len(author) > 20:
|
|
||||||
author = author[:17] + "..."
|
|
||||||
|
|
||||||
table.add_row(
|
|
||||||
str(i),
|
|
||||||
title,
|
|
||||||
author,
|
|
||||||
video.display_duration,
|
|
||||||
video_type,
|
|
||||||
key=video.video_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Focus the table
|
|
||||||
table.focus()
|
|
||||||
|
|
||||||
def update_pagination(self) -> None:
|
|
||||||
"""Update pagination controls"""
|
|
||||||
page_indicator = self.query_one("#page-indicator", Static)
|
|
||||||
page_indicator.update(f"Page {self.page} of {self.total_pages}")
|
|
||||||
|
|
||||||
prev_btn = self.query_one("#prev-btn", Button)
|
|
||||||
next_btn = self.query_one("#next-btn", Button)
|
|
||||||
|
|
||||||
# Disable previous button on first page
|
|
||||||
prev_btn.disabled = self.page <= 1
|
|
||||||
|
|
||||||
# Disable next button if we're on the last known page and have fewer results
|
|
||||||
if len(self.videos) < self.max_per_page:
|
|
||||||
next_btn.disabled = True
|
|
||||||
else:
|
|
||||||
next_btn.disabled = False
|
|
||||||
|
|
||||||
def update_status(self, message: str) -> None:
|
|
||||||
"""Update the status bar message"""
|
|
||||||
status_bar = self.query_one("#status-bar", Static)
|
|
||||||
status_bar.update(f"[bold white]{message}[/bold white]")
|
|
||||||
|
|
||||||
def action_add_to_queue(self) -> None:
|
|
||||||
"""Add selected video to queue"""
|
|
||||||
table = self.query_one("#results-table", DataTable)
|
|
||||||
selected_row = table.cursor_row
|
|
||||||
|
|
||||||
if selected_row < 0 or selected_row >= len(self.videos):
|
|
||||||
self.update_status("[yellow]Select a video to add to queue[/yellow]")
|
|
||||||
return
|
|
||||||
|
|
||||||
video = self.videos[selected_row]
|
|
||||||
|
|
||||||
# Show modal for category selection
|
|
||||||
self.app.push_screen(
|
|
||||||
CategorySelectionModal(),
|
|
||||||
lambda category: self._add_to_queue_with_category(video, category),
|
|
||||||
)
|
|
||||||
|
|
||||||
def action_download(self) -> None:
|
|
||||||
"""Download selected video - add to queue"""
|
|
||||||
self.action_add_to_queue()
|
|
||||||
|
|
||||||
def action_next_page(self) -> None:
|
|
||||||
"""Go to next page"""
|
|
||||||
if self.page < self.total_pages or len(self.videos) >= self.max_per_page:
|
|
||||||
self.page += 1
|
|
||||||
# Cancel any existing load task and start a new one
|
|
||||||
if hasattr(self, "load_task") and self.load_task:
|
|
||||||
self.load_task.cancel()
|
|
||||||
self.load_task = asyncio.create_task(self.load_results())
|
|
||||||
self.update_status(f"[blue]Loading page {self.page}...[/blue]")
|
|
||||||
|
|
||||||
def action_previous_page(self) -> None:
|
|
||||||
"""Go to previous page"""
|
|
||||||
if self.page > 1:
|
|
||||||
self.page -= 1
|
|
||||||
# Cancel any existing load task and start a new one
|
|
||||||
if hasattr(self, "load_task") and self.load_task:
|
|
||||||
self.load_task.cancel()
|
|
||||||
self.load_task = asyncio.create_task(self.load_results())
|
|
||||||
self.update_status(f"[blue]Loading page {self.page}...[/blue]")
|
|
||||||
|
|
||||||
def action_go_back(self) -> None:
|
|
||||||
"""Go back to search screen"""
|
|
||||||
self.app.pop_screen()
|
|
||||||
|
|
||||||
def action_quit(self) -> None:
|
|
||||||
"""Quit to search screen"""
|
|
||||||
self.app.pop_screen()
|
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
||||||
"""Handle button presses"""
|
|
||||||
if event.button.id == "prev-btn":
|
|
||||||
self.action_previous_page()
|
|
||||||
elif event.button.id == "next-btn":
|
|
||||||
self.action_next_page()
|
|
||||||
|
|
||||||
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
|
||||||
"""Handle row selection (Enter key) - add to queue"""
|
|
||||||
self._add_selected_video_to_queue()
|
|
||||||
|
|
||||||
def on_data_table_cell_selected(self, event: DataTable.CellSelected) -> None:
|
|
||||||
"""Handle cell click - add to queue"""
|
|
||||||
self._add_selected_video_to_queue()
|
|
||||||
|
|
||||||
def _add_to_queue_with_category(self, video: Video, category: str | None) -> None:
|
|
||||||
"""Add video to queue with selected category (callback from modal)"""
|
|
||||||
if category is None:
|
|
||||||
self.update_status("[yellow]Category selection cancelled[/yellow]")
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
if hasattr(self.app, "download_queue") and self.app.download_queue:
|
|
||||||
self.app.download_queue.add_video(video, category=category)
|
|
||||||
self.update_status(
|
|
||||||
f"[green]Added to queue: {video.display_title}[/green]"
|
|
||||||
)
|
|
||||||
self.app.notify(
|
|
||||||
f"Added to queue: {video.display_title}",
|
|
||||||
title="Queue",
|
|
||||||
severity="information",
|
|
||||||
timeout=3,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.update_status("[yellow]Queue not available[/yellow]")
|
|
||||||
except Exception as e:
|
|
||||||
self.update_status(f"[red]Error adding to queue: {e}[/red]")
|
|
||||||
|
|
||||||
def _add_selected_video_to_queue(self) -> None:
|
|
||||||
"""Helper method to add selected video to queue"""
|
|
||||||
table = self.query_one("#results-table", DataTable)
|
|
||||||
row_index = table.cursor_row
|
|
||||||
|
|
||||||
if row_index < 0 or row_index >= len(self.videos):
|
|
||||||
return
|
|
||||||
|
|
||||||
video = self.videos[row_index]
|
|
||||||
|
|
||||||
# Show modal for category selection
|
|
||||||
self.app.push_screen(
|
|
||||||
CategorySelectionModal(),
|
|
||||||
lambda category: self._add_to_queue_with_category(video, category),
|
|
||||||
)
|
|
||||||
@ -1,156 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Search Screen for YouTube TUI
|
|
||||||
"""
|
|
||||||
|
|
||||||
from textual.app import ComposeResult
|
|
||||||
from textual.containers import Container
|
|
||||||
from textual.screen import Screen
|
|
||||||
from textual.widgets import (
|
|
||||||
Button,
|
|
||||||
Footer,
|
|
||||||
Header,
|
|
||||||
Input,
|
|
||||||
Static,
|
|
||||||
)
|
|
||||||
|
|
||||||
from youtube_tui.services.youtube import YouTubeService
|
|
||||||
|
|
||||||
|
|
||||||
class SearchScreen(Screen):
|
|
||||||
"""Screen for searching YouTube videos"""
|
|
||||||
|
|
||||||
CSS = """
|
|
||||||
SearchScreen {
|
|
||||||
align: center middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#search-container {
|
|
||||||
width: 80%;
|
|
||||||
height: auto;
|
|
||||||
border: double #555555;
|
|
||||||
padding: 1 2;
|
|
||||||
margin: 2 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#search-input {
|
|
||||||
width: 100%;
|
|
||||||
margin: 1 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#buttons {
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
dock: bottom;
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
width: 20;
|
|
||||||
margin: 1 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#status-bar {
|
|
||||||
dock: bottom;
|
|
||||||
height: 1;
|
|
||||||
background: #333333;
|
|
||||||
color: #aaaaaa;
|
|
||||||
padding: 0 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#loading-indicator {
|
|
||||||
height: 3;
|
|
||||||
margin: 1 0;
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
BINDINGS = [
|
|
||||||
("escape", "go_back", "Back"),
|
|
||||||
("enter", "search", "Search"),
|
|
||||||
("ctrl+f", "search_from_anywhere", "Search"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
self.youtube_service = YouTubeService()
|
|
||||||
self.search_term = ""
|
|
||||||
self.is_searching = False
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
"""Compose the search screen"""
|
|
||||||
yield Header()
|
|
||||||
yield Container(
|
|
||||||
Static("YouTube Search", id="search-title", classes="title"),
|
|
||||||
Static("Enter a search term to find YouTube videos", id="search-hint"),
|
|
||||||
Input(placeholder="Search for videos...", id="search-input"),
|
|
||||||
Button("Search", id="search-button"),
|
|
||||||
Button("Cancel", id="cancel-button"),
|
|
||||||
id="search-container",
|
|
||||||
)
|
|
||||||
yield Static(id="status-bar")
|
|
||||||
yield Footer()
|
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
|
||||||
"""Called when screen is mounted"""
|
|
||||||
self.query_one(Input).focus()
|
|
||||||
self.update_status(
|
|
||||||
"Ready to search - Press Enter to search, Ctrl+F to search from anywhere"
|
|
||||||
)
|
|
||||||
|
|
||||||
def action_search_from_anywhere(self) -> None:
|
|
||||||
"""Open search from anywhere"""
|
|
||||||
# This is a fallback for the key binding
|
|
||||||
self.action_search()
|
|
||||||
|
|
||||||
def update_status(self, message: str) -> None:
|
|
||||||
"""Update the status bar message"""
|
|
||||||
status_bar = self.query_one("#status-bar", Static)
|
|
||||||
status_bar.update(f"[bold white]{message}[/bold white]")
|
|
||||||
|
|
||||||
def action_search(self) -> None:
|
|
||||||
"""Perform the search operation"""
|
|
||||||
input_widget = self.query_one(Input)
|
|
||||||
search_term = input_widget.value.strip()
|
|
||||||
|
|
||||||
if not search_term:
|
|
||||||
self.update_status("[red]Please enter a search term[/red]")
|
|
||||||
return
|
|
||||||
|
|
||||||
self.search_term = search_term
|
|
||||||
self.update_status(f"[blue]Searching for: {search_term}[/blue]")
|
|
||||||
|
|
||||||
# Trigger search - get the app and push results screen
|
|
||||||
app = self.app
|
|
||||||
if hasattr(app, "push_results_screen"):
|
|
||||||
app.push_results_screen(search_term)
|
|
||||||
else:
|
|
||||||
# Fallback if app methods aren't available
|
|
||||||
from youtube_tui.screens.results import ResultsScreen
|
|
||||||
|
|
||||||
self.app.push_screen(ResultsScreen(search_term))
|
|
||||||
|
|
||||||
def action_cancel(self) -> None:
|
|
||||||
"""Handle cancel action"""
|
|
||||||
self.action_go_back()
|
|
||||||
|
|
||||||
def action_go_back(self) -> None:
|
|
||||||
"""Go back to previous screen"""
|
|
||||||
self.app.pop_screen()
|
|
||||||
|
|
||||||
def action_quit(self) -> None:
|
|
||||||
"""Quit the application"""
|
|
||||||
# Only quit if we're at the root level
|
|
||||||
if len(self.app.screen_stack) <= 2: # Header + Footer + Screen
|
|
||||||
self.app.exit()
|
|
||||||
else:
|
|
||||||
self.action_go_back()
|
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
||||||
"""Handle button presses"""
|
|
||||||
if event.button.id == "search-button":
|
|
||||||
self.action_search()
|
|
||||||
elif event.button.id == "cancel-button":
|
|
||||||
self.action_quit()
|
|
||||||
|
|
||||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
||||||
"""Handle enter key in search input"""
|
|
||||||
self.action_search()
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
"""
|
|
||||||
Services package for YouTube TUI
|
|
||||||
"""
|
|
||||||
|
|
||||||
from youtube_tui.services.youtube import YouTubeService
|
|
||||||
|
|
||||||
__all__ = ["YouTubeService"]
|
|
||||||
@ -1,220 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Download Manager for YouTube TUI
|
|
||||||
Handles background downloads sequentially
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
from logging.handlers import RotatingFileHandler
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from rich.console import Console
|
|
||||||
|
|
||||||
from youtube_tui.models.queue_item import QueueItem, QueueStatus
|
|
||||||
from youtube_tui.models.video import Video
|
|
||||||
from youtube_tui.services.queue import DownloadQueue
|
|
||||||
from youtube_tui.services.youtube import YouTubeService
|
|
||||||
|
|
||||||
console = Console()
|
|
||||||
|
|
||||||
# Configure logging
|
|
||||||
LOG_DIR = Path.home() / ".config" / "youtube_cli" / "logs"
|
|
||||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
LOG_FILE = LOG_DIR / "app.log"
|
|
||||||
|
|
||||||
# Use RotatingFileHandler for log rotation (10MB, 5 backups)
|
|
||||||
file_handler = RotatingFileHandler(LOG_FILE, maxBytes=10 * 1024 * 1024, backupCount=5)
|
|
||||||
file_handler.setLevel(logging.DEBUG)
|
|
||||||
file_handler.setFormatter(
|
|
||||||
logging.Formatter(
|
|
||||||
"%(asctime)s | %(name)s | %(levelname)s | %(message)s",
|
|
||||||
"%Y-%m-%d %H:%M:%S",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create console handler
|
|
||||||
console_handler = logging.StreamHandler()
|
|
||||||
console_handler.setLevel(logging.INFO)
|
|
||||||
console_handler.setFormatter(logging.Formatter("%(message)s"))
|
|
||||||
|
|
||||||
# Configure root logger
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.DEBUG,
|
|
||||||
handlers=[
|
|
||||||
file_handler,
|
|
||||||
console_handler,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class DownloadManager:
|
|
||||||
"""Manages background downloads from the queue"""
|
|
||||||
|
|
||||||
def __init__(self, queue: DownloadQueue, youtube_service: YouTubeService):
|
|
||||||
self._queue: DownloadQueue = queue
|
|
||||||
self._active_task: Optional[asyncio.Task] = None
|
|
||||||
self._current_item: Optional[QueueItem] = None
|
|
||||||
self._is_running = False
|
|
||||||
self._cancel_requested = False
|
|
||||||
self._youtube_service = youtube_service
|
|
||||||
|
|
||||||
def add_to_queue(
|
|
||||||
self,
|
|
||||||
video: Video,
|
|
||||||
category: Optional[str] = None,
|
|
||||||
network_folder: Optional[str] = None,
|
|
||||||
) -> QueueItem:
|
|
||||||
"""Add a video to the download queue"""
|
|
||||||
return self._queue.add_video(video, category, network_folder)
|
|
||||||
|
|
||||||
def remove_from_queue(self, item_id: str) -> bool:
|
|
||||||
"""Remove an item from the queue by UUID string"""
|
|
||||||
return self._queue.remove_item(item_id)
|
|
||||||
|
|
||||||
def cancel_active_download(self) -> None:
|
|
||||||
"""Cancel the currently active download"""
|
|
||||||
self._cancel_requested = True
|
|
||||||
if self._active_task:
|
|
||||||
self._active_task.cancel()
|
|
||||||
|
|
||||||
def get_queue_status(self) -> dict:
|
|
||||||
"""Get queue status information"""
|
|
||||||
stats = self._queue.get_stats()
|
|
||||||
# Use _current_item to determine if there's an active download
|
|
||||||
has_active_download = self._current_item is not None
|
|
||||||
return {
|
|
||||||
"pending_count": stats["pending"],
|
|
||||||
"downloading_count": 1 if has_active_download else stats["downloading"],
|
|
||||||
"total_count": stats["total"],
|
|
||||||
"has_active_download": has_active_download,
|
|
||||||
"active_item": self._current_item.to_dict() if self._current_item else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_active_item(self) -> Optional[QueueItem]:
|
|
||||||
"""Get the currently downloading item"""
|
|
||||||
return self._current_item
|
|
||||||
|
|
||||||
def start_processing(self) -> None:
|
|
||||||
"""Start the background download processing task"""
|
|
||||||
if not self._is_running:
|
|
||||||
self._is_running = True
|
|
||||||
self._active_task = asyncio.create_task(self._process_queue())
|
|
||||||
|
|
||||||
def stop_processing(self) -> None:
|
|
||||||
"""Stop the background download processing task"""
|
|
||||||
self._is_running = False
|
|
||||||
if self._active_task:
|
|
||||||
self._active_task.cancel()
|
|
||||||
|
|
||||||
async def _process_queue(self) -> None:
|
|
||||||
"""Process the download queue sequentially"""
|
|
||||||
# Loop is available via asyncio.run() in main context
|
|
||||||
|
|
||||||
while self._is_running:
|
|
||||||
try:
|
|
||||||
# Check if we have a pending item
|
|
||||||
item = self._queue.get_next_pending()
|
|
||||||
if item is None:
|
|
||||||
await asyncio.sleep(1) # Wait for new items
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Mark item as current
|
|
||||||
self._current_item = item
|
|
||||||
self._cancel_requested = False
|
|
||||||
|
|
||||||
# Start downloading
|
|
||||||
await self._download_item(item)
|
|
||||||
|
|
||||||
# Clear current item after completion
|
|
||||||
self._current_item = None
|
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
# Task was cancelled
|
|
||||||
logger.warning("Download manager cancelled")
|
|
||||||
break
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Error in download manager: {e}")
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
|
|
||||||
async def _download_item(self, item: QueueItem) -> None:
|
|
||||||
"""Download a single queue item"""
|
|
||||||
# Update status to downloading
|
|
||||||
item.start_download()
|
|
||||||
if item.video:
|
|
||||||
self._queue.update_item_status(str(item.id), QueueStatus.DOWNLOADING)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Determine if it's a playlist or video
|
|
||||||
is_playlist = item.video and (
|
|
||||||
"/playlist" in item.video.url.lower()
|
|
||||||
or "list=" in item.video.url.lower()
|
|
||||||
)
|
|
||||||
|
|
||||||
# Download with progress callback
|
|
||||||
async def progress_callback(percentage: int) -> bool:
|
|
||||||
"""Progress callback that checks for cancellation"""
|
|
||||||
# Update progress
|
|
||||||
if item.video:
|
|
||||||
self._queue.update_progress(str(item.id), percentage)
|
|
||||||
item.update_progress(percentage)
|
|
||||||
|
|
||||||
# Check for cancellation
|
|
||||||
if self._cancel_requested:
|
|
||||||
raise asyncio.CancelledError("Download cancelled by user")
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
if is_playlist and item.video:
|
|
||||||
success = await self._youtube_service.download_playlist(
|
|
||||||
item.video,
|
|
||||||
category=item.category,
|
|
||||||
network_folder=item.network_folder,
|
|
||||||
progress_callback=progress_callback,
|
|
||||||
)
|
|
||||||
elif item.video:
|
|
||||||
success = await self._youtube_service.download_video(
|
|
||||||
item.video,
|
|
||||||
category=item.category,
|
|
||||||
network_folder=item.network_folder,
|
|
||||||
progress_callback=progress_callback,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# No video to download
|
|
||||||
if item.video is None:
|
|
||||||
item.fail(error_message="No video data available")
|
|
||||||
success = False
|
|
||||||
|
|
||||||
# Check final status
|
|
||||||
if self._cancel_requested:
|
|
||||||
# Download was cancelled
|
|
||||||
if item.video:
|
|
||||||
self._queue.update_item_status(str(item.id), QueueStatus.CANCELLED)
|
|
||||||
item.cancel()
|
|
||||||
elif success:
|
|
||||||
# Download succeeded
|
|
||||||
if item.video:
|
|
||||||
self._queue.update_item_status(str(item.id), QueueStatus.COMPLETED)
|
|
||||||
item.complete()
|
|
||||||
else:
|
|
||||||
# Download failed
|
|
||||||
if item.video:
|
|
||||||
self._queue.update_item_status(str(item.id), QueueStatus.FAILED)
|
|
||||||
item.fail(error_message="Download failed")
|
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
# Task was cancelled
|
|
||||||
if item.video:
|
|
||||||
self._queue.update_item_status(str(item.id), QueueStatus.CANCELLED)
|
|
||||||
item.cancel()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Download error: {e}")
|
|
||||||
if item.video:
|
|
||||||
self._queue.update_item_status(str(item.id), QueueStatus.FAILED)
|
|
||||||
item.fail(error_message=str(e))
|
|
||||||
|
|
||||||
def is_processing(self) -> bool:
|
|
||||||
"""Check if download manager is processing queue"""
|
|
||||||
return self._is_running
|
|
||||||
@ -1,268 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Download Queue Service for YouTube TUI
|
|
||||||
Manages the queue of videos to download
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from logging.handlers import RotatingFileHandler
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
from rich.console import Console
|
|
||||||
|
|
||||||
from youtube_tui.models.queue_item import QueueItem, QueueStatus
|
|
||||||
from youtube_tui.models.video import Video
|
|
||||||
|
|
||||||
console = Console()
|
|
||||||
|
|
||||||
# Configure logging
|
|
||||||
LOG_DIR = Path.home() / ".config" / "youtube_cli" / "logs"
|
|
||||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
LOG_FILE = LOG_DIR / "app.log"
|
|
||||||
|
|
||||||
# Use RotatingFileHandler for log rotation (10MB, 5 backups)
|
|
||||||
file_handler = RotatingFileHandler(LOG_FILE, maxBytes=10 * 1024 * 1024, backupCount=5)
|
|
||||||
file_handler.setLevel(logging.DEBUG)
|
|
||||||
file_handler.setFormatter(
|
|
||||||
logging.Formatter(
|
|
||||||
"%(asctime)s | %(name)s | %(levelname)s | %(message)s",
|
|
||||||
"%Y-%m-%d %H:%M:%S",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create console handler
|
|
||||||
console_handler = logging.StreamHandler()
|
|
||||||
console_handler.setLevel(logging.INFO)
|
|
||||||
console_handler.setFormatter(logging.Formatter("%(message)s"))
|
|
||||||
|
|
||||||
# Configure root logger
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.DEBUG,
|
|
||||||
handlers=[
|
|
||||||
file_handler,
|
|
||||||
console_handler,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class DownloadQueue:
|
|
||||||
"""Manages the download queue"""
|
|
||||||
|
|
||||||
_archive_file = Path.home() / ".config" / "youtube_cli" / "download_queue.json"
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._queue: List[QueueItem] = []
|
|
||||||
self._load_queue()
|
|
||||||
|
|
||||||
def _load_queue(self) -> None:
|
|
||||||
"""Load queue from archive file"""
|
|
||||||
try:
|
|
||||||
if self._archive_file.exists():
|
|
||||||
with open(self._archive_file, "r") as f:
|
|
||||||
data = json.load(f)
|
|
||||||
self._queue = [QueueItem.from_dict(item) for item in data]
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Error loading queue: {e}")
|
|
||||||
self._queue = []
|
|
||||||
|
|
||||||
def _save_queue(self) -> None:
|
|
||||||
"""Save queue to archive file"""
|
|
||||||
try:
|
|
||||||
self._archive_file.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
with open(self._archive_file, "w") as f:
|
|
||||||
data = [item.to_dict() for item in self._queue]
|
|
||||||
json.dump(data, f, indent=2)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Error saving queue: {e}")
|
|
||||||
|
|
||||||
def add_video(
|
|
||||||
self,
|
|
||||||
video: Video,
|
|
||||||
category: Optional[str] = None,
|
|
||||||
network_folder: Optional[str] = None,
|
|
||||||
) -> QueueItem:
|
|
||||||
"""Add a video to the queue"""
|
|
||||||
item = QueueItem(video=video, category=category, network_folder=network_folder)
|
|
||||||
self._queue.append(item)
|
|
||||||
self._save_queue()
|
|
||||||
return item
|
|
||||||
|
|
||||||
def remove_item(self, item_id: str) -> bool:
|
|
||||||
"""Remove a queue item by its UUID string"""
|
|
||||||
try:
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
item_uuid = uuid.UUID(item_id)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return False
|
|
||||||
|
|
||||||
for i, item in enumerate(self._queue):
|
|
||||||
if item.id == item_uuid:
|
|
||||||
del self._queue[i]
|
|
||||||
self._save_queue()
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def get_next_pending(self) -> Optional[QueueItem]:
|
|
||||||
"""Get the next pending video to download"""
|
|
||||||
for item in self._queue:
|
|
||||||
if item.status == QueueStatus.PENDING:
|
|
||||||
return item
|
|
||||||
return None
|
|
||||||
|
|
||||||
def update_item_status(self, item_id: str, status: QueueStatus) -> None:
|
|
||||||
"""Update the status of a queue item by UUID string"""
|
|
||||||
try:
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
item_uuid = uuid.UUID(item_id)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return
|
|
||||||
|
|
||||||
for item in self._queue:
|
|
||||||
if item.id == item_uuid:
|
|
||||||
item.status = status
|
|
||||||
self._save_queue()
|
|
||||||
return
|
|
||||||
|
|
||||||
def update_progress(self, item_id: str, percentage: int) -> None:
|
|
||||||
"""Update the progress of a queue item by UUID string"""
|
|
||||||
try:
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
item_uuid = uuid.UUID(item_id)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return
|
|
||||||
|
|
||||||
for item in self._queue:
|
|
||||||
if item.id == item_uuid:
|
|
||||||
item.update_progress(percentage)
|
|
||||||
self._save_queue()
|
|
||||||
return
|
|
||||||
|
|
||||||
def get_all_items(self) -> List[QueueItem]:
|
|
||||||
"""Get all queue items"""
|
|
||||||
return self._queue.copy()
|
|
||||||
|
|
||||||
def get_active_count(self) -> int:
|
|
||||||
"""Get count of active items (pending + downloading)"""
|
|
||||||
return sum(
|
|
||||||
1
|
|
||||||
for item in self._queue
|
|
||||||
if item.status in (QueueStatus.PENDING, QueueStatus.DOWNLOADING)
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_pending_count(self) -> int:
|
|
||||||
"""Get count of pending items"""
|
|
||||||
return sum(1 for item in self._queue if item.status == QueueStatus.PENDING)
|
|
||||||
|
|
||||||
def cancel_item(self, item_id: str) -> None:
|
|
||||||
"""Cancel a queue item by UUID string"""
|
|
||||||
try:
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
item_uuid = uuid.UUID(item_id)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return
|
|
||||||
|
|
||||||
for item in self._queue:
|
|
||||||
if item.id == item_uuid:
|
|
||||||
item.cancel()
|
|
||||||
self._save_queue()
|
|
||||||
return
|
|
||||||
|
|
||||||
def clear_completed(self) -> int:
|
|
||||||
"""Remove completed and cancelled items from queue"""
|
|
||||||
initial_count = len(self._queue)
|
|
||||||
self._queue = [
|
|
||||||
item
|
|
||||||
for item in self._queue
|
|
||||||
if item.status not in (QueueStatus.COMPLETED, QueueStatus.CANCELLED)
|
|
||||||
]
|
|
||||||
removed = initial_count - len(self._queue)
|
|
||||||
if removed > 0:
|
|
||||||
self._save_queue()
|
|
||||||
return removed
|
|
||||||
|
|
||||||
def clear_failed(self) -> int:
|
|
||||||
"""Remove failed items from queue"""
|
|
||||||
initial_count = len(self._queue)
|
|
||||||
self._queue = [
|
|
||||||
item for item in self._queue if item.status != QueueStatus.FAILED
|
|
||||||
]
|
|
||||||
removed = initial_count - len(self._queue)
|
|
||||||
if removed > 0:
|
|
||||||
self._save_queue()
|
|
||||||
return removed
|
|
||||||
|
|
||||||
def get_downloading_item(self) -> Optional[QueueItem]:
|
|
||||||
"""Get the currently downloading item"""
|
|
||||||
for item in self._queue:
|
|
||||||
if item.status == QueueStatus.DOWNLOADING:
|
|
||||||
return item
|
|
||||||
return None
|
|
||||||
|
|
||||||
def remove_video(self, video_id: str) -> bool:
|
|
||||||
"""Remove a video from the queue by video ID (alias for remove_by_video_id)"""
|
|
||||||
return self.remove_by_video_id(video_id)
|
|
||||||
|
|
||||||
def update_status(
|
|
||||||
self, video_id: str, status: QueueStatus, progress: Optional[int] = None
|
|
||||||
) -> None:
|
|
||||||
"""Update the status of a video in the queue by video ID"""
|
|
||||||
for item in self._queue:
|
|
||||||
if item.video and item.video.video_id == video_id:
|
|
||||||
item.status = status
|
|
||||||
if progress is not None:
|
|
||||||
item.update_progress(progress)
|
|
||||||
self._save_queue()
|
|
||||||
return
|
|
||||||
|
|
||||||
def cancel_video(self, video_id: str) -> bool:
|
|
||||||
"""Cancel a video in the queue by video ID"""
|
|
||||||
for item in self._queue:
|
|
||||||
if item.video and item.video.video_id == video_id:
|
|
||||||
item.cancel()
|
|
||||||
self._save_queue()
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def remove_by_video_id(self, video_id: str) -> bool:
|
|
||||||
"""Remove a video from the queue by video ID"""
|
|
||||||
for i, item in enumerate(self._queue):
|
|
||||||
if item.video and item.video.video_id == video_id:
|
|
||||||
del self._queue[i]
|
|
||||||
self._save_queue()
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def get_queue(self) -> List[QueueItem]:
|
|
||||||
"""Get all queue items (alias for get_all_items)"""
|
|
||||||
return self.get_all_items()
|
|
||||||
|
|
||||||
def get_stats(self) -> dict:
|
|
||||||
"""Get queue statistics"""
|
|
||||||
total = len(self._queue)
|
|
||||||
pending = sum(1 for item in self._queue if item.status == QueueStatus.PENDING)
|
|
||||||
downloading = sum(
|
|
||||||
1 for item in self._queue if item.status == QueueStatus.DOWNLOADING
|
|
||||||
)
|
|
||||||
completed = sum(
|
|
||||||
1 for item in self._queue if item.status == QueueStatus.COMPLETED
|
|
||||||
)
|
|
||||||
cancelled = sum(
|
|
||||||
1 for item in self._queue if item.status == QueueStatus.CANCELLED
|
|
||||||
)
|
|
||||||
failed = sum(1 for item in self._queue if item.status == QueueStatus.FAILED)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"total": total,
|
|
||||||
"pending": pending,
|
|
||||||
"downloading": downloading,
|
|
||||||
"completed": completed,
|
|
||||||
"cancelled": cancelled,
|
|
||||||
"failed": failed,
|
|
||||||
}
|
|
||||||
@ -1,344 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
YouTube service wrapper around YouTubeCLI - Async implementation
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
from logging.handlers import RotatingFileHandler
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Dict, List, Optional, Set
|
|
||||||
|
|
||||||
from rich.console import Console
|
|
||||||
|
|
||||||
from youtube_cli.main import YouTubeCLI
|
|
||||||
from youtube_tui.models.video import Video
|
|
||||||
|
|
||||||
console = Console()
|
|
||||||
|
|
||||||
# Configure logging
|
|
||||||
LOG_DIR = Path.home() / ".config" / "youtube_cli" / "logs"
|
|
||||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
LOG_FILE = LOG_DIR / "app.log"
|
|
||||||
|
|
||||||
# Use RotatingFileHandler for log rotation (10MB, 5 backups)
|
|
||||||
file_handler = RotatingFileHandler(LOG_FILE, maxBytes=10 * 1024 * 1024, backupCount=5)
|
|
||||||
file_handler.setLevel(logging.DEBUG)
|
|
||||||
file_handler.setFormatter(
|
|
||||||
logging.Formatter(
|
|
||||||
"%(asctime)s | %(name)s | %(levelname)s | %(message)s",
|
|
||||||
"%Y-%m-%d %H:%M:%S",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create console handler
|
|
||||||
console_handler = logging.StreamHandler()
|
|
||||||
console_handler.setLevel(logging.INFO)
|
|
||||||
console_handler.setFormatter(logging.Formatter("%(message)s"))
|
|
||||||
|
|
||||||
# Configure root logger
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.DEBUG,
|
|
||||||
handlers=[
|
|
||||||
file_handler,
|
|
||||||
console_handler,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class YouTubeServiceError(Exception):
|
|
||||||
"""Base exception for YouTubeService errors"""
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class SearchError(YouTubeServiceError):
|
|
||||||
"""Exception raised during search operations"""
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class DownloadError(YouTubeServiceError):
|
|
||||||
"""Exception raised during download operations"""
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class ArchiveError(YouTubeServiceError):
|
|
||||||
"""Exception raised during archive operations"""
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class YouTubeService:
|
|
||||||
"""Service class that wraps YouTubeCLI for TUI integration with async support"""
|
|
||||||
|
|
||||||
def __init__(self, config_path: Optional[str] = None):
|
|
||||||
"""Initialize the YouTube service"""
|
|
||||||
self.cli = YouTubeCLI(config_path=config_path)
|
|
||||||
self.console = Console()
|
|
||||||
|
|
||||||
async def search_videos(
|
|
||||||
self,
|
|
||||||
query: str,
|
|
||||||
page: int = 1,
|
|
||||||
per_page: int = 15,
|
|
||||||
) -> List[Video]:
|
|
||||||
"""
|
|
||||||
Search for videos on YouTube (async)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query: Search query string
|
|
||||||
page: Page number (1-indexed)
|
|
||||||
per_page: Number of videos per page (ignored, hardcoded to 15 in CLI)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of Video objects
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
SearchError: If search fails
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Use asyncio.to_thread to run blocking subprocess calls
|
|
||||||
def _search() -> List[Video]:
|
|
||||||
try:
|
|
||||||
# Call the search_videos method with return_results=True
|
|
||||||
results = self.cli.search_videos(
|
|
||||||
query, self.cli.config, page, return_results=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not results:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# Convert results to Video objects
|
|
||||||
return [self._create_video_from_result(r) for r in results]
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error searching videos: {e}")
|
|
||||||
raise SearchError(f"Failed to search videos: {e}") from e
|
|
||||||
|
|
||||||
return await asyncio.to_thread(_search)
|
|
||||||
|
|
||||||
async def download_video(
|
|
||||||
self,
|
|
||||||
video: Video,
|
|
||||||
category: Optional[str] = None,
|
|
||||||
network_folder: Optional[str] = None,
|
|
||||||
progress_callback=None,
|
|
||||||
) -> bool:
|
|
||||||
"""
|
|
||||||
Download a video (async)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
video: Video object to download
|
|
||||||
category: Category folder for download location
|
|
||||||
network_folder: Optional network share folder
|
|
||||||
progress_callback: Optional callback to report progress (percentage: int)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if download succeeded, False otherwise
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
DownloadError: If download fails
|
|
||||||
"""
|
|
||||||
|
|
||||||
def _download() -> bool:
|
|
||||||
try:
|
|
||||||
success = self.cli.download_video(
|
|
||||||
video.url,
|
|
||||||
self.cli.config,
|
|
||||||
category=category,
|
|
||||||
network_folder=network_folder,
|
|
||||||
progress_callback=progress_callback,
|
|
||||||
)
|
|
||||||
return success is not False # download_video returns None on error
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error downloading video: {e}")
|
|
||||||
raise DownloadError(f"Failed to download video: {e}") from e
|
|
||||||
|
|
||||||
return await asyncio.to_thread(_download)
|
|
||||||
|
|
||||||
async def download_playlist(
|
|
||||||
self,
|
|
||||||
video: Video,
|
|
||||||
category: Optional[str] = None,
|
|
||||||
network_folder: Optional[str] = None,
|
|
||||||
progress_callback=None,
|
|
||||||
) -> bool:
|
|
||||||
"""
|
|
||||||
Download a playlist (async)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
video: Video object containing playlist URL
|
|
||||||
category: Category folder for download location
|
|
||||||
network_folder: Optional network share folder
|
|
||||||
progress_callback: Optional callback to report progress (percentage: int)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if download succeeded, False otherwise
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
DownloadError: If download fails
|
|
||||||
"""
|
|
||||||
|
|
||||||
def _download_playlist() -> bool:
|
|
||||||
try:
|
|
||||||
success = self.cli.download_playlist(
|
|
||||||
video.url,
|
|
||||||
self.cli.config,
|
|
||||||
category=category,
|
|
||||||
network_folder=network_folder,
|
|
||||||
progress_callback=progress_callback,
|
|
||||||
)
|
|
||||||
return success is not False # download_playlist returns None on error
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error downloading playlist: {e}")
|
|
||||||
raise DownloadError(f"Failed to download playlist: {e}") from e
|
|
||||||
|
|
||||||
return await asyncio.to_thread(_download_playlist)
|
|
||||||
|
|
||||||
async def get_categories(self) -> List[str]:
|
|
||||||
"""
|
|
||||||
Get available download categories (async)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of category names
|
|
||||||
"""
|
|
||||||
|
|
||||||
def _get_categories() -> List[str]:
|
|
||||||
return self.cli.get_categories(self.cli.config)
|
|
||||||
|
|
||||||
return await asyncio.to_thread(_get_categories)
|
|
||||||
|
|
||||||
async def is_video_downloaded(self, video_id: str) -> bool:
|
|
||||||
"""
|
|
||||||
Check if a video has already been downloaded (async)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
video_id: YouTube video ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if video is in archive, False otherwise
|
|
||||||
"""
|
|
||||||
|
|
||||||
def _check_archive() -> bool:
|
|
||||||
return self.cli.is_video_downloaded(video_id)
|
|
||||||
|
|
||||||
return await asyncio.to_thread(_check_archive)
|
|
||||||
|
|
||||||
async def add_to_archive(self, video: Video) -> None:
|
|
||||||
"""
|
|
||||||
Add a video to the archive (async)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
video: Video object to add
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ArchiveError: If archive operation fails
|
|
||||||
"""
|
|
||||||
|
|
||||||
def _add_to_archive() -> None:
|
|
||||||
try:
|
|
||||||
self.cli.add_to_archive(
|
|
||||||
{
|
|
||||||
"url": video.url,
|
|
||||||
"id": video.video_id,
|
|
||||||
"title": video.title,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error adding to archive: {e}")
|
|
||||||
raise ArchiveError(f"Failed to add video to archive: {e}") from e
|
|
||||||
|
|
||||||
await asyncio.to_thread(_add_to_archive)
|
|
||||||
|
|
||||||
async def get_archive(self) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Load the entire archive (async)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Archive dictionary containing all downloaded videos
|
|
||||||
"""
|
|
||||||
|
|
||||||
def _load_archive() -> Dict[str, Any]:
|
|
||||||
return self.cli.load_archive()
|
|
||||||
|
|
||||||
return await asyncio.to_thread(_load_archive)
|
|
||||||
|
|
||||||
async def get_downloaded_video_ids(self) -> Set[str]:
|
|
||||||
"""
|
|
||||||
Get set of all downloaded video IDs (async)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Set of video IDs that have been downloaded
|
|
||||||
"""
|
|
||||||
archive = await self.get_archive()
|
|
||||||
return set(archive.keys())
|
|
||||||
|
|
||||||
async def remove_from_archive(self, video_id: str) -> bool:
|
|
||||||
"""
|
|
||||||
Remove a video from the archive (async)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
video_id: YouTube video ID to remove
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if video was removed, False if not found
|
|
||||||
"""
|
|
||||||
|
|
||||||
def _remove_from_archive() -> bool:
|
|
||||||
try:
|
|
||||||
archive = self.cli.load_archive()
|
|
||||||
if video_id in archive:
|
|
||||||
del archive[video_id]
|
|
||||||
self.cli.save_archive(archive)
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
return await asyncio.to_thread(_remove_from_archive)
|
|
||||||
|
|
||||||
def _create_video_from_result(self, result: Dict[str, Any]) -> Video:
|
|
||||||
"""
|
|
||||||
Create a Video object from yt-dlp result
|
|
||||||
|
|
||||||
Args:
|
|
||||||
result: yt-dlp search result dictionary
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Video object
|
|
||||||
"""
|
|
||||||
duration = result.get("length", "0:00")
|
|
||||||
if duration:
|
|
||||||
duration = str(duration)
|
|
||||||
else:
|
|
||||||
duration = "0:00"
|
|
||||||
|
|
||||||
# Extract channel from author
|
|
||||||
channel = result.get("author", result.get("channel", "Unknown"))
|
|
||||||
|
|
||||||
return Video(
|
|
||||||
video_id=result.get("id", ""),
|
|
||||||
title=result.get("title", "Unknown"),
|
|
||||||
channel=channel,
|
|
||||||
channel_id=result.get("channel_id", ""),
|
|
||||||
duration=duration,
|
|
||||||
view_count=str(result.get("view_count", "0")),
|
|
||||||
upload_date=result.get("upload_date", ""),
|
|
||||||
description=result.get("description", ""),
|
|
||||||
thumbnail_url=result.get("thumbnail"),
|
|
||||||
url=result.get("url", ""),
|
|
||||||
)
|
|
||||||
|
|
||||||
def format_duration(self, seconds: int) -> str:
|
|
||||||
"""
|
|
||||||
Format duration in seconds to MM:SS or HH:MM:SS format
|
|
||||||
|
|
||||||
Args:
|
|
||||||
seconds: Duration in seconds
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Formatted duration string
|
|
||||||
"""
|
|
||||||
return self.cli.format_duration(seconds)
|
|
||||||
@ -1,152 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Test script for YouTube TUI
|
|
||||||
"""
|
|
||||||
|
|
||||||
from youtube_tui.app import YouTubeTUI
|
|
||||||
|
|
||||||
|
|
||||||
def test_imports():
|
|
||||||
"""Test that all imports work correctly"""
|
|
||||||
try:
|
|
||||||
print("✓ App imported successfully")
|
|
||||||
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
print(f"✗ Import failed: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def test_app_creation():
|
|
||||||
"""Test that the app can be created"""
|
|
||||||
try:
|
|
||||||
app = YouTubeTUI()
|
|
||||||
print("✓ YouTubeTUI created successfully")
|
|
||||||
print(f" - App version: {app.VERSION}")
|
|
||||||
print(f" - yt-dlp version: {app.yt_dlp_version}")
|
|
||||||
print(f" - Search history loaded: {len(app.search_history)} items")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
print(f"✗ App creation failed: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def test_video_model():
|
|
||||||
"""Test the Video model"""
|
|
||||||
try:
|
|
||||||
from youtube_tui.models.video import Video
|
|
||||||
|
|
||||||
video = Video(
|
|
||||||
video_id="dQw4w9WgXcQ",
|
|
||||||
title="Test Video",
|
|
||||||
channel="Test Channel",
|
|
||||||
channel_id="UC123",
|
|
||||||
duration="3:45",
|
|
||||||
view_count="1000000",
|
|
||||||
upload_date="20230101",
|
|
||||||
description="Test description",
|
|
||||||
)
|
|
||||||
|
|
||||||
print("✓ Video model created successfully")
|
|
||||||
print(f" - Video ID: {video.video_id}")
|
|
||||||
print(f" - Title: {video.title}")
|
|
||||||
print(f" - Display title: {video.display_title}")
|
|
||||||
print(f" - Duration: {video.duration}")
|
|
||||||
print(f" - URL: {video.url}")
|
|
||||||
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
print(f"✗ Video model test failed: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def test_search_history():
|
|
||||||
"""Test search history functionality"""
|
|
||||||
try:
|
|
||||||
app = YouTubeTUI()
|
|
||||||
|
|
||||||
# Test adding to history
|
|
||||||
app.add_to_search_history("test search 1")
|
|
||||||
app.add_to_search_history("test search 2")
|
|
||||||
|
|
||||||
print("✓ Search history operations successful")
|
|
||||||
print(f" - History items: {len(app.search_history)}")
|
|
||||||
|
|
||||||
# Test that duplicates are removed
|
|
||||||
app.add_to_search_history("test search 1")
|
|
||||||
print(f" - After duplicate: {len(app.search_history)} items")
|
|
||||||
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
print(f"✗ Search history test failed: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def test_video_from_dict():
|
|
||||||
"""Test Video model from_dict method"""
|
|
||||||
try:
|
|
||||||
from youtube_tui.models.video import Video
|
|
||||||
|
|
||||||
data = {
|
|
||||||
"video_id": "abc123",
|
|
||||||
"title": "Test Video",
|
|
||||||
"channel": "Test Channel",
|
|
||||||
"channel_id": "UC123",
|
|
||||||
"duration": "5:30",
|
|
||||||
"view_count": "500000",
|
|
||||||
"upload_date": "20230101",
|
|
||||||
"description": "Test description",
|
|
||||||
}
|
|
||||||
|
|
||||||
video = Video.from_dict(data)
|
|
||||||
|
|
||||||
print("✓ Video.from_dict() works correctly")
|
|
||||||
print(f" - Video ID: {video.video_id}")
|
|
||||||
print(f" - Title: {video.title}")
|
|
||||||
|
|
||||||
# Test to_dict
|
|
||||||
video_dict = video.to_dict()
|
|
||||||
print(f" - to_dict() keys: {list(video_dict.keys())}")
|
|
||||||
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
print(f"✗ Video from_dict test failed: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
print("=" * 60)
|
|
||||||
print("YouTube TUI Test Suite")
|
|
||||||
print("=" * 60)
|
|
||||||
print()
|
|
||||||
|
|
||||||
tests = [
|
|
||||||
("Imports", test_imports),
|
|
||||||
("App Creation", test_app_creation),
|
|
||||||
("Video Model", test_video_model),
|
|
||||||
("Search History", test_search_history),
|
|
||||||
("Video from Dict", test_video_from_dict),
|
|
||||||
]
|
|
||||||
|
|
||||||
results = []
|
|
||||||
for name, test_func in tests:
|
|
||||||
print(f"\nTesting: {name}")
|
|
||||||
print("-" * 40)
|
|
||||||
result = test_func()
|
|
||||||
results.append((name, result))
|
|
||||||
print()
|
|
||||||
|
|
||||||
print("=" * 60)
|
|
||||||
print("Test Results Summary")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
passed = sum(1 for _, r in results if r)
|
|
||||||
total = len(results)
|
|
||||||
|
|
||||||
for name, result in results:
|
|
||||||
status = "✓ PASS" if result else "✗ FAIL"
|
|
||||||
print(f"{status}: {name}")
|
|
||||||
|
|
||||||
print()
|
|
||||||
print(f"Total: {passed}/{total} tests passed")
|
|
||||||
print("=" * 60)
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
"""
|
|
||||||
Widgets package for YouTube TUI
|
|
||||||
"""
|
|
||||||
|
|
||||||
from youtube_tui.widgets.command_palette import CommandPalette
|
|
||||||
from youtube_tui.widgets.status_bar import StatusBar
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"StatusBar",
|
|
||||||
"CommandPalette",
|
|
||||||
]
|
|
||||||
@ -1,199 +0,0 @@
|
|||||||
"""
|
|
||||||
Command Palette Widget for YouTube TUI
|
|
||||||
"""
|
|
||||||
|
|
||||||
from textual.app import ComposeResult
|
|
||||||
from textual.containers import Container
|
|
||||||
from textual.screen import ModalScreen
|
|
||||||
from textual.widgets import Footer, Header, Input, ListView, ListItem, Static
|
|
||||||
|
|
||||||
|
|
||||||
class CommandPalette(ModalScreen):
|
|
||||||
"""Command palette for quick access to actions"""
|
|
||||||
|
|
||||||
DEFAULT_COMMANDS = [
|
|
||||||
("Search", "Search for videos"),
|
|
||||||
("Download", "Quick download mode"),
|
|
||||||
("History", "Show search history"),
|
|
||||||
("Settings", "Open settings"),
|
|
||||||
("Help", "Show help screen"),
|
|
||||||
("Quit", "Quit application"),
|
|
||||||
]
|
|
||||||
|
|
||||||
CSS = """
|
|
||||||
CommandPalette {
|
|
||||||
align: center middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#palette-container {
|
|
||||||
width: 60%;
|
|
||||||
height: auto;
|
|
||||||
border: solid #555555;
|
|
||||||
background: $surface;
|
|
||||||
padding: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#palette-title {
|
|
||||||
width: 100%;
|
|
||||||
height: 3;
|
|
||||||
dock: top;
|
|
||||||
background: $primary;
|
|
||||||
content-align: center middle;
|
|
||||||
color: $text;
|
|
||||||
}
|
|
||||||
|
|
||||||
#palette-input {
|
|
||||||
width: 100%;
|
|
||||||
margin: 1 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#palette-list {
|
|
||||||
width: 100%;
|
|
||||||
height: 20;
|
|
||||||
margin: 1 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#palette-actions {
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
dock: bottom;
|
|
||||||
margin-top: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
ListItem {
|
|
||||||
height: 3;
|
|
||||||
padding: 0 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
ListItem:hover {
|
|
||||||
background: $primary-darken-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
ListItem.--highlight {
|
|
||||||
background: $primary;
|
|
||||||
}
|
|
||||||
|
|
||||||
.command-description {
|
|
||||||
color: $text-muted;
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
BINDINGS = [
|
|
||||||
("escape", "close_palette", "Close"),
|
|
||||||
("up", "cursor_up", "Cursor Up"),
|
|
||||||
("down", "cursor_down", "Cursor Down"),
|
|
||||||
("enter", "select_command", "Select"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
self.selected_index = 0
|
|
||||||
self.commands = self.DEFAULT_COMMANDS.copy()
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
"""Compose the command palette"""
|
|
||||||
yield Header()
|
|
||||||
yield Container(
|
|
||||||
Static("Command Palette", id="palette-title"),
|
|
||||||
Input(placeholder="Type to filter commands...", id="palette-input"),
|
|
||||||
ListView(id="palette-list-view"),
|
|
||||||
id="palette-container",
|
|
||||||
)
|
|
||||||
yield Footer()
|
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
|
||||||
"""Called when palette is mounted"""
|
|
||||||
self.update_list()
|
|
||||||
self.query_one(Input).focus()
|
|
||||||
|
|
||||||
def update_list(self) -> None:
|
|
||||||
"""Update the command list"""
|
|
||||||
input_widget = self.query_one("#palette-input", Input)
|
|
||||||
filter_text = input_widget.value.lower()
|
|
||||||
|
|
||||||
# Filter commands
|
|
||||||
if filter_text:
|
|
||||||
self.commands = [
|
|
||||||
(cmd, desc)
|
|
||||||
for cmd, desc in self.DEFAULT_COMMANDS
|
|
||||||
if filter_text in cmd.lower() or filter_text in desc.lower()
|
|
||||||
]
|
|
||||||
else:
|
|
||||||
self.commands = self.DEFAULT_COMMANDS.copy()
|
|
||||||
|
|
||||||
# Update the list view
|
|
||||||
list_view = self.query_one("#palette-list-view", ListView)
|
|
||||||
list_view.clear()
|
|
||||||
|
|
||||||
for i, (command, description) in enumerate(self.commands):
|
|
||||||
item = ListItem(
|
|
||||||
Static(f"[bold]{command}[/bold]\n{description}"),
|
|
||||||
)
|
|
||||||
list_view.append(item)
|
|
||||||
|
|
||||||
# Update selected index if needed
|
|
||||||
if self.selected_index >= len(self.commands):
|
|
||||||
self.selected_index = max(0, len(self.commands) - 1)
|
|
||||||
|
|
||||||
# Highlight selected item
|
|
||||||
self.highlight_selected()
|
|
||||||
|
|
||||||
def highlight_selected(self) -> None:
|
|
||||||
"""Highlight the selected item"""
|
|
||||||
list_view = self.query_one("#palette-list-view", ListView)
|
|
||||||
|
|
||||||
for i, item in enumerate(list_view.children):
|
|
||||||
if i == self.selected_index:
|
|
||||||
item.add_class("--highlight")
|
|
||||||
else:
|
|
||||||
item.remove_class("--highlight")
|
|
||||||
|
|
||||||
def action_cursor_up(self) -> None:
|
|
||||||
"""Move selection up"""
|
|
||||||
if self.commands:
|
|
||||||
self.selected_index = max(0, self.selected_index - 1)
|
|
||||||
self.highlight_selected()
|
|
||||||
|
|
||||||
def action_cursor_down(self) -> None:
|
|
||||||
"""Move selection down"""
|
|
||||||
if self.commands:
|
|
||||||
self.selected_index = min(len(self.commands) - 1, self.selected_index + 1)
|
|
||||||
self.highlight_selected()
|
|
||||||
|
|
||||||
def action_select_command(self) -> None:
|
|
||||||
"""Execute the selected command"""
|
|
||||||
if not self.commands:
|
|
||||||
return
|
|
||||||
|
|
||||||
command = self.commands[self.selected_index][0]
|
|
||||||
self.execute_command(command)
|
|
||||||
|
|
||||||
def execute_command(self, command: str) -> None:
|
|
||||||
"""Execute a command"""
|
|
||||||
if command == "Search":
|
|
||||||
self.app.push_screen("search")
|
|
||||||
self.app.pop_screen()
|
|
||||||
elif command == "Download":
|
|
||||||
# Go to search for quick download
|
|
||||||
self.app.push_screen("search")
|
|
||||||
self.app.pop_screen()
|
|
||||||
elif command == "History":
|
|
||||||
self.app.push_screen("history")
|
|
||||||
elif command == "Settings":
|
|
||||||
self.app.push_screen("settings")
|
|
||||||
elif command == "Help":
|
|
||||||
self.app.push_screen("help")
|
|
||||||
elif command == "Quit":
|
|
||||||
self.app.exit()
|
|
||||||
|
|
||||||
def action_close_palette(self) -> None:
|
|
||||||
"""Close the palette"""
|
|
||||||
self.app.pop_screen()
|
|
||||||
|
|
||||||
def on_input_changed(self, event: Input.Changed) -> None:
|
|
||||||
"""Handle input changes"""
|
|
||||||
self.update_list()
|
|
||||||
|
|
||||||
def on_data_table_row_selected(self, event) -> None:
|
|
||||||
"""Handle row selection"""
|
|
||||||
self.action_select_command()
|
|
||||||
@ -1,106 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Custom Footer Widget for YouTube TUI
|
|
||||||
Includes status bar with queue information
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from textual.widgets import Footer
|
|
||||||
from textual.app import App
|
|
||||||
|
|
||||||
|
|
||||||
class CustomFooter(Footer):
|
|
||||||
"""Custom footer widget with status bar that includes queue info"""
|
|
||||||
|
|
||||||
def __init__(self, app: App, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
self._app = app
|
|
||||||
self.current_screen = "Search"
|
|
||||||
self.status_message = "Ready"
|
|
||||||
self.downloading = False
|
|
||||||
self.queue_pending = 0
|
|
||||||
self.download_progress = 0
|
|
||||||
self.yt_dlp_version = "unknown"
|
|
||||||
self.update_version()
|
|
||||||
|
|
||||||
def update_version(self) -> None:
|
|
||||||
"""Update yt-dlp version"""
|
|
||||||
try:
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
result = subprocess.run(
|
|
||||||
["yt-dlp", "--version"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=5,
|
|
||||||
)
|
|
||||||
if result.returncode == 0:
|
|
||||||
self.yt_dlp_version = result.stdout.strip()
|
|
||||||
else:
|
|
||||||
self.yt_dlp_version = "not installed"
|
|
||||||
except Exception:
|
|
||||||
self.yt_dlp_version = "unknown"
|
|
||||||
|
|
||||||
def set_screen(self, screen_name: str) -> None:
|
|
||||||
"""Set the current screen name"""
|
|
||||||
self.current_screen = screen_name
|
|
||||||
self.refresh()
|
|
||||||
|
|
||||||
def set_downloading(self, downloading: bool) -> None:
|
|
||||||
"""Set downloading state"""
|
|
||||||
self.downloading = downloading
|
|
||||||
self.refresh()
|
|
||||||
|
|
||||||
def set_status(self, message: str) -> None:
|
|
||||||
"""Set status message"""
|
|
||||||
self.status_message = message
|
|
||||||
self.refresh()
|
|
||||||
|
|
||||||
def set_queue_pending(self, count: int) -> None:
|
|
||||||
"""Set the number of pending queue items"""
|
|
||||||
self.queue_pending = count
|
|
||||||
self.refresh()
|
|
||||||
|
|
||||||
def set_download_progress(self, progress: int) -> None:
|
|
||||||
"""Set the current download progress percentage"""
|
|
||||||
self.download_progress = max(0, min(100, progress))
|
|
||||||
self.refresh()
|
|
||||||
|
|
||||||
def update_time(self) -> None:
|
|
||||||
"""Update the time display"""
|
|
||||||
self.refresh()
|
|
||||||
|
|
||||||
def render(self):
|
|
||||||
"""Render the footer content with queue status"""
|
|
||||||
# Get current time
|
|
||||||
current_time = datetime.now().strftime("%H:%M:%S")
|
|
||||||
|
|
||||||
# Get theme info
|
|
||||||
theme_name = getattr(self._app, "theme", "css")
|
|
||||||
|
|
||||||
# Build status string
|
|
||||||
status_parts = [
|
|
||||||
f"[bold]{self.current_screen}[/bold]",
|
|
||||||
f"v{getattr(self._app, 'VERSION', '0.1.0')}",
|
|
||||||
f"yt-dlp {self.yt_dlp_version}",
|
|
||||||
]
|
|
||||||
|
|
||||||
# Add queue status if available
|
|
||||||
if self.queue_pending > 0:
|
|
||||||
status_parts.append(f"[cyan]Queue: {self.queue_pending} pending[/cyan]")
|
|
||||||
|
|
||||||
# Add download progress if available
|
|
||||||
if self.downloading and self.download_progress > 0:
|
|
||||||
status_parts.append(
|
|
||||||
f"| [yellow]Downloading: {self.download_progress}%[/yellow]"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add status message
|
|
||||||
status_parts.append(f"[bold]{self.status_message}[/bold]")
|
|
||||||
|
|
||||||
# Add footer elements
|
|
||||||
status_parts.append(f"[dim]{current_time}[/dim]")
|
|
||||||
status_parts.append(f"[dim]{theme_name} theme[/dim]")
|
|
||||||
|
|
||||||
return " ".join(status_parts)
|
|
||||||
@ -1,119 +0,0 @@
|
|||||||
"""
|
|
||||||
Status Bar Widget for YouTube TUI
|
|
||||||
Simple status bar widget (custom footer handles queue status)
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from textual.app import App
|
|
||||||
from textual.widgets import Static
|
|
||||||
|
|
||||||
|
|
||||||
class StatusBar(Static):
|
|
||||||
"""Simple status bar widget for YouTube TUI"""
|
|
||||||
|
|
||||||
def __init__(self, app: App, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
# Store app as _app since app is a read-only property in Static
|
|
||||||
self._app = app
|
|
||||||
self.current_screen = "Search"
|
|
||||||
self.status_message = "Ready"
|
|
||||||
self.downloading = False
|
|
||||||
self.queue_pending = 0
|
|
||||||
self.download_progress = 0
|
|
||||||
self.yt_dlp_version = "unknown"
|
|
||||||
self.update_version()
|
|
||||||
# Note: set_interval requires active app context, so we skip it in tests
|
|
||||||
# The timer functionality is tested separately if needed
|
|
||||||
try:
|
|
||||||
self.set_interval(1, self.update_time)
|
|
||||||
except Exception:
|
|
||||||
# Timer not available in test context, skip
|
|
||||||
pass
|
|
||||||
|
|
||||||
def update_version(self) -> None:
|
|
||||||
"""Update yt-dlp version"""
|
|
||||||
try:
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
result = subprocess.run(
|
|
||||||
["yt-dlp", "--version"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=5,
|
|
||||||
)
|
|
||||||
if result.returncode == 0:
|
|
||||||
self.yt_dlp_version = result.stdout.strip()
|
|
||||||
else:
|
|
||||||
self.yt_dlp_version = "not installed"
|
|
||||||
except Exception:
|
|
||||||
self.yt_dlp_version = "unknown"
|
|
||||||
|
|
||||||
def set_screen(self, screen_name: str) -> None:
|
|
||||||
"""Set the current screen name"""
|
|
||||||
self.current_screen = screen_name
|
|
||||||
self.refresh()
|
|
||||||
|
|
||||||
def set_status(self, message: str) -> None:
|
|
||||||
"""Set status message"""
|
|
||||||
self.status_message = message
|
|
||||||
self.refresh()
|
|
||||||
|
|
||||||
def set_downloading(self, downloading: bool) -> None:
|
|
||||||
"""Set downloading state"""
|
|
||||||
self.downloading = downloading
|
|
||||||
self.refresh()
|
|
||||||
|
|
||||||
def set_queue_pending(self, count: int) -> None:
|
|
||||||
"""Set the number of pending queue items"""
|
|
||||||
self.queue_pending = count
|
|
||||||
self.refresh()
|
|
||||||
|
|
||||||
def set_download_progress(self, progress: int) -> None:
|
|
||||||
"""Set the current download progress percentage"""
|
|
||||||
self.download_progress = max(0, min(100, progress))
|
|
||||||
self.refresh()
|
|
||||||
|
|
||||||
def update_time(self) -> None:
|
|
||||||
"""Update the time display"""
|
|
||||||
self.refresh()
|
|
||||||
|
|
||||||
def render(self):
|
|
||||||
"""Render the status bar content"""
|
|
||||||
# Get current time
|
|
||||||
current_time = datetime.now().strftime("%H:%M:%S")
|
|
||||||
|
|
||||||
# Get theme info
|
|
||||||
theme_name = getattr(self._app, "theme", "css")
|
|
||||||
|
|
||||||
# Build status string
|
|
||||||
status_parts = [
|
|
||||||
f"[bold]{self.current_screen}[/bold]",
|
|
||||||
f"v{getattr(self._app, 'VERSION', '0.1.0')}",
|
|
||||||
f"yt-dlp {self.yt_dlp_version}",
|
|
||||||
]
|
|
||||||
|
|
||||||
# Add download indicator if downloading
|
|
||||||
if self.downloading:
|
|
||||||
status_parts.append("[bold green]↓[/bold green]")
|
|
||||||
|
|
||||||
# Add queue status if there are pending items
|
|
||||||
if self.queue_pending > 0:
|
|
||||||
status_parts.append(
|
|
||||||
f"[bold cyan]Queue: {self.queue_pending} pending[/bold cyan]"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add download progress if downloading
|
|
||||||
if self.downloading and self.download_progress > 0:
|
|
||||||
status_parts.append(
|
|
||||||
f"[bold yellow]Downloading: {self.download_progress}%[/bold yellow]"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add status message
|
|
||||||
status_parts.append(f"[bold]{self.status_message}[/bold]")
|
|
||||||
|
|
||||||
# Add footer elements
|
|
||||||
status_parts.append(f"[dim]{current_time}[/dim]")
|
|
||||||
status_parts.append(f"[dim]{theme_name} theme[/dim]")
|
|
||||||
|
|
||||||
return " ".join(status_parts)
|
|
||||||
Loading…
x
Reference in New Issue
Block a user