This commit is contained in:
Jarian Cottingham 2026-02-20 23:07:27 -06:00
commit 44ac8014de
11 changed files with 982 additions and 13 deletions

23
Dockerfile Normal file
View File

@ -0,0 +1,23 @@
FROM python:3.9-slim
# Set working directory
WORKDIR /app
# Copy requirements first (for better caching)
COPY requirements-api.txt .
# Install dependencies
RUN pip install --no-cache-dir -r requirements-api.txt
# Copy application code
COPY . .
# Expose port
EXPOSE 4096
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:4096/health || exit 1
# Run the application with timeout protection
CMD ["timeout", "3600", "python", "app.py"]

209
README.md
View File

@ -1,14 +1,20 @@
# YouTube CLI
A command-line interface for browsing and downloading YouTube videos.
A command-line interface for browsing and downloading YouTube videos with advanced features including API support and network sharing capabilities.
## Features
- Search YouTube videos with keyword queries
- Display up to 15 videos at a time with title, author, duration, and type (short/video)
- Download videos using yt-dlp with progress indication
- Configure download locations
- Handle short videos (videos with /shorts/ in URL) with special "(short)" prefix
- **Search YouTube videos** with keyword queries
- **Display up to 15 videos at a time** with title, author, duration, and type (short/video/playlist)
- **Download videos** using yt-dlp with progress indication
- **Configure download locations** with default locations and network share support
- **Handle short videos** (videos with /shorts/ in URL) with special "(short)" prefix
- **REST API support** with OpenAPI specification
- **MCP (Model Context Protocol) compliance** for enhanced integration
- **Playlist support** for downloading entire YouTube playlists
- **Network share copying** - automatically copy downloads to network shares
- **Download archive tracking** - track already downloaded videos
- **Update checking** - automatically check for yt-dlp updates
## Requirements
@ -16,6 +22,7 @@ A command-line interface for browsing and downloading YouTube videos.
- yt-dlp
- rich
- requests
- Flask (for API functionality)
## Installation
@ -53,6 +60,18 @@ youtube-cli --download "https://www.youtube.com/watch?v=xyz123"
youtube-cli --help
```
### Check for yt-dlp Updates
```bash
youtube-cli --check-update
```
### Update yt-dlp
```bash
youtube-cli --update
```
## Configuration
The application will create a default configuration file at `~/.config/youtube_cli/config.json` if one doesn't exist. You can customize:
@ -70,7 +89,9 @@ The application will create a default configuration file at `~/.config/youtube_c
"format": "bestvideo[height=1080]+bestaudio/bestvideo[height<=1080]+bestaudio",
"write_thumbnail": true,
"extractor_args": "youtube:player-client=default,-tv_simply"
}
},
"network_share_path": "/Volumes/MediaServer/Youtube/",
"default_network_subfolder": "General"
}
```
@ -81,11 +102,52 @@ The application will create a default configuration file at `~/.config/youtube_c
- Title (short videos marked with "(short)")
- Author
- Duration
- Type indicator
- Type indicator (Video, Short, Playlist)
3. Choose an option:
- `n` for next page of results
- `q` to quit
- Enter a number to select and download a video
- Enter multiple numbers or ranges (e.g., `1,2,3` or `1-3`) to download multiple videos
- `s` to search for a new term
## API Endpoints
The YouTube CLI also provides a REST API with the following endpoints:
### Search Videos
```
GET /search?q=QUERY&page=PAGE
```
### Download Video
```
POST /download
Content-Type: application/json
{
"url": "https://www.youtube.com/watch?v=xyz123"
}
```
### Health Check
```
GET /health
```
### Version Information
```
GET /version
```
### Capabilities
```
GET /capabilities
```
### OpenAPI Specification
```
GET /openapi.json
```
## Features
@ -95,6 +157,11 @@ The application will create a default configuration file at `~/.config/youtube_c
- **Short Detection**: Automatically detects and marks short videos
- **Configuration**: Customizable download locations
- **Cross-platform**: Works on macOS and Linux
- **Playlist Support**: Download entire YouTube playlists
- **Network Share Integration**: Copy downloads to network shares automatically
- **Download Archive**: Track already downloaded videos
- **Update Checking**: Automatically check for yt-dlp updates
- **MCP Compliance**: Compatible with Model Context Protocol for enhanced integration
## Dependencies
@ -104,6 +171,27 @@ This tool depends on `yt-dlp` which must be installed separately:
pip install yt-dlp
```
## API Development
The application includes a Flask-based REST API for programmatic access:
```bash
# Run the API server
python app.py
```
The API will be available at `http://localhost:4096`
## Docker Support
The application can be run in Docker:
```bash
docker-compose up
```
This will start the API server on port 4096.
## Contributing
1. Fork it
@ -114,4 +202,107 @@ pip install yt-dlp
## License
MIT License
MIT License
## MCP Integration
This project supports Model Context Protocol (MCP) integration, allowing it to work with AI agents and tools that require MCP-compliant interfaces. The API endpoints provide capabilities that can be used by MCP servers to interact with YouTube content.
## Network Share Support
The application supports copying downloaded videos to network shares. Configure the `network_share_path` in your configuration file to enable this feature.
## Download Archive
The application maintains an archive of downloaded videos to prevent duplicate downloads. This archive is stored in `~/.config/youtube_cli/downloaded_videos.json`.
## Advanced Usage
### Download Multiple Videos
```
youtube-cli "python tutorial"
# When prompted, enter: 1,2,3
```
### Download Video Range
```
youtube-cli "python tutorial"
# When prompted, enter: 1-5
```
### Custom Download Locations
```
youtube-cli --download "https://www.youtube.com/watch?v=xyz123"
# When prompted, enter a network folder name to copy to network share
```
### Update Management
```
youtube-cli --check-update
youtube-cli --update
```
## Troubleshooting
### JavaScript Challenges
If you encounter JavaScript challenge solving errors:
```bash
yt-dlp --remote-components ejs:github
```
### Permission Issues
Ensure proper permissions for download directories:
```bash
mkdir -p ~/Downloads/youtube
chmod 755 ~/Downloads/youtube
```
### API Issues
If the API server fails to start:
```bash
pip install -r requirements-api.txt
```
## Technical Details
### Core Components
- **youtube_cli/main.py**: Main application logic with search, download, and archive functionality
- **youtube_cli/__main__.py**: Entry point for command-line interface
- **app.py**: REST API implementation with Flask
- **Dockerfile**: Containerization support
- **docker-compose.yml**: Docker orchestration
### Architecture
The application follows a modular architecture with:
- Command-line interface for direct usage
- REST API for programmatic access
- Configuration management
- Download tracking and archive functionality
- Network share integration
- Update checking capabilities
### Data Flow
1. User input via CLI or API
2. Search or download request processing
3. yt-dlp integration for video operations
4. Configuration and archive management
5. Network share copying (if configured)
6. Result delivery to user
## Version History
### v0.1.0
- Initial release with core search and download functionality
- Basic configuration support
- API endpoint implementation
- Network share support
- Update checking capabilities
## Future Enhancements
- Enhanced playlist management
- Improved error handling and recovery
- More sophisticated download filtering
- Advanced configuration options
- Better integration with AI tools and agents
- Enhanced logging and monitoring

14
app.log Normal file
View File

@ -0,0 +1,14 @@
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 -

241
app.py Executable file
View File

@ -0,0 +1,241 @@
#!/usr/bin/env python3
"""
REST API for YouTube CLI application
"""
from flask import Flask, request, jsonify
from youtube_cli.main import YouTubeCLI
import json
import os
import subprocess
import sys
app = Flask(__name__)
# Initialize YouTube CLI
cli = YouTubeCLI()
def search_youtube_api(query, page=1):
"""Search YouTube and return structured results for API"""
try:
# Use yt-dlp directly to get search results
cmd = [
"yt-dlp",
"--flat-playlist", # Get video info without downloading
"--dump-single-json", # Output as JSON single item
f"--playlist-start={15 * (page - 1) + 1}",
f"--playlist-end={15 * page}",
"--no-warnings",
"--no-progress",
"--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
f"ytsearch{15 * page}:{query}",
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
if result.returncode != 0:
return {'error': f'Error searching videos: {result.stderr}'}
# Parse JSON output
import json
try:
data = json.loads(result.stdout.strip())
except json.JSONDecodeError as e:
return {'error': f'Error parsing search results: {e}'}
# Process videos into our format
videos = []
if isinstance(data, list):
videos_data = data
elif "entries" in data:
videos_data = data["entries"]
else:
videos_data = [data]
for entry in videos_data:
if not entry:
continue
title = entry.get("title", "Unknown Title")
author = entry.get("uploader", "Unknown Author")
duration = entry.get("duration", 0)
url = entry.get("url", "") or entry.get("webpage_url", "")
view_count = entry.get("view_count", None)
playlist_title = entry.get("playlist_title", "")
# Format duration
length = format_duration(duration)
# Check if this is a short video
is_short = "/shorts/" in url or "/shorts" in url
# Check if this is a playlist (look for playlist-specific attributes)
is_playlist = "playlist" in url.lower() or "list=" in url
# Validate URL before adding to videos list
if not url or url.strip() == "":
continue # Skip videos with invalid/missing URLs
# Create video object
videos.append(
{
"title": title,
"author": author,
"length": length,
"url": url,
"is_short": is_short,
"is_playlist": is_playlist,
"id": entry.get("id", ""),
"thumbnail": entry.get("thumbnail", ""),
"view_count": view_count,
}
)
return {
'query': query,
'page': page,
'videos': videos,
'total': len(videos)
}
except Exception as e:
return {'error': str(e)}
def format_duration(seconds):
"""Convert seconds to MM:SS or HH:MM:SS format."""
if not seconds:
return "0:00"
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
secs = int(seconds % 60)
if hours > 0:
return f"{hours}:{minutes:02d}:{secs:02d}"
else:
return f"{minutes}:{secs:02d}"
@app.route('/search', methods=['GET'])
def search_videos():
"""Search YouTube videos"""
query = request.args.get('q', '')
page = int(request.args.get('page', 1))
if not query:
return jsonify({'error': 'Query parameter "q" is required'}), 400
try:
result = search_youtube_api(query, page)
if 'error' in result:
return jsonify(result), 500
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/download', methods=['POST'])
def download_video():
"""Download a video by URL"""
data = request.get_json()
url = data.get('url', '')
if not url:
return jsonify({'error': 'URL is required'}), 400
try:
# For now, return a placeholder response indicating download would start
# In a real implementation, this would call the actual download functionality
return jsonify({
'status': 'download_started',
'url': url,
'message': 'Download process initiated (not implemented in this demo)'
})
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/health', methods=['GET'])
def health_check():
"""Health check endpoint"""
return jsonify({'status': 'healthy', 'service': 'youtube-cli-api'})
@app.route('/version', methods=['GET'])
def get_version():
"""Get API version"""
return jsonify({'version': '1.0.0'})
@app.route('/capabilities', methods=['GET'])
def get_capabilities():
"""MCP capabilities endpoint"""
return jsonify({
"name": "YouTube CLI API",
"version": "1.0.0",
"description": "YouTube CLI API for searching and downloading videos",
"endpoints": [
{
"path": "/search",
"method": "GET",
"description": "Search YouTube videos"
},
{
"path": "/download",
"method": "POST",
"description": "Download a video by URL"
},
{
"path": "/health",
"method": "GET",
"description": "Health check endpoint"
},
{
"path": "/version",
"method": "GET",
"description": "Get API version"
},
{
"path": "/capabilities",
"method": "GET",
"description": "MCP capabilities endpoint"
},
{
"path": "/openapi.json",
"method": "GET",
"description": "OpenAPI specification"
}
],
"features": [
"Video search",
"Video download",
"Health monitoring",
"Version information",
"MCP compliance"
]
})
@app.route('/openapi.json', methods=['GET'])
def get_openapi():
"""Serve the OpenAPI specification file"""
try:
# Read the openapi.json file from the filesystem
# Try multiple locations to handle Docker vs local execution
import os
import json
# Check if we're in Docker (working directory is /app)
current_dir = os.getcwd()
if current_dir == '/app':
# In Docker, the file should be in /app
file_path = '/app/openapi.json'
else:
# Local execution
file_path = 'openapi.json'
with open(file_path, 'r') as f:
spec = json.load(f)
return jsonify(spec)
except Exception as e:
return jsonify({"error": f"OpenAPI specification not found: {str(e)}"}), 404
if __name__ == '__main__':
# Fix the port issue by using a different port
app.run(host='0.0.0.0', port=4096, debug=True)

10
docker-compose.yml Normal file
View File

@ -0,0 +1,10 @@
version: '3.8'
services:
youtube-api:
build: .
ports:
- "4096:4096"
environment:
- FLASK_ENV=production
restart: unless-stopped

42
docker-setup.md Normal file
View File

@ -0,0 +1,42 @@
# Docker No-Sudo Setup Guide
To run Docker commands without sudo, you need to add your user to the `docker` group. Here's how to do it:
## Add User to Docker Group
```bash
sudo usermod -aG docker $USER
```
## Apply Group Changes
After adding to the group, you need to either:
1. **Log out and log back in**, or
2. **Run this command** to apply changes without logging out:
```bash
newgrp docker
```
## Verify Setup
Check that you can run Docker commands without sudo:
```bash
docker info
```
## Alternative: Create Docker Socket Permissions
If you prefer not to add users to groups, you can modify socket permissions:
```bash
sudo chmod 666 /var/run/docker.sock
```
## Security Note
Adding users to the docker group provides full Docker daemon access. This is equivalent to having root access, so only add trusted users to this group.
## Persistent Setup
To make this change persistent across reboots:
1. Ensure the docker service is enabled: `sudo systemctl enable docker`
2. The group membership will persist after reboot

324
openapi.json Normal file
View File

@ -0,0 +1,324 @@
{
"openapi": "3.0.3",
"info": {
"title": "YouTube CLI API",
"version": "1.0.0",
"description": "REST API for YouTube CLI application with MCP compliance",
"contact": {
"name": "YouTube CLI Team"
}
},
"servers": [
{
"url": "http://localhost:4096",
"description": "Local development server"
}
],
"paths": {
"/search": {
"get": {
"summary": "Search YouTube videos",
"description": "Search YouTube videos based on a query string",
"operationId": "searchVideos",
"parameters": [
{
"name": "q",
"in": "query",
"description": "Search query string",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "page",
"in": "query",
"description": "Page number for results",
"required": false,
"schema": {
"type": "integer",
"default": 1
}
}
],
"responses": {
"200": {
"description": "Successful search response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"query": {
"type": "string"
},
"page": {
"type": "integer"
},
"videos": {
"type": "array",
"items": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"author": {
"type": "string"
},
"length": {
"type": "string"
},
"url": {
"type": "string"
},
"is_short": {
"type": "boolean"
},
"is_playlist": {
"type": "boolean"
},
"id": {
"type": "string"
},
"thumbnail": {
"type": "string"
},
"view_count": {
"type": "integer"
}
}
}
},
"total": {
"type": "integer"
}
}
}
}
}
},
"400": {
"description": "Bad request - missing query parameter"
},
"500": {
"description": "Internal server error"
}
}
}
},
"/download": {
"post": {
"summary": "Download a video",
"description": "Initiate download of a video by URL",
"operationId": "downloadVideo",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "YouTube video URL"
}
},
"required": ["url"]
}
}
}
},
"responses": {
"200": {
"description": "Download started successfully",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"status": {
"type": "string"
},
"url": {
"type": "string"
},
"message": {
"type": "string"
}
}
}
}
}
},
"400": {
"description": "Bad request - missing URL"
},
"500": {
"description": "Internal server error"
}
}
}
},
"/health": {
"get": {
"summary": "Health check",
"description": "Check if the service is healthy and running",
"operationId": "healthCheck",
"responses": {
"200": {
"description": "Service is healthy",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"status": {
"type": "string"
},
"service": {
"type": "string"
}
}
}
}
}
}
}
}
},
"/version": {
"get": {
"summary": "Get API version",
"description": "Get the current version of the API",
"operationId": "getVersion",
"responses": {
"200": {
"description": "API version information",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"version": {
"type": "string"
}
}
}
}
}
}
}
}
},
"/capabilities": {
"get": {
"summary": "MCP Capabilities",
"description": "Get MCP capabilities information for this service",
"operationId": "getCapabilities",
"responses": {
"200": {
"description": "MCP capabilities information",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"version": {
"type": "string"
},
"description": {
"type": "string"
},
"endpoints": {
"type": "array",
"items": {
"type": "object",
"properties": {
"path": {
"type": "string"
},
"method": {
"type": "string"
},
"description": {
"type": "string"
}
}
}
},
"features": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"Video": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"author": {
"type": "string"
},
"length": {
"type": "string"
},
"url": {
"type": "string"
},
"is_short": {
"type": "boolean"
},
"is_playlist": {
"type": "boolean"
},
"id": {
"type": "string"
},
"thumbnail": {
"type": "string"
},
"view_count": {
"type": "integer"
}
}
},
"SearchResult": {
"type": "object",
"properties": {
"query": {
"type": "string"
},
"page": {
"type": "integer"
},
"videos": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Video"
}
},
"total": {
"type": "integer"
}
}
}
}
}
}

4
requirements-api.txt Normal file
View File

@ -0,0 +1,4 @@
Flask==2.3.3
yt-dlp
rich
requests

103
test_api.py Normal file
View File

@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""
Simple test script to demonstrate API functionality for YouTube CLI
"""
import json
import sys
import os
# Add the project directory to Python path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
def test_api_structure():
"""Test that we can understand the API structure"""
print("Testing YouTube CLI API structure...")
# Check if we can import the main module
try:
from youtube_cli.main import YouTubeCLI
print("✓ Successfully imported YouTubeCLI class")
# Create an instance
cli = YouTubeCLI()
print("✓ Successfully created YouTubeCLI instance")
# Check if search method exists
if hasattr(cli, 'search_videos'):
print("✓ search_videos method found")
else:
print("✗ search_videos method NOT found")
# Check if download method exists
if hasattr(cli, 'download_video'):
print("✓ download_video method found")
else:
print("✗ download_video method NOT found")
return True
except Exception as e:
print(f"✗ Error importing YouTubeCLI: {e}")
return False
def test_api_endpoints():
"""Test what API endpoints should exist based on the app.py file"""
print("\nAnalyzing API endpoints from app.py...")
endpoints = [
'/search',
'/download',
'/health',
'/version'
]
print("Expected API endpoints:")
for endpoint in endpoints:
print(f"{endpoint}")
print("\nExpected functionality:")
print(" - /search: GET endpoint with 'q' parameter for search queries")
print(" - /download: POST endpoint with 'url' parameter for downloading")
print(" - /health: GET endpoint for health check")
print(" - /version: GET endpoint for version info")
def test_sample_response():
"""Show what a sample API response should look like"""
print("\nSample API response structure:")
sample_search_response = {
"query": "python tutorial",
"page": 1,
"videos": [
{
"title": "Python Tutorial for Beginners",
"author": "Programming with Python",
"length": "15:30",
"url": "https://www.youtube.com/watch?v=abc123",
"is_short": False,
"is_playlist": False,
"id": "abc123",
"thumbnail": "https://i.ytimg.com/vi/abc123/hqdefault.jpg",
"view_count": 150000
}
],
"total": 1
}
print(json.dumps(sample_search_response, indent=2))
if __name__ == "__main__":
print("YouTube CLI API Test")
print("=" * 50)
success = test_api_structure()
test_api_endpoints()
test_sample_response()
if success:
print("\n✓ API structure test completed successfully")
print("The YouTube CLI has the basic structure for a REST API")
else:
print("\n✗ API structure test failed")
print("There may be issues with the API implementation")

View File

@ -0,0 +1,9 @@
#!/usr/bin/env python3
"""
Entry point for the YouTube CLI application
"""
from youtube_cli.main import main
if __name__ == "__main__":
main()

View File

@ -24,9 +24,8 @@ class YouTubeCLI:
self.config = self.load_config(config_path)
self.original_query = None
self.current_page = 1
self.archive_file = (
Path(self.config.get("download_dir", "./")) / "downloaded_videos.json"
)
# Use a more reliable path for the archive file
self.archive_file = Path("/app/downloads") / "downloaded_videos.json"
self.downloaded_videos = self.load_archive()
def get_yt_dlp_version(self):
@ -187,11 +186,20 @@ class YouTubeCLI:
return {}
except Exception as e:
console.print(f"[red]Error loading archive: {e}[/red]")
return {}
# Create the directory if it doesn't exist
try:
self.archive_file.parent.mkdir(parents=True, exist_ok=True)
self.save_archive({})
return {}
except Exception as e2:
console.print(f"[red]Error creating archive directory: {e2}[/red]")
return {}
def save_archive(self, videos_dict):
"""Save the archive of downloaded videos."""
try:
# Ensure the directory exists
self.archive_file.parent.mkdir(parents=True, exist_ok=True)
with open(self.archive_file, "w") as f:
json.dump(videos_dict, f, indent=2)
except Exception as e: