chore: repoint PRbL submodule to Circuit-Forge fork, add mediatools
Replaces yt-dlp bashrc snippet with broader mediatools aliases/functions and adds a couple of standalone scripts (protonvpn-cli, audio-grabber).
This commit is contained in:
parent
2bb8eecd18
commit
1357eb7148
5 changed files with 1784 additions and 4 deletions
2
.gitmodules
vendored
2
.gitmodules
vendored
|
|
@ -1,3 +1,3 @@
|
|||
[submodule "PRbL"]
|
||||
path = PRbL
|
||||
url = https://github.com/pyr0ball/PRbL.git
|
||||
url = https://git.opensourcesolarpunk.com/Circuit-Forge/PRbL.git
|
||||
|
|
|
|||
294
lib/skel/.bashrc.d/02-mediatools.bashrc
Normal file
294
lib/skel/.bashrc.d/02-mediatools.bashrc
Normal file
|
|
@ -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
|
||||
|
|
@ -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'
|
||||
741
scripts/audio-grabber.sh
Executable file
741
scripts/audio-grabber.sh
Executable file
|
|
@ -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] <URL>
|
||||
# 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] <YOUTUBE_URL>
|
||||
|
||||
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
|
||||
748
scripts/protonvpn-cli
Normal file
748
scripts/protonvpn-cli
Normal file
|
|
@ -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 <command> [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
|
||||
Loading…
Reference in a new issue