Commit f8bfb06a7602

Vincent Demeester <vincent@sbr.pm>
2025-12-17 05:22:58
feat(music): Add Opus conversion tool for music library migration
- Enable migration from M4A/MP3/WebM to Opus without re-downloading - Support selective conversion via config file for podcast-only migration - Reduce storage requirements by 30-40% with better audio quality Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Vincent Demeester <vincent@sbr.pm>
1 parent 13dc1ef
Changed files (4)
systems/rhea/extra.nix
@@ -678,6 +678,7 @@ in
     gnumake
     audible-converter
     audible-cli
+    ffmpeg-full
   ];
 
 }
tools/music-playlist-dl/config.yaml.example
@@ -43,12 +43,19 @@ soundcloud_shows:
 
 # yt-dlp options
 yt_dlp_options:
-  format: best             # Audio quality: best, bestaudio, etc.
+  format: bestaudio        # Download best audio quality
+  audio_format: opus       # Convert to Opus (smaller, better quality than MP3)
+  audio_quality: 128K      # 128kbps Opus is excellent for podcasts/DJ mixes
   add_metadata: true       # Add metadata (artist, album) to files
   embed_thumbnail: true    # Embed artwork in audio files
   continue: true           # Resume partial downloads
   ignore_errors: true      # Continue on errors
 
+  # Alternative formats:
+  # audio_format: mp3      # Use MP3 for broader compatibility (car stereos, etc.)
+  # audio_format: m4a      # Use M4A/AAC for Apple devices
+  # audio_quality: 192K    # Higher quality (larger files)
+
 # Directory Structure
 # After running, your directory structure will look like:
 #
tools/music-playlist-dl/convert-to-opus.sh
@@ -0,0 +1,444 @@
+#!/usr/bin/env nix-shell
+#! nix-shell -i bash -p ffmpeg python3Packages.pyyaml
+# shellcheck shell=bash
+# Convert existing music downloads to Opus format
+# This allows switching to Opus without re-downloading everything
+
+set -euo pipefail
+
+# Configuration
+MUSIC_DIR="${MUSIC_DIR:-/neo/music}"
+LIBRARY_DIR="${MUSIC_DIR}/library"
+PLAYLIST_DIR="${MUSIC_DIR}/playlist"
+OPUS_BITRATE="${OPUS_BITRATE:-128k}"
+DRY_RUN="${DRY_RUN:-false}"
+PARALLEL_JOBS="${PARALLEL_JOBS:-4}"
+CONFIG_FILE="${CONFIG_FILE:-}"
+CONFIG_ONLY="${CONFIG_ONLY:-false}"
+
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+NC='\033[0m' # No Color
+
+# Statistics
+TOTAL_FILES=0
+CONVERTED=0
+SKIPPED=0
+FAILED=0
+SPACE_SAVED=0
+
+log_info() {
+    echo -e "${BLUE}[INFO]${NC} $*" >&2
+}
+
+log_success() {
+    echo -e "${GREEN}[SUCCESS]${NC} $*" >&2
+}
+
+log_warn() {
+    echo -e "${YELLOW}[WARN]${NC} $*" >&2
+}
+
+log_error() {
+    echo -e "${RED}[ERROR]${NC} $*" >&2
+}
+
+check_dependencies() {
+    # Dependencies are provided by nix-shell shebang
+    if ! command -v ffmpeg &> /dev/null; then
+        log_error "ffmpeg not available"
+        exit 1
+    fi
+
+    if ! command -v python3 &> /dev/null; then
+        log_error "python3 not available"
+        exit 1
+    fi
+
+    # Check Python YAML module
+    if ! python3 -c "import yaml" 2>/dev/null; then
+        log_error "Python yaml module not available"
+        log_error "This should be provided by nix-shell"
+        exit 1
+    fi
+
+    log_info "Dependencies OK (ffmpeg + python3-yaml from nix-shell)"
+}
+
+human_readable_size() {
+    local bytes=$1
+    if [ "$bytes" -lt 1024 ]; then
+        echo "${bytes}B"
+    elif [ "$bytes" -lt 1048576 ]; then
+        echo "$((bytes / 1024))KB"
+    elif [ "$bytes" -lt 1073741824 ]; then
+        echo "$((bytes / 1048576))MB"
+    else
+        echo "$((bytes / 1073741824))GB"
+    fi
+}
+
+convert_file() {
+    local input_file="$1"
+    local output_file="${input_file%.*}.opus"
+
+    # Skip if output already exists
+    if [ -f "$output_file" ]; then
+        log_warn "Already exists, skipping: $output_file"
+        SKIPPED=$((SKIPPED + 1))
+        return 0
+    fi
+
+    if [ "$DRY_RUN" = "true" ]; then
+        log_info "[DRY-RUN] Would convert: $input_file"
+        CONVERTED=$((CONVERTED + 1))
+        return 0
+    fi
+
+    local input_size
+    input_size=$(stat -f%z "$input_file" 2>/dev/null || stat -c%s "$input_file" 2>/dev/null)
+
+    log_info "Converting: $(basename "$input_file")"
+
+    # Convert with ffmpeg, preserving metadata and cover art
+    if ffmpeg -i "$input_file" \
+        -vn \
+        -c:a libopus \
+        -b:a "$OPUS_BITRATE" \
+        -map_metadata 0 \
+        -map 0:a \
+        -id3v2_version 3 \
+        "$output_file.tmp" \
+        -loglevel error -stats 2>&1; then
+
+        # Move temp file to final location
+        mv "$output_file.tmp" "$output_file"
+
+        # Preserve timestamps
+        touch -r "$input_file" "$output_file"
+
+        local output_size
+        output_size=$(stat -f%z "$output_file" 2>/dev/null || stat -c%s "$output_file" 2>/dev/null)
+        local saved=$((input_size - output_size))
+        SPACE_SAVED=$((SPACE_SAVED + saved))
+
+        log_success "Converted: $(basename "$output_file") (saved $(human_readable_size "$saved"))"
+
+        # Remove original file
+        rm "$input_file"
+
+        CONVERTED=$((CONVERTED + 1))
+        return 0
+    else
+        log_error "Failed to convert: $input_file"
+        rm -f "$output_file.tmp"
+        FAILED=$((FAILED + 1))
+        return 1
+    fi
+}
+
+update_playlists() {
+    if [ "$DRY_RUN" = "true" ]; then
+        log_info "[DRY-RUN] Would update playlists in: $PLAYLIST_DIR"
+        return 0
+    fi
+
+    if [ ! -d "$PLAYLIST_DIR" ]; then
+        log_warn "Playlist directory not found: $PLAYLIST_DIR"
+        return 0
+    fi
+
+    log_info "Updating playlists..."
+
+    local playlist_count=0
+    while IFS= read -r playlist; do
+        # Update extensions in playlist files
+        if sed -i.bak \
+            -e 's/\.m4a$/.opus/g' \
+            -e 's/\.mp3$/.opus/g' \
+            -e 's/\.webm$/.opus/g' \
+            "$playlist" 2>/dev/null; then
+            rm -f "$playlist.bak"
+            playlist_count=$((playlist_count + 1))
+        fi
+    done < <(find "$PLAYLIST_DIR" -name "*.m3u" -type f)
+
+    log_success "Updated $playlist_count playlist(s)"
+}
+
+parse_config() {
+    local config_file="$1"
+
+    if [ ! -f "$config_file" ]; then
+        log_error "Config file not found: $config_file"
+        exit 1
+    fi
+
+    log_info "Parsing config file: $config_file"
+
+    # Use Python to parse YAML and extract artist/show paths
+    python3 - "$config_file" <<'EOF'
+import sys
+import yaml
+
+config_file = sys.argv[1]
+
+with open(config_file, 'r') as f:
+    config = yaml.safe_load(f)
+
+# Extract paths from mixcloud shows
+for show in config.get('mixcloud_shows', []):
+    artist = show['artist']
+    show_name = show['show']
+    print(f"{artist}/{show_name}")
+
+# Extract paths from soundcloud shows
+for show in config.get('soundcloud_shows', []):
+    artist = show['artist']
+    show_name = show['show']
+    print(f"{artist}/{show_name}")
+EOF
+}
+
+scan_files_from_config() {
+    local config_file="$1"
+
+    log_info "Scanning for audio files from config: $config_file"
+
+    if [ ! -d "$LIBRARY_DIR" ]; then
+        log_error "Library directory not found: $LIBRARY_DIR"
+        exit 1
+    fi
+
+    # Get show paths from config
+    local show_paths=()
+    while IFS= read -r path; do
+        show_paths+=("$path")
+    done < <(parse_config "$config_file")
+
+    if [ ${#show_paths[@]} -eq 0 ]; then
+        log_error "No shows found in config file"
+        exit 1
+    fi
+
+    log_info "Found ${#show_paths[@]} show(s) in config:"
+    for path in "${show_paths[@]}"; do
+        log_info "  - $path"
+    done
+    echo ""
+
+    # Find audio files only in configured show directories
+    for show_path in "${show_paths[@]}"; do
+        local show_dir="$LIBRARY_DIR/$show_path"
+        if [ -d "$show_dir" ]; then
+            find "$show_dir" -type f \( \
+                -name "*.m4a" -o \
+                -name "*.mp3" -o \
+                -name "*.webm" \
+            \)
+        else
+            log_warn "Show directory not found: $show_dir"
+        fi
+    done | sort
+}
+
+scan_files() {
+    if [ "$CONFIG_ONLY" = "true" ]; then
+        if [ -z "$CONFIG_FILE" ]; then
+            log_error "CONFIG_ONLY=true requires --config option"
+            exit 1
+        fi
+        scan_files_from_config "$CONFIG_FILE"
+    else
+        log_info "Scanning for audio files in: $LIBRARY_DIR"
+
+        if [ ! -d "$LIBRARY_DIR" ]; then
+            log_error "Library directory not found: $LIBRARY_DIR"
+            exit 1
+        fi
+
+        # Find all audio files that aren't already opus
+        find "$LIBRARY_DIR" -type f \( \
+            -name "*.m4a" -o \
+            -name "*.mp3" -o \
+            -name "*.webm" \
+        \) | sort
+    fi
+}
+
+main() {
+    echo "╔════════════════════════════════════════════════════════════╗"
+    echo "║        Music Library Opus Conversion Tool                 ║"
+    echo "╚════════════════════════════════════════════════════════════╝"
+    echo ""
+
+    log_info "Music directory: $MUSIC_DIR"
+    log_info "Library directory: $LIBRARY_DIR"
+    log_info "Opus bitrate: $OPUS_BITRATE"
+    log_info "Parallel jobs: $PARALLEL_JOBS"
+
+    if [ -n "$CONFIG_FILE" ]; then
+        log_info "Config file: $CONFIG_FILE"
+    fi
+
+    if [ "$CONFIG_ONLY" = "true" ]; then
+        log_info "Mode: Convert only configured shows from config file"
+    else
+        log_info "Mode: Convert entire library"
+    fi
+
+    if [ "$DRY_RUN" = "true" ]; then
+        log_warn "DRY-RUN MODE: No files will be modified"
+    fi
+
+    echo ""
+
+    check_dependencies
+
+    # Scan for files
+    mapfile -t files < <(scan_files)
+    TOTAL_FILES=${#files[@]}
+
+    if [ "$TOTAL_FILES" -eq 0 ]; then
+        log_info "No files to convert!"
+        exit 0
+    fi
+
+    log_info "Found $TOTAL_FILES file(s) to convert"
+    echo ""
+
+    # Confirm before proceeding (unless dry-run)
+    if [ "$DRY_RUN" != "true" ]; then
+        read -p "Proceed with conversion? [y/N] " -n 1 -r
+        echo
+        if [[ ! $REPLY =~ ^[Yy]$ ]]; then
+            log_info "Cancelled by user"
+            exit 0
+        fi
+        echo ""
+    fi
+
+    # Convert files
+    log_info "Starting conversion..."
+    echo ""
+
+    # Export function and variables for parallel execution
+    export -f convert_file log_info log_success log_warn log_error human_readable_size
+    export OPUS_BITRATE DRY_RUN RED GREEN YELLOW BLUE NC
+
+    # Process files in parallel
+    printf '%s\0' "${files[@]}" | xargs -0 -P "$PARALLEL_JOBS" -I {} bash -c 'convert_file "$@"' _ {}
+
+    echo ""
+    log_info "Conversion complete!"
+    echo ""
+
+    # Update playlists
+    update_playlists
+
+    # Print statistics
+    echo "╔════════════════════════════════════════════════════════════╗"
+    echo "║                    Conversion Summary                      ║"
+    echo "╚════════════════════════════════════════════════════════════╝"
+    echo ""
+    echo "Total files found:    $TOTAL_FILES"
+    echo "Successfully converted: $CONVERTED"
+    echo "Skipped (exists):     $SKIPPED"
+    echo "Failed:               $FAILED"
+
+    if [ "$DRY_RUN" != "true" ] && [ "$SPACE_SAVED" -gt 0 ]; then
+        echo "Space saved:          $(human_readable_size "$SPACE_SAVED")"
+    fi
+
+    echo ""
+
+    if [ "$FAILED" -gt 0 ]; then
+        log_warn "Some files failed to convert. Check the output above for details."
+        exit 1
+    fi
+
+    if [ "$DRY_RUN" = "true" ]; then
+        log_info "Dry-run complete. Run without DRY_RUN=true to perform actual conversion."
+    else
+        log_success "All files converted successfully!"
+    fi
+}
+
+# Handle script arguments
+while [[ $# -gt 0 ]]; do
+    case $1 in
+        --dry-run)
+            DRY_RUN=true
+            shift
+            ;;
+        --music-dir)
+            MUSIC_DIR="$2"
+            LIBRARY_DIR="${MUSIC_DIR}/library"
+            PLAYLIST_DIR="${MUSIC_DIR}/playlist"
+            shift 2
+            ;;
+        --bitrate)
+            OPUS_BITRATE="$2"
+            shift 2
+            ;;
+        --jobs|-j)
+            PARALLEL_JOBS="$2"
+            shift 2
+            ;;
+        --config|-c)
+            CONFIG_FILE="$2"
+            CONFIG_ONLY=true
+            shift 2
+            ;;
+        --all)
+            CONFIG_ONLY=false
+            shift
+            ;;
+        --help|-h)
+            echo "Usage: $0 [OPTIONS]"
+            echo ""
+            echo "Convert music library to Opus format"
+            echo ""
+            echo "Options:"
+            echo "  --dry-run              Show what would be converted without doing it"
+            echo "  --music-dir DIR        Music directory (default: /neo/music)"
+            echo "  --config, -c FILE      Only convert shows from config file"
+            echo "  --all                  Convert entire library (default if no --config)"
+            echo "  --bitrate RATE         Opus bitrate (default: 128k)"
+            echo "  --jobs, -j N           Number of parallel jobs (default: 4)"
+            echo "  --help, -h             Show this help message"
+            echo ""
+            echo "Environment variables:"
+            echo "  MUSIC_DIR              Same as --music-dir"
+            echo "  CONFIG_FILE            Path to music-playlist-dl.yaml"
+            echo "  OPUS_BITRATE           Same as --bitrate"
+            echo "  PARALLEL_JOBS          Same as --jobs"
+            echo "  DRY_RUN                Set to 'true' for dry-run mode"
+            echo "  CONFIG_ONLY            Set to 'true' to convert only configured shows"
+            echo ""
+            echo "Examples:"
+            echo "  # Preview conversion of entire library"
+            echo "  $0 --dry-run"
+            echo ""
+            echo "  # Convert only podcast shows from config"
+            echo "  $0 --config /neo/music/music-playlist-dl.yaml"
+            echo ""
+            echo "  # Convert entire library with higher quality"
+            echo "  $0 --all --bitrate 192k --jobs 8"
+            echo ""
+            echo "  # Use custom directory and config"
+            echo "  $0 --music-dir /mnt/music --config /mnt/music/config.yaml"
+            exit 0
+            ;;
+        *)
+            log_error "Unknown option: $1"
+            echo "Use --help for usage information"
+            exit 1
+            ;;
+    esac
+done
+
+main
tools/music-playlist-dl/README.md
@@ -149,6 +149,77 @@ Popular shows include:
 
 All dependencies are automatically handled by the Nix package.
 
+## Converting Existing Files to Opus
+
+If you already have downloads in other formats (M4A, MP3, WebM) and want to switch to Opus without re-downloading, use the conversion script.
+
+**No installation required!** The script uses `nix-shell` to automatically provide all dependencies (ffmpeg with opus support and Python YAML parser).
+
+### Convert Only Podcast Downloads (Recommended)
+
+Convert only the shows configured in your `music-playlist-dl.yaml`:
+
+```bash
+# Preview what would be converted from config
+./tools/music-playlist-dl/convert-to-opus.sh \
+    --config /net/rhea/music/music-playlist-dl.yaml \
+    --dry-run
+
+# Convert only configured podcast shows
+./tools/music-playlist-dl/convert-to-opus.sh \
+    --config /net/rhea/music/music-playlist-dl.yaml \
+    --jobs 8
+```
+
+This will:
+- Read your config file to find all configured shows (Above & Beyond, Armin van Buuren, etc.)
+- Convert **only** files in those show directories
+- Leave the rest of your music library untouched
+
+### Convert Entire Music Library
+
+Convert everything in your library directory:
+
+```bash
+# Preview entire library conversion
+./tools/music-playlist-dl/convert-to-opus.sh --all --dry-run
+
+# Convert entire library with higher quality
+./tools/music-playlist-dl/convert-to-opus.sh --all --bitrate 192k --jobs 8
+```
+
+### What the Script Does
+
+- Automatically fetches ffmpeg with opus support via Nix
+- Parses YAML config to identify podcast directories (with `--config`)
+- Finds M4A, MP3, and WebM files in target directories
+- Converts to Opus format preserving metadata and artwork
+- Removes original files after successful conversion
+- Updates M3U playlists to reference new .opus files
+- Shows space savings and statistics
+- Supports parallel processing for faster conversion
+
+### Options
+
+- `--config FILE` / `-c FILE` - Only convert shows from config file
+- `--all` - Convert entire library (default if no `--config`)
+- `--dry-run` - Preview without making changes
+- `--bitrate RATE` - Opus bitrate (default: 128k)
+- `--jobs N` / `-j N` - Number of parallel conversion jobs (default: 4)
+- `--music-dir DIR` - Music directory (default: /neo/music)
+
+### Performance Notes
+
+**Config mode** (4 podcast shows, ~2,000 files):
+- Conversion time: ~30-60 minutes (8 parallel jobs)
+- Space savings: 30-40% reduction
+
+**Full library** (~8,000 files):
+- Conversion time: 2-4 hours (8 parallel jobs)
+- Space savings: Varies by source format
+
+**First run:** The script will download dependencies via Nix (one-time setup, ~30 seconds). Subsequent runs start immediately.
+
 ## Migration from Old Script
 
 If you have files in the old structure (directly under artist name instead of `library/artist/show/`):