#!/bin/bash # Media Library FLAC to Opus Conversion Script # Uses opus-tools (opusenc) for high-quality FLAC to Opus conversion # Also copies cover art files to maintain album artwork # Check if required tools are installed check_dependencies() { local missing_tools=() if ! command -v opusenc &> /dev/null; then missing_tools+=("opusenc (from opus-tools package)") fi if ! command -v flac &> /dev/null; then missing_tools+=("flac (for metadata extraction)") fi if [ ${#missing_tools[@]} -gt 0 ]; then echo "Error: Missing required tools:" for tool in "${missing_tools[@]}"; do echo " - $tool" done echo "" echo "To install missing dependencies:" echo " Ubuntu/Debian: sudo apt install opus-tools flac" echo " macOS: brew install opus-tools flac" echo " Arch Linux: sudo pacman -S opus-tools flac" echo " Fedora: sudo dnf install opus-tools flac" exit 1 fi } # Function to display usage show_usage() { echo "Usage: $0 [OPTIONS] " echo "" echo "Options:" echo " -o, --output DIR Output directory (default: creates _opus suffix)" echo " -r, --replace Replace original files with Opus (use with caution)" echo " -p, --preserve Create Opus files alongside original FLAC files" echo " -t, --test Test mode - show what would be processed" echo " -f, --force Force re-conversion of existing Opus files" echo " -b, --bitrate RATE Opus bitrate in kbps (default: 192)" echo " -q, --quality LEVEL Use VBR quality instead of bitrate (0-10, 10=best)" echo " --no-covers Skip copying cover art files" echo " --no-metadata Skip copying metadata from FLAC files" echo " -h, --help Show this help message" echo "" echo "Examples:" echo " $0 /path/to/music/library" echo " $0 -o /path/to/output /path/to/music/library" echo " $0 --test /path/to/music/library" echo " $0 --replace /path/to/music/library" echo " $0 -b 256 /path/to/music/library" echo " $0 -q 8 /path/to/music/library # VBR quality 8 (very good) with .opus.ogg extension" echo " $0 --no-covers /path/to/music/library" } # Default options OUTPUT_DIR="" REPLACE_MODE=false PRESERVE_MODE=false TEST_MODE=false FORCE_MODE=false COPY_COVERS=true COPY_METADATA=true OPUS_BITRATE=192 OPUS_QUALITY="" USE_VBR_QUALITY=false PROCESSED_COUNT=0 SKIPPED_COUNT=0 ERROR_COUNT=0 COVERS_COPIED=0 COVERS_SKIPPED=0 # Common cover art filenames to look for COVER_FILENAMES=("cover.jpg" "Cover.jpg" "COVER.jpg" "folder.jpg" "Folder.jpg" "FOLDER.jpg" "album.jpg" "Album.jpg" "ALBUM.jpg" "front.jpg" "Front.jpg" "FRONT.jpg" "cover.jpeg" "Cover.jpeg" "COVER.jpeg" "folder.jpeg" "Folder.jpeg" "FOLDER.jpeg" "album.jpeg" "Album.jpeg" "ALBUM.jpeg" "front.jpeg" "Front.jpeg" "FRONT.jpeg" "cover.png" "Cover.png" "COVER.png" "folder.png" "Folder.png" "FOLDER.png" "album.png" "Album.png" "ALBUM.png" "front.png" "Front.png" "FRONT.png") # Parse command line arguments while [[ $# -gt 0 ]]; do case $1 in -o|--output) OUTPUT_DIR="$2" shift 2 ;; -r|--replace) REPLACE_MODE=true shift ;; -p|--preserve) PRESERVE_MODE=true shift ;; -t|--test) TEST_MODE=true shift ;; -f|--force) FORCE_MODE=true shift ;; -b|--bitrate) OPUS_BITRATE="$2" USE_VBR_QUALITY=false shift 2 ;; -q|--quality) OPUS_QUALITY="$2" USE_VBR_QUALITY=true shift 2 ;; --no-covers) COPY_COVERS=false shift ;; --no-metadata) COPY_METADATA=false shift ;; -h|--help) show_usage exit 0 ;; -*) echo "Unknown option: $1" show_usage exit 1 ;; *) MEDIA_LIBRARY="$1" shift ;; esac done # Check dependencies check_dependencies # Validate quality or bitrate settings if [ "$USE_VBR_QUALITY" = true ]; then if ! [[ "$OPUS_QUALITY" =~ ^[0-9]+$ ]] || [ "$OPUS_QUALITY" -lt 0 ] || [ "$OPUS_QUALITY" -gt 10 ]; then echo "Error: Invalid quality level '$OPUS_QUALITY'. Must be between 0 and 10" echo "Quality levels: 0 (lowest), 5 (good), 8 (very good), 10 (best)" exit 1 fi else if ! [[ "$OPUS_BITRATE" =~ ^[0-9]+$ ]] || [ "$OPUS_BITRATE" -lt 6 ] || [ "$OPUS_BITRATE" -gt 510 ]; then echo "Error: Invalid bitrate '$OPUS_BITRATE'. Must be between 6 and 510 kbps" echo "Recommended values: 96 (good), 128 (very good), 192 (excellent), 256 (overkill)" exit 1 fi fi # Check if media library path is provided if [ -z "$MEDIA_LIBRARY" ]; then echo "Error: Media library path is required" show_usage exit 1 fi # Check if media library exists if [ ! -d "$MEDIA_LIBRARY" ]; then echo "Error: Media library directory '$MEDIA_LIBRARY' not found" exit 1 fi # Set output directory if not specified if [ -z "$OUTPUT_DIR" ] && [ "$REPLACE_MODE" = false ]; then OUTPUT_DIR="${MEDIA_LIBRARY}_opus" fi # Function to check if file needs conversion needs_conversion() { local input_file="$1" local output_file="$2" # Check if input file is readable if [ ! -r "$input_file" ]; then echo " WARNING: File not readable: $input_file" return 1 fi # If force mode, always convert if [ "$FORCE_MODE" = true ]; then return 0 fi # Check if output file already exists and is newer than input if [ -f "$output_file" ] && [ "$output_file" -nt "$input_file" ]; then return 1 # No conversion needed fi return 0 # Needs conversion } # Function to extract embedded cover art from FLAC file extract_flac_cover_art() { local flac_file="$1" local temp_cover_file="$2" if [ "$COPY_COVERS" = false ]; then return 1 fi # Try to extract embedded cover art using metaflac if command -v metaflac &> /dev/null; then # Check if the FLAC file has embedded pictures if metaflac --list --block-type=PICTURE "$flac_file" 2>/dev/null | grep -q "PICTURE"; then # Extract the first picture (cover art) if metaflac --export-picture-to="$temp_cover_file" "$flac_file" 2>/dev/null; then return 0 fi fi fi # Fallback: try ffmpeg to extract embedded art if command -v ffmpeg &> /dev/null; then if ffmpeg -hide_banner -y -i "$flac_file" -an -vcodec copy "$temp_cover_file" 2>/dev/null; then # Check if the extracted file has content if [ -s "$temp_cover_file" ]; then return 0 fi fi fi return 1 } # Function to extract metadata from FLAC file extract_flac_metadata() { local flac_file="$1" local temp_tags_file="$2" if [ "$COPY_METADATA" = false ]; then return fi # Extract metadata using metaflac (preferred method) if command -v metaflac &> /dev/null; then metaflac --export-tags-to="$temp_tags_file" "$flac_file" 2>/dev/null # If metaflac succeeded and file has content, we're done if [ -s "$temp_tags_file" ]; then return fi fi # Fallback: use ffprobe if available (often more comprehensive) if command -v ffprobe &> /dev/null; then ffprobe -v quiet -show_entries format_tags -of csv=p=0:s=x "$flac_file" 2>/dev/null | \ sed 's/^tag://; s/x/\n/g' | grep '=' > "$temp_tags_file" # If ffprobe succeeded and file has content, we're done if [ -s "$temp_tags_file" ]; then return fi fi # Last fallback: use flac command to list tags if command -v flac &> /dev/null; then flac --list --block-type=VORBIS_COMMENT "$flac_file" 2>/dev/null | \ grep -E "^\s+comment\[[0-9]+\]:" | \ sed 's/^\s*comment\[[0-9]*\]: //' > "$temp_tags_file" fi } # Function to copy cover art files copy_cover_art() { local source_dir="$1" local dest_dir="$2" local relative_path="$3" # Skip if covers are disabled if [ "$COPY_COVERS" = false ]; then return fi # Skip if in replace or preserve mode (covers stay in original location) if [ "$REPLACE_MODE" = true ] || [ "$PRESERVE_MODE" = true ]; then return fi # Look for cover art files local found_cover=false for cover_name in "${COVER_FILENAMES[@]}"; do local cover_file="$source_dir/$cover_name" if [ -f "$cover_file" ]; then local dest_cover="$dest_dir/$cover_name" # Check if we need to copy (force mode or destination doesn't exist/is older) if [ "$FORCE_MODE" = true ] || [ ! -f "$dest_cover" ] || [ "$cover_file" -nt "$dest_cover" ]; then if [ "$TEST_MODE" = true ]; then echo " WOULD COPY COVER: $relative_path/$cover_name" else echo " COPYING COVER: $cover_name" if cp "$cover_file" "$dest_cover" 2>/dev/null; then ((COVERS_COPIED++)) else echo " WARNING: Failed to copy $cover_name" fi fi found_cover=true else echo " SKIP COVER: $cover_name (already exists and is newer)" ((COVERS_SKIPPED++)) found_cover=true fi # Only copy the first cover file found to avoid duplicates break fi done if [ "$found_cover" = false ] && [ "$TEST_MODE" = false ]; then echo " NO COVER: No cover art found in $(basename "$source_dir")" fi } # Function to process a single FLAC file process_flac_file() { local input_file="$1" local relative_path="$2" local output_file="" # Determine output file path with .opus.ogg extension if [ "$REPLACE_MODE" = true ]; then # Replace .flac with .opus.ogg in same location output_file="${input_file%.flac}.opus.ogg" elif [ "$PRESERVE_MODE" = true ]; then local dir=$(dirname "$input_file") local filename=$(basename "$input_file" .flac) output_file="$dir/${filename}.opus.ogg" else # Change extension from .flac to .opus.ogg local opus_relative_path="${relative_path%.flac}.opus.ogg" output_file="$OUTPUT_DIR/$opus_relative_path" fi # Check if conversion is needed if ! needs_conversion "$input_file" "$output_file"; then echo " SKIP: $relative_path (Opus file exists and is newer)" ((SKIPPED_COUNT++)) return fi if [ "$TEST_MODE" = true ]; then if [ "$USE_VBR_QUALITY" = true ]; then echo " WOULD CONVERT: $relative_path → $(basename "$output_file") @ VBR quality ${OPUS_QUALITY}" else echo " WOULD CONVERT: $relative_path → $(basename "$output_file") @ ${OPUS_BITRATE}kbps" fi return fi # Create output directory if needed local output_dir=$(dirname "$output_file") if [ ! -d "$output_dir" ]; then echo " Creating directory: $output_dir" if ! mkdir -p "$output_dir"; then echo " ✗ ERROR: Cannot create output directory: $output_dir" ((ERROR_COUNT++)) return fi fi # Verify input file exists and is readable if [ ! -f "$input_file" ]; then echo " ✗ ERROR: Input file not found: $input_file" ((ERROR_COUNT++)) return fi if [ ! -r "$input_file" ]; then echo " ✗ ERROR: Input file not readable: $input_file" ((ERROR_COUNT++)) return fi # Create temporary files local temp_file="${output_file}.tmp" local temp_cover_file="${output_file}.cover.tmp" # Extract embedded cover art from FLAC file local has_embedded_cover=false if extract_flac_cover_art "$input_file" "$temp_cover_file"; then has_embedded_cover=true echo " Found embedded cover art" fi # Look for external cover art if no embedded cover found local external_cover_file="" if [ "$has_embedded_cover" = false ]; then local source_dir=$(dirname "$input_file") for cover_name in "${COVER_FILENAMES[@]}"; do local cover_file="$source_dir/$cover_name" if [ -f "$cover_file" ]; then external_cover_file="$cover_file" echo " Found external cover art: $cover_name" break fi done fi # Build opusenc command local opusenc_cmd=(opusenc) # Add quality or bitrate settings if [ "$USE_VBR_QUALITY" = true ]; then opusenc_cmd+=(--vbr --comp "$OPUS_QUALITY") echo " CONVERTING: $relative_path → Opus VBR quality ${OPUS_QUALITY}" else opusenc_cmd+=(--bitrate "$OPUS_BITRATE") echo " CONVERTING: $relative_path → Opus ${OPUS_BITRATE}kbps" fi # Add embedded cover art if available if [ "$has_embedded_cover" = true ] && [ -f "$temp_cover_file" ]; then opusenc_cmd+=(--picture "$temp_cover_file") echo " Embedding cover art from FLAC" elif [ -n "$external_cover_file" ]; then opusenc_cmd+=(--picture "$external_cover_file") echo " Embedding external cover art" fi # Note: opusenc automatically copies metadata from FLAC files # Only add manual metadata if we need to override or add specific tags if [ "$COPY_METADATA" = true ]; then echo " Metadata: opusenc will automatically copy FLAC tags" else # If metadata copying is disabled, use --discard-comments opusenc_cmd+=(--discard-comments) echo " Metadata: Disabled (using --discard-comments)" fi # Add input and output files opusenc_cmd+=("$input_file" "$temp_file") echo " Command: ${opusenc_cmd[*]}" # Run opusenc with error logging if "${opusenc_cmd[@]}" 2>"${temp_file}.log"; then # Verify the output file was created and has content if [ -s "$temp_file" ]; then # Move temp file to final location mv "$temp_file" "$output_file" echo " ✓ DONE: $(basename "$output_file")" rm -f "${temp_file}.log" "$temp_cover_file" ((PROCESSED_COUNT++)) # If replace mode, remove original FLAC file if [ "$REPLACE_MODE" = true ]; then rm "$input_file" echo " Removed original: $(basename "$input_file")" fi else echo " ✗ ERROR: Output file is empty or wasn't created properly" rm -f "$temp_file" "$temp_cover_file" ((ERROR_COUNT++)) fi else echo " ✗ ERROR: Failed to convert $relative_path" echo " Error log saved to: ${temp_file}.log" if [ -f "${temp_file}.log" ]; then echo " Error details:" tail -n 5 "${temp_file}.log" | sed 's/^/ /' fi rm -f "$temp_file" "$temp_cover_file" ((ERROR_COUNT++)) fi } # Function to process an album directory process_album() { local album_dir="$1" local album_name=$(basename "$album_dir") local relative_album_path="${album_dir#$MEDIA_LIBRARY/}" echo "Processing album: $album_name" # Find all FLAC files in the album directory local flac_files=() while IFS= read -r -d '' file; do flac_files+=("$file") done < <(find "$album_dir" -name "*.flac" -type f -print0) if [ ${#flac_files[@]} -eq 0 ]; then echo " No FLAC files found in $album_name" return fi # Process each FLAC file for flac_file in "${flac_files[@]}"; do local relative_path="${flac_file#$MEDIA_LIBRARY/}" process_flac_file "$flac_file" "$relative_path" done # Copy cover art if needed (only for output directory mode) if [ "$REPLACE_MODE" = false ] && [ "$PRESERVE_MODE" = false ]; then local output_album_dir="$OUTPUT_DIR/$relative_album_path" copy_cover_art "$album_dir" "$output_album_dir" "$relative_album_path" fi echo "" } # Test opusenc with version info echo "Testing opus-tools..." opusenc_version=$(opusenc --version 2>&1 | head -n1) echo "Found: $opusenc_version" # Main processing echo "" echo "Media Library FLAC to Opus Converter (using opus-tools)" echo "=======================================================" echo "Input: $MEDIA_LIBRARY" if [ "$USE_VBR_QUALITY" = true ]; then echo "Quality: VBR level ${OPUS_QUALITY} (0=lowest, 10=best)" else echo "Bitrate: ${OPUS_BITRATE}kbps CBR" fi if [ "$TEST_MODE" = true ]; then echo "Mode: TEST MODE (no files will be modified)" elif [ "$REPLACE_MODE" = true ]; then echo "Mode: REPLACE (original FLAC files will be deleted after conversion)" elif [ "$PRESERVE_MODE" = true ]; then echo "Mode: PRESERVE (Opus files created alongside originals)" else echo "Output: $OUTPUT_DIR" fi if [ "$FORCE_MODE" = true ]; then echo "Force: Enabled (will re-convert existing Opus files)" fi if [ "$COPY_COVERS" = true ]; then echo "Covers: Enabled (will embed cover art in Opus files and copy external files)" else echo "Covers: Disabled" fi if [ "$COPY_METADATA" = true ]; then echo "Metadata: Enabled (will copy tags from FLAC files)" else echo "Metadata: Disabled" fi echo "" # Find all directories that contain FLAC files (potential albums) album_dirs=() while IFS= read -r -d '' dir; do if find "$dir" -maxdepth 1 -name "*.flac" -type f | grep -q .; then album_dirs+=("$dir") fi done < <(find "$MEDIA_LIBRARY" -type d -print0) if [ ${#album_dirs[@]} -eq 0 ]; then echo "No albums with FLAC files found in $MEDIA_LIBRARY" exit 0 fi echo "Found ${#album_dirs[@]} album(s) with FLAC files" echo "" # Process each album for album_dir in "${album_dirs[@]}"; do process_album "$album_dir" done # Summary echo "Conversion Summary:" echo "==================" echo "Converted: $PROCESSED_COUNT files" echo "Skipped: $SKIPPED_COUNT files (already converted or newer)" echo "Errors: $ERROR_COUNT files" if [ "$COPY_COVERS" = true ]; then echo "Covers copied: $COVERS_COPIED files" echo "Covers skipped: $COVERS_SKIPPED files (already exist or newer)" fi if [ "$ERROR_COUNT" -gt 0 ]; then echo "" echo "Some files failed to convert. Check the error logs for details." exit 1 fi