commit cd4e2c56ba6aa2a1db8a623e173fd806c46f0fe1 Author: Jarian Cottingham Date: Sat Dec 6 01:17:27 2025 -0600 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..93526df --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +venv/ +__pycache__/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..a38683f --- /dev/null +++ b/README.md @@ -0,0 +1,117 @@ +# YouTube CLI + +A command-line interface for browsing and downloading YouTube videos. + +## 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 + +## Requirements + +- Python 3.6+ +- yt-dlp +- rich +- requests + +## 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 +``` + +## Usage + +### Basic Search + +```bash +youtube-cli "python tutorial" +``` + +### Download a Video + +```bash +youtube-cli --download "https://www.youtube.com/watch?v=xyz123" +``` + +### View Help + +```bash +youtube-cli --help +``` + +## Configuration + +The application will create a default configuration file at `~/.config/youtube_cli/config.json` if one doesn't exist. You can customize: + +```json +{ + "download_dir": "~/Downloads/youtube", + "default_locations": [ + "~/Downloads/youtube", + "~/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" + } +} +``` + +## How to Use + +1. Run a search query to find videos +2. Videos will be displayed with: + - Title (short videos marked with "(short)") + - Author + - Duration + - Type indicator +3. Choose an option: + - `n` for next page of results + - `q` to quit + - Enter a number to select and download a video + +## Features + +- **Search**: Search YouTube videos using keyword queries +- **Pagination**: View 15 videos at a time with option for more +- **Download**: Download videos directly with progress indication +- **Short Detection**: Automatically detects and marks short videos +- **Configuration**: Customizable download locations +- **Cross-platform**: Works on macOS and Linux + +## Dependencies + +This tool depends on `yt-dlp` which must be installed separately: + +```bash +pip install yt-dlp +``` + +## Contributing + +1. Fork it +2. Create your feature branch (`git checkout -b feature/AmazingFeature`) +3. Commit your changes (`git commit -m 'Add some AmazingFeature'`) +4. Push to the branch (`git push origin feature/AmazingFeature`) +5. Create a Pull Request + +## License + +MIT License \ No newline at end of file diff --git a/config.json b/config.json new file mode 100644 index 0000000..3c2c482 --- /dev/null +++ b/config.json @@ -0,0 +1,14 @@ +{ + "download_dir": "~/Downloads/youtube", + "default_locations": [ + "~/Downloads/youtube", + "~/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" + } +} diff --git a/prompt.md b/prompt.md new file mode 100644 index 0000000..d79effd --- /dev/null +++ b/prompt.md @@ -0,0 +1,18 @@ +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. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2b6e879 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +yt-dlp +rich +requests diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..8ff00de --- /dev/null +++ b/run.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +# Simple run script for YouTube CLI application + +# Get the directory where this script is located +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Activate virtual environment if it exists +if [ -f "$SCRIPT_DIR/venv/bin/activate" ]; then + source "$SCRIPT_DIR/venv/bin/activate" +fi + +# Run the youtube-cli application +python -m youtube_cli "$@" diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..82c8b47 --- /dev/null +++ b/setup.py @@ -0,0 +1,33 @@ +from setuptools import find_packages, setup + +with open("README.md", "r", encoding="utf-8") as fh: + long_description = fh.read() + +setup( + name="youtube-cli", + version="0.1.0", + author="Your Name", + author_email="your.email@example.com", + description="A command-line interface for browsing and downloading YouTube videos", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/yourusername/youtube-cli", + packages=find_packages(), + classifiers=[ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + ], + python_requires=">=3.6", + install_requires=[ + "yt-dlp", + "rich", + "requests", + ], + entry_points={ + "console_scripts": [ + "youtube-cli=youtube_cli.main:main", + ], + }, + include_package_data=True, +) diff --git a/youtube_cli.egg-info/PKG-INFO b/youtube_cli.egg-info/PKG-INFO new file mode 100644 index 0000000..12ee43f --- /dev/null +++ b/youtube_cli.egg-info/PKG-INFO @@ -0,0 +1,142 @@ +Metadata-Version: 2.4 +Name: youtube-cli +Version: 0.1.0 +Summary: A command-line interface for browsing and downloading YouTube videos +Home-page: https://github.com/yourusername/youtube-cli +Author: Your Name +Author-email: your.email@example.com +Classifier: Programming Language :: Python :: 3 +Classifier: License :: OSI Approved :: MIT License +Classifier: Operating System :: OS Independent +Requires-Python: >=3.6 +Description-Content-Type: text/markdown +Requires-Dist: yt-dlp +Requires-Dist: rich +Requires-Dist: requests +Dynamic: author +Dynamic: author-email +Dynamic: classifier +Dynamic: description +Dynamic: description-content-type +Dynamic: home-page +Dynamic: requires-dist +Dynamic: requires-python +Dynamic: summary + +# YouTube CLI + +A command-line interface for browsing and downloading YouTube videos. + +## 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 + +## Requirements + +- Python 3.6+ +- yt-dlp +- rich +- requests + +## 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 +``` + +## Usage + +### Basic Search + +```bash +youtube-cli "python tutorial" +``` + +### Download a Video + +```bash +youtube-cli --download "https://www.youtube.com/watch?v=xyz123" +``` + +### View Help + +```bash +youtube-cli --help +``` + +## Configuration + +The application will create a default configuration file at `~/.config/youtube_cli/config.json` if one doesn't exist. You can customize: + +```json +{ + "download_dir": "~/Downloads/youtube", + "default_locations": [ + "~/Downloads/youtube", + "~/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" + } +} +``` + +## How to Use + +1. Run a search query to find videos +2. Videos will be displayed with: + - Title (short videos marked with "(short)") + - Author + - Duration + - Type indicator +3. Choose an option: + - `n` for next page of results + - `q` to quit + - Enter a number to select and download a video + +## Features + +- **Search**: Search YouTube videos using keyword queries +- **Pagination**: View 15 videos at a time with option for more +- **Download**: Download videos directly with progress indication +- **Short Detection**: Automatically detects and marks short videos +- **Configuration**: Customizable download locations +- **Cross-platform**: Works on macOS and Linux + +## Dependencies + +This tool depends on `yt-dlp` which must be installed separately: + +```bash +pip install yt-dlp +``` + +## Contributing + +1. Fork it +2. Create your feature branch (`git checkout -b feature/AmazingFeature`) +3. Commit your changes (`git commit -m 'Add some AmazingFeature'`) +4. Push to the branch (`git push origin feature/AmazingFeature`) +5. Create a Pull Request + +## License + +MIT License diff --git a/youtube_cli.egg-info/SOURCES.txt b/youtube_cli.egg-info/SOURCES.txt new file mode 100644 index 0000000..e6d8eb0 --- /dev/null +++ b/youtube_cli.egg-info/SOURCES.txt @@ -0,0 +1,11 @@ +README.md +setup.py +youtube_cli/__init__.py +youtube_cli/__main__.py +youtube_cli/main.py +youtube_cli.egg-info/PKG-INFO +youtube_cli.egg-info/SOURCES.txt +youtube_cli.egg-info/dependency_links.txt +youtube_cli.egg-info/entry_points.txt +youtube_cli.egg-info/requires.txt +youtube_cli.egg-info/top_level.txt \ No newline at end of file diff --git a/youtube_cli.egg-info/dependency_links.txt b/youtube_cli.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/youtube_cli.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/youtube_cli.egg-info/entry_points.txt b/youtube_cli.egg-info/entry_points.txt new file mode 100644 index 0000000..376c289 --- /dev/null +++ b/youtube_cli.egg-info/entry_points.txt @@ -0,0 +1,2 @@ +[console_scripts] +youtube-cli = youtube_cli.main:main diff --git a/youtube_cli.egg-info/requires.txt b/youtube_cli.egg-info/requires.txt new file mode 100644 index 0000000..2b6e879 --- /dev/null +++ b/youtube_cli.egg-info/requires.txt @@ -0,0 +1,3 @@ +yt-dlp +rich +requests diff --git a/youtube_cli.egg-info/top_level.txt b/youtube_cli.egg-info/top_level.txt new file mode 100644 index 0000000..4d43fe9 --- /dev/null +++ b/youtube_cli.egg-info/top_level.txt @@ -0,0 +1 @@ +youtube_cli diff --git a/youtube_cli/__init__.py b/youtube_cli/__init__.py new file mode 100644 index 0000000..caa5549 --- /dev/null +++ b/youtube_cli/__init__.py @@ -0,0 +1,5 @@ +""" +YouTube CLI - A command-line interface for browsing and downloading YouTube videos +""" + +__version__ = "0.1.0" diff --git a/youtube_cli/__main__.py b/youtube_cli/__main__.py new file mode 100644 index 0000000..b9e4e3e --- /dev/null +++ b/youtube_cli/__main__.py @@ -0,0 +1,9 @@ +#!/usr/bin/env python3 +""" +YouTube CLI - A command-line interface for browsing and downloading YouTube videos +""" + +from .main import main + +if __name__ == "__main__": + main() diff --git a/youtube_cli/main.py b/youtube_cli/main.py new file mode 100644 index 0000000..eb716a8 --- /dev/null +++ b/youtube_cli/main.py @@ -0,0 +1,359 @@ +#!/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()