2025-12-06 01:17:27 -06:00

360 lines
11 KiB
Python

#!/usr/bin/env python3
"""
YouTube CLI - A command-line interface for browsing and downloading YouTube videos
"""
import argparse
import json
import os
import subprocess
import sys
from pathlib import Path
from rich.console import Console
from rich.progress import Progress, SpinnerColumn, TextColumn
from rich.table import Table
console = Console()
def main():
"""Main entry point for the YouTube CLI application."""
parser = argparse.ArgumentParser(
description="YouTube CLI - Browse and download YouTube videos"
)
parser.add_argument("query", nargs="?", help="Search query for YouTube")
parser.add_argument(
"--download", action="store_true", help="Download a specific video"
)
parser.add_argument("--config", help="Configuration file path")
parser.add_argument(
"--list", action="store_true", help="List videos (default behavior)"
)
parser.add_argument("--page", type=int, default=1, help="Page number for results")
args = parser.parse_args()
# Initialize configuration
config = load_config(args.config)
if args.download:
# Handle download functionality
if not args.query:
print("Error: You must provide a video URL for downloading")
return
download_video(args.query, config)
return
# Default behavior - show search results
if args.query:
search_videos(args.query, config, page=args.page)
else:
# Show help if no arguments given
parser.print_help()
def load_config(config_path=None):
"""Load configuration from file or use defaults."""
default_config = {
"download_dir": str(Path.home() / "Downloads" / "youtube"),
"default_locations": [
str(Path.home() / "Downloads" / "youtube"),
str(Path.home() / "Movies" / "youtube"),
"/tmp/youtube",
],
"max_videos_per_page": 15,
"yt_dlp_args": {
"format": "bestvideo[height=1080]+bestaudio/bestvideo[height<=1080]+bestaudio",
"write_thumbnail": True,
"extractor_args": "youtube:player-client=default,-tv_simply",
},
}
if config_path and os.path.exists(config_path):
try:
with open(config_path, "r") as f:
config = json.load(f)
# Merge with defaults
for key, value in default_config.items():
if key not in config:
config[key] = value
return config
except Exception as e:
print(f"Error loading config: {e}")
return default_config
def search_videos(query, config, page=1):
"""Search YouTube videos based on the query using yt-dlp."""
console.print(f"[blue]Searching YouTube for:[/blue] {query}")
# Construct yt-dlp command for search
cmd = [
"yt-dlp",
"--flat-playlist", # Get video info without downloading
"--dump-single-json", # Output as JSON for single video
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}",
]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
if result.returncode != 0:
console.print(f"[red]Error searching videos: {result.stderr}[/red]")
return
# Parse JSON output
import json
try:
data = json.loads(result.stdout.strip())
except json.JSONDecodeError:
console.print(
"[red]Error parsing search results. Try another search.[/red]"
)
return
# 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", "")
# Format duration
length = format_duration(duration)
# Check if this is a short video
is_short = "/shorts/" in url
# Create video object
videos.append(
{
"title": title,
"author": author,
"length": length,
"url": url,
"is_short": is_short,
"id": entry.get("id", ""),
"thumbnail": entry.get("thumbnail", ""),
}
)
if not videos:
console.print("[yellow]No videos found for your search.[/yellow]")
return
display_videos(videos, config, page=page)
except subprocess.TimeoutExpired:
console.print("[red]Search timed out. Please try again.[/red]")
except Exception as e:
console.print(f"[red]Error during search: {str(e)}[/red]")
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}"
def display_videos(videos, config, page=1):
"""Display videos in a formatted table."""
console.print("\n" + "=" * 80)
console.print(f"[bold]YouTube Search Results - Page {page}[/bold]")
console.print("=" * 80)
table = Table(
title=f"Page {page} of search results",
show_header=True,
header_style="bold magenta",
)
table.add_column("No.", style="dim", width=3)
table.add_column("Title", width=35)
table.add_column("Author", width=20)
table.add_column("Duration", width=10)
table.add_column("Type", width=8)
for i, video in enumerate(videos, 1):
title = video["title"]
author = video["author"]
length = video["length"]
# Mark short videos
if video["is_short"]:
title = f"(short) {title}"
table.add_row(
str(i),
title,
author[:18] + "..." if len(author) > 18 else author,
length,
"Short" if video["is_short"] else "Video",
)
console.print(table)
# Show pagination options
console.print("=" * 80)
console.print("[blue]Options:[/blue]")
console.print(" [green]n[/green] - Next page")
console.print(" [red]q[/red] - Quit")
console.print(" [yellow]Number[/yellow] - Select and download video (e.g., 1)")
# Get user input
user_input = input("\nChoose an option: ").strip().lower()
if user_input == "q":
console.print("[green]Goodbye![/green]")
return
elif user_input == "n":
console.print(f"[blue]Loading page {page + 1}...[/blue]")
search_videos(
videos[0]["title"].split(" ")[0], config, page=page + 1
) # Simple approach
return
else:
try:
choice = int(user_input)
if 1 <= choice <= len(videos):
selected_video = videos[choice - 1]
console.print(
f"\n[blue]Selected video:[/blue] {selected_video['title']}"
)
# Download the video
download_video(selected_video["url"], config)
else:
console.print("[red]Invalid selection. Please try again.[/red]")
except ValueError:
console.print("[red]Invalid input. Please enter a number or 'q'/'n'.[/red]")
def download_video(url, config):
"""Download a video using yt-dlp with progress bar."""
console.print(f"[blue]Preparing to download:[/blue] {url}")
# Check if yt-dlp is available
try:
subprocess.run(["yt-dlp", "--version"], capture_output=True, check=True)
except (subprocess.CalledProcessError, FileNotFoundError):
console.print(
"[red]Error: yt-dlp not found. Please install it with 'pip install yt-dlp'[/red]"
)
return
# Set download directory
download_dir = Path(config["download_dir"])
download_dir.mkdir(parents=True, exist_ok=True)
# Prepare yt-dlp command
cmd = [
"yt-dlp",
"--no-warnings",
"-o",
str(download_dir / "%(title)s.%(ext)s"),
"--write-thumbnail",
]
# Add custom args from config if they exist
ytdlp_args = config.get("yt_dlp_args", {})
if "format" in ytdlp_args:
cmd.extend(["--format", ytdlp_args["format"]])
if ytdlp_args.get("write_thumbnail", False):
cmd.append("--write-thumbnail")
# Add extractor args
if "extractor_args" in ytdlp_args:
cmd.extend(["--extractor-args", ytdlp_args["extractor_args"]])
# Add URL
cmd.append(url)
try:
console.print("[blue]Starting download...[/blue]")
# Run command with progress tracking
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
universal_newlines=True,
bufsize=1,
)
# Create a simple status indicator
progress_console = Console()
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
console=progress_console,
) as progress:
task = progress.add_task("[blue]Downloading...", total=100)
while True:
output = process.stdout.readline()
if output == "" and process.poll() is not None:
break
if output:
# Simple progress tracking (yt-dlp doesn't support progress events directly)
progress.update(task, advance=1)
# Check if download completed successfully
return_code = process.poll()
if return_code == 0:
console.print("[green]Download completed successfully![/green]")
else:
console.print("[red]Download failed.[/red]")
except Exception as e:
console.print(f"[red]Error during download: {str(e)}[/red]")
# Optionally, ask to select a different download location
locations = config.get("default_locations", [config["download_dir"]])
if len(locations) > 1:
console.print("\n[blue]Select download location:[/blue]")
for i, loc in enumerate(locations, 1):
console.print(f" {i}. {loc}")
try:
choice = int(input("Enter selection (or press Enter to use default): "))
if 1 <= choice <= len(locations):
new_dir = Path(locations[choice - 1])
config["download_dir"] = str(new_dir)
console.print(f"[green]Download location set to: {new_dir}[/green]")
except ValueError:
pass
if __name__ == "__main__":
main()