refactor: extract TUI and web UI into separate projects; fix placeholder URLs
Some checks are pending
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
CI / docker-build (push) Waiting to run
CI / security (push) Waiting to run
CI / build-result (push) Blocked by required conditions

This commit is contained in:
Jarian Cottingham 2026-08-21 17:11:22 +00:00
parent c58943f6b8
commit 03c9173a8b
90 changed files with 15 additions and 17416 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +0,0 @@
textual>=8.0
yt-dlp
rich
requests

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +0,0 @@
{
"status": "passed",
"failedTests": []
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +0,0 @@
"""
YouTube TUI - A Text-based User Interface for browsing and downloading YouTube videos
"""
__version__ = "0.1.0"

View File

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

View File

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

View File

@ -1,7 +0,0 @@
"""
Models package for YouTube TUI
"""
from youtube_tui.models.video import Video
__all__ = ["Video"]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +0,0 @@
"""
Services package for YouTube TUI
"""
from youtube_tui.services.youtube import YouTubeService
__all__ = ["YouTubeService"]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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