main
  1#!/usr/bin/env bash
  2# Audible to Audiobookshelf converter
  3# Downloads audiobooks from Audible and converts them to M4B format
  4
  5set -euo pipefail
  6
  7# Configuration
  8TEMP_DIR="${AUDIBLE_TEMP_DIR:-/tmp/audible-download}"
  9OUTPUT_DIR="${AUDIBLE_OUTPUT_DIR:-$HOME/audiobooks}"
 10QUALITY="${AUDIBLE_QUALITY:-best}"
 11FORMAT="${AUDIBLE_FORMAT:-m4b}"
 12AUTHCODE="${AUDIBLE_AUTHCODE:-}"
 13CLEANUP_ON_EXIT="${AUDIBLE_CLEANUP_ON_EXIT:-false}" # Set to 'true' to auto-cleanup temp files
 14
 15# Colors for output
 16RED='\033[0;31m'
 17GREEN='\033[0;32m'
 18YELLOW='\033[1;33m'
 19NC='\033[0m' # No Color
 20
 21# Logging functions
 22log_info() {
 23	echo -e "${GREEN}[INFO]${NC} $*"
 24}
 25
 26log_warn() {
 27	echo -e "${YELLOW}[WARN]${NC} $*"
 28}
 29
 30log_error() {
 31	echo -e "${RED}[ERROR]${NC} $*"
 32}
 33
 34# Usage information
 35usage() {
 36	cat <<EOF
 37Usage: $(basename "$0") [OPTIONS] [COMMAND]
 38
 39Audible to Audiobookshelf converter - Downloads and converts audiobooks
 40
 41COMMANDS:
 42    download-all    Download all books from Audible library
 43    download ASIN   Download specific book by ASIN
 44    convert FILE    Convert existing AAX file to M4B
 45    sync            Download and convert all new books (default)
 46    list            List Audible library
 47
 48OPTIONS:
 49    -o, --output DIR     Output directory (default: \$HOME/audiobooks)
 50    -t, --temp DIR       Temporary download directory (default: /tmp/audible-download)
 51    -q, --quality QUAL   Audio quality: best, high, normal (default: $QUALITY)
 52    -f, --format FMT     Output format: m4b, mp3, m4a (default: $FORMAT)
 53    -a, --authcode CODE  Audible activation bytes for AAX DRM removal
 54    -h, --help           Show this help message
 55
 56ENVIRONMENT VARIABLES:
 57    AUDIBLE_OUTPUT_DIR      Output directory for converted books
 58    AUDIBLE_TEMP_DIR        Temporary directory for downloads (kept by default)
 59    AUDIBLE_QUALITY         Audio quality setting
 60    AUDIBLE_FORMAT          Output format
 61    AUDIBLE_AUTHCODE        Activation bytes for AAX DRM removal
 62    AUDIBLE_CLEANUP_ON_EXIT Set to 'true' to auto-delete temp files on exit
 63
 64EXAMPLES:
 65    # Sync library (download and convert new books)
 66    $(basename "$0") sync
 67
 68    # Download specific book
 69    $(basename "$0") download B01234567X
 70
 71    # Convert existing AAX file
 72    $(basename "$0") convert /path/to/book.aax
 73
 74    # Download all books to custom directory
 75    $(basename "$0") --output /mnt/audiobooks download-all
 76
 77EOF
 78}
 79
 80# Check dependencies
 81check_dependencies() {
 82	local deps=("audible" "aaxtomp3" "ffmpeg" "mediainfo" "jq")
 83	local missing=()
 84
 85	for dep in "${deps[@]}"; do
 86		if ! command -v "$dep" &>/dev/null; then
 87			missing+=("$dep")
 88		fi
 89	done
 90
 91	if [ ${#missing[@]} -ne 0 ]; then
 92		log_error "Missing required dependencies: ${missing[*]}"
 93		log_error "Please install: nix-shell -p audible-cli aaxtomp3 ffmpeg mediainfo jq"
 94		exit 1
 95	fi
 96}
 97
 98# Check if authenticated with Audible
 99check_auth() {
100	if ! audible library list &>/dev/null; then
101		log_error "Not authenticated with Audible"
102		log_error "Please run: audible quickstart"
103		exit 1
104	fi
105}
106
107# Create directories
108setup_dirs() {
109	mkdir -p "$TEMP_DIR"
110	mkdir -p "$OUTPUT_DIR"
111}
112
113# Cleanup temporary files (manual or via AUDIBLE_CLEANUP_ON_EXIT=true)
114# By default, we keep temp files to reuse downloaded AAX files on subsequent runs
115cleanup() {
116	if [ -d "$TEMP_DIR" ]; then
117		log_info "Cleaning up temporary files in: $TEMP_DIR"
118		[[ -n "$TEMP_DIR" ]] && rm -rf "$TEMP_DIR"
119		log_info "Cleanup complete"
120	fi
121}
122
123# Download entire library
124download_all() {
125	log_info "Exporting library metadata..."
126	local library_json="$TEMP_DIR/library.json"
127
128	audible library export --format json --output "$library_json"
129
130	local total_books
131	total_books=$(jq length "$library_json")
132	log_info "Found $total_books books in library"
133
134	local count=0
135	jq -r '.[].asin' "$library_json" | while read -r asin; do
136		count=$((count + 1))
137		log_info "Downloading book $count/$total_books (ASIN: $asin)..."
138
139		if ! audible download \
140			--asin "$asin" \
141			--aax-fallback \
142			--chapter \
143			--annotation \
144			--pdf \
145			--cover \
146			--quality "$QUALITY" \
147			--ignore-podcasts \
148			--output-dir "$TEMP_DIR" 2>&1; then
149			log_warn "Failed to download ASIN: $asin (may already be downloaded)"
150		fi
151	done
152}
153
154# Download specific book by ASIN
155download_book() {
156	local asin="$1"
157	log_info "Downloading book ASIN: $asin..."
158
159	audible download \
160		--asin "$asin" \
161		--aax \
162		--quality "$QUALITY" \
163		--ignore-podcasts \
164		--output-dir "$TEMP_DIR"
165}
166
167# Convert AAX/AAXC files to M4B
168# Usage: convert_books [directory|file]
169convert_books() {
170	local source_path="${1:-$TEMP_DIR}"
171	local aax_files=()
172
173	# Determine if we're converting a directory or a single file
174	if [ -d "$source_path" ]; then
175		# Find all AAX and AAXC files in directory
176		log_info "Searching for AAX/AAXC files in: $source_path"
177		mapfile -t aax_files < <(find "$source_path" -maxdepth 1 -type f \( -name "*.aax" -o -name "*.AAX" -o -name "*.aaxc" -o -name "*.AAXC" \))
178
179		if [ ${#aax_files[@]} -eq 0 ]; then
180			log_warn "No AAX/AAXC files found in $source_path"
181			return 0
182		fi
183		log_info "Found ${#aax_files[@]} AAX/AAXC file(s)"
184	elif [ -f "$source_path" ]; then
185		# Single file (both AAX and AAXC supported)
186		aax_files=("$source_path")
187	else
188		log_error "Invalid path: $source_path"
189		return 1
190	fi
191
192	local total_files=${#aax_files[@]}
193	log_info "Converting $total_files AAX/AAXC file(s) to $FORMAT format..."
194
195	# Get authcode (either provided or from audible-cli)
196	local authcode="$AUTHCODE"
197	if [ -z "$authcode" ]; then
198		# Try to get activation bytes from audible-cli
199		log_info "Attempting to retrieve activation bytes from audible-cli..."
200		if authcode=$(audible activation-bytes 2>/dev/null); then
201			if [ -n "$authcode" ]; then
202				log_info "Using activation bytes from audible-cli: $authcode"
203			else
204				log_warn "Activation bytes command succeeded but returned empty value"
205				authcode=""
206			fi
207		else
208			log_warn "Could not retrieve activation bytes from audible-cli"
209			authcode=""
210		fi
211	fi
212
213	# Base aaxtomp3 options (no --batch, we'll loop instead)
214	local aaxtomp3_opts=(--target_dir "$OUTPUT_DIR" --no-clobber)
215
216	# Add authcode if available, otherwise use audible-cli data
217	if [ -n "$authcode" ]; then
218		aaxtomp3_opts+=(--authcode "$authcode")
219	else
220		log_warn "No authcode available, trying --use-audible-cli-data flag"
221		aaxtomp3_opts+=(--use-audible-cli-data)
222	fi
223
224	case "$FORMAT" in
225	m4b)
226		aaxtomp3_opts+=(--single)
227		;;
228	mp3)
229		aaxtomp3_opts+=(--codec libmp3lame)
230		;;
231	m4a)
232		# Default M4A output
233		;;
234	*)
235		log_error "Unknown format: $FORMAT"
236		exit 1
237		;;
238	esac
239
240	# Convert each file individually (--batch doesn't work with --authcode)
241	# Note: --no-clobber flag makes aaxtomp3 skip existing files automatically
242	local converted=0
243	local failed=0
244	local current=0
245
246	log_info "Starting conversion loop for ${#aax_files[@]} files..."
247
248	for aax_file in "${aax_files[@]}"; do
249		local base_filename
250		base_filename=$(basename "$aax_file")
251		current=$((current + 1))
252
253		log_info "[$current/$total_files] Converting: $base_filename"
254		echo "----------------------------------------"
255
256		# Run aaxtomp3 directly (let output flow to terminal)
257		# Temporarily disable exit-on-error for this command
258		set +e
259		aaxtomp3 "${aaxtomp3_opts[@]}" "$aax_file"
260		local exit_code=$?
261		set -e
262
263		if [ $exit_code -eq 0 ]; then
264			converted=$((converted + 1))
265			log_info "✓ Successfully converted: $base_filename"
266		else
267			failed=$((failed + 1))
268			log_warn "✗ Failed to convert: $base_filename (exit code: $exit_code)"
269		fi
270		echo ""
271	done
272
273	# Report summary
274	local skipped=$((total_files - converted - failed))
275	log_info "Conversion complete!"
276	log_info "  Converted: $converted/$total_files"
277	if [ $skipped -gt 0 ]; then
278		log_info "  Skipped: $skipped/$total_files (already existed)"
279	fi
280	if [ $failed -gt 0 ]; then
281		log_warn "  Failed: $failed/$total_files"
282		log_error ""
283		log_error "If you got 'Missing authcode' errors, follow these steps:"
284		log_error "  1. Authenticate with Audible:"
285		log_error "     audible quickstart"
286		log_error ""
287		log_error "  2. Verify activation bytes are available:"
288		log_error "     audible activation-bytes"
289		log_error ""
290		log_error "  3. Alternatively, provide authcode manually:"
291		log_error "     --authcode YOUR_ACTIVATION_BYTES"
292		log_error "     or: export AUDIBLE_AUTHCODE=YOUR_ACTIVATION_BYTES"
293		exit 1
294	fi
295	log_info "Books saved to: $OUTPUT_DIR"
296}
297
298# List Audible library
299list_library() {
300	log_info "Fetching Audible library..."
301	audible library list | jq -r '.[] | "\(.title) by \(.authors) (ASIN: \(.asin))"'
302}
303
304# Sync library (download and convert new books)
305sync_library() {
306	log_info "Syncing Audible library..."
307	download_all
308	convert_books
309}
310
311# Main function
312main() {
313	local command="sync"
314	local -a positional_args=()
315
316	# Parse arguments
317	while [[ $# -gt 0 ]]; do
318		case $1 in
319		-o | --output)
320			OUTPUT_DIR="$2"
321			shift 2
322			;;
323		-t | --temp)
324			TEMP_DIR="$2"
325			shift 2
326			;;
327		-q | --quality)
328			QUALITY="$2"
329			shift 2
330			;;
331		-f | --format)
332			FORMAT="$2"
333			shift 2
334			;;
335		-a | --authcode)
336			AUTHCODE="$2"
337			shift 2
338			;;
339		-h | --help)
340			usage
341			exit 0
342			;;
343		download-all | download | convert | sync | list)
344			command="$1"
345			shift
346			;;
347		-*)
348			log_error "Unknown option: $1"
349			usage
350			exit 1
351			;;
352		*)
353			# Collect positional arguments (file paths, ASINs, etc.)
354			positional_args+=("$1")
355			shift
356			;;
357		esac
358	done
359
360	# Optionally cleanup on exit (default: keep files for reuse)
361	if [ "$CLEANUP_ON_EXIT" = "true" ]; then
362		log_info "Auto-cleanup enabled (AUDIBLE_CLEANUP_ON_EXIT=true)"
363		trap cleanup EXIT
364	else
365		log_info "Keeping temp files in $TEMP_DIR for reuse (set AUDIBLE_CLEANUP_ON_EXIT=true to auto-delete)"
366	fi
367
368	# Check dependencies
369	check_dependencies
370
371	# Setup directories
372	setup_dirs
373
374	# Execute command
375	case $command in
376	download-all)
377		check_auth
378		download_all
379		;;
380	download)
381		if [ ${#positional_args[@]} -eq 0 ]; then
382			log_error "ASIN required for download command"
383			usage
384			exit 1
385		fi
386		check_auth
387		download_book "${positional_args[0]}"
388		;;
389	convert)
390		if [ ${#positional_args[@]} -eq 0 ]; then
391			log_error "File or directory path required for convert command"
392			usage
393			exit 1
394		fi
395		# Get the last positional argument as the path
396		local convert_path="${positional_args[-1]}"
397		if [ ! -e "$convert_path" ]; then
398			log_error "Path not found: $convert_path"
399			exit 1
400		fi
401
402		# If it's a directory, convert all AAX files in it
403		# If it's a file, convert just that specific file
404		if [ -d "$convert_path" ]; then
405			log_info "Converting all AAX files in directory: $convert_path"
406			convert_books "$convert_path"
407		elif [ -f "$convert_path" ]; then
408			log_info "Converting single file: $(basename "$convert_path")"
409			convert_books "$convert_path"
410		else
411			log_error "Path must be a file or directory: $convert_path"
412			exit 1
413		fi
414		;;
415	sync)
416		check_auth
417		sync_library
418		;;
419	list)
420		check_auth
421		list_library
422		;;
423	*)
424		log_error "Unknown command: $command"
425		usage
426		exit 1
427		;;
428	esac
429}
430
431# Run main function
432main "$@"