#!/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()