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