audiobookshelf-metadata-sync
Audiobookshelf Metadata Sync
Overview
Reference guide for synchronizing metadata between audiobookshelf server and local audio files. When audiobookshelf server metadata (manually verified as correct) differs from embedded audio file metadata, use this skill to update files to match the server.
Core principle: audiobookshelf server metadata is the source of truth. Update local files to match, avoiding duplicate modifications unless explicitly required.
Quick Reference
| Task | API Endpoint / Command | Notes |
|---|---|---|
| Login | POST /api/login |
Get API token |
| Get all libraries | GET /api/libraries |
List libraries |
| Get library items | GET /api/libraries/{id}/items |
List all items in library |
| Get item details | GET /api/items/{itemId} |
Full item with media metadata |
| Update item media | PATCH /api/items/{itemId}/media |
Update server metadata |
| Embed metadata | ffmpeg -i in.m4a -c copy -metadata key="value" out.m4a |
Write to file |
| Batch update | POST /api/items/batch-update |
Update multiple items |
When to Use
- audiobookshelf server metadata and local file metadata are inconsistent
- Need to sync server metadata to local audio files
- Files were imported/matched but embedded metadata wasn't updated
- Preparing files for backup or export with correct metadata
- Need to verify metadata consistency across library
When NOT to Use
- When you want to use local file metadata as source of truth
- When only server-side changes are needed (no file modification)
- When working with podcasts that should preserve original episode metadata
Authentication
Get API Token
Option 1: Login endpoint
curl -X POST "http://your-audiobookshelf-server/api/login" \
-H "Content-Type: application/json" \
-d '{"username":"your-username","password":"your-password"}'
Response:
{
"user": {
"token": "eyJhbGciOiJIiJ9.eyJ1c2VyIjoiNDEyODc4fQ.ZraBFohS4Tg39NszY...",
"id": "user-id"
},
"serverSettings": {...}
}
Option 2: From web UI
- Open browser developer tools
- Go to audiobookshelf web UI
- Find any API request in Network tab
- Copy
Authorization: Bearer <token>header
Use API Token
# In header (recommended)
curl -X GET "http://your-audiobookshelf-server/api/libraries" \
-H "Authorization: Bearer YOUR_TOKEN"
# Or as query parameter for GET requests
curl -X GET "http://your-audiobookshelf-server/api/libraries?token=YOUR_TOKEN"
API Workflow
Step 1: Get All Libraries
curl -X GET "http://your-audiobookshelf-server/api/libraries" \
-H "Authorization: Bearer YOUR_TOKEN"
Response:
{
"libraries": [
{
"id": "lib-id-1",
"name": "Audiobooks",
"mediaType": "book",
"folders": [{"id": "folder-id", "fullPath": "/path/to/audiobooks"}]
}
]
}
Step 2: Get Library Items
curl -X GET "http://your-audiobookshelf-server/api/libraries/lib-id-1/items" \
-H "Authorization: Bearer YOUR_TOKEN"
Response:
{
"results": [
{
"id": "item-id-1",
"libraryId": "lib-id-1",
"path": "/path/to/audiobooks/Book Title",
"mediaType": "book",
"media": {
"metadata": {
"title": "Book Title",
"authorName": "Author Name",
"seriesName": "Series Name",
"publishedYear": "2024",
"genres": ["Fiction", "Adventure"],
"asin": "B00XXX",
"isbn": "978-xxx"
},
"audioFiles": [
{
"index": 0,
"ino": "file-ino",
"metadata": {
"filename": "chapter01.m4a",
"path": "/path/to/audiobooks/Book Title/chapter01.m4a"
},
"duration": 1234.56,
"bitRate": 128000,
"format": "mp4",
"metaTags": {
"title": "Chapter 1",
"artist": "Author Name"
}
}
]
}
}
]
}
Step 3: Get Specific Item Details
curl -X GET "http://your-audiobookshelf-server/api/items/item-id-1" \
-H "Authorization: Bearer YOUR_TOKEN"
Response includes chapters information:
{
"id": "item-id-1",
"media": {
"numChapters": 32,
"chapters": [
{
"title": "01 鲁宾逊漂流记 01",
"start": 0,
"end": 1262.948
},
{
"title": "02 鲁宾逊漂流记 02",
"start": 1262.948,
"end": 2606.32
}
],
"metadata": { ... }
}
}
Key fields:
media.chapters[].title— Chapter name (set in UI)media.chapters[].start— Chapter start time (seconds)media.chapters[].end— Chapter end time (seconds)
Step 4: Extract Metadata for File Sync
Key metadata fields to sync to audio files:
Book/Podcast Metadata:
title— Book or podcast titleauthorName— Author or podcast authornarratorName— Narrator (for audiobooks)seriesName— Series nameseriesSequence— Series sequence numbergenres— Genres/categoriespublishedYear— Publication yearpublisher— Publisher namedescription— Book/podcast descriptionisbn/asin— Identifierslanguage— Language codeexplicit— Explicit content flag
Audio File Metadata (embedded tags):
title— Chapter/episode titleartist— Usually author/narratoralbum— Book/podcast titlealbumArtist— Author (useful for compilations)genre— Genreyear— Publication yeartrack— Track number (e.g., "1/12")disc— Disc number (for multi-part)comment— Description or notescover— Embedded album art
Sync Process
Single File Sync with ffmpeg
Important: FFmpeg cannot edit files in-place. You MUST write to a temporary file first, then rename.
Note on chapter titles: Use chapter title from media.chapters[].title (set in audiobookshelf UI), NOT the filename. Filename may be generic like chapter01.m4a, but chapter title is descriptive like 01 鲁宾逊漂流记 01.
# Read current metadata
ffprobe -i "chapter01.m4a" -show_entries format_tags -of default=noprint_wrappers=1
# Chapter title from audiobookshelf (NOT filename)
CHAPTER_TITLE="01 鲁宾逊漂流记 01"
BOOK_TITLE="鲁宾逊漂流记"
AUTHOR="丹尼尔·笛福"
# Step 1: Write to temporary file (must use same extension for format detection)
ffmpeg -i "chapter01.m4a" -c copy \
-metadata title="$CHAPTER_TITLE" \
-metadata artist="$AUTHOR" \
-metadata album="$BOOK_TITLE" \
-metadata album_artist="$AUTHOR" \
-y "chapter01.temp.m4a"
# Step 2: Verify temp file was created successfully
if [ -f "chapter01.temp.m4a" ]; then
# Step 3: Replace original
mv "chapter01.temp.m4a" "chapter01.m4a"
echo "Success: metadata updated"
else
echo "ERROR: Failed to create temp file"
exit 1
fi
Why temp file is required:
- FFmpeg error when writing directly:
FFmpeg cannot edit existing files in-place. - Temp file must have correct extension (
.temp.m4anot.tmp) for format detection -yflag overwrites temp file if it exists
Batch Sync Script Pattern
#!/bin/bash
# Sync metadata from audiobookshelf to local files
SERVER="http://your-audiobookshelf-server"
TOKEN="your-api-token"
LIBRARY_ID="lib-id"
# Get all items
items=$(curl -s "$SERVER/api/libraries/$LIBRARY_ID/items" \
-H "Authorization: Bearer $TOKEN")
# Process each item
echo "$items" | jq -c '.results[]' | while read -r item; do
item_id=$(echo "$item" | jq -r '.id')
# Get full item details
full_item=$(curl -s "$SERVER/api/items/$item_id" \
-H "Authorization: Bearer $TOKEN")
# Extract metadata
title=$(echo "$full_item" | jq -r '.media.metadata.title')
author=$(echo "$full_item" | jq -r '.media.metadata.authorName')
genre=$(echo "$full_item" | jq -r '.media.metadata.genres[0] // ""')
year=$(echo "$full_item" | jq -r '.media.metadata.publishedYear')
# Process each audio file
echo "$full_item" | jq -c '.media.audioFiles[]' | while read -r audio_file; do
file_path=$(echo "$audio_file" | jq -r '.metadata.path')
file_duration=$(echo "$audio_file" | jq -r '.duration')
if [ -f "$file_path" ]; then
echo "Processing: $file_path"
# ffmpeg command to update metadata
# ...
fi
done
done
Metadata Field Mapping
| audiobookshelf field | ID3v2 (MP3) | MP4 (M4A) | Vorbis (FLAC) |
|---|---|---|---|
metadata.title |
title |
title |
title |
metadata.authorName |
artist / albumartist |
artist / albumartist |
artist / albumartist |
metadata.seriesName |
grouping |
series |
series |
metadata.genres |
genre |
genre |
genre |
metadata.publishedYear |
year |
year |
date |
metadata.description |
comment |
description |
comment |
metadata.isbn |
isbn |
isbn |
isbn |
metadata.language |
language |
language |
language |
Path Prefix Mapping
Important: API paths may differ from local filesystem paths due to Docker volumes, NAS mounts, or different system configurations.
Common Scenarios
| Scenario | API Path | Local Path |
|---|---|---|
| Docker container | /books/... |
/mnt/nas/audiobooks/... |
| NAS mount | /audiobooks/... |
/Volumes/NAS/Media/Books/... |
| WSL2 | /mnt/media/books/... |
D:\Media\Books\... |
| Different OS | /data/audiobooks/... |
/Volumes/Drive/audiobooks/... |
Configuration
Define path prefix mappings at the start of your sync script:
#!/bin/bash
# Path prefix mapping: API path -> Local path
# Add mappings for each library/folder
declare -A PATH_MAPPINGS=(
["/books"]="/mnt/nas/audiobooks"
["/audiobooks"]="/Volumes/NAS/Media/Books"
["/data/media"]="/media"
)
Path Translation Function
# Translate API path to local path
translate_path() {
local api_path="$1"
local local_path=""
for api_prefix in "${!PATH_MAPPINGS[@]}"; do
if [[ "$api_path" == "$api_prefix"* ]]; then
local_prefix="${PATH_MAPPINGS[$api_prefix]}"
local_path="${api_path/$api_prefix/$local_prefix}"
echo "$local_path"
return 0
fi
done
# No mapping found, return original
echo "$api_path"
return 1
}
# Usage example:
api_path="/books/Book Title/chapter01.m4a"
local_path=$(translate_path "$api_path")
# Result: /mnt/nas/audiobooks/Book Title/chapter01.m4a
Verify Path Translation
# Test path translation before processing
verify_paths() {
echo "Testing path mappings..."
for api_prefix in "${!PATH_MAPPINGS[@]}"; do
local_prefix="${PATH_MAPPINGS[$api_prefix]}"
if [ -d "$local_prefix" ]; then
echo "OK: $api_prefix -> $local_prefix"
else
echo "WARNING: Local path not found: $local_prefix"
fi
done
}
File-Chapter Mapping Logic
For typical audiobooks (1 file = 1 chapter):
Files and chapters are both sorted by order. Match them by index:
| audioFiles index | chapters index | Result |
|---|---|---|
audioFiles[0] |
chapters[0] |
File 1 → Chapter 1 title |
audioFiles[1] |
chapters[1] |
File 2 → Chapter 2 title |
audioFiles[i] |
chapters[i] |
File i+1 → Chapter i+1 title |
Why index-based matching works:
- Audiobookshelf ensures
audioFilesandchaptersarrays are in the same order - Each audio file corresponds to exactly one chapter
- No need for complex time-range calculations
Batch Sync with Chapters and Path Translation
#!/bin/bash
SERVER="http://your-audiobookshelf-server"
TOKEN="your-api-token"
LIBRARY_ID="lib-id"
# Path mappings
declare -A PATH_MAPPINGS=(
["/books"]="/mnt/nas/audiobooks"
)
translate_path() {
local api_path="$1"
for api_prefix in "${!PATH_MAPPINGS[@]}"; do
if [[ "$api_path" == "$api_prefix"* ]]; then
echo "${api_path/$api_prefix/${PATH_MAPPINGS[$api_prefix]}}"
return 0
fi
done
echo "$api_path"
return 1
}
# Get all items
items=$(curl -s "$SERVER/api/libraries/$LIBRARY_ID/items" \
-H "Authorization: Bearer $TOKEN")
echo "$items" | jq -c '.results[]' | while read -r item; do
item_id=$(echo "$item" | jq -r '.id')
full_item=$(curl -s "$SERVER/api/items/$item_id" \
-H "Authorization: Bearer $TOKEN")
# Extract book-level metadata
book_title=$(echo "$full_item" | jq -r '.media.metadata.title')
author=$(echo "$full_item" | jq -r '.media.metadata.authorName')
genre=$(echo "$full_item" | jq -r '.media.metadata.genres[0] // ""')
year=$(echo "$full_item" | jq -r '.media.metadata.publishedYear')
# Extract chapters array
chapters=$(echo "$full_item" | jq -c '.media.chapters // []')
num_chapters=$(echo "$chapters" | jq 'length')
echo "Processing: $book_title ($num_chapters chapters)"
# Process each audio file with index-based chapter matching
# audioFiles[0] matches chapters[0], audioFiles[1] matches chapters[1], etc.
echo "$full_item" | jq -c '.media.audioFiles[]' | jq -s 'to_entries[]' | while read -r audio_file_entry; do
# Get file info
file_index=$(echo "$audio_file_entry" | jq -r '.key')
audio_file=$(echo "$audio_file_entry" | jq -c '.value')
api_path=$(echo "$audio_file" | jq -r '.metadata.path')
# Translate to local path
local_path=$(translate_path "$api_path")
if [ ! -f "$local_path" ]; then
echo " WARNING: File not found: $local_path"
continue
fi
# Get chapter title by index (simple 1:1 mapping)
chapter_title=""
if [ "$file_index" -lt "$num_chapters" ]; then
chapter_title=$(echo "$chapters" | jq -r ".[$file_index].title")
fi
# Fallback: use generic chapter name if no match
if [ -z "$chapter_title" ] || [ "$chapter_title" = "null" ]; then
chapter_title="Chapter $((file_index + 1))"
fi
echo " File: $(basename "$local_path")"
echo " Chapter: $chapter_title (track $((file_index + 1)))"
# Get file extension for temp file
ext="${local_path##*.}"
temp_file="${local_path%.${ext}}.temp.${ext}"
# Update metadata: write to temp file first, then rename
# Use chapter title from audiobookshelf, NOT filename
if ffmpeg -i "$local_path" -c copy \
-metadata title="$chapter_title" \
-metadata artist="$author" \
-metadata album="$book_title" \
-metadata album_artist="$author" \
-metadata genre="$genre" \
-metadata year="$year" \
-metadata track="$((file_index + 1))" \
-y "$temp_file" 2>/dev/null; then
mv "$temp_file" "$local_path"
echo " Success: metadata updated"
else
echo " ERROR: failed to update metadata"
rm -f "$temp_file"
fi
done
done
File-Level Deduplication
To avoid duplicate modifications:
-
Track processed files:
# Use a marker file or extended attributes xattr -w user.audiobookshelf.synced "true" file.m4a # Check before processing xattr -l file.m4a | grep -q "user.audiobookshelf.synced" -
Compare metadata before writing:
# Get current title current_title=$(ffprobe -i file.m4a -show_entries format_tags=title \ -of default=noprint_wrappers=1:nokey=1 2>/dev/null) # Only update if different if [ "$current_title" != "$target_title" ]; then # Update metadata fi -
Use checksums:
# Store hash after sync md5sum file.m4a >> .audiobookshelf-sync-cache # Skip if file unchanged
Common Mistakes
| Mistake | Fix |
|---|---|
Forgetting -c copy |
Causes unnecessary re-encoding, quality loss |
| Not backing up before batch sync | Always backup or create temp files first |
| Using wrong metadata field names | MP4 uses album_artist, FLAC uses albumartist |
| Not forcing ID3v2.3 for MP3 | Add -id3v2_version 3 for compatibility |
| Syncing all files when one changed | Track per-file sync status |
| Overwriting manually-corrected files | Verify server metadata is source of truth |
| Assuming API paths match local paths | Always configure path prefix mappings |
| Using hardcoded paths | Use path translation function with mappings |
| Writing directly to original file | FFmpeg requires temp file first |
Using wrong temp extension (.tmp) |
Use .temp.m4a or same extension as source |
Error Handling
Path Translation Failures
# When translated path still doesn't exist
if [ ! -f "$local_path" ]; then
echo "ERROR: Cannot locate file"
echo " API path: $api_path"
echo " Translated: $local_path"
# Option 1: Try to find by filename
filename=$(basename "$api_path")
found=$(find /local/search/paths -name "$filename" 2>/dev/null | head -1)
if [ -n "$found" ]; then
echo " Found by filename: $found"
local_path="$found"
fi
# Option 2: Log for manual review
echo "$api_path|$local_path" >> .sync-path-errors
fi
Authentication Errors
# Check if token expired
curl -I "http://server/api/libraries" \
-H "Authorization: Bearer $TOKEN"
# If 401, re-login
File Not Found
# Server path may differ from actual filesystem
# Verify file exists before processing
if [ ! -f "$file_path" ]; then
echo "File not found: $file_path"
# Option: scan filesystem to find matching file
fi
Metadata Write Failures
# Some formats are read-only
# Check file permissions
ls -la file.m4a
# For MP4, may need to rewrite file (not in-place edit)
ffmpeg -i in.m4a -c copy -metadata title="New" temp.m4a && mv temp.m4a in.m4a
Related Tools
- ffprobe/ffmpeg — Read/write audio file metadata
- AtomicParsley — MP4/M4A metadata tool
- id3v2/libid3tag — MP3 metadata tool
- metaflac — FLAC metadata tool
- MusicBrainz Picard — Auto-tagging with fingerprinting
API Discovery
Since official API docs are outdated:
-
Browser DevTools:
- Open audiobookshelf web UI
- Perform actions (edit metadata, scan library)
- Watch Network tab for API calls
-
Server logs:
- Check audiobookshelf server logs
- Shows all API requests
-
Source code:
- GitHub: advply/audiobookshelf
- Check
server/controllers/for API routes
-
Community resources:
Version History
| Version | Date | Changes |
|---|---|---|
| 1.0.3 | 2026-03-13 | Simplify file-chapter mapping: use index-based matching (1 file = 1 chapter) |
| 1.0.2 | 2026-03-13 | Use chapter title from media.chapters[].title instead of filename |
| 1.0.1 | 2026-03-12 | Add temp file requirement for ffmpeg writes (ffmpeg cannot edit in-place) |
| 1.0.0 | 2026-03-12 | Initial release: API reference, metadata mapping, path prefix translation, deduplication |