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 "$@"