#!/usr/bin/env bash set -u set -o pipefail CONFIG_FILE="/opt/podcast-pownloader/PODCAST_NAME/podcast_settings.conf" if [[ ! -f "$CONFIG_FILE" ]]; then echo "ERROR: Configuration file not found at $CONFIG_FILE" exit 1 fi source "$CONFIG_FILE" validate_config() { local required_vars=( "DOWNLOAD_DIR" "FINAL_DIR" "LOG_DIR" "LOG_FILE" "DOWNLOADS_LOG" "DOWNLOADS_LOCK" "MAX_LOG_SIZE" "QUALITY" "VIDEO_FORMAT" "AUDIO_FORMAT" "SLEEP_INTERVAL" "RATE_LIMIT" "CONNECTION_TIMEOUT" "MAX_RETRIES" ) for var in "${required_vars[@]}"; do if [[ -z "${!var:-}" ]]; then echo "ERROR: Required variable '$var' not set in $CONFIG_FILE" exit 1 fi done } validate_config mkdir -p "$DOWNLOAD_DIR" "$FINAL_DIR" "$LOG_DIR" "$(dirname "$DOWNLOADS_LOG")" [[ -f "$LOG_FILE" ]] || : > "$LOG_FILE" if [[ ! -f "$DOWNLOADS_LOG" ]]; then { echo "# YouTube Downloads Database - $(date)" echo "# Format: VIDEO_ID|TITLE|FILENAME|DOWNLOADED_DATE" } >> "$DOWNLOADS_LOG" fi rotate_log() { local size size=$(stat -c%s "$LOG_FILE" 2>/dev/null || stat -f%z "$LOG_FILE" 2>/dev/null || echo 0) if [[ "$size" -gt "$MAX_LOG_SIZE" ]]; then mv "$LOG_FILE" "$LOG_FILE.$(date +%s)" echo "# YouTube Downloader Log - $(date)" > "$LOG_FILE" log_message "Log rotated (previous log too large)" fi } log_message() { local timestamp timestamp=$(date '+%Y-%m-%d %H:%M:%S') echo "[$timestamp] $1" | tee -a "$LOG_FILE" rotate_log } sanitize_filename() { local name="$1" name="${name//\//-}" name="${name//:/-}" name="${name//\*/-}" name="${name//\?/}" name="${name//\"/}" name="${name///}" name="${name//|/-}" name="${name//\\/}" echo "$name" | tr -s ' ' ' ' | sed 's/^ *//; s/ *$//' } acquire_lock() { local timeout=30 local elapsed=0 while [[ $elapsed -lt $timeout ]]; do if mkdir "$DOWNLOADS_LOCK" 2>/dev/null; then return 0 fi sleep 1 ((elapsed++)) done return 1 } release_lock() { rmdir "$DOWNLOADS_LOCK" 2>/dev/null || true } is_downloaded() { local video_id="${1:-}" [[ -z "$video_id" ]] && return 1 acquire_lock || { log_message "WARNING: Could not acquire lock for is_downloaded check" return 1 } grep -q -F -- "${video_id}|" "$DOWNLOADS_LOG" local result=$? release_lock return $result } record_download() { local video_id="${1:-}" local title="${2:-}" local filename="${3:-}" acquire_lock || { log_message "ERROR: Could not acquire lock for recording download" return 1 } local timestamp timestamp=$(date '+%Y-%m-%d %H:%M:%S') echo "${video_id}|${title}|${filename}|${timestamp}" >> "$DOWNLOADS_LOG" local result=$? release_lock return $result } process_downloaded_video() { local video_id="${1:-}" local title="${2:-}" local temp_dir="${3:-}" if [[ -z "$video_id" || -z "$temp_dir" ]]; then log_message "ERROR: Missing video_id or temp_dir in process_downloaded_video" return 1 fi if is_downloaded "$video_id"; then log_message "SKIP: '$title' (already in database)" find "$temp_dir" -maxdepth 1 -type f -name "${video_id}.*" -delete 2>/dev/null return 0 fi local video_file audio_file video_file=$(find "$temp_dir" -maxdepth 1 -type f -name "${video_id}.video.*" 2>/dev/null | head -n 1) audio_file=$(find "$temp_dir" -maxdepth 1 -type f -name "${video_id}.audio.mp3" 2>/dev/null | head -n 1) if [[ -z "${video_file:-}" || ! -f "$video_file" ]]; then log_message "ERROR: Video file not found for '$title' (id: $video_id)" return 1 fi if [[ -z "${audio_file:-}" || ! -f "$audio_file" ]]; then log_message "ERROR: Audio MP3 file not found for '$title' (id: $video_id)" return 1 fi local clean_title base_name final_video final_audio clean_title=$(sanitize_filename "$title") base_name="$clean_title" final_video="$FINAL_DIR/${base_name}.${VIDEO_FORMAT}" final_audio="$FINAL_DIR/${base_name}.mp3" if [[ -e "$final_video" || -e "$final_audio" ]]; then base_name="${clean_title}_${video_id}" final_video="$FINAL_DIR/${base_name}.${VIDEO_FORMAT}" final_audio="$FINAL_DIR/${base_name}.mp3" fi if mv "$video_file" "$final_video" && mv "$audio_file" "$final_audio"; then if record_download "$video_id" "$title" "${base_name}.${VIDEO_FORMAT} + ${base_name}.mp3"; then log_message "MOVED: '$title' -> '${base_name}.${VIDEO_FORMAT}' and '${base_name}.mp3'" return 0 else log_message "ERROR: Failed to record download for '$title'" return 1 fi else log_message "ERROR: Failed to move files for '$title' to $FINAL_DIR" return 1 fi } wait_for_next() { local remaining=$SLEEP_INTERVAL while [[ $remaining -gt 0 ]]; do printf "\rWaiting %ss before next download...\033[K" "$remaining" sleep 1 ((remaining--)) done echo } format_time() { local seconds=$1 local hours=$((seconds / 3600)) local minutes=$(((seconds % 3600) / 60)) local secs=$((seconds % 60)) if [[ $hours -gt 0 ]]; then printf "%dh %dm %ds" "$hours" "$minutes" "$secs" elif [[ $minutes -gt 0 ]]; then printf "%dm %ds" "$minutes" "$secs" else printf "%ds" "$secs" fi } estimate_download_time() { local playlist_url="${1:-}" [[ -z "$playlist_url" ]] && return 0 log_message "Estimating playlist size..." local video_count video_count=$(yt-dlp --no-warnings --flat-playlist --print "%(id)s" "$playlist_url" 2>/dev/null \ | grep -E '^[a-zA-Z0-9_-]{11}$' | wc -l) if [[ $video_count -gt 0 ]]; then local estimated_seconds=$(( (video_count * 300) + (video_count * SLEEP_INTERVAL) )) log_message "Estimated: $video_count videos, approximately $(format_time "$estimated_seconds") total time" fi } show_log() { echo echo "=== Downloaded Videos (Last 20) ===" echo tail -n 20 "$DOWNLOADS_LOG" 2>/dev/null | grep -E '^[a-zA-Z0-9_-]{11}\|' | \ awk -F'|' '{printf "%-15s | %-50s | %s\n", $1, substr($2,1,50), $4}' || echo "No downloads yet" } download_playlist() { local playlist_url="${1:-}" local temp_dir="" local video_list="" local current=0 local video_count=0 if [[ -z "$playlist_url" ]]; then log_message "ERROR: No playlist URL provided" echo "Usage: $0 [youtube_playlist_url]" return 1 fi cleanup() { [[ -n "${video_list:-}" && -f "$video_list" ]] && rm -f "$video_list" [[ -n "${temp_dir:-}" && -d "$temp_dir" ]] && rm -rf "$temp_dir" } trap cleanup EXIT log_message "Starting download for playlist: $playlist_url" log_message "Settings: Quality=$QUALITY, Rate Limit=$RATE_LIMIT, Sleep Interval=${SLEEP_INTERVAL}s" temp_dir=$(mktemp -d) || { log_message "ERROR: Failed to create temporary directory" return 1 } video_list=$(mktemp) || { log_message "ERROR: Failed to create temporary file" return 1 } log_message "Fetching playlist information..." yt-dlp \ --no-warnings \ --flat-playlist \ --socket-timeout "$CONNECTION_TIMEOUT" \ --retries "$MAX_RETRIES" \ --print "%(id)s" \ "$playlist_url" 2>>"$LOG_FILE" | grep -E '^[a-zA-Z0-9_-]{11}$' > "$video_list" if [[ ! -s "$video_list" ]]; then log_message "ERROR: Failed to fetch playlist or playlist is empty" return 1 fi video_count=$(wc -l < "$video_list") log_message "Found $video_count videos to process" while IFS= read -r video_id; do ((current++)) [[ -z "${video_id:-}" ]] && continue if ! [[ "$video_id" =~ ^[a-zA-Z0-9_-]{11}$ ]]; then log_message "[$current/$video_count] WARNING: Invalid video ID format: '$video_id'" continue fi title=$(yt-dlp \ --no-warnings \ --skip-download \ --socket-timeout "$CONNECTION_TIMEOUT" \ --retries "$MAX_RETRIES" \ --print "%(title)s" \ "https://www.youtube.com/watch?v=$video_id" 2>>"$LOG_FILE" | head -n 1) [[ -z "$title" || "$title" == "NA" ]] && title="$video_id" if is_downloaded "$video_id"; then log_message "[$current/$video_count] SKIP: '$title' (already downloaded)" continue fi log_message "[$current/$video_count] Downloading: '$title'" yt-dlp \ --no-warnings \ --format "$QUALITY" \ --merge-output-format "$VIDEO_FORMAT" \ --limit-rate "$RATE_LIMIT" \ --socket-timeout "$CONNECTION_TIMEOUT" \ --retries "$MAX_RETRIES" \ --no-continue \ --output "$temp_dir/%(id)s.video.%(ext)s" \ "https://www.youtube.com/watch?v=$video_id" \ 2>>"$LOG_FILE" video_status=$? yt-dlp \ --no-warnings \ --extract-audio \ --audio-format mp3 \ --audio-quality 0 \ --socket-timeout "$CONNECTION_TIMEOUT" \ --retries "$MAX_RETRIES" \ --no-continue \ --output "$temp_dir/%(id)s.audio.%(ext)s" \ "https://www.youtube.com/watch?v=$video_id" \ 2>>"$LOG_FILE" audio_status=$? if [[ $video_status -eq 0 && $audio_status -eq 0 ]]; then if process_downloaded_video "$video_id" "$title" "$temp_dir"; then log_message "[$current/$video_count] Successfully processed '$title'" [[ $current -lt $video_count ]] && wait_for_next else log_message "[$current/$video_count] ERROR: Processing failed for '$title'" fi else log_message "[$current/$video_count] ERROR: Download failed for '$title' (video: $video_status, audio: $audio_status)" fi done < "$video_list" log_message "Playlist download completed" return 0 } main() { local start_time end_time duration formatted_duration start_time=$(date +%s) local playlist_url="${1:-${PLAYLIST_URL:-}}" if [[ -z "$playlist_url" ]]; then echo "ERROR: No playlist URL provided and PLAYLIST_URL is not set in the config" exit 1 fi estimate_download_time "$playlist_url" download_playlist "$playlist_url" local result=$? show_log end_time=$(date +%s) duration=$((end_time - start_time)) formatted_duration=$(format_time "$duration") log_message "Total execution time: $formatted_duration" log_message "Full downloads database saved to: $DOWNLOADS_LOG" return $result } main "$@"