diff --git a/.gitmodules b/.gitmodules index 850d241..b73abe2 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ [submodule "PRbL"] path = PRbL - url = https://github.com/pyr0ball/PRbL.git + url = https://git.opensourcesolarpunk.com/Circuit-Forge/PRbL.git diff --git a/lib/skel/.bashrc.d/02-mediatools.bashrc b/lib/skel/.bashrc.d/02-mediatools.bashrc new file mode 100644 index 0000000..e4c169d --- /dev/null +++ b/lib/skel/.bashrc.d/02-mediatools.bashrc @@ -0,0 +1,294 @@ +alias grabVideo='yt-dlp --output "%(title)s.%(ext)s" --restrict-filenames --write-sub --sub-lang en --convert-subs srt --write-auto-sub' +alias grabAudio='yt-dlp --extract-audio --audio-format mp3 --output "%(title)s.%(ext)s" --restrict-filenames' +alias grabAlbum='yt-dlp --extract-audio --audio-format mp3 --output "%(title)s.%(ext)s" --restrict-filenames --split-chapters' + +fix_aspect_ratio() { + local input_file="$1" + local output_file="$2" + + # Validate inputs + if [[ -z "$input_file" ]]; then + log_error "Input file not specified" + return 1 + fi + + if [[ ! -f "$input_file" ]]; then + log_error "Input file does not exist: $input_file" + return 1 + fi + + if [[ -z "$output_file" ]]; then + # Generate output filename + local basename="${input_file%.*}" + local extension="${input_file##*.}" + output_file="${basename}_fixed.${extension}" + fi + + log_info "Fixing aspect ratio for: $input_file" + log_info "Output will be saved as: $output_file" + + # Check if ffmpeg is available + if ! command -v ffmpeg &> /dev/null; then + log_error "ffmpeg is not installed or not in PATH" + return 1 + fi + + # Get video dimensions for verification + local dimensions=$(ffprobe -v quiet -select_streams v:0 -show_entries stream=width,height -of csv=s=x:p=0 "$input_file") + log_info "Current video dimensions: $dimensions" + + # Fix the aspect ratio by scaling width to 4:3 ratio based on height + # This assumes the height is correct and the width was stretched + log_info "Converting stretched 16:9 video back to proper 4:3 aspect ratio..." + + ffmpeg -i "$input_file" \ + -vf "scale=ih*4/3:ih" \ + -c:a copy \ + -y \ + "$output_file" + + if [[ $? -eq 0 ]]; then + log_success "Aspect ratio correction completed successfully" + log_info "Original file: $input_file" + log_info "Fixed file: $output_file" + + # Show new dimensions + local new_dimensions=$(ffprobe -v quiet -select_streams v:0 -show_entries stream=width,height -of csv=s=x:p=0 "$output_file") + log_info "New video dimensions: $new_dimensions" + else + log_error "Aspect ratio correction failed" + return 1 + fi +} + +# Alternative function if you want to add pillarboxing instead of changing dimensions +fix_aspect_ratio_pillarbox() { + local input_file="$1" + local output_file="$2" + + if [[ -z "$output_file" ]]; then + local basename="${input_file%.*}" + local extension="${input_file##*.}" + output_file="${basename}_pillarboxed.${extension}" + fi + + log_info "Adding pillarbox to maintain 16:9 container with proper 4:3 content" + + # This scales the video to proper 4:3 proportions and adds black bars + ffmpeg -i "$input_file" \ + -vf "scale=ih*4/3:ih,pad=ih*16/9:ih:(ow-iw)/2:0:black" \ + -c:a copy \ + -y \ + "$output_file" +} + +# Simple live stream download +record_live_basic() { + local url="$1" + local output_dir="${2:-./recordings}" + + mkdir -p "$output_dir" + yt-dlp \ + --live-from-start \ + --output "$output_dir/%(title)s-%(upload_date)s-%(id)s.%(ext)s" \ + "$url" +} + +# Record live stream with specific quality and format +record_live_quality() { + local url="$1" + local output_dir="${2:-./recordings}" + local quality="${3:-}" # Default to no format selection + + mkdir -p "$output_dir" + + local format_opts=() + if [[ -n "$quality" ]]; then + format_opts+=(--format "$quality") + fi + + yt-dlp \ + --live-from-start \ + "${format_opts[@]}" \ + --output "$output_dir/%(title)s-%(upload_date)s-%(id)s.%(ext)s" \ + --write-description \ + --write-info-json \ + "$url" +} + +# Record with duration limit (better function signature) +record_live_with_duration() { + local url="$1" + local duration_seconds="$2" + local output_dir="${3:-./recordings}" + local quality="${4:-}" + + mkdir -p "$output_dir" + + local format_opts=() + if [[ -n "$quality" ]]; then + format_opts+=(--format "$quality") + fi + + timeout "$duration_seconds" yt-dlp \ + --live-from-start \ + "${format_opts[@]}" \ + --output "$output_dir/%(title)s-%(upload_date)s-%(id)s.%(ext)s" \ + "$url" +} + +# Check if stream is live +check_stream_status() { + local url="$1" + + echo "Checking stream status..." + yt-dlp --quiet --simulate --print "%(title)s" --print "%(is_live)s" --print "%(live_status)s" "$url" 2>/dev/null || { + echo "Error: Could not access stream or stream not found" + return 1 + } +} + +# List available formats +list_stream_formats() { + local url="$1" + + echo "Available formats for stream:" + yt-dlp --list-formats "$url" +} + +# Record live stream until it ends +record_live_wait() { + local url="$1" + local output_dir="${2:-./recordings}" + + mkdir -p "$output_dir" + yt-dlp \ + --live-from-start \ + --wait-for-video 60 \ + --output "$output_dir/%(title)s-%(upload_date)s-%(id)s.%(ext)s" \ + --write-thumbnail \ + --embed-metadata \ + "$url" +} + +# Record with time limit +record_live_duration() { + local url="$1" + local duration="$2" # in seconds + local output_dir="${3:-./recordings}" + + mkdir -p "$output_dir" + timeout "$duration" yt-dlp \ + --live-from-start \ + --output "$output_dir/%(title)s-%(upload_date)s-%(id)s.%(ext)s" \ + "$url" +} + +# Monitor and record when live starts +monitor_and_record() { + local url="$1" + local output_dir="${2:-./recordings}" + local check_interval="${3:-300}" # 5 minutes + + mkdir -p "$output_dir" + + while true; do + echo "Checking if stream is live..." + + # Check if stream is currently live + if yt-dlp --quiet --simulate --print "%(is_live)s" "$url" 2>/dev/null | grep -q "True"; then + echo "Stream is live! Starting recording..." + yt-dlp \ + --live-from-start \ + --output "$output_dir/%(title)s-%(upload_date)s-%(id)s.%(ext)s" \ + --write-description \ + --write-info-json \ + "$url" + break + else + echo "Stream not live yet. Waiting $check_interval seconds..." + sleep "$check_interval" + fi + done +} + +# Alternative using streamlink for better live stream handling +record_with_streamlink() { + local url="$1" + local quality="${2:-best}" + local output_file="$3" + + if command -v streamlink >/dev/null 2>&1; then + streamlink "$url" "$quality" --output "$output_file" + else + echo "Streamlink not installed. Install with: pip install streamlink" + return 1 + fi +} + +# FFmpeg direct recording (for RTMP/HLS streams) +record_with_ffmpeg() { + local stream_url="$1" + local output_file="$2" + local duration="${3:-}" + + local ffmpeg_opts=() + + if [[ -n "$duration" ]]; then + ffmpeg_opts+=(-t "$duration") + fi + + ffmpeg -i "$stream_url" \ + "${ffmpeg_opts[@]}" \ + -c copy \ + -f mp4 \ + "$output_file" +} + +# Troubleshooting functions +troubleshoot_stream() { + local url="$1" + + echo "=== STREAM TROUBLESHOOTING ===" + echo "URL: $url" + echo + + echo "1. Checking if URL is accessible..." + if check_stream_status "$url"; then + echo "✓ Stream URL is accessible" + else + echo "✗ Stream URL is not accessible" + return 1 + fi + + echo + echo "2. Available formats:" + list_stream_formats "$url" + + echo + echo "3. Recommended commands:" + echo " Basic recording (no format selection):" + echo " record_live_basic \"$url\"" + echo + echo " With duration (30 minutes):" + echo " record_live_with_duration \"$url\" \$((60*30))" + echo + echo " With specific quality:" + echo " record_live_with_duration \"$url\" \$((60*30)) \"./recordings\" \"720p\"" +} + +# Example usage (CORRECTED) +# Basic recording: +# record_live_basic "https://youtube.com/watch?v=STREAM_ID" + +# Record for 30 minutes: +# record_live_with_duration "https://youtube.com/watch?v=STREAM_ID" $((60*30)) + +# Record with specific quality and duration: +# record_live_with_duration "https://youtube.com/watch?v=STREAM_ID" $((60*30)) "./recordings" "720p" + +# Troubleshoot a problematic stream: +# troubleshoot_stream "https://youtube.com/watch?v=STREAM_ID" + +# Monitor for when stream goes live: +# monitor_and_record "https://youtube.com/watch?v=STREAM_ID" "./recordings" 600 \ No newline at end of file diff --git a/lib/skel/.bashrc.d/02-yt-dlp.bashrc b/lib/skel/.bashrc.d/02-yt-dlp.bashrc deleted file mode 100644 index 63a39ad..0000000 --- a/lib/skel/.bashrc.d/02-yt-dlp.bashrc +++ /dev/null @@ -1,3 +0,0 @@ -alias grabVideo='yt-dlp --output "%(title)s.%(ext)s" --restrict-filenames --write-sub --sub-lang en --convert-subs srt --write-auto-sub' -alias grabAudio='yt-dlp --extract-audio --audio-format mp3 --output "%(title)s.%(ext)s" --restrict-filenames' -alias grabAlbum='yt-dlp --extract-audio --audio-format mp3 --output "%(title)s.%(ext)s" --restrict-filenames --split-chapters' diff --git a/scripts/audio-grabber.sh b/scripts/audio-grabber.sh new file mode 100755 index 0000000..a237feb --- /dev/null +++ b/scripts/audio-grabber.sh @@ -0,0 +1,741 @@ +#!/bin/bash + +# ~/.local/share/prbl/scripts/enhanced-audio-grabber.sh +# Purpose: Smart YouTube audio downloader with automatic album organization, metadata tagging, and Plex optimization +# Usage: ./enhanced-audio-grabber.sh [OPTIONS] +# Run from artist directory - script auto-detects album name and creates subfolders +# +# Features: +# - Auto-detects album/playlist names and creates appropriate folders +# - Embeds album art and metadata for Plex compatibility +# - Handles artist directory structure intelligently +# - Fixes common ffmpeg detection issues +# - Supports batch processing of playlists +# - Fixed stdout/stderr handling to prevent directory name corruption + +set -euo pipefail + +# Script configuration +SCRIPT_NAME="enhanced-audio-grabber" +VERSION="2.0.2" +DEFAULT_FORMAT="mp3" +DEFAULT_QUALITY="192" +DEFAULT_BITRATE="192k" + +# Color codes for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +# Function to print colored output (always to stderr to avoid capture) +print_status() { + local level=$1 + shift + case $level in + "error") echo -e "${RED}[ERROR]${NC} $*" >&2 ;; + "warning") echo -e "${YELLOW}[WARNING]${NC} $*" >&2 ;; + "success") echo -e "${GREEN}[SUCCESS]${NC} $*" >&2 ;; + "info") echo -e "${BLUE}[INFO]${NC} $*" >&2 ;; + "debug") echo -e "${CYAN}[DEBUG]${NC} $*" >&2 ;; + *) echo "$*" >&2 ;; + esac +} + +# Function to check if a command exists +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# Function to find ffmpeg path +find_ffmpeg() { + local ffmpeg_candidates=( + "$(command -v ffmpeg 2>/dev/null || true)" + "/usr/bin/ffmpeg" + "/usr/local/bin/ffmpeg" + "/opt/homebrew/bin/ffmpeg" + "$HOME/bin/ffmpeg" + "/snap/bin/ffmpeg" + "./ffmpeg" + ) + + for path in "${ffmpeg_candidates[@]}"; do + if [[ -n "$path" && -x "$path" ]]; then + echo "$path" + return 0 + fi + done + + return 1 +} + +# Function to sanitize filename/directory names +sanitize_name() { + local name="$1" + # Remove problematic characters but preserve readability + echo "$name" | sed -E 's/[<>:"/\\|?*]/_/g' | sed -E 's/__+/_/g' | sed -E 's/^_+|_+$//g' | sed -E 's/\.$//g' +} + +# Function to get YouTube metadata +get_youtube_metadata() { + local url="$1" + local temp_info="/tmp/yt_info_$$.json" + + # Get metadata without downloading + if yt-dlp --dump-json --flat-playlist "$url" > "$temp_info" 2>/dev/null; then + echo "$temp_info" + return 0 + else + rm -f "$temp_info" + return 1 + fi +} + +# Function to extract album info from metadata +extract_album_info() { + local metadata_file="$1" + local album_title="" + local artist_name="" + local track_count="" + + # Try to extract playlist/album title + if command_exists jq; then + album_title=$(jq -r '.title // .playlist_title // empty' "$metadata_file" 2>/dev/null | head -n1) + artist_name=$(jq -r '.uploader // .channel // empty' "$metadata_file" 2>/dev/null | head -n1) + track_count=$(jq -r '.playlist_count // empty' "$metadata_file" 2>/dev/null) + else + # Fallback to grep if jq not available + album_title=$(grep -o '"title":\s*"[^"]*"' "$metadata_file" | head -n1 | cut -d'"' -f4) + artist_name=$(grep -o '"uploader":\s*"[^"]*"' "$metadata_file" | head -n1 | cut -d'"' -f4) + fi + + # Clean up the names + album_title=$(sanitize_name "$album_title") + artist_name=$(sanitize_name "$artist_name") + + echo "$album_title|$artist_name|$track_count" +} + +# Function to detect current directory context +detect_directory_context() { + local current_dir=$(basename "$PWD") + local parent_dir=$(basename "$(dirname "$PWD")") + + # Check if we're in what looks like an artist directory + if [[ -d "../$current_dir" ]] && [[ $(find . -maxdepth 1 -type d | wc -l) -gt 1 ]]; then + echo "artist|$current_dir" + elif [[ -d "../../$parent_dir" ]] && [[ -d "../$current_dir" ]]; then + echo "album|$current_dir|$parent_dir" + else + echo "unknown|$current_dir" + fi +} + +# Function to create album directory and organize (FIXED - no stdout pollution) +setup_album_directory() { + local album_name="$1" + local artist_name="$2" + local context="$3" + + local target_dir="" + + case $context in + "artist") + # We're in artist directory, create album subdirectory + target_dir="./$album_name" + ;; + "album") + # We're already in an album directory + target_dir="." + ;; + *) + # Unknown context, create artist/album structure + if [[ -n "$artist_name" ]]; then + target_dir="./$artist_name/$album_name" + else + target_dir="./$album_name" + fi + ;; + esac + + # Create directory if it doesn't exist + if [[ "$target_dir" != "." ]]; then + mkdir -p "$target_dir" + print_status "info" "Created album directory: $target_dir" + fi + + # Only echo the target directory to stdout (for capture) + echo "$target_dir" +} + +# Function to embed metadata and album art +embed_metadata() { + local audio_file="$1" + local album_title="$2" + local artist_name="$3" + local track_number="$4" + local album_art="$5" + local year="$6" + + if [[ ! -f "$audio_file" ]]; then + return 1 + fi + + # Find ffmpeg path for metadata embedding + local ffmpeg_path + if ! ffmpeg_path=$(find_ffmpeg); then + print_status "warning" "ffmpeg not found for metadata embedding" + return 1 + fi + + # Build ffmpeg metadata command + local temp_output="${audio_file}.tmp" + local cmd=("$ffmpeg_path" -i "$audio_file" -c copy) + + # Add metadata + if [[ -n "$album_title" ]]; then + cmd+=(-metadata "album=$album_title") + fi + + if [[ -n "$artist_name" ]]; then + cmd+=(-metadata "artist=$artist_name") + cmd+=(-metadata "album_artist=$artist_name") + fi + + if [[ -n "$track_number" ]]; then + cmd+=(-metadata "track=$track_number") + fi + + if [[ -n "$year" ]]; then + cmd+=(-metadata "date=$year") + fi + + # Add album art if available + if [[ -n "$album_art" && -f "$album_art" ]]; then + cmd+=(-i "$album_art" -map 0 -map 1 -disposition:v:0 attached_pic) + fi + + cmd+=(-y "$temp_output") + + # Execute metadata embedding + if "${cmd[@]}" >/dev/null 2>&1; then + mv "$temp_output" "$audio_file" + return 0 + else + rm -f "$temp_output" + return 1 + fi +} + +# Function to download and process album +download_album() { + local url="$1" + local audio_format="$2" + local quality="$3" + local embed_metadata_flag="$4" + local verbose="$5" + + print_status "info" "Analyzing URL: $url" + + # Get YouTube metadata + local metadata_file + if ! metadata_file=$(get_youtube_metadata "$url"); then + print_status "error" "Failed to get metadata from YouTube URL" + return 1 + fi + + # Extract album information + local album_info + album_info=$(extract_album_info "$metadata_file") + IFS='|' read -r album_title artist_name track_count <<< "$album_info" + + if [[ -z "$album_title" ]]; then + print_status "warning" "Could not detect album title, using generic name" + album_title="Downloaded_Album_$(date +%Y%m%d_%H%M%S)" + fi + + print_status "info" "Album: $album_title" + [[ -n "$artist_name" ]] && print_status "info" "Artist: $artist_name" + [[ -n "$track_count" ]] && print_status "info" "Tracks: $track_count" + + # Detect directory context + local dir_context + dir_context=$(detect_directory_context) + + # Parse directory context info + local context_type current_name parent_name + context_type=$(echo "$dir_context" | cut -d'|' -f1) + current_name=$(echo "$dir_context" | cut -d'|' -f2) + parent_name=$(echo "$dir_context" | cut -d'|' -f3) + + # Setup album directory (FIXED - properly capture only the directory path) + local album_dir + album_dir=$(setup_album_directory "$album_title" "$artist_name" "$context_type") + + # Find ffmpeg + local ffmpeg_path + if ffmpeg_path=$(find_ffmpeg); then + print_status "debug" "Found ffmpeg at: $ffmpeg_path" + else + print_status "error" "ffmpeg not found. Please install ffmpeg." + rm -f "$metadata_file" + return 1 + fi + + # Build yt-dlp command for actual download + local yt_cmd=(yt-dlp) + + # Add ffmpeg location + yt_cmd+=(--ffmpeg-location "$ffmpeg_path") + + # Audio extraction settings + yt_cmd+=(--extract-audio --audio-format "$audio_format" --audio-quality "$quality") + + # Output template with track numbering for playlists + yt_cmd+=(--output "$album_dir/%(playlist_index)02d - %(title)s.%(ext)s") + + # Additional options for better quality and metadata + yt_cmd+=( + --ignore-errors + --add-metadata + --embed-thumbnail + --write-info-json + --write-thumbnail + --restrict-filenames + --no-warnings + ) + + # Add verbose flag if requested + if [[ "$verbose" == "true" ]]; then + yt_cmd+=(--verbose) + fi + + print_status "info" "Starting download to: $album_dir" + + # Execute download + if "${yt_cmd[@]}" "$url"; then + print_status "success" "Download completed" + + # Post-process files if metadata embedding is requested + if [[ "$embed_metadata_flag" == "true" ]]; then + post_process_album "$album_dir" "$album_title" "$artist_name" "$audio_format" "$verbose" + fi + + # Clean up temporary files + find "$album_dir" -name "*.info.json" -delete 2>/dev/null || true + find "$album_dir" -name "*.webp" -delete 2>/dev/null || true + + else + print_status "error" "Download failed" + rm -f "$metadata_file" + return 1 + fi + + rm -f "$metadata_file" + return 0 +} + +# Function to post-process downloaded album (FIXED - added audio_format parameter) +post_process_album() { + local album_dir="$1" + local album_title="$2" + local artist_name="$3" + local audio_format="$4" + local verbose="$5" + + print_status "info" "Post-processing album metadata..." + + # Find album art (thumbnail downloaded by yt-dlp) + local album_art + album_art=$(find "$album_dir" -name "*.jpg" -o -name "*.png" -o -name "*.webp" | head -n1) + + # Get current year + local year=$(date +%Y) + + # Process each audio file + local track_num=1 + while IFS= read -r -d '' audio_file; do + if [[ "$verbose" == "true" ]]; then + print_status "debug" "Processing: $(basename "$audio_file")" + fi + + # Extract track number from filename if present + local filename=$(basename "$audio_file") + if [[ $filename =~ ^([0-9]+) ]]; then + track_num="${BASH_REMATCH[1]#0}" # Remove leading zeros + fi + + # Embed metadata + if ! embed_metadata "$audio_file" "$album_title" "$artist_name" "$track_num" "$album_art" "$year"; then + print_status "warning" "Failed to embed metadata for: $(basename "$audio_file")" + fi + + ((track_num++)) + done < <(find "$album_dir" -maxdepth 1 -name "*.$audio_format" -print0 | sort -z) + + # Create a folder.jpg for Plex if we have album art + if [[ -n "$album_art" && -f "$album_art" ]]; then + cp "$album_art" "$album_dir/folder.jpg" + print_status "success" "Created folder.jpg for Plex" + fi + + print_status "success" "Metadata processing completed" +} + +# Function to show usage +show_usage() { + cat << EOF +$SCRIPT_NAME v$VERSION - Smart YouTube Audio Downloader + +DESCRIPTION: + Downloads YouTube playlists/albums with automatic organization and metadata tagging. + Designed to work seamlessly with Plex and media server setups. + +USAGE: + $0 [OPTIONS] + +OPTIONS: + -f, --format FORMAT Audio format (mp3, aac, flac, m4a) [default: $DEFAULT_FORMAT] + -q, --quality QUALITY Audio quality (0-320 for mp3, 0-500 for aac) [default: $DEFAULT_QUALITY] + -m, --metadata Embed album art and metadata in files + -s, --skip-metadata Skip metadata embedding (faster) + -b, --bitrate BITRATE Specify exact bitrate (e.g., 320k, 192k) [default: $DEFAULT_BITRATE] + -v, --verbose Verbose output + -d, --dry-run Show what would be downloaded without downloading + -h, --help Show this help message + +DIRECTORY BEHAVIOR: + - Run from artist directory: Creates album subdirectory automatically + - Run from album directory: Downloads to current directory + - Run from anywhere else: Creates Artist/Album structure + +EXAMPLES: + # Download album with metadata (recommended) + $0 -m https://www.youtube.com/playlist?list=PL224DDF005A5C86A9 + + # High quality FLAC with metadata + $0 -f flac -m -q 0 https://www.youtube.com/playlist?list=example + + # Quick download without metadata processing + $0 -s https://www.youtube.com/watch?v=example + +METADATA FEATURES: + - Automatically extracts album/artist names from YouTube + - Embeds album artwork in individual tracks + - Creates folder.jpg for Plex media server recognition + - Numbers tracks correctly for proper playback order + - Adds year, artist, and album metadata tags + +DEPENDENCIES: + - yt-dlp (pip install yt-dlp) + - ffmpeg (auto-detected from PATH or common locations) + - jq (optional, for better metadata parsing) + +CLEANUP: + To remove corrupted directories with ANSI codes in names: + find . -name "*\$'*" -type d -exec rm -rf {} + + +EOF +} + +# Function to check dependencies +check_dependencies() { + local missing_deps=() + + # Check for yt-dlp + if ! command_exists yt-dlp; then + missing_deps+=("yt-dlp") + fi + + # Check for ffmpeg and set path + if FFMPEG_PATH=$(find_ffmpeg); then + export FFMPEG_PATH + print_status "debug" "Found ffmpeg at: $FFMPEG_PATH" + else + missing_deps+=("ffmpeg") + fi + + # Check for jq (optional but recommended) + if ! command_exists jq; then + print_status "warning" "jq not found - metadata parsing will be limited" + print_status "info" "Install jq for better results: sudo apt install jq # or brew install jq" + fi + + # Report missing dependencies + if [[ ${#missing_deps[@]} -gt 0 ]]; then + print_status "error" "Missing required dependencies: ${missing_deps[*]}" + print_status "info" "Install commands:" + for dep in "${missing_deps[@]}"; do + case $dep in + "yt-dlp") + echo " pip install yt-dlp" >&2 + echo " # or: brew install yt-dlp" >&2 + echo " # or: sudo apt install yt-dlp" >&2 + ;; + "ffmpeg") + echo " # Ubuntu/Debian: sudo apt install ffmpeg" >&2 + echo " # macOS: brew install ffmpeg" >&2 + echo " # Arch: sudo pacman -S ffmpeg" >&2 + ;; + esac + done + return 1 + fi + + return 0 +} + +# Function for dry run - show what would be downloaded +dry_run() { + local url="$1" + + print_status "info" "DRY RUN - Analyzing what would be downloaded" + + # Get metadata + local metadata_file + if ! metadata_file=$(get_youtube_metadata "$url"); then + print_status "error" "Failed to get metadata for dry run" + return 1 + fi + + # Extract info + local album_info + album_info=$(extract_album_info "$metadata_file") + IFS='|' read -r album_title artist_name track_count <<< "$album_info" + + # Show what would happen (all to stderr to avoid capture issues) + { + echo + echo "DOWNLOAD PLAN:" + echo "==============" + echo "Album: ${album_title:-Unknown}" + echo "Artist: ${artist_name:-Unknown}" + echo "Tracks: ${track_count:-Unknown}" + echo "Current directory: $PWD" + + local dir_context + dir_context=$(detect_directory_context) + + # Parse directory context + local context_type current_name + context_type=$(echo "$dir_context" | cut -d'|' -f1) + current_name=$(echo "$dir_context" | cut -d'|' -f2) + + local target_dir + case $context_type in + "artist") + target_dir="$PWD/$album_title" + echo "Target directory: $target_dir (new album subdirectory)" + ;; + "album") + target_dir="$PWD" + echo "Target directory: $target_dir (current album directory)" + ;; + *) + if [[ -n "$artist_name" ]]; then + target_dir="$PWD/$artist_name/$album_title" + else + target_dir="$PWD/$album_title" + fi + echo "Target directory: $target_dir (new artist/album structure)" + ;; + esac + + echo "Audio format: $audio_format" + echo "Quality: $quality" + echo + + # Show actual tracks that would be downloaded + if command_exists jq && [[ -f "$metadata_file" ]]; then + echo "TRACKS:" + echo "=======" + jq -r '.entries[]?.title // .title // "Unknown Track"' "$metadata_file" 2>/dev/null | nl -w2 -s'. ' + fi + } >&2 + + rm -f "$metadata_file" +} + +# Function to clean up corrupted directories +cleanup_corrupted_dirs() { + print_status "info" "Searching for directories with ANSI escape codes in their names..." + + # Find directories with ANSI escape sequences + local corrupted_dirs + corrupted_dirs=$(find . -maxdepth 1 -name "*\$'*" -type d 2>/dev/null || true) + + if [[ -n "$corrupted_dirs" ]]; then + print_status "warning" "Found corrupted directories:" + echo "$corrupted_dirs" >&2 + + echo -n "Remove these directories? (y/N): " >&2 + read -r response + + if [[ "$response" =~ ^[Yy]$ ]]; then + while IFS= read -r dir; do + if [[ -d "$dir" ]]; then + print_status "info" "Removing: $dir" + rm -rf "$dir" + fi + done <<< "$corrupted_dirs" + print_status "success" "Corrupted directories cleaned up" + else + print_status "info" "Skipping cleanup" + fi + else + print_status "info" "No corrupted directories found" + fi +} + +# Main function +main() { + # Default values + local audio_format="$DEFAULT_FORMAT" + local quality="$DEFAULT_QUALITY" + local bitrate="$DEFAULT_BITRATE" + local embed_metadata_flag="true" # Default to embedding metadata + local verbose="false" + local dry_run_flag="false" + local cleanup_flag="false" + local url="" + + # Parse command line arguments + while [[ $# -gt 0 ]]; do + case $1 in + -o|--output) + output_dir="$2" + shift 2 + ;; + -f|--format) + audio_format="$2" + shift 2 + ;; + -q|--quality) + quality="$2" + shift 2 + ;; + -b|--bitrate) + bitrate="$2" + shift 2 + ;; + -m|--metadata) + embed_metadata_flag="true" + shift + ;; + -s|--skip-metadata) + embed_metadata_flag="false" + shift + ;; + -v|--verbose) + verbose="true" + shift + ;; + -d|--dry-run) + dry_run_flag="true" + shift + ;; + --cleanup) + cleanup_flag="true" + shift + ;; + -h|--help) + show_usage + exit 0 + ;; + -*) + print_status "error" "Unknown option: $1" + show_usage + exit 1 + ;; + *) + if [[ -z "$url" ]]; then + url="$1" + else + print_status "error" "Multiple URLs not supported" + exit 1 + fi + shift + ;; + esac + done + + # Handle cleanup mode + if [[ "$cleanup_flag" == "true" ]]; then + cleanup_corrupted_dirs + exit 0 + fi + + # Validate inputs + if [[ -z "$url" ]]; then + print_status "error" "No YouTube URL provided" + show_usage + exit 1 + fi + + # Validate audio format + case $audio_format in + mp3|aac|flac|m4a|wav|ogg) ;; + *) + print_status "error" "Unsupported audio format: $audio_format" + print_status "info" "Supported formats: mp3, aac, flac, m4a, wav, ogg" + exit 1 + ;; + esac + + # Check dependencies + if ! check_dependencies; then + exit 1 + fi + + # Handle dry run + if [[ "$dry_run_flag" == "true" ]]; then + dry_run "$url" + exit 0 + fi + + # Show configuration if verbose + if [[ "$verbose" == "true" ]]; then + print_status "debug" "Configuration:" + echo " Format: $audio_format" >&2 + echo " Quality: $quality" >&2 + echo " Bitrate: $bitrate" >&2 + echo " Embed metadata: $embed_metadata_flag" >&2 + echo " Current directory: $PWD" >&2 + fi + + # Download and process + if download_album "$url" "$audio_format" "$quality" "$embed_metadata_flag" "$verbose"; then + print_status "success" "Album download and processing completed!" + print_status "info" "Files are ready for Plex media server" + else + print_status "error" "Download failed" + exit 1 + fi +} + +# Helper function to create config file +create_config_example() { + local config_dir="$HOME/.config/transcribe" + local config_file="$config_dir/config.json" + + mkdir -p "$config_dir" + + cat > "$config_file" << 'EOF' +{ + "hf_token": "your_huggingface_token_here", + "default_audio_format": "mp3", + "default_quality": "192", + "auto_embed_metadata": true +} +EOF + + print_status "info" "Created example config file at: $config_file" + print_status "info" "Edit this file to set your preferences" +} + +# Run main function if script is executed directly +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi diff --git a/scripts/protonvpn-cli b/scripts/protonvpn-cli new file mode 100644 index 0000000..eb777aa --- /dev/null +++ b/scripts/protonvpn-cli @@ -0,0 +1,748 @@ +#!/usr/bin/env bash + +# ProtonVPN CLI Tool +# Path: ~/bin/protonvpn-cli or /usr/local/bin/protonvpn-cli +# +# Purpose: Command-line interface for managing ProtonVPN WireGuard connections +# Provides nordvpn-like functionality: auto-connect, country selection, speed testing +# +# Usage: +# protonvpn-cli connect [country] - Connect to fastest server (optionally from specific country) +# protonvpn-cli disconnect - Disconnect from current server +# protonvpn-cli status - Show connection status +# protonvpn-cli list [countries] - List available servers or countries +# protonvpn-cli update - Update server configurations +# protonvpn-cli speedtest - Test speeds of available servers +# protonvpn-cli help - Show this help message + +set -euo pipefail + +# Configuration +readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly CONFIG_DIR="${HOME}/.config/protonvpn-cli" +readonly CONFIGS_DIR="${CONFIG_DIR}/configs" +readonly STATE_FILE="${CONFIG_DIR}/state.json" +readonly SPEED_CACHE="${CONFIG_DIR}/speeds.json" +readonly GENERATOR_SCRIPT="${SCRIPT_DIR}/protonvpn_generator.py" +readonly LOG_FILE="${CONFIG_DIR}/protonvpn-cli.log" + +# Colors for output +readonly RED='\033[0;31m' +readonly GREEN='\033[0;32m' +readonly YELLOW='\033[1;33m' +readonly BLUE='\033[0;34m' +readonly CYAN='\033[0;36m' +readonly BOLD='\033[1m' +readonly NC='\033[0m' # No Color + +# Global variables +VERBOSE=false +DRY_RUN=false +CURRENT_CONNECTION="" + +####################################### +# Print colored status messages to stderr +# Arguments: +# $1 - log level (INFO, WARN, ERROR, SUCCESS) +# $2+ - message +####################################### +print_status() { + local level="$1" + shift + local timestamp + timestamp=$(date '+%Y-%m-%d %H:%M:%S') + + case "$level" in + "INFO") + echo -e "${BLUE}[INFO]${NC} $*" >&2 + ;; + "WARN") + echo -e "${YELLOW}[WARN]${NC} $*" >&2 + ;; + "ERROR") + echo -e "${RED}[ERROR]${NC} $*" >&2 + ;; + "SUCCESS") + echo -e "${GREEN}[SUCCESS]${NC} $*" >&2 + ;; + "DEBUG") + if [[ "$VERBOSE" == "true" ]]; then + echo -e "${CYAN}[DEBUG]${NC} $*" >&2 + fi + ;; + esac + + # Log to file + echo "[$timestamp] [$level] $*" >> "$LOG_FILE" +} + +####################################### +# Check if a command exists +# Arguments: +# $1 - command name +# Returns: +# 0 if command exists, 1 otherwise +####################################### +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +####################################### +# Check system dependencies +####################################### +check_dependencies() { + print_status "DEBUG" "Checking system dependencies..." + + local missing_deps=() + + # Essential commands + for cmd in wg wg-quick jq curl ping; do + if ! command_exists "$cmd"; then + missing_deps+=("$cmd") + fi + done + + # Python for generator script + if ! command_exists python3; then + missing_deps+=("python3") + fi + + if [[ ${#missing_deps[@]} -gt 0 ]]; then + print_status "ERROR" "Missing required dependencies: ${missing_deps[*]}" + print_status "INFO" "Install with: sudo apt update && sudo apt install wireguard-tools jq curl iputils-ping python3" + return 1 + fi + + # Check if running as root for WireGuard operations + if [[ $EUID -ne 0 ]] && [[ "${1:-}" != "list" ]] && [[ "${1:-}" != "status" ]] && [[ "${1:-}" != "help" ]]; then + print_status "WARN" "WireGuard operations require root privileges" + print_status "INFO" "Try: sudo $0 $*" + return 1 + fi + + return 0 +} + +####################################### +# Initialize configuration directory +####################################### +init_config() { + print_status "DEBUG" "Initializing configuration directory..." + + mkdir -p "$CONFIG_DIR" "$CONFIGS_DIR" + + # Create state file if it doesn't exist + if [[ ! -f "$STATE_FILE" ]]; then + echo '{"current_connection": "", "last_update": 0}' > "$STATE_FILE" + fi + + # Create speed cache if it doesn't exist + if [[ ! -f "$SPEED_CACHE" ]]; then + echo '{}' > "$SPEED_CACHE" + fi + + # Ensure log file exists + touch "$LOG_FILE" +} + +####################################### +# Get current connection status +# Returns: +# Connection info or empty string +####################################### +get_connection_status() { + local active_interface + active_interface=$(wg show 2>/dev/null | grep -E '^interface:' | head -1 | cut -d' ' -f2 || true) + + if [[ -n "$active_interface" ]]; then + # Extract server info from interface name (assuming format like wg-CH-01) + local server_info + server_info=$(echo "$active_interface" | sed 's/^wg-//') + echo "$server_info" + else + echo "" + fi +} + +####################################### +# Parse WireGuard config to extract server info +# Arguments: +# $1 - config file path +####################################### +parse_config_info() { + local config_file="$1" + local server_name endpoint country + + server_name=$(basename "$config_file" .conf) + endpoint=$(grep -E '^Endpoint' "$config_file" | cut -d'=' -f2 | tr -d ' ' | cut -d':' -f1) + + # Extract country from filename (assuming format like wg-CH-01) + country=$(echo "$server_name" | sed 's/^wg-//' | cut -d'-' -f1) + + echo "$server_name|$endpoint|$country" +} + +####################################### +# List available servers or countries +# Arguments: +# $1 - "countries" to list countries, otherwise list servers +####################################### +list_servers() { + local list_type="${1:-servers}" + + if [[ ! -d "$CONFIGS_DIR" ]] || [[ -z "$(ls -A "$CONFIGS_DIR" 2>/dev/null)" ]]; then + print_status "WARN" "No server configurations found. Run 'protonvpn update' first." + return 1 + fi + + case "$list_type" in + "countries") + print_status "INFO" "Available countries:" + local countries=() + while IFS= read -r -d '' config; do + local info + info=$(parse_config_info "$config") + local country + country=$(echo "$info" | cut -d'|' -f3) + countries+=("$country") + done < <(find "$CONFIGS_DIR" -name "*.conf" -print0) + + # Sort and deduplicate countries + printf '%s\n' "${countries[@]}" | sort -u | while read -r country; do + printf " %s\n" "$country" + done + ;; + *) + print_status "INFO" "Available servers:" + printf "%-20s %-15s %-10s %s\n" "SERVER" "ENDPOINT" "COUNTRY" "STATUS" + printf "%-20s %-15s %-10s %s\n" "------" "--------" "-------" "------" + + local current_server + current_server=$(get_connection_status) + + while IFS= read -r -d '' config; do + local info + info=$(parse_config_info "$config") + IFS='|' read -r server_name endpoint country <<< "$info" + + local status="Available" + if [[ "$server_name" == *"$current_server"* ]] && [[ -n "$current_server" ]]; then + status="${GREEN}Connected${NC}" + fi + + printf "%-20s %-15s %-10s %s\n" "$server_name" "$endpoint" "$country" "$status" + done < <(find "$CONFIGS_DIR" -name "*.conf" -print0 | sort -z) + ;; + esac +} + +####################################### +# Test server speed/latency +# Arguments: +# $1 - server endpoint IP +# Returns: +# Latency in milliseconds or 9999 if unreachable +####################################### +test_server_speed() { + local endpoint="$1" + local latency + + print_status "DEBUG" "Testing latency to $endpoint..." + + # Use ping to test latency (3 packets, 2 second timeout) + if latency=$(ping -c 3 -W 2 "$endpoint" 2>/dev/null | tail -1 | awk -F '/' '{print $5}' 2>/dev/null); then + if [[ -n "$latency" ]]; then + printf "%.0f" "$latency" + return 0 + fi + fi + + # If ping fails, return high latency + echo "9999" + return 1 +} + +####################################### +# Find fastest server +# Arguments: +# $1 - optional country filter +# Returns: +# Path to fastest server config +####################################### +find_fastest_server() { + local country_filter="${1:-}" + local best_config="" + local best_latency=9999 + local tested_count=0 + local max_tests=10 # Limit tests for performance + + print_status "INFO" "Finding fastest server${country_filter:+ in $country_filter}..." + + # Create array of matching configs + local configs=() + while IFS= read -r -d '' config; do + if [[ -n "$country_filter" ]]; then + local info + info=$(parse_config_info "$config") + local country + country=$(echo "$info" | cut -d'|' -f3) + if [[ "$country" == "$country_filter" ]]; then + configs+=("$config") + fi + else + configs+=("$config") + fi + done < <(find "$CONFIGS_DIR" -name "*.conf" -print0) + + if [[ ${#configs[@]} -eq 0 ]]; then + print_status "ERROR" "No servers found${country_filter:+ for country $country_filter}" + return 1 + fi + + # Shuffle array to test random subset + local shuffled_configs=() + while [[ ${#configs[@]} -gt 0 ]]; do + local index=$((RANDOM % ${#configs[@]})) + shuffled_configs+=("${configs[$index]}") + unset 'configs[$index]' + configs=("${configs[@]}") # Re-index array + done + + # Test servers + for config in "${shuffled_configs[@]}"; do + if [[ $tested_count -ge $max_tests ]]; then + break + fi + + local info + info=$(parse_config_info "$config") + local server_name endpoint + IFS='|' read -r server_name endpoint _ <<< "$info" + + local latency + latency=$(test_server_speed "$endpoint") + + print_status "DEBUG" "Server $server_name: ${latency}ms" + + if (( $(echo "$latency < $best_latency" | bc -l 2>/dev/null || echo "$latency -lt $best_latency") )); then + best_latency=$latency + best_config="$config" + fi + + ((tested_count++)) + done + + if [[ -n "$best_config" ]]; then + local info + info=$(parse_config_info "$best_config") + local server_name + server_name=$(echo "$info" | cut -d'|' -f1) + print_status "SUCCESS" "Fastest server: $server_name (${best_latency}ms)" + echo "$best_config" + return 0 + else + print_status "ERROR" "Could not find accessible server" + return 1 + fi +} + +####################################### +# Connect to VPN server +# Arguments: +# $1 - optional country or specific server +####################################### +connect_vpn() { + local target="${1:-}" + local config_file="" + + # Check if already connected + local current_connection + current_connection=$(get_connection_status) + if [[ -n "$current_connection" ]]; then + print_status "INFO" "Already connected to $current_connection" + print_status "INFO" "Disconnect first with: protonvpn disconnect" + return 1 + fi + + # Determine which server to connect to + if [[ -z "$target" ]]; then + # Auto-select fastest server + config_file=$(find_fastest_server) + elif [[ -f "$CONFIGS_DIR/wg-${target}.conf" ]]; then + # Specific server by name + config_file="$CONFIGS_DIR/wg-${target}.conf" + print_status "INFO" "Using specific server: $target" + elif [[ ${#target} -eq 2 ]] && [[ "$target" =~ ^[A-Z]{2}$ ]]; then + # Country code - find fastest in country + config_file=$(find_fastest_server "$target") + else + # Try to find server containing the target string + local matches + mapfile -t matches < <(find "$CONFIGS_DIR" -name "*${target}*" -name "*.conf") + + if [[ ${#matches[@]} -eq 1 ]]; then + config_file="${matches[0]}" + elif [[ ${#matches[@]} -gt 1 ]]; then + print_status "ERROR" "Multiple servers match '$target':" + for match in "${matches[@]}"; do + local info + info=$(parse_config_info "$match") + printf " %s\n" "$(echo "$info" | cut -d'|' -f1)" >&2 + done + return 1 + else + print_status "ERROR" "No server found matching '$target'" + return 1 + fi + fi + + if [[ -z "$config_file" ]]; then + print_status "ERROR" "Failed to determine server configuration" + return 1 + fi + + # Extract interface name from config file + local interface_name + interface_name=$(basename "$config_file" .conf) + + print_status "INFO" "Connecting to $(parse_config_info "$config_file" | cut -d'|' -f1)..." + + if [[ "$DRY_RUN" == "true" ]]; then + print_status "INFO" "DRY RUN: Would execute: wg-quick up $config_file" + return 0 + fi + + # Connect using wg-quick + if wg-quick up "$config_file" >/dev/null 2>&1; then + # Update state file + jq --arg conn "$(parse_config_info "$config_file" | cut -d'|' -f1)" \ + '.current_connection = $conn' "$STATE_FILE" > "${STATE_FILE}.tmp" && \ + mv "${STATE_FILE}.tmp" "$STATE_FILE" + + print_status "SUCCESS" "Connected to $(parse_config_info "$config_file" | cut -d'|' -f1)" + + # Show connection info + show_status + else + print_status "ERROR" "Failed to connect to VPN" + return 1 + fi +} + +####################################### +# Disconnect from VPN +####################################### +disconnect_vpn() { + local current_connection + current_connection=$(get_connection_status) + + if [[ -z "$current_connection" ]]; then + print_status "INFO" "Not connected to any VPN server" + return 0 + fi + + print_status "INFO" "Disconnecting from $current_connection..." + + if [[ "$DRY_RUN" == "true" ]]; then + print_status "INFO" "DRY RUN: Would disconnect from all WireGuard interfaces" + return 0 + fi + + # Disconnect all WireGuard interfaces + local interfaces + mapfile -t interfaces < <(wg show 2>/dev/null | grep -E '^interface:' | cut -d' ' -f2 || true) + + local disconnected=false + for interface in "${interfaces[@]}"; do + if wg-quick down "$interface" >/dev/null 2>&1; then + print_status "SUCCESS" "Disconnected from $interface" + disconnected=true + fi + done + + if [[ "$disconnected" == "true" ]]; then + # Update state file + jq '.current_connection = ""' "$STATE_FILE" > "${STATE_FILE}.tmp" && \ + mv "${STATE_FILE}.tmp" "$STATE_FILE" + else + print_status "WARN" "No active connections found to disconnect" + fi +} + +####################################### +# Show current connection status +####################################### +show_status() { + local current_connection + current_connection=$(get_connection_status) + + if [[ -n "$current_connection" ]]; then + print_status "INFO" "Status: ${GREEN}Connected${NC}" + printf "Server: %s\n" "$current_connection" >&2 + + # Show WireGuard interface details + if command_exists wg && [[ "$VERBOSE" == "true" ]]; then + print_status "DEBUG" "WireGuard interface details:" + wg show 2>/dev/null | while IFS= read -r line; do + printf " %s\n" "$line" >&2 + done + fi + + # Test current connection + if command_exists curl; then + local public_ip + if public_ip=$(curl -s --max-time 5 ifconfig.me 2>/dev/null); then + printf "Public IP: %s\n" "$public_ip" >&2 + fi + fi + else + print_status "INFO" "Status: ${RED}Disconnected${NC}" + fi +} + +####################################### +# Update server configurations +####################################### +update_configs() { + if [[ ! -f "$GENERATOR_SCRIPT" ]]; then + print_status "ERROR" "Generator script not found: $GENERATOR_SCRIPT" + print_status "INFO" "Please ensure the ProtonVPN generator script is available" + return 1 + fi + + print_status "INFO" "Updating server configurations..." + print_status "WARN" "This may take a while due to API rate limiting..." + + if [[ "$DRY_RUN" == "true" ]]; then + print_status "INFO" "DRY RUN: Would run generator script to update configs" + return 0 + fi + + # Update OUTPUT_DIR in generator script to point to our configs directory + local temp_script="${CONFIG_DIR}/temp_generator.py" + sed "s|OUTPUT_DIR = \"./configs\"|OUTPUT_DIR = \"$CONFIGS_DIR\"|" "$GENERATOR_SCRIPT" > "$temp_script" + + if python3 "$temp_script"; then + print_status "SUCCESS" "Server configurations updated" + + # Update state file with last update time + jq --arg time "$(date +%s)" '.last_update = ($time | tonumber)' "$STATE_FILE" > "${STATE_FILE}.tmp" && \ + mv "${STATE_FILE}.tmp" "$STATE_FILE" + + # Clean up + rm -f "$temp_script" + else + print_status "ERROR" "Failed to update configurations" + rm -f "$temp_script" + return 1 + fi +} + +####################################### +# Run speed tests on available servers +####################################### +run_speedtest() { + local country_filter="${1:-}" + + print_status "INFO" "Running speed tests${country_filter:+ for $country_filter}..." + + # Create results array + declare -a results + local count=0 + + while IFS= read -r -d '' config; do + local info + info=$(parse_config_info "$config") + IFS='|' read -r server_name endpoint country <<< "$info" + + # Apply country filter if specified + if [[ -n "$country_filter" ]] && [[ "$country" != "$country_filter" ]]; then + continue + fi + + local latency + latency=$(test_server_speed "$endpoint") + + results+=("$latency|$server_name|$endpoint|$country") + ((count++)) + + printf "." >&2 # Progress indicator + done < <(find "$CONFIGS_DIR" -name "*.conf" -print0) + + printf "\n" >&2 + + if [[ $count -eq 0 ]]; then + print_status "WARN" "No servers found to test" + return 1 + fi + + # Sort results by latency and display + printf "\n%-20s %-15s %-10s %s\n" "SERVER" "ENDPOINT" "COUNTRY" "LATENCY" >&2 + printf "%-20s %-15s %-10s %s\n" "------" "--------" "-------" "-------" >&2 + + printf '%s\n' "${results[@]}" | sort -t'|' -k1 -n | while IFS='|' read -r latency server_name endpoint country; do + local latency_display + if [[ "$latency" == "9999" ]]; then + latency_display="${RED}Timeout${NC}" + else + latency_display="${latency}ms" + fi + printf "%-20s %-15s %-10s %s\n" "$server_name" "$endpoint" "$country" "$latency_display" >&2 + done +} + +####################################### +# Show help message +####################################### +show_help() { + cat >&2 << 'EOF' +ProtonVPN CLI - WireGuard VPN Manager + +USAGE: + protonvpn [options] + +COMMANDS: + connect [target] Connect to VPN server + - No target: auto-select fastest server + - Country code (e.g., US, CH): fastest in country + - Server name: specific server + + disconnect Disconnect from current VPN server + + status Show current connection status + + list [countries] List available servers or countries + + update Update server configurations from ProtonVPN + + speedtest [country] Test latency of available servers + + help Show this help message + +GLOBAL OPTIONS: + -v, --verbose Enable verbose output + -n, --dry-run Show what would be done without executing + -h, --help Show help message + +EXAMPLES: + protonvpn connect # Connect to fastest available server + protonvpn connect US # Connect to fastest US server + protonvpn connect CH-01 # Connect to specific server + protonvpn list countries # List available countries + protonvpn speedtest CH # Test Swiss servers + protonvpn status # Show connection status + protonvpn disconnect # Disconnect from VPN + +CONFIGURATION: + Config files: ~/.config/protonvpn/ + WireGuard configs: ~/.config/protonvpn/configs/ + +REQUIREMENTS: + - wireguard-tools (wg, wg-quick) + - jq (JSON processing) + - curl (IP detection) + - bc (calculations) + - Root privileges for connection operations + +EOF +} + +####################################### +# Main function +####################################### +main() { + # Parse global options first + while [[ $# -gt 0 ]]; do + case $1 in + -v|--verbose) + VERBOSE=true + shift + ;; + -n|--dry-run) + DRY_RUN=true + shift + ;; + -h|--help) + show_help + exit 0 + ;; + -*) + print_status "ERROR" "Unknown option: $1" + show_help + exit 1 + ;; + *) + break + ;; + esac + done + + # Initialize configuration + init_config + + # Get command + local command="${1:-help}" + shift || true + + # Check dependencies for most commands + if ! check_dependencies "$command"; then + exit 1 + fi + + # Execute command + case "$command" in + "connect"|"c") + connect_vpn "$@" + ;; + "disconnect"|"d") + disconnect_vpn + ;; + "status"|"s") + show_status + ;; + "list"|"l") + list_servers "$@" + ;; + "update"|"u") + update_configs + ;; + "speedtest"|"speed"|"test") + run_speedtest "$@" + ;; + "split") + case "${1:-}" in + "enable"|"disable") + jq --arg enabled "$([[ "$1" == "enable" ]] && echo true || echo false)" '.enabled = ($enabled == "true")' "$SPLIT_TUNNEL_CONFIG" > "${SPLIT_TUNNEL_CONFIG}.tmp" && mv "${SPLIT_TUNNEL_CONFIG}.tmp" "$SPLIT_TUNNEL_CONFIG" + print_status "SUCCESS" "Split tunneling $1d" + ;; + "add-local") + local network="${2:-}" + if [[ -n "$network" ]]; thenupda + jq --arg net "$network" '.local_networks += [$net] | .local_networks |= unique' "$SPLIT_TUNNEL_CONFIG" > "${SPLIT_TUNNEL_CONFIG}.tmp" && mv "${SPLIT_TUNNEL_CONFIG}.tmp" "$SPLIT_TUNNEL_CONFIG" + print_status "SUCCESS" "Added local network: $network" + fi + ;; + "list") + print_status "INFO" "Split tunneling configuration:" + jq '.' "$SPLIT_TUNNEL_CONFIG" + ;; + *) + print_status "ERROR" "Unknown split command. Use: enable|disable|add-local|list" + ;; + esac + ;; + "help"|"h"|"--help") + show_help + ;; + *) + print_status "ERROR" "Unknown command: $command" + show_help + exit 1 + ;; + esac +} + +# Execute main function if script is run directly +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi