Compare commits
11 Commits
bbfd646f8b
...
c58943f6b8
| Author | SHA1 | Date | |
|---|---|---|---|
| c58943f6b8 | |||
| 3c5b10466a | |||
| 483611f357 | |||
| 7842cb1f7f | |||
| 6ae8f3befc | |||
| 7432ad644c | |||
| 3adafe8bbd | |||
| 4538857ce3 | |||
| 2789b03f45 | |||
| b33bed7a6e | |||
| 669b677fb3 |
38
.gitea/workflows/publish.yml
Normal file
38
.gitea/workflows/publish.yml
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
name: Publish to PyPI
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
GITEA_URL: https://git.example.com
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Clone repo
|
||||||
|
run: |
|
||||||
|
rm -rf $GITHUB_WORKSPACE/*
|
||||||
|
git clone --depth 1 $GITEA_URL/$GITHUB_REPOSITORY $GITHUB_WORKSPACE
|
||||||
|
git -C $GITHUB_WORKSPACE checkout $GITHUB_SHA 2>/dev/null || true
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install build tools
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install build twine
|
||||||
|
|
||||||
|
- name: Build package
|
||||||
|
run: python -m build
|
||||||
|
|
||||||
|
- name: Publish to PyPI
|
||||||
|
env:
|
||||||
|
TWINE_USERNAME: __token__
|
||||||
|
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
|
||||||
|
run: twine upload dist/*
|
||||||
29
.github/workflows/test.yml
vendored
Normal file
29
.github/workflows/test.yml
vendored
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
name: Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
python-version: ["3.10", "3.11", "3.12"]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: astral-sh/setup-uv@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --all-extras --locked
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: uv run ruff check .
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: uv run python -m pytest tests/ -v
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 jarianc
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@ -1,187 +0,0 @@
|
|||||||
# Comprehensive Logging Implementation - Final Summary
|
|
||||||
|
|
||||||
## Project: YouTube CLI
|
|
||||||
## Date: February 25, 2026
|
|
||||||
|
|
||||||
## Summary of Changes
|
|
||||||
|
|
||||||
### 1. Logs Directory Structure
|
|
||||||
```
|
|
||||||
/Users/user/Projects/youtube-cli/logs/
|
|
||||||
├── app.log # Main application log file (auto-created)
|
|
||||||
└── .gitignore (logs/ already present)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Logging Configuration
|
|
||||||
|
|
||||||
**Global Settings:**
|
|
||||||
- **Log Directory**: `~/.config/youtube_cli/logs/`
|
|
||||||
- **Log File**: `app.log`
|
|
||||||
- **Max Size**: 10MB
|
|
||||||
- **Backup Count**: 5 rotations
|
|
||||||
- **File Level**: DEBUG
|
|
||||||
- **Console Level**: INFO
|
|
||||||
|
|
||||||
**Log Format:**
|
|
||||||
```
|
|
||||||
YYYY-MM-DD HH:MM:SS | module.name | LEVEL | message
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Files Modified
|
|
||||||
|
|
||||||
| File | Logger Added | Console Print Replaced |
|
|
||||||
|------|-------------|----------------------|
|
|
||||||
| `youtube_cli/main.py` | ✅ | 30+ statements |
|
|
||||||
| `youtube_tui/app.py` | ✅ | N/A (no console.print) |
|
|
||||||
| `youtube_tui/services/download_manager.py` | ✅ | 3 statements |
|
|
||||||
| `youtube_tui/services/youtube.py` | ✅ | 4 statements |
|
|
||||||
| `youtube_tui/services/queue.py` | ✅ | 2 statements |
|
|
||||||
| `app.py` (REST API) | ✅ | N/A (no console.print) |
|
|
||||||
|
|
||||||
### 4. Logging Implementation Pattern
|
|
||||||
|
|
||||||
**Standard Import Block:**
|
|
||||||
```python
|
|
||||||
import logging
|
|
||||||
from logging.handlers import RotatingFileHandler
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# Configure logging
|
|
||||||
LOG_DIR = Path.home() / ".config" / "youtube_cli" / "logs"
|
|
||||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
LOG_FILE = LOG_DIR / "app.log"
|
|
||||||
|
|
||||||
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"
|
|
||||||
))
|
|
||||||
|
|
||||||
console_handler = logging.StreamHandler()
|
|
||||||
console_handler.setLevel(logging.INFO)
|
|
||||||
console_handler.setFormatter(logging.Formatter("%(message)s"))
|
|
||||||
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.DEBUG,
|
|
||||||
handlers=[file_handler, console_handler],
|
|
||||||
)
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Usage Pattern:**
|
|
||||||
```python
|
|
||||||
logger.debug("Detailed debugging info")
|
|
||||||
logger.info("Normal operations")
|
|
||||||
logger.warning("Non-critical issues")
|
|
||||||
logger.error("Errors with full context")
|
|
||||||
logger.exception("Exceptions with traceback")
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Logging Coverage
|
|
||||||
|
|
||||||
#### youtube_cli/main.py
|
|
||||||
- **Search Operations**: Query logging, pagination, result display
|
|
||||||
- **Download Operations**: URL validation, directory setup, format selection, completion status
|
|
||||||
- **Archive Operations**: Loading, saving, adding videos
|
|
||||||
- **Configuration**: Loading, validation, defaults
|
|
||||||
- **Network Share**: Copy operations, path validation
|
|
||||||
- **Error Handling**: Full error context with file paths and parameters
|
|
||||||
|
|
||||||
#### youtube_tui/services/download_manager.py
|
|
||||||
- **Download Management**: Queue processing, cancellation, errors
|
|
||||||
- **Progress Tracking**: Download status updates
|
|
||||||
- **Error Recovery**: Exception handling with context
|
|
||||||
|
|
||||||
#### youtube_tui/services/youtube.py
|
|
||||||
- **Service Errors**: Search failures, download errors, archive issues
|
|
||||||
- **Async Operations**: Thread execution tracking
|
|
||||||
|
|
||||||
#### youtube_tui/services/queue.py
|
|
||||||
- **Queue Operations**: Loading, saving, status updates
|
|
||||||
- **Item Management**: Add/remove operations
|
|
||||||
|
|
||||||
#### app.py (REST API)
|
|
||||||
- **API Operations**: Request handling, error responses
|
|
||||||
- **Module Initialization**: Flask app setup
|
|
||||||
|
|
||||||
### 6. Benefits
|
|
||||||
|
|
||||||
1. **Production Monitoring**: Full visibility into application behavior
|
|
||||||
2. **Troubleshooting**: Detailed logs with timestamps and module context
|
|
||||||
3. **Disk Space Management**: Automatic log rotation prevents unbounded growth
|
|
||||||
4. **Development Efficiency**: Console output for quick debugging, file logs for detailed analysis
|
|
||||||
5. **Consistency**: Unified logging approach across all modules
|
|
||||||
|
|
||||||
### 7. Testing Results
|
|
||||||
|
|
||||||
**Verification Commands:**
|
|
||||||
```bash
|
|
||||||
# Test logging
|
|
||||||
python3 -c "from youtube_cli.main import logger; logger.info('Test message')"
|
|
||||||
|
|
||||||
# View logs
|
|
||||||
tail -f ~/.config/youtube_cli/logs/app.log
|
|
||||||
|
|
||||||
# Search logs
|
|
||||||
grep "ERROR" ~/.config/youtube_cli/logs/app.log
|
|
||||||
grep "WARNING" ~/.config/youtube_cli/logs/app.log
|
|
||||||
```
|
|
||||||
|
|
||||||
**Test Output:**
|
|
||||||
```
|
|
||||||
2026-02-25 20:14:40 | youtube_cli.main | INFO | Test info message
|
|
||||||
2026-02-25 20:14:40 | youtube_cli.main | WARNING | Test warning message
|
|
||||||
2026-02-25 20:14:40 | youtube_cli.main | ERROR | Test error message
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8. Git Ignore
|
|
||||||
|
|
||||||
The `logs/` directory is already in `.gitignore`:
|
|
||||||
```
|
|
||||||
logs/
|
|
||||||
```
|
|
||||||
|
|
||||||
This ensures log files are never committed to the repository.
|
|
||||||
|
|
||||||
### 9. Next Steps for Users
|
|
||||||
|
|
||||||
1. **View logs**: `tail -f ~/.config/youtube_cli/logs/app.log`
|
|
||||||
2. **Search errors**: `grep "ERROR" ~/.config/youtube_cli/logs/app.log`
|
|
||||||
3. **Debug mode**: Check DEBUG level messages in the log file
|
|
||||||
4. **Rotation management**: Logs automatically rotate at 10MB with 5 backups
|
|
||||||
|
|
||||||
### 10. Files Created/Modified
|
|
||||||
|
|
||||||
**Created:**
|
|
||||||
- `/Users/user/Projects/youtube-cli/logs/` (directory)
|
|
||||||
- `/Users/user/Projects/youtube-cli/LOGGING_SUMMARY.md` (documentation)
|
|
||||||
|
|
||||||
**Modified:**
|
|
||||||
- `/Users/user/Projects/youtube-cli/youtube_cli/main.py`
|
|
||||||
- `/Users/user/Projects/youtube-cli/youtube_tui/app.py`
|
|
||||||
- `/Users/user/Projects/youtube-cli/youtube_tui/services/download_manager.py`
|
|
||||||
- `/Users/user/Projects/youtube-cli/youtube_tui/services/youtube.py`
|
|
||||||
- `/Users/user/Projects/youtube-cli/youtube_tui/services/queue.py`
|
|
||||||
- `/Users/user/Projects/youtube-cli/app.py`
|
|
||||||
|
|
||||||
### 11. Requirements Met
|
|
||||||
|
|
||||||
✅ Logs directory created at `/Users/user/Projects/youtube-cli/logs/`
|
|
||||||
✅ Logs directory added to `.gitignore`
|
|
||||||
✅ Logging configured with rotation (10MB max, 5 backups)
|
|
||||||
✅ Logging added to all specified files:
|
|
||||||
- `/Users/user/Projects/youtube-cli/youtube_cli/main.py`
|
|
||||||
- `/Users/user/Projects/youtube-cli/youtube_tui/app.py`
|
|
||||||
- `/Users/user/Projects/youtube-cli/youtube_tui/services/download_manager.py`
|
|
||||||
- `/Users/user/Projects/youtube-cli/youtube_tui/services/youtube.py`
|
|
||||||
- `/Users/user/Projects/youtube-cli/youtube_tui/services/queue.py`
|
|
||||||
- `/Users/user/Projects/youtube-cli/app.py`
|
|
||||||
✅ Module-level loggers created
|
|
||||||
✅ All console.print statements replaced with appropriate logger calls
|
|
||||||
✅ Logging initialized in main entry points
|
|
||||||
✅ Summary document provided
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Implementation Complete**: All requirements have been met and verified.
|
|
||||||
@ -1,114 +0,0 @@
|
|||||||
# Logging Implementation Summary
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
Comprehensive logging has been added to all Python files in the YouTube CLI project.
|
|
||||||
|
|
||||||
## Changes Made
|
|
||||||
|
|
||||||
### 1. Logs Directory
|
|
||||||
- Created `/Users/user/Projects/youtube-cli/logs/` directory
|
|
||||||
- Added `logs/` to `.gitignore` (already present)
|
|
||||||
|
|
||||||
### 2. Logging Configuration
|
|
||||||
All Python files now have:
|
|
||||||
- **Log file**: `~/.config/youtube_cli/logs/app.log`
|
|
||||||
- **Rotation**: 10MB max, keep 5 backups
|
|
||||||
- **Level**: DEBUG (file), INFO (console)
|
|
||||||
- **Format**: `%(asctime)s | %(name)s | %(levelname)s | %(message)s`
|
|
||||||
|
|
||||||
### 3. Files Modified
|
|
||||||
|
|
||||||
#### `/Users/user/Projects/youtube-cli/youtube_cli/main.py`
|
|
||||||
- Added `logging` and `RotatingFileHandler` imports
|
|
||||||
- Added module-level logger: `logger = logging.getLogger(__name__)`
|
|
||||||
- Replaced all `console.print()` statements with appropriate logger calls:
|
|
||||||
- `logger.debug()` for verbose debugging info
|
|
||||||
- `logger.info()` for normal operations
|
|
||||||
- `logger.warning()` for warnings
|
|
||||||
- `logger.error()` for errors
|
|
||||||
- `logger.exception()` for exceptions (includes traceback)
|
|
||||||
|
|
||||||
Key logging additions:
|
|
||||||
- Search operations
|
|
||||||
- Download operations
|
|
||||||
- Archive operations
|
|
||||||
- Configuration loading
|
|
||||||
- Network share operations
|
|
||||||
- Error handling with full context
|
|
||||||
|
|
||||||
#### `/Users/user/Projects/youtube-cli/youtube_tui/app.py`
|
|
||||||
- Added logging imports and configuration
|
|
||||||
- Added module-level logger
|
|
||||||
- Added logging initialization in `main()` function
|
|
||||||
|
|
||||||
#### `/Users/user/Projects/youtube-cli/youtube_tui/services/download_manager.py`
|
|
||||||
- Added logging imports and configuration
|
|
||||||
- Added module-level logger
|
|
||||||
- Replaced `console.print()` with:
|
|
||||||
- `logger.warning()` for cancellations
|
|
||||||
- `logger.error()` for errors
|
|
||||||
|
|
||||||
#### `/Users/user/Projects/youtube-cli/youtube_tui/services/youtube.py`
|
|
||||||
- Added logging imports and configuration
|
|
||||||
- Added module-level logger
|
|
||||||
- Replaced `self.console.print()` with `logger.error()` for service errors
|
|
||||||
|
|
||||||
#### `/Users/user/Projects/youtube-cli/youtube_tui/services/queue.py`
|
|
||||||
- Added logging imports and configuration
|
|
||||||
- Added module-level logger
|
|
||||||
- Replaced `console.print()` with `logger.warning()` for queue operations
|
|
||||||
|
|
||||||
#### `/Users/user/Projects/youtube-cli/app.py` (REST API)
|
|
||||||
- Added logging imports and configuration
|
|
||||||
- Added module-level logger
|
|
||||||
- Added logging initialization in module scope
|
|
||||||
|
|
||||||
### 4. Logging Levels Used
|
|
||||||
|
|
||||||
| Level | Usage | Example |
|
|
||||||
|-------|-------|---------|
|
|
||||||
| `DEBUG` | Detailed debugging info | Download directory paths |
|
|
||||||
| `INFO` | Normal operations | Search queries, download starts, completions |
|
|
||||||
| `WARNING` | Non-critical issues | Invalid inputs, skipped operations |
|
|
||||||
| `ERROR` | Errors | Failed downloads, missing dependencies |
|
|
||||||
| `CRITICAL` | Critical failures | Not used (reserved for severe issues) |
|
|
||||||
|
|
||||||
### 5. Benefits
|
|
||||||
|
|
||||||
1. **Production Monitoring**: Full visibility into application behavior
|
|
||||||
2. **Troubleshooting**: Detailed logs with timestamps and context
|
|
||||||
3. **Performance Tracking**: Log rotation prevents disk space issues
|
|
||||||
4. **Debugging**: Both file and console output for development
|
|
||||||
5. **Consistency**: Same logging approach across all modules
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### View Logs in Real-Time
|
|
||||||
```bash
|
|
||||||
tail -f ~/.config/youtube_cli/logs/app.log
|
|
||||||
```
|
|
||||||
|
|
||||||
### Search Logs
|
|
||||||
```bash
|
|
||||||
grep "ERROR" ~/.config/youtube_cli/logs/app.log
|
|
||||||
grep "WARNING" ~/.config/youtube_cli/logs/app.log
|
|
||||||
```
|
|
||||||
|
|
||||||
### Log File Location
|
|
||||||
- Logs are stored in: `~/.config/youtube_cli/logs/app.log`
|
|
||||||
- Old logs are automatically rotated and archived
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
To verify logging works:
|
|
||||||
1. Run the CLI: `python -m youtube_cli "test query"`
|
|
||||||
2. Run the TUI: `python youtube_tui/app.py`
|
|
||||||
3. Run the API: `python app.py`
|
|
||||||
4. Check logs: `cat ~/.config/youtube_cli/logs/app.log`
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- The `RotatingFileHandler` ensures log files don't grow indefinitely
|
|
||||||
- Console output is limited to INFO level for cleaner terminal output
|
|
||||||
- File output captures all DEBUG level messages for thorough logging
|
|
||||||
- All exceptions are logged with full tracebacks using `logger.error()`
|
|
||||||
10
MANIFEST.in
Normal file
10
MANIFEST.in
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
include README.md
|
||||||
|
include LICENSE
|
||||||
|
include pyproject.toml
|
||||||
|
include requirements.txt
|
||||||
|
recursive-include youtube_cli *.py
|
||||||
|
recursive-include youtube_tui *.py
|
||||||
|
recursive-include tests *.py
|
||||||
|
global-exclude __pycache__
|
||||||
|
global-exclude *.py[cod]
|
||||||
|
global-exclude .venv
|
||||||
@ -350,4 +350,4 @@ youtube-tui
|
|||||||
|
|
||||||
### Documentation
|
### Documentation
|
||||||
|
|
||||||
See [TUI.md](TUI.md) for complete TUI documentation.
|
See [TUI.md](docs/TUI.md) for complete TUI documentation.
|
||||||
122
agent.md
122
agent.md
@ -1,122 +0,0 @@
|
|||||||
# YouTube CLI Agent
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
This is a command-line interface for browsing and downloading YouTube videos. The application allows users to search YouTube, display videos in pages of 15, and download videos using yt-dlp with progress indication.
|
|
||||||
|
|
||||||
## Core Features
|
|
||||||
- **Search YouTube videos** with keyword queries
|
|
||||||
- **Display videos** with title, author, duration, and type (short/video)
|
|
||||||
- **Download videos** using yt-dlp with progress indication
|
|
||||||
- **Configure download locations** through configuration files
|
|
||||||
- **Handle short videos** (videos with /shorts/ in URL) with special "(short)" prefix
|
|
||||||
- **Pagination support** to view more than 15 videos per search
|
|
||||||
- **Network share integration** for copying downloaded videos
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
```
|
|
||||||
youtube-cli/
|
|
||||||
├── youtube_cli/ # Main package directory
|
|
||||||
│ ├── __init__.py # Package initialization
|
|
||||||
│ ├── __main__.py # Main entry point
|
|
||||||
│ └── main.py # Core application logic
|
|
||||||
├── setup.py # Python package setup
|
|
||||||
├── requirements.txt # Python dependencies
|
|
||||||
├── README.md # Project documentation
|
|
||||||
├── prompt.md # Prompt template
|
|
||||||
├── structure.md # Project structure documentation
|
|
||||||
└── run.sh # Run script
|
|
||||||
```
|
|
||||||
|
|
||||||
## Main Components
|
|
||||||
|
|
||||||
### youtube_cli/main.py
|
|
||||||
The core application logic file containing:
|
|
||||||
- `YouTubeCLI` class with all main functionality
|
|
||||||
- Search videos using yt-dlp with pagination support
|
|
||||||
- Display videos in formatted tables with Rich library
|
|
||||||
- Download videos with progress indication
|
|
||||||
- Configuration management
|
|
||||||
- Archive tracking for downloaded videos
|
|
||||||
- Network share copying functionality
|
|
||||||
- Update checking for yt-dlp
|
|
||||||
|
|
||||||
### Key Methods in YouTubeCLI Class
|
|
||||||
- `search_videos()`: Search YouTube videos using yt-dlp
|
|
||||||
- `display_videos()`: Display videos in a formatted table
|
|
||||||
- `download_video()`: Download individual videos with progress
|
|
||||||
- `download_playlist()`: Download YouTube playlists
|
|
||||||
- `load_config()`: Load configuration from file or use defaults
|
|
||||||
- `load_archive()`: Load archive of already downloaded videos
|
|
||||||
- `add_to_archive()`: Add videos to download archive
|
|
||||||
- `copy_to_network_share()`: Copy downloaded videos to network share
|
|
||||||
- `check_for_updates()`: Check and update yt-dlp if needed
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
The application creates a default configuration file at `~/.config/youtube_cli/config.json` if one doesn't exist. Configuration includes:
|
|
||||||
- `download_dir`: Default download directory (set to `/Volumes/MediaServer/Youtube/`)
|
|
||||||
- `default_locations`: List of default download locations including Tech, AI, Art, Homes, Cooking, Fitness, Music, Gaming, Education, Travel, Business, Science, History, Comedy, News, Sports, Nature, Photography, Language, and Automotive categories
|
|
||||||
- `max_videos_per_page`: Number of videos per page (default: 15)
|
|
||||||
- `yt_dlp_args`: Custom yt-dlp arguments for video format and extraction
|
|
||||||
- `network_share_path`: Network share path for copying videos
|
|
||||||
- `default_network_subfolder`: Default subfolder on network share
|
|
||||||
|
|
||||||
## Usage Examples
|
|
||||||
```bash
|
|
||||||
# Search for videos
|
|
||||||
youtube-cli "python tutorial"
|
|
||||||
|
|
||||||
# Download a specific video
|
|
||||||
youtube-cli --download "https://www.youtube.com/watch?v=xyz123"
|
|
||||||
|
|
||||||
# View help
|
|
||||||
youtube-cli --help
|
|
||||||
|
|
||||||
# Check for yt-dlp updates
|
|
||||||
youtube-cli --check-update
|
|
||||||
|
|
||||||
# Update yt-dlp
|
|
||||||
youtube-cli --update
|
|
||||||
```
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
- Python 3.6+
|
|
||||||
- yt-dlp (for video downloading)
|
|
||||||
- rich (for formatted console output)
|
|
||||||
- requests (for API calls)
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
### From Source
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/yourusername/youtube-cli.git
|
|
||||||
cd youtube-cli
|
|
||||||
pip install -e .
|
|
||||||
```
|
|
||||||
|
|
||||||
### Using pip
|
|
||||||
```bash
|
|
||||||
pip install youtube-cli
|
|
||||||
```
|
|
||||||
|
|
||||||
## How It Works
|
|
||||||
1. **Search**: Users enter a search query to find YouTube videos
|
|
||||||
2. **Display**: Videos are shown 15 at a time with title, author, duration, and type
|
|
||||||
3. **Navigation**: Users can go to next page (n), search for new term (s), or quit (q)
|
|
||||||
4. **Selection**: Users can select videos by number to download
|
|
||||||
5. **Download**: Videos are downloaded using yt-dlp with progress indication
|
|
||||||
6. **Tracking**: Downloaded videos are tracked in an archive file
|
|
||||||
|
|
||||||
## Special Features
|
|
||||||
- **Short Video Detection**: Automatically detects and marks short videos with "(short)" prefix
|
|
||||||
- **Pagination**: View 15 videos at a time with option for more
|
|
||||||
- **Multiple Download Locations**: Choose from default locations or specify custom paths
|
|
||||||
- **Network Share Integration**: Copy downloaded videos to network shares after download
|
|
||||||
- **Update Checking**: Automatically checks for and updates yt-dlp when needed
|
|
||||||
- **Archive Tracking**: Keeps track of already downloaded videos to avoid duplicates
|
|
||||||
|
|
||||||
## Technical Details
|
|
||||||
- Uses yt-dlp for all YouTube operations
|
|
||||||
- Implements Rich library for beautiful console output
|
|
||||||
- Supports both individual video and playlist downloads
|
|
||||||
- Handles JavaScript challenges with remote components
|
|
||||||
- Includes retry mechanisms for downloads
|
|
||||||
- Cross-platform compatibility (macOS and Linux)
|
|
||||||
14
app.log
14
app.log
@ -1,14 +0,0 @@
|
|||||||
Error saving archive: [Errno 13] Permission denied: '/app'
|
|
||||||
* Serving Flask app 'app'
|
|
||||||
* Debug mode: on
|
|
||||||
[31m[1mWARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.[0m
|
|
||||||
* Running on all addresses (0.0.0.0)
|
|
||||||
* Running on http://127.0.0.1:4096
|
|
||||||
* Running on http://192.168.122.203:4096
|
|
||||||
[33mPress CTRL+C to quit[0m
|
|
||||||
* Restarting with stat
|
|
||||||
Error saving archive: [Errno 13] Permission denied: '/app'
|
|
||||||
* Debugger is active!
|
|
||||||
* Debugger PIN: 556-686-523
|
|
||||||
192.168.122.1 - - [03/Feb/2026 19:10:11] "GET /search?q=roo+vs+cline HTTP/1.1" 200 -
|
|
||||||
127.0.0.1 - - [03/Feb/2026 19:10:13] "GET /health HTTP/1.1" 200 -
|
|
||||||
@ -4,8 +4,12 @@ Manual test script for YouTube TUI
|
|||||||
Tests all required functionality
|
Tests all required functionality
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
def print_section(title):
|
def print_section(title):
|
||||||
@ -145,7 +149,7 @@ def test_unit_tests():
|
|||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=30,
|
timeout=30,
|
||||||
cwd="/Users/user/Projects/youtube-cli",
|
cwd=str(REPO_ROOT),
|
||||||
)
|
)
|
||||||
|
|
||||||
print(result.stdout)
|
print(result.stdout)
|
||||||
@ -431,12 +435,13 @@ print("OK")
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
os.chdir(REPO_ROOT)
|
||||||
"""Run all tests"""
|
"""Run all tests"""
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
print(" YouTube TUI Manual Test Suite")
|
print(" YouTube TUI Manual Test Suite")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
print(f"\nPython: {sys.version}")
|
print(f"\nPython: {sys.version}")
|
||||||
print("Working directory: /Users/user/Projects/youtube-cli")
|
print(f"Working directory: {REPO_ROOT}")
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
|
|||||||
18
prompt.md
18
prompt.md
@ -1,18 +0,0 @@
|
|||||||
The goal is to make a cli app that lets you traverse videos through youtube search and listed all the videos that are display for search. It should show at max 15 videos at a time and give options for asking for more. It should denote what videos are shorts (very short videos with /short in the url) with a name starting with (short). It should allow the user to search all of youtube with a query.
|
|
||||||
|
|
||||||
Display the Title, Name, Author, Video length of each youtube video on the list.
|
|
||||||
Give an option to get the next 15 videos in the list.
|
|
||||||
Give an option to download the video and show a status bar for the download.
|
|
||||||
Provide a configuration file that the user can set the default download location for all of this.
|
|
||||||
Let the user select from a set of default locations for where to download videos.
|
|
||||||
|
|
||||||
When downloading the video, it should leverage yt-dlp. A valid command in yt-dlp looks like
|
|
||||||
|
|
||||||
yt-dlp \
|
|
||||||
--format "bestvideo[height=1080]+bestaudio/bestvideo[height<=1080]+bestaudio" \
|
|
||||||
--download-archive "/mnt/centralstoragemedia/Youtube/Caseoh/caseoh-archive" \
|
|
||||||
--playlist-items 1:2 "https://www.youtube.com/@caseoh_/videos" \
|
|
||||||
-o "/mnt/centralstoragemedia/Youtube/Caseoh/%(title)s.%(ext)s" \
|
|
||||||
--write-thumbnail --extractor-args "youtube:player-client=default,-tv_simply"
|
|
||||||
|
|
||||||
Coding Language should be python and it should work on Mac and Linux.
|
|
||||||
@ -9,7 +9,7 @@ description = "A command-line interface for browsing and downloading YouTube vid
|
|||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Your Name", email = "your.email@example.com"},
|
{name = "Jarian Cottingham", email = "jarianc@proton.me"},
|
||||||
]
|
]
|
||||||
license = {text = "MIT"}
|
license = {text = "MIT"}
|
||||||
classifiers = [
|
classifiers = [
|
||||||
@ -43,8 +43,10 @@ youtube-cli = "youtube_cli.main:main"
|
|||||||
youtube-tui = "youtube_tui.__main__:main"
|
youtube-tui = "youtube_tui.__main__:main"
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Homepage = "https://github.com/yourusername/youtube-cli"
|
Homepage = "https://git.example.com/jarianc/youtube-cli"
|
||||||
Issues = "https://github.com/yourusername/youtube-cli/issues"
|
Issues = "https://git.example.com/jarianc/youtube-cli/issues"
|
||||||
|
Documentation = "https://git.example.com/jarianc/youtube-cli"
|
||||||
|
Source = "https://git.example.com/jarianc/youtube-cli"
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
@ -60,7 +62,7 @@ api = [
|
|||||||
"Flask==2.3.3",
|
"Flask==2.3.3",
|
||||||
]
|
]
|
||||||
tui = [
|
tui = [
|
||||||
"textual>=0.40.0",
|
"textual>=0.40.0,<2.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
|
|||||||
17
setup.py
17
setup.py
@ -6,12 +6,18 @@ with open("README.md", "r", encoding="utf-8") as fh:
|
|||||||
setup(
|
setup(
|
||||||
name="youtube-cli",
|
name="youtube-cli",
|
||||||
version="0.1.0",
|
version="0.1.0",
|
||||||
author="Your Name",
|
author="Jarian Cottingham",
|
||||||
author_email="your.email@example.com",
|
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",
|
||||||
long_description=long_description,
|
long_description=long_description,
|
||||||
long_description_content_type="text/markdown",
|
long_description_content_type="text/markdown",
|
||||||
url="https://github.com/yourusername/youtube-cli",
|
url="https://git.example.com/jarianc/youtube-cli",
|
||||||
|
project_urls={
|
||||||
|
"Documentation": "https://git.example.com/jarianc/youtube-cli",
|
||||||
|
"Issue Tracker": "https://git.example.com/jarianc/youtube-cli/issues",
|
||||||
|
"Source": "https://git.example.com/jarianc/youtube-cli",
|
||||||
|
},
|
||||||
packages=find_packages(),
|
packages=find_packages(),
|
||||||
classifiers=[
|
classifiers=[
|
||||||
"Programming Language :: Python :: 3",
|
"Programming Language :: Python :: 3",
|
||||||
@ -26,16 +32,13 @@ setup(
|
|||||||
"Operating System :: MacOS",
|
"Operating System :: MacOS",
|
||||||
"Operating System :: POSIX :: Linux",
|
"Operating System :: POSIX :: Linux",
|
||||||
"Programming Language :: Python :: 3 :: Only",
|
"Programming Language :: Python :: 3 :: Only",
|
||||||
"Programming Language :: Python :: 3.7",
|
|
||||||
"Programming Language :: Python :: 3.8",
|
|
||||||
"Programming Language :: Python :: 3.9",
|
|
||||||
"Programming Language :: Python :: 3.10",
|
"Programming Language :: Python :: 3.10",
|
||||||
"Programming Language :: Python :: 3.11",
|
"Programming Language :: Python :: 3.11",
|
||||||
"Programming Language :: Python :: 3.12",
|
"Programming Language :: Python :: 3.12",
|
||||||
"Topic :: Multimedia :: Video",
|
"Topic :: Multimedia :: Video",
|
||||||
"Topic :: Utilities",
|
"Topic :: Utilities",
|
||||||
],
|
],
|
||||||
python_requires=">=3.7",
|
python_requires=">=3.10",
|
||||||
install_requires=[
|
install_requires=[
|
||||||
"yt-dlp",
|
"yt-dlp",
|
||||||
"rich>=13.0.0",
|
"rich>=13.0.0",
|
||||||
|
|||||||
@ -40,15 +40,17 @@ class TestSearchScreen:
|
|||||||
|
|
||||||
async def test_search_action_with_empty_input(self, app):
|
async def test_search_action_with_empty_input(self, app):
|
||||||
"""Test search action with empty input"""
|
"""Test search action with empty input"""
|
||||||
|
from youtube_tui.screens.search import SearchScreen
|
||||||
|
|
||||||
|
screen = SearchScreen()
|
||||||
# Use app.run_test() to ensure proper screen mounting
|
# Use app.run_test() to ensure proper screen mounting
|
||||||
async with app.run_test() as pilot:
|
async with app.run_test() as pilot:
|
||||||
|
app.push_screen(screen)
|
||||||
# Wait for the screen to be fully mounted
|
# Wait for the screen to be fully mounted
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
|
|
||||||
# Mock the update_status method to track calls
|
# Mock the update_status method to track calls
|
||||||
update_calls = []
|
update_calls = []
|
||||||
screen = app.screen
|
|
||||||
screen.update_status = lambda message: update_calls.append(message)
|
screen.update_status = lambda message: update_calls.append(message)
|
||||||
|
|
||||||
# Simulate pressing Enter with empty input
|
# Simulate pressing Enter with empty input
|
||||||
@ -62,8 +64,11 @@ class TestSearchScreen:
|
|||||||
"""Test search action with valid input"""
|
"""Test search action with valid input"""
|
||||||
|
|
||||||
search_term = "python tutorial"
|
search_term = "python tutorial"
|
||||||
|
from youtube_tui.screens.search import SearchScreen
|
||||||
|
|
||||||
|
screen = SearchScreen()
|
||||||
async with app.run_test() as pilot:
|
async with app.run_test() as pilot:
|
||||||
|
app.push_screen(screen)
|
||||||
# Wait for the screen to be fully mounted
|
# Wait for the screen to be fully mounted
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
|
|
||||||
@ -83,8 +88,11 @@ class TestSearchScreen:
|
|||||||
"""Test search from anywhere action"""
|
"""Test search from anywhere action"""
|
||||||
|
|
||||||
search_term = "music"
|
search_term = "music"
|
||||||
|
from youtube_tui.screens.search import SearchScreen
|
||||||
|
|
||||||
|
screen = SearchScreen()
|
||||||
async with app.run_test() as pilot:
|
async with app.run_test() as pilot:
|
||||||
|
app.push_screen(screen)
|
||||||
# Wait for the screen to be fully mounted
|
# Wait for the screen to be fully mounted
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
|
|
||||||
|
|||||||
@ -190,7 +190,7 @@ class TestCustomWidgets:
|
|||||||
from textual.widgets import Static
|
from textual.widgets import Static
|
||||||
|
|
||||||
widget = Static("Test content")
|
widget = Static("Test content")
|
||||||
assert widget._Static__content == "Test content"
|
assert widget.renderable == "Test content"
|
||||||
|
|
||||||
def test_static_widget_with_rich_markup(self):
|
def test_static_widget_with_rich_markup(self):
|
||||||
"""Test Static widget with Rich markup"""
|
"""Test Static widget with Rich markup"""
|
||||||
@ -198,14 +198,14 @@ class TestCustomWidgets:
|
|||||||
|
|
||||||
widget = Static("[bold]Test[/bold] [red]content[/red]")
|
widget = Static("[bold]Test[/bold] [red]content[/red]")
|
||||||
# The content should contain the markup
|
# The content should contain the markup
|
||||||
assert "[bold]" in str(widget._Static__content)
|
assert "[bold]" in str(widget.renderable)
|
||||||
|
|
||||||
def test_button_widget_creation(self):
|
def test_button_widget_creation(self):
|
||||||
"""Test Button widget"""
|
"""Test Button widget"""
|
||||||
from textual.widgets import Button
|
from textual.widgets import Button
|
||||||
|
|
||||||
button = Button("Click me")
|
button = Button("Click me")
|
||||||
assert button.label == "Click me"
|
assert str(button.label) == "Click me"
|
||||||
|
|
||||||
def test_input_widget_creation(self):
|
def test_input_widget_creation(self):
|
||||||
"""Test Input widget"""
|
"""Test Input widget"""
|
||||||
|
|||||||
17
uv.lock
generated
17
uv.lock
generated
@ -309,7 +309,7 @@ name = "exceptiongroup"
|
|||||||
version = "1.3.1"
|
version = "1.3.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
{ name = "typing-extensions" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
@ -484,6 +484,9 @@ wheels = [
|
|||||||
linkify = [
|
linkify = [
|
||||||
{ name = "linkify-it-py" },
|
{ name = "linkify-it-py" },
|
||||||
]
|
]
|
||||||
|
plugins = [
|
||||||
|
{ name = "mdit-py-plugins" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "markupsafe"
|
name = "markupsafe"
|
||||||
@ -831,19 +834,17 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "textual"
|
name = "textual"
|
||||||
version = "8.0.2"
|
version = "1.0.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "markdown-it-py", extra = ["linkify"] },
|
{ name = "markdown-it-py", extra = ["linkify", "plugins"] },
|
||||||
{ name = "mdit-py-plugins" },
|
|
||||||
{ name = "platformdirs" },
|
{ name = "platformdirs" },
|
||||||
{ name = "pygments" },
|
|
||||||
{ name = "rich" },
|
{ name = "rich" },
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/be/08/c6bcb1e3c4c9528ec9049f4ac685afdafc72866664270f0deb416ccbba2a/textual-8.0.2.tar.gz", hash = "sha256:7b342f3ee9a5f2f1bd42d7b598cae00ff1275da68536769510db4b7fe8cabf5d", size = 6099270, upload-time = "2026-03-03T20:23:46.858Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/1f/b6/59b1de04bb4dca0f21ed7ba0b19309ed7f3f5de4396edf20cc2855e53085/textual-1.0.0.tar.gz", hash = "sha256:bec9fe63547c1c552569d1b75d309038b7d456c03f86dfa3706ddb099b151399", size = 1532733, upload-time = "2024-12-12T10:42:03.286Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/77/bc/0cd17f96f00b6e8bfbca64c574088c85f3c614912b3030f313752e30a099/textual-8.0.2-py3-none-any.whl", hash = "sha256:4ceadbe0e8a30eb80f9995000f4d031f711420a31b02da38f3482957b7c50ce4", size = 719174, upload-time = "2026-03-03T20:23:50.46Z" },
|
{ url = "https://files.pythonhosted.org/packages/ac/bb/5fb6656c625019cd653d5215237d7cd6e0b12e7eae4195c3d1c91b2136fc/textual-1.0.0-py3-none-any.whl", hash = "sha256:2d4a701781c05104925e463ae370c630567c70c2880e92ab838052e3e23c986f", size = 660456, upload-time = "2024-12-12T10:42:00.375Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -989,7 +990,7 @@ requires-dist = [
|
|||||||
{ name = "requests", specifier = ">=2.28.0" },
|
{ name = "requests", specifier = ">=2.28.0" },
|
||||||
{ name = "rich", specifier = ">=13.0.0" },
|
{ name = "rich", specifier = ">=13.0.0" },
|
||||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" },
|
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" },
|
||||||
{ name = "textual", marker = "extra == 'tui'", specifier = ">=0.40.0" },
|
{ name = "textual", marker = "extra == 'tui'", specifier = ">=0.40.0,<2.0.0" },
|
||||||
{ name = "types-requests", marker = "extra == 'dev'", specifier = ">=2.28.0" },
|
{ name = "types-requests", marker = "extra == 'dev'", specifier = ">=2.28.0" },
|
||||||
{ name = "yt-dlp" },
|
{ name = "yt-dlp" },
|
||||||
]
|
]
|
||||||
|
|||||||
@ -24,8 +24,9 @@ if os.environ.get('DOCKER'):
|
|||||||
errorlog = "-"
|
errorlog = "-"
|
||||||
loglevel = "info"
|
loglevel = "info"
|
||||||
else:
|
else:
|
||||||
accesslog = "/home/user/repos/youtube-cli/web/server/gunicorn-access.log"
|
log_dir = os.environ.get("GUNICORN_LOG_DIR", os.path.dirname(os.path.abspath(__file__)))
|
||||||
errorlog = "/home/user/repos/youtube-cli/web/server/gunicorn-error.log"
|
accesslog = os.path.join(log_dir, "gunicorn-access.log")
|
||||||
|
errorlog = os.path.join(log_dir, "gunicorn-error.log")
|
||||||
loglevel = "debug"
|
loglevel = "debug"
|
||||||
|
|
||||||
# Process naming
|
# Process naming
|
||||||
|
|||||||
@ -754,7 +754,7 @@ class YouTubeCLI:
|
|||||||
"""Download a video using yt-dlp with progress bar."""
|
"""Download a video using yt-dlp with progress bar."""
|
||||||
|
|
||||||
# Validate URL before proceeding
|
# Validate URL before proceeding
|
||||||
if not url or not isinstance(url, str) or url.strip() == "":
|
if not url or not isinstance(url, str):
|
||||||
logger.error("Invalid or empty video URL provided.")
|
logger.error("Invalid or empty video URL provided.")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@ -825,9 +825,7 @@ class YouTubeCLI:
|
|||||||
else:
|
else:
|
||||||
logger.info("Using 1080p quality by default")
|
logger.info("Using 1080p quality by default")
|
||||||
|
|
||||||
# Run command and let yt-dlp handle progress natively
|
# Run command with progress bar
|
||||||
# Removed timeout to support long-running downloads in queue
|
|
||||||
# Use a pipe to capture progress and support cancellation
|
|
||||||
process = subprocess.Popen(
|
process = subprocess.Popen(
|
||||||
cmd,
|
cmd,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
@ -836,12 +834,71 @@ class YouTubeCLI:
|
|||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
# Rich progress bar for downloads
|
||||||
stdout, _ = process.communicate(timeout=None) # No timeout
|
from rich.progress import (
|
||||||
result = subprocess.CompletedProcess(
|
BarColumn,
|
||||||
cmd, process.returncode, stdout, ""
|
DownloadColumn,
|
||||||
|
Progress,
|
||||||
|
TextColumn,
|
||||||
|
TimeRemainingColumn,
|
||||||
|
TransferSpeedColumn,
|
||||||
)
|
)
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
|
progress = Progress(
|
||||||
|
TextColumn("[bold blue]{task.description}"),
|
||||||
|
BarColumn(bar_width=40),
|
||||||
|
"[progress.percentage]{task.percentage:>3.1f}%",
|
||||||
|
"•",
|
||||||
|
DownloadColumn(),
|
||||||
|
"•",
|
||||||
|
TransferSpeedColumn(),
|
||||||
|
"•",
|
||||||
|
TimeRemainingColumn(),
|
||||||
|
)
|
||||||
|
|
||||||
|
downloaded_bytes = 0
|
||||||
|
total_bytes = 0
|
||||||
|
download_task = None
|
||||||
|
output_lines = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
with progress:
|
||||||
|
for line in process.stdout:
|
||||||
|
output_lines.append(line)
|
||||||
|
|
||||||
|
# Parse progress line: "[download] 5.0% of ~ 10.00MiB at 1.23MiB/s ETA 00:08"
|
||||||
|
import re as re_mod
|
||||||
|
|
||||||
|
progress_match = re_mod.search(
|
||||||
|
r"\[download\]\s+(\d+\.?\d*)%\s+of\s+[~]?\s*([\d.]+)\s*(B|KiB|MiB|GiB)",
|
||||||
|
line,
|
||||||
|
)
|
||||||
|
if progress_match:
|
||||||
|
pct = float(progress_match.group(1))
|
||||||
|
size_str = progress_match.group(2)
|
||||||
|
size_unit = progress_match.group(3)
|
||||||
|
total_bytes = self._parse_size(size_str, size_unit)
|
||||||
|
downloaded_bytes = int(total_bytes * pct / 100)
|
||||||
|
|
||||||
|
if download_task is None:
|
||||||
|
download_task = progress.add_task(
|
||||||
|
"Downloading...",
|
||||||
|
total=total_bytes if total_bytes > 0 else None,
|
||||||
|
)
|
||||||
|
progress.update(
|
||||||
|
download_task,
|
||||||
|
completed=downloaded_bytes,
|
||||||
|
total=total_bytes if total_bytes > 0 else None,
|
||||||
|
)
|
||||||
|
elif "[download]" in line and "[" not in line.split("[download]")[1].split()[0]:
|
||||||
|
pass
|
||||||
|
elif "[download] Deprecated commandline" in line:
|
||||||
|
pass
|
||||||
|
|
||||||
|
result = subprocess.CompletedProcess(
|
||||||
|
cmd, process.returncode, "".join(output_lines), ""
|
||||||
|
)
|
||||||
|
except KeyboardInterrupt:
|
||||||
process.kill()
|
process.kill()
|
||||||
logger.warning("Download cancelled by user")
|
logger.warning("Download cancelled by user")
|
||||||
return False
|
return False
|
||||||
@ -855,7 +912,6 @@ class YouTubeCLI:
|
|||||||
|
|
||||||
# Add video to archive for tracking
|
# Add video to archive for tracking
|
||||||
try:
|
try:
|
||||||
# Extract video ID from URL for archive
|
|
||||||
import re
|
import re
|
||||||
|
|
||||||
video_id = None
|
video_id = None
|
||||||
@ -880,8 +936,6 @@ class YouTubeCLI:
|
|||||||
if result.stdout:
|
if result.stdout:
|
||||||
logger.error(f"Error details: {result.stdout}")
|
logger.error(f"Error details: {result.stdout}")
|
||||||
|
|
||||||
# Try to check if we have a different problem
|
|
||||||
# Check for specific JavaScript challenge errors and recommend solutions
|
|
||||||
if (
|
if (
|
||||||
"Solving JS challenges" in result.stdout
|
"Solving JS challenges" in result.stdout
|
||||||
or "challenge solving failed" in result.stdout
|
or "challenge solving failed" in result.stdout
|
||||||
@ -895,6 +949,11 @@ class YouTubeCLI:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error during download: {str(e)}")
|
logger.error(f"Error during download: {str(e)}")
|
||||||
|
|
||||||
|
def _parse_size(self, size_str: str, unit: str) -> int:
|
||||||
|
"""Parse size string with unit to bytes."""
|
||||||
|
multipliers = {"B": 1, "KiB": 1024, "MiB": 1024**2, "GiB": 1024**3}
|
||||||
|
return int(float(size_str) * multipliers.get(unit, 1))
|
||||||
|
|
||||||
def download_playlist(
|
def download_playlist(
|
||||||
self,
|
self,
|
||||||
url,
|
url,
|
||||||
|
|||||||
@ -131,7 +131,7 @@ class YouTubeTUI(App):
|
|||||||
)
|
)
|
||||||
self.download_manager.start_processing()
|
self.download_manager.start_processing()
|
||||||
|
|
||||||
self.push_search_screen()
|
self.push_home_screen()
|
||||||
self.current_screen = self.screen
|
self.current_screen = self.screen
|
||||||
|
|
||||||
def on_screen_stack_changed(self) -> None:
|
def on_screen_stack_changed(self) -> None:
|
||||||
@ -141,6 +141,12 @@ class YouTubeTUI(App):
|
|||||||
self.current_search_term = current_screen.search_term
|
self.current_search_term = current_screen.search_term
|
||||||
self.current_screen = current_screen
|
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:
|
def push_search_screen(self) -> None:
|
||||||
"""Push the search screen"""
|
"""Push the search screen"""
|
||||||
from youtube_tui.screens.search import SearchScreen
|
from youtube_tui.screens.search import SearchScreen
|
||||||
|
|||||||
@ -121,9 +121,16 @@ class DownloadScreen(Screen):
|
|||||||
async def start_download(self) -> None:
|
async def start_download(self) -> None:
|
||||||
"""Start the download process"""
|
"""Start the download process"""
|
||||||
try:
|
try:
|
||||||
# Perform the download (async method)
|
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(
|
success = await self.youtube_service.download_video(
|
||||||
self.video, self.category
|
self.video, self.category, progress_callback=progress_callback
|
||||||
)
|
)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
|
|||||||
192
youtube_tui/screens/home.py
Normal file
192
youtube_tui/screens/home.py
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
#!/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()
|
||||||
@ -130,6 +130,21 @@ class QueueScreen(Screen):
|
|||||||
self.update_stats()
|
self.update_stats()
|
||||||
self.update_status("[green]Queue loaded[/green]")
|
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:
|
def action_refresh_screen(self) -> None:
|
||||||
"""Refresh the screen"""
|
"""Refresh the screen"""
|
||||||
self.update_table()
|
self.update_table()
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user