Compare commits

...

11 Commits

Author SHA1 Message Date
c58943f6b8 Merge pull request 'chore: clean repo for public release' (#16) from improve/v1 into main
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
Reviewed-on: https://git.example.com/jarianc/youtube-cli/pulls/16
2026-08-20 16:07:31 -05:00
3c5b10466a chore: clean repo for public release
- Remove runtime artifacts (app.log, .DS_Store, committed home dir data)
- Remove AI session notes (prompt.md, agent.md, LOGGING_*.md)
- Move TUI.md, structure.md, docker-setup.md to docs/
- Normalize author metadata to Jarian Cottingham
- Fix hardcoded personal paths in gunicorn config and manual test script
- Pin textual <2.0 (8.x grid layout breaks TUI rendering)
- Fix stale tests: private attr access, missing screen push
- Add CI workflow (ruff + pytest, Python 3.10-3.12)
2026-08-20 21:03:50 +00:00
483611f357 Merge pull request 'feat: enhance TUI with home screen, download progress, queue auto-refresh' (#15) from fix/issue-2 into main
Reviewed-on: https://git.example.com/jarianc/youtube-cli/pulls/15
2026-07-05 01:56:35 -05:00
7842cb1f7f feat: enhance TUI with home screen, download progress, queue auto-refresh
- Add home screen as default landing page with quick actions
- Wire download progress callback to TUI progress bar
- Add queue auto-refresh every 2s during active downloads
- Improve UX with keyboard shortcuts and button navigation
2026-07-05 06:54:42 +00:00
6ae8f3befc Merge pull request 'Fix #3: Add progress bars to downloads' (#14) from fix/issue-3 into main
Reviewed-on: https://git.example.com/jarianc/youtube-cli/pulls/14
2026-07-05 01:51:29 -05:00
7432ad644c feat: add rich progress bars to video downloads
Parse yt-dlp progress output and display real-time progress bar
with percentage, downloaded size, transfer speed, and ETA using
rich library.
2026-07-05 06:50:05 +00:00
3adafe8bbd Merge pull request 'Fix #5: Prepare package for PyPI publishing' (#13) from fix/issue-5 into main
Reviewed-on: https://git.example.com/jarianc/youtube-cli/pulls/13
2026-07-05 01:48:55 -05:00
4538857ce3 feat: prepare package for PyPI publishing
- Fix author metadata (name, email, URLs)
- Add LICENSE file (MIT)
- Add MANIFEST.in for proper sdist packaging
- Add PyPI publish workflow (.gitea/workflows/publish.yml)
- Add keywords for discoverability
- Align python_requires to >=3.10 across setup.py and pyproject.toml
2026-07-05 06:47:32 +00:00
2789b03f45 Merge pull request 'Fix #4: Remove 3rd useless URL check' (#12) from fix/issue-4 into main
Reviewed-on: https://git.example.com/jarianc/youtube-cli/pulls/12
2026-07-05 01:46:32 -05:00
b33bed7a6e fix: remove redundant url.strip() check in download_video
not url already catches empty string, making url.strip() == ''
the 3rd useless condition.
2026-07-05 06:44:44 +00:00
669b677fb3 Merge pull request 'CI: remove --no-cache for docker layer caching' (#11) from demo/ci-cache into main 2026-07-04 21:56:33 -05:00
27 changed files with 438 additions and 2843 deletions

BIN
.DS_Store vendored

Binary file not shown.

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

View File

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

View File

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

View File

@ -350,4 +350,4 @@ youtube-tui
### Documentation
See [TUI.md](TUI.md) for complete TUI documentation.
See [TUI.md](docs/TUI.md) for complete TUI documentation.

122
agent.md
View File

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

@ -1,14 +0,0 @@
Error saving archive: [Errno 13] Permission denied: '/app'
* Serving Flask app 'app'
* Debug mode: on
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
* Running on all addresses (0.0.0.0)
* Running on http://127.0.0.1:4096
* Running on http://192.168.122.203:4096
Press CTRL+C to quit
* 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 -

View File

View File

@ -4,8 +4,12 @@ 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):
@ -145,7 +149,7 @@ def test_unit_tests():
capture_output=True,
text=True,
timeout=30,
cwd="/Users/user/Projects/youtube-cli",
cwd=str(REPO_ROOT),
)
print(result.stdout)
@ -431,12 +435,13 @@ print("OK")
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("Working directory: /Users/user/Projects/youtube-cli")
print(f"Working directory: {REPO_ROOT}")
results = []

View File

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

View File

@ -9,7 +9,7 @@ description = "A command-line interface for browsing and downloading YouTube vid
readme = "README.md"
requires-python = ">=3.10"
authors = [
{name = "Your Name", email = "your.email@example.com"},
{name = "Jarian Cottingham", email = "jarianc@proton.me"},
]
license = {text = "MIT"}
classifiers = [
@ -43,8 +43,10 @@ youtube-cli = "youtube_cli.main:main"
youtube-tui = "youtube_tui.__main__:main"
[project.urls]
Homepage = "https://github.com/yourusername/youtube-cli"
Issues = "https://github.com/yourusername/youtube-cli/issues"
Homepage = "https://git.example.com/jarianc/youtube-cli"
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]
dev = [
@ -60,7 +62,7 @@ api = [
"Flask==2.3.3",
]
tui = [
"textual>=0.40.0",
"textual>=0.40.0,<2.0.0",
]
[tool.uv]

View File

@ -6,12 +6,18 @@ with open("README.md", "r", encoding="utf-8") as fh:
setup(
name="youtube-cli",
version="0.1.0",
author="Your Name",
author_email="your.email@example.com",
author="Jarian Cottingham",
author_email="jarianc@proton.me",
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_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(),
classifiers=[
"Programming Language :: Python :: 3",
@ -26,16 +32,13 @@ setup(
"Operating System :: MacOS",
"Operating System :: POSIX :: Linux",
"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.11",
"Programming Language :: Python :: 3.12",
"Topic :: Multimedia :: Video",
"Topic :: Utilities",
],
python_requires=">=3.7",
python_requires=">=3.10",
install_requires=[
"yt-dlp",
"rich>=13.0.0",

View File

@ -40,15 +40,17 @@ class TestSearchScreen:
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 = app.screen
screen.update_status = lambda message: update_calls.append(message)
# Simulate pressing Enter with empty input
@ -62,8 +64,11 @@ class TestSearchScreen:
"""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()
@ -83,8 +88,11 @@ class TestSearchScreen:
"""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()

View File

@ -190,7 +190,7 @@ class TestCustomWidgets:
from textual.widgets import Static
widget = Static("Test content")
assert widget._Static__content == "Test content"
assert widget.renderable == "Test content"
def test_static_widget_with_rich_markup(self):
"""Test Static widget with Rich markup"""
@ -198,14 +198,14 @@ class TestCustomWidgets:
widget = Static("[bold]Test[/bold] [red]content[/red]")
# 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):
"""Test Button widget"""
from textual.widgets import Button
button = Button("Click me")
assert button.label == "Click me"
assert str(button.label) == "Click me"
def test_input_widget_creation(self):
"""Test Input widget"""

17
uv.lock generated
View File

@ -309,7 +309,7 @@ name = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
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" }
wheels = [
@ -484,6 +484,9 @@ wheels = [
linkify = [
{ name = "linkify-it-py" },
]
plugins = [
{ name = "mdit-py-plugins" },
]
[[package]]
name = "markupsafe"
@ -831,19 +834,17 @@ wheels = [
[[package]]
name = "textual"
version = "8.0.2"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py", extra = ["linkify"] },
{ name = "mdit-py-plugins" },
{ name = "markdown-it-py", extra = ["linkify", "plugins"] },
{ name = "platformdirs" },
{ name = "pygments" },
{ name = "rich" },
{ 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 = [
{ 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]]
@ -989,7 +990,7 @@ requires-dist = [
{ name = "requests", specifier = ">=2.28.0" },
{ name = "rich", specifier = ">=13.0.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 = "yt-dlp" },
]

View File

@ -24,8 +24,9 @@ if os.environ.get('DOCKER'):
errorlog = "-"
loglevel = "info"
else:
accesslog = "/home/user/repos/youtube-cli/web/server/gunicorn-access.log"
errorlog = "/home/user/repos/youtube-cli/web/server/gunicorn-error.log"
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

View File

@ -754,7 +754,7 @@ class YouTubeCLI:
"""Download a video using yt-dlp with progress bar."""
# 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.")
return False
@ -825,9 +825,7 @@ class YouTubeCLI:
else:
logger.info("Using 1080p quality by default")
# Run command and let yt-dlp handle progress natively
# Removed timeout to support long-running downloads in queue
# Use a pipe to capture progress and support cancellation
# Run command with progress bar
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
@ -836,12 +834,71 @@ class YouTubeCLI:
text=True,
)
# Rich progress bar for downloads
from rich.progress import (
BarColumn,
DownloadColumn,
Progress,
TextColumn,
TimeRemainingColumn,
TransferSpeedColumn,
)
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:
stdout, _ = process.communicate(timeout=None) # No timeout
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, stdout, ""
cmd, process.returncode, "".join(output_lines), ""
)
except subprocess.TimeoutExpired:
except KeyboardInterrupt:
process.kill()
logger.warning("Download cancelled by user")
return False
@ -855,7 +912,6 @@ class YouTubeCLI:
# Add video to archive for tracking
try:
# Extract video ID from URL for archive
import re
video_id = None
@ -880,8 +936,6 @@ class YouTubeCLI:
if 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 (
"Solving JS challenges" in result.stdout
or "challenge solving failed" in result.stdout
@ -895,6 +949,11 @@ class YouTubeCLI:
except Exception as 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(
self,
url,

View File

@ -131,7 +131,7 @@ class YouTubeTUI(App):
)
self.download_manager.start_processing()
self.push_search_screen()
self.push_home_screen()
self.current_screen = self.screen
def on_screen_stack_changed(self) -> None:
@ -141,6 +141,12 @@ class YouTubeTUI(App):
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

View File

@ -121,9 +121,16 @@ class DownloadScreen(Screen):
async def start_download(self) -> None:
"""Start the download process"""
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(
self.video, self.category
self.video, self.category, progress_callback=progress_callback
)
if success:

192
youtube_tui/screens/home.py Normal file
View 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()

View File

@ -130,6 +130,21 @@ class QueueScreen(Screen):
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()

File diff suppressed because it is too large Load Diff