main
  1#!/usr/bin/env nix-shell
  2#! nix-shell -i bash -p ffmpeg python3Packages.pyyaml
  3# shellcheck shell=bash
  4# Convert existing music downloads to Opus format
  5# This allows switching to Opus without re-downloading everything
  6
  7set -euo pipefail
  8
  9# Configuration
 10MUSIC_DIR="${MUSIC_DIR:-/neo/music}"
 11LIBRARY_DIR="${MUSIC_DIR}/library"
 12PLAYLIST_DIR="${MUSIC_DIR}/playlist"
 13OPUS_BITRATE="${OPUS_BITRATE:-128k}"
 14DRY_RUN="${DRY_RUN:-false}"
 15PARALLEL_JOBS="${PARALLEL_JOBS:-4}"
 16CONFIG_FILE="${CONFIG_FILE:-}"
 17CONFIG_ONLY="${CONFIG_ONLY:-false}"
 18
 19# Colors for output
 20RED='\033[0;31m'
 21GREEN='\033[0;32m'
 22YELLOW='\033[1;33m'
 23BLUE='\033[0;34m'
 24NC='\033[0m' # No Color
 25
 26# Statistics
 27TOTAL_FILES=0
 28CONVERTED=0
 29SKIPPED=0
 30FAILED=0
 31SPACE_SAVED=0
 32
 33log_info() {
 34    echo -e "${BLUE}[INFO]${NC} $*" >&2
 35}
 36
 37log_success() {
 38    echo -e "${GREEN}[SUCCESS]${NC} $*" >&2
 39}
 40
 41log_warn() {
 42    echo -e "${YELLOW}[WARN]${NC} $*" >&2
 43}
 44
 45log_error() {
 46    echo -e "${RED}[ERROR]${NC} $*" >&2
 47}
 48
 49check_dependencies() {
 50    # Dependencies are provided by nix-shell shebang
 51    if ! command -v ffmpeg &> /dev/null; then
 52        log_error "ffmpeg not available"
 53        exit 1
 54    fi
 55
 56    if ! command -v python3 &> /dev/null; then
 57        log_error "python3 not available"
 58        exit 1
 59    fi
 60
 61    # Check Python YAML module
 62    if ! python3 -c "import yaml" 2>/dev/null; then
 63        log_error "Python yaml module not available"
 64        log_error "This should be provided by nix-shell"
 65        exit 1
 66    fi
 67
 68    log_info "Dependencies OK (ffmpeg + python3-yaml from nix-shell)"
 69}
 70
 71human_readable_size() {
 72    local bytes=$1
 73    if [ "$bytes" -lt 1024 ]; then
 74        echo "${bytes}B"
 75    elif [ "$bytes" -lt 1048576 ]; then
 76        echo "$((bytes / 1024))KB"
 77    elif [ "$bytes" -lt 1073741824 ]; then
 78        echo "$((bytes / 1048576))MB"
 79    else
 80        echo "$((bytes / 1073741824))GB"
 81    fi
 82}
 83
 84convert_file() {
 85    local input_file="$1"
 86
 87    # Skip empty filenames
 88    if [ -z "$input_file" ]; then
 89        log_warn "Skipping empty filename"
 90        SKIPPED=$((SKIPPED + 1))
 91        return 0
 92    fi
 93
 94    local output_file="${input_file%.*}.opus"
 95
 96    # Skip if output already exists
 97    if [ -f "$output_file" ]; then
 98        log_warn "Already exists, skipping: $output_file"
 99        SKIPPED=$((SKIPPED + 1))
100        return 0
101    fi
102
103    if [ "$DRY_RUN" = "true" ]; then
104        log_info "[DRY-RUN] Would convert: $input_file"
105        CONVERTED=$((CONVERTED + 1))
106        return 0
107    fi
108
109    local input_size
110    input_size=$(stat -f%z "$input_file" 2>/dev/null || stat -c%s "$input_file" 2>/dev/null)
111
112    log_info "Converting: $(basename "$input_file")"
113
114    # Convert with ffmpeg, preserving metadata and cover art
115    if ffmpeg -i "$input_file" \
116        -vn \
117        -c:a libopus \
118        -b:a "$OPUS_BITRATE" \
119        -map_metadata 0 \
120        -map 0:a \
121        -id3v2_version 3 \
122        -f opus \
123        "$output_file.tmp" \
124        -loglevel error -stats 2>&1; then
125
126        # Move temp file to final location
127        mv "$output_file.tmp" "$output_file"
128
129        # Preserve timestamps
130        touch -r "$input_file" "$output_file"
131
132        local output_size
133        output_size=$(stat -f%z "$output_file" 2>/dev/null || stat -c%s "$output_file" 2>/dev/null)
134        local saved=$((input_size - output_size))
135        SPACE_SAVED=$((SPACE_SAVED + saved))
136
137        log_success "Converted: $(basename "$output_file") (saved $(human_readable_size "$saved"))"
138
139        # Remove original file
140        rm "$input_file"
141
142        CONVERTED=$((CONVERTED + 1))
143        return 0
144    else
145        log_error "Failed to convert: $input_file"
146        rm -f "$output_file.tmp"
147        FAILED=$((FAILED + 1))
148        return 1
149    fi
150}
151
152update_playlists() {
153    if [ "$DRY_RUN" = "true" ]; then
154        log_info "[DRY-RUN] Would update playlists in: $PLAYLIST_DIR"
155        return 0
156    fi
157
158    if [ ! -d "$PLAYLIST_DIR" ]; then
159        log_warn "Playlist directory not found: $PLAYLIST_DIR"
160        return 0
161    fi
162
163    log_info "Updating playlists..."
164
165    local playlist_count=0
166    while IFS= read -r playlist; do
167        # Update extensions in playlist files
168        if sed -i.bak \
169            -e 's/\.m4a$/.opus/g' \
170            -e 's/\.mp3$/.opus/g' \
171            -e 's/\.webm$/.opus/g' \
172            "$playlist" 2>/dev/null; then
173            rm -f "$playlist.bak"
174            playlist_count=$((playlist_count + 1))
175        fi
176    done < <(find "$PLAYLIST_DIR" -name "*.m3u" -type f)
177
178    log_success "Updated $playlist_count playlist(s)"
179}
180
181parse_config() {
182    local config_file="$1"
183
184    if [ ! -f "$config_file" ]; then
185        log_error "Config file not found: $config_file"
186        exit 1
187    fi
188
189    log_info "Parsing config file: $config_file"
190
191    # Use Python to parse YAML and extract artist/show paths
192    python3 - "$config_file" <<'EOF'
193import sys
194import yaml
195
196config_file = sys.argv[1]
197
198with open(config_file, 'r') as f:
199    config = yaml.safe_load(f)
200
201# Extract paths from mixcloud shows
202for show in config.get('mixcloud_shows', []):
203    artist = show.get('artist', '').strip()
204    show_name = show.get('show', '').strip()
205    if artist and show_name:
206        print(f"{artist}/{show_name}")
207
208# Extract paths from soundcloud shows
209for show in config.get('soundcloud_shows', []):
210    artist = show.get('artist', '').strip()
211    show_name = show.get('show', '').strip()
212    if artist and show_name:
213        print(f"{artist}/{show_name}")
214EOF
215}
216
217scan_files_from_config() {
218    local config_file="$1"
219
220    log_info "Scanning for audio files from config: $config_file"
221
222    if [ ! -d "$LIBRARY_DIR" ]; then
223        log_error "Library directory not found: $LIBRARY_DIR"
224        exit 1
225    fi
226
227    # Get show paths from config
228    local show_paths=()
229    while IFS= read -r path; do
230        # Skip empty paths
231        if [ -n "$path" ]; then
232            show_paths+=("$path")
233        fi
234    done < <(parse_config "$config_file")
235
236    if [ ${#show_paths[@]} -eq 0 ]; then
237        log_error "No shows found in config file"
238        exit 1
239    fi
240
241    log_info "Found ${#show_paths[@]} show(s) in config:"
242    for path in "${show_paths[@]}"; do
243        log_info "  - $path"
244    done
245    echo ""
246
247    # Find audio files only in configured show directories
248    for show_path in "${show_paths[@]}"; do
249        # Skip empty paths (defensive check)
250        if [ -z "$show_path" ]; then
251            continue
252        fi
253        local show_dir="$LIBRARY_DIR/$show_path"
254        if [ -d "$show_dir" ]; then
255            find "$show_dir" -type f \( \
256                -name "*.m4a" -o \
257                -name "*.mp3" -o \
258                -name "*.webm" \
259            \)
260        else
261            log_warn "Show directory not found: $show_dir"
262        fi
263    done | sort
264}
265
266scan_files() {
267    if [ "$CONFIG_ONLY" = "true" ]; then
268        if [ -z "$CONFIG_FILE" ]; then
269            log_error "CONFIG_ONLY=true requires --config option"
270            exit 1
271        fi
272        scan_files_from_config "$CONFIG_FILE"
273    else
274        log_info "Scanning for audio files in: $LIBRARY_DIR"
275
276        if [ ! -d "$LIBRARY_DIR" ]; then
277            log_error "Library directory not found: $LIBRARY_DIR"
278            exit 1
279        fi
280
281        # Find all audio files that aren't already opus
282        find "$LIBRARY_DIR" -type f \( \
283            -name "*.m4a" -o \
284            -name "*.mp3" -o \
285            -name "*.webm" \
286        \) | sort
287    fi
288}
289
290main() {
291    echo "╔════════════════════════════════════════════════════════════╗"
292    echo "║        Music Library Opus Conversion Tool                 ║"
293    echo "╚════════════════════════════════════════════════════════════╝"
294    echo ""
295
296    log_info "Music directory: $MUSIC_DIR"
297    log_info "Library directory: $LIBRARY_DIR"
298    log_info "Opus bitrate: $OPUS_BITRATE"
299    log_info "Parallel jobs: $PARALLEL_JOBS"
300
301    if [ -n "$CONFIG_FILE" ]; then
302        log_info "Config file: $CONFIG_FILE"
303    fi
304
305    if [ "$CONFIG_ONLY" = "true" ]; then
306        log_info "Mode: Convert only configured shows from config file"
307    else
308        log_info "Mode: Convert entire library"
309    fi
310
311    if [ "$DRY_RUN" = "true" ]; then
312        log_warn "DRY-RUN MODE: No files will be modified"
313    fi
314
315    echo ""
316
317    check_dependencies
318
319    # Scan for files
320    mapfile -t all_files < <(scan_files)
321
322    # Filter out empty entries
323    files=()
324    for file in "${all_files[@]}"; do
325        if [ -n "$file" ]; then
326            files+=("$file")
327        fi
328    done
329
330    TOTAL_FILES=${#files[@]}
331
332    if [ "$TOTAL_FILES" -eq 0 ]; then
333        log_info "No files to convert!"
334        exit 0
335    fi
336
337    log_info "Found $TOTAL_FILES file(s) to convert"
338    echo ""
339
340    # Confirm before proceeding (unless dry-run)
341    if [ "$DRY_RUN" != "true" ]; then
342        read -p "Proceed with conversion? [y/N] " -n 1 -r
343        echo
344        if [[ ! $REPLY =~ ^[Yy]$ ]]; then
345            log_info "Cancelled by user"
346            exit 0
347        fi
348        echo ""
349    fi
350
351    # Convert files
352    log_info "Starting conversion..."
353    echo ""
354
355    # Export function and variables for parallel execution
356    export -f convert_file log_info log_success log_warn log_error human_readable_size
357    export OPUS_BITRATE DRY_RUN RED GREEN YELLOW BLUE NC
358
359    # Process files in parallel
360    printf '%s\0' "${files[@]}" | xargs -0 -P "$PARALLEL_JOBS" -I {} bash -c 'convert_file "$@"' _ {}
361
362    echo ""
363    log_info "Conversion complete!"
364    echo ""
365
366    # Update playlists
367    update_playlists
368
369    # Print statistics
370    echo "╔════════════════════════════════════════════════════════════╗"
371    echo "║                    Conversion Summary                      ║"
372    echo "╚════════════════════════════════════════════════════════════╝"
373    echo ""
374    echo "Total files found:    $TOTAL_FILES"
375    echo "Successfully converted: $CONVERTED"
376    echo "Skipped (exists):     $SKIPPED"
377    echo "Failed:               $FAILED"
378
379    if [ "$DRY_RUN" != "true" ] && [ "$SPACE_SAVED" -gt 0 ]; then
380        echo "Space saved:          $(human_readable_size "$SPACE_SAVED")"
381    fi
382
383    echo ""
384
385    if [ "$FAILED" -gt 0 ]; then
386        log_warn "Some files failed to convert. Check the output above for details."
387        exit 1
388    fi
389
390    if [ "$DRY_RUN" = "true" ]; then
391        log_info "Dry-run complete. Run without DRY_RUN=true to perform actual conversion."
392    else
393        log_success "All files converted successfully!"
394    fi
395}
396
397# Handle script arguments
398while [[ $# -gt 0 ]]; do
399    case $1 in
400        --dry-run)
401            DRY_RUN=true
402            shift
403            ;;
404        --music-dir)
405            MUSIC_DIR="$2"
406            LIBRARY_DIR="${MUSIC_DIR}/library"
407            PLAYLIST_DIR="${MUSIC_DIR}/playlist"
408            shift 2
409            ;;
410        --bitrate)
411            OPUS_BITRATE="$2"
412            shift 2
413            ;;
414        --jobs|-j)
415            PARALLEL_JOBS="$2"
416            shift 2
417            ;;
418        --config|-c)
419            CONFIG_FILE="$2"
420            CONFIG_ONLY=true
421            shift 2
422            ;;
423        --all)
424            CONFIG_ONLY=false
425            shift
426            ;;
427        --help|-h)
428            echo "Usage: $0 [OPTIONS]"
429            echo ""
430            echo "Convert music library to Opus format"
431            echo ""
432            echo "Options:"
433            echo "  --dry-run              Show what would be converted without doing it"
434            echo "  --music-dir DIR        Music directory (default: /neo/music)"
435            echo "  --config, -c FILE      Only convert shows from config file"
436            echo "  --all                  Convert entire library (default if no --config)"
437            echo "  --bitrate RATE         Opus bitrate (default: 128k)"
438            echo "  --jobs, -j N           Number of parallel jobs (default: 4)"
439            echo "  --help, -h             Show this help message"
440            echo ""
441            echo "Environment variables:"
442            echo "  MUSIC_DIR              Same as --music-dir"
443            echo "  CONFIG_FILE            Path to music-playlist-dl.yaml"
444            echo "  OPUS_BITRATE           Same as --bitrate"
445            echo "  PARALLEL_JOBS          Same as --jobs"
446            echo "  DRY_RUN                Set to 'true' for dry-run mode"
447            echo "  CONFIG_ONLY            Set to 'true' to convert only configured shows"
448            echo ""
449            echo "Examples:"
450            echo "  # Preview conversion of entire library"
451            echo "  $0 --dry-run"
452            echo ""
453            echo "  # Convert only podcast shows from config"
454            echo "  $0 --config /neo/music/music-playlist-dl.yaml"
455            echo ""
456            echo "  # Convert entire library with higher quality"
457            echo "  $0 --all --bitrate 192k --jobs 8"
458            echo ""
459            echo "  # Use custom directory and config"
460            echo "  $0 --music-dir /mnt/music --config /mnt/music/config.yaml"
461            exit 0
462            ;;
463        *)
464            log_error "Unknown option: $1"
465            echo "Use --help for usage information"
466            exit 1
467            ;;
468    esac
469done
470
471main