No DRM · No subscriptions · 100% Free (and always will be)
AudioVault is a free Android audiobook player that streams from your own server or directly from your phone. You own your books — forever.
Overview
AudioVault works in two independent modes: Local Storage & Home Server. Choose the one that fits your needs the best — or switch between them at any time, with only a few taps.
Copy your audiobooks to a folder on your Android device, point the app at that folder, and — that's it! No server, no setup, no tech knowledge needed.
Run a lightweight server at home (a Raspberry Pi works perfectly). Stream your entire library over Wi-Fi or mobile data, with automatic sync across all your devices.
Local Storage Mode
No server, no terminal. Just files.
Using your file manager, create a dedicated folder for your audiobooks —
for example Audiobooks in your internal storage or SD card.
Each book must be in its own subfolder. The folder name becomes the book title in the app. Recommended format:
⚠ Renaming a folder will reset your listening progress for that book.
Open the app → tap the settings icon → select Local storage → tap Choose books folder → navigate to your audiobooks folder and confirm. The app scans it once and caches the library for instant startup next time.
Tracks inside a folder are played in alphabetical order.
Name your files so they sort correctly — e.g. chapter01.mp3,
chapter02.mp3, and so on.
💡 Keep only audio files and one cover image in each book folder. Other files won't cause problems, but keeping things tidy is recommended.
Server Mode
This guide uses a Raspberry Pi (any model with Wi-Fi), but the same steps work on any Linux machine. You'll need basic comfort with a terminal.
Optional — automated setup
If you prefer not to go through the steps manually, an interactive installer script is available. It asks you a few questions and handles everything automatically — packages, drive mounting, config files, nginx, and services.
The script is fully open source and available on GitHub. You are encouraged to read it before running it — it contains comments explaining every action it takes.
curl -s https://raw.githubusercontent.com/dejnastosic986/audiobook-player/main/install.sh -o install.sh
nano install.sh
bash install.sh
Or run it directly in one command:
bash <(curl -s https://raw.githubusercontent.com/dejnastosic986/audiobook-player/main/install.sh)
💡 The installer does the same things described in the step-by-step guide below — nothing more, nothing hidden. If something goes wrong, the manual steps are there so you understand exactly what the script was trying to do.
Prefer to do it yourself? Follow the steps below.
⚠ Do not store your audiobooks on the SD card. SD cards are slow for large media files and have a limited number of write cycles — constant access will shorten their lifespan significantly. Use an external USB hard drive or SSD instead. The steps below assume this setup.
Use Raspberry Pi Imager to flash Raspberry Pi OS Lite (64-bit) to your SD card. Before writing, click the ⚙ gear icon (or press Ctrl+Shift+X) to open advanced settings and configure the following:
audioserverInsert the SD card into your Pi and power it on. Wait about 60 seconds for it to boot.
Open a terminal on your computer (on Windows use PuTTY or the built-in Windows Terminal, on Mac and Linux use the Terminal app) and connect to your Pi:
ssh [email protected]
If that doesn't work, find your Pi's IP address from your router's admin page and connect directly:
ssh [email protected]
Type yes when asked about the fingerprint, then enter your password.
You won't see any characters while typing — that's normal.
Once connected, update the system:
sudo apt update && sudo apt upgrade -y
This may take a few minutes. Press Y and Enter if prompted.
sudo apt install -y nginx python3 python3-pip python3-venv ffmpeg inotify-tools
The API runs inside its own virtual environment at
/srv/audiobook-api/venv so its packages never conflict
with the system's Python:
sudo mkdir -p /srv/audiobook-api
sudo chown -R $USER:$USER /srv/audiobook-api
python3 -m venv /srv/audiobook-api/venv
/srv/audiobook-api/venv/bin/pip install --quiet flask gunicorn python-dotenv
Connect your USB hard drive or SSD to the Pi. First, find its identifier:
lsblk
Look for your drive in the output — it will typically appear as
sda with a partition like sda1.
Then find its UUID (a permanent identifier that survives reboots):
sudo blkid /dev/sda1
You will see output like:
/dev/sda1: UUID="a1b2c3d4-..." TYPE="ext4"
Create the mount point and add it to /etc/fstab
so the drive mounts automatically on every boot:
sudo mkdir -p /mnt/audiodrive
sudo nano /etc/fstab
Add this line at the end of the file.
Replace a1b2c3d4-... with the exact UUID you got
from the blkid command above — copy and paste it
to avoid typos:
UUID=a1b2c3d4-... /mnt/audiodrive ext4 defaults,nofail 0 2
The nofail option is important — it prevents the Pi from
hanging on boot if the drive is not connected.
Apply the change without rebooting:
sudo mount -a
💡 If your drive is formatted as NTFS (common for drives
previously used on Windows), replace ext4 with ntfs-3g
and install the driver first: sudo apt install ntfs-3g
All paths are stored in a single config file so you never have to edit multiple scripts when your folder structure changes.
Throughout this guide, whenever we say "save the following as [filename]",
use this pattern — open the file in the nano text editor,
paste the content, then save and exit:
sudo nano /path/to/filename
Paste the content, then press Ctrl+O to save, Enter to confirm the filename, and Ctrl+X to exit.
Create /srv/audiobook-api/config.env (the API, scanner and
watcher all read their books path from this one file):
sudo nano /srv/audiobook-api/config.env
Paste the following content:
# Path to the folder containing your audiobook subfolders.
# Change this if your drive is mounted elsewhere or your folder has a different name.
AUDIOBOOKS_DIR=/mnt/audiodrive/audiobooks
Save and exit nano (Ctrl+O → Enter → Ctrl+X), then create the data directory
(this is where library.json and progress.json are
generated — it's a fixed path used directly by the scripts, not read from
config.env):
sudo mkdir -p /srv/audiobook-data
sudo chown -R www-data:www-data /srv/audiobook-data
mkdir -p /mnt/audiodrive/audiobooks
💡 $USER is a variable that automatically expands to your
current username — the same one you set in Raspberry Pi Imager.
You don't need to replace it manually.
Copy your books to the external drive. One subfolder per book, cover image inside:
First, install the required Python packages:
The venv from step 3 already has Flask installed. Create the API file:
sudo nano /srv/audiobook-api/audiobook_api.py
Paste the following content:
from flask import Flask, jsonify, request
import json, os, time
from pathlib import Path
from tempfile import NamedTemporaryFile
app = Flask(__name__)
DATA_DIR = Path("/srv/audiobook-data")
PROGRESS_FILE = DATA_DIR / "progress.json"
def atomic_write_json(path: Path, data: dict):
path.parent.mkdir(parents=True, exist_ok=True)
tmp = NamedTemporaryFile("w", delete=False, dir=str(path.parent), encoding="utf-8")
try:
json.dump(data, tmp, ensure_ascii=False, indent=2)
tmp.flush()
os.fsync(tmp.fileno())
tmp.close()
os.replace(tmp.name, str(path))
os.chmod(str(path), 0o664)
finally:
try:
os.unlink(tmp.name)
except Exception:
pass
def load_json(path: Path, default: dict):
try:
if path.exists():
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
pass
return default
@app.route("/api/progress", methods=["GET"])
def get_progress():
profile = (request.args.get("profile") or "").strip()
book_id = (request.args.get("bookId") or "").strip()
root = load_json(PROGRESS_FILE, {"profiles": {}})
item = root.get("profiles", {}).get(profile, {}).get(book_id)
return jsonify(item or {})
@app.route("/api/progress", methods=["POST"])
def save_progress():
data = request.get_json(force=True, silent=True) or {}
profile = (data.get("profile") or "").strip()
book_id = (data.get("bookId") or "").strip()
track_idx = data.get("trackIndex")
pos_ms = data.get("positionMs")
if not profile or not book_id:
return jsonify({"ok": False, "error": "Missing profile or bookId"}), 400
root = load_json(PROGRESS_FILE, {"profiles": {}})
root.setdefault("profiles", {}).setdefault(profile, {})[book_id] = {
"trackIndex": track_idx,
"positionMs": pos_ms,
"updatedAt": int(time.time())
}
atomic_write_json(PROGRESS_FILE, root)
return jsonify({"ok": True})
@app.route("/api/profile-rename", methods=["POST"])
def rename_profile():
data = request.get_json(force=True, silent=True) or {}
old_profile = (data.get("oldProfile") or "").strip()
new_profile = (data.get("newProfile") or "").strip()
if not old_profile or not new_profile:
return jsonify({"ok": False, "error": "Missing profile names"}), 400
if old_profile == new_profile:
return jsonify({"ok": True})
root = load_json(PROGRESS_FILE, {"profiles": {}})
old_data = root["profiles"].get(old_profile, {})
root["profiles"].setdefault(new_profile, {})
for book_id, entry in old_data.items():
existing = root["profiles"][new_profile].get(book_id)
if not existing or existing.get("updatedAt", 0) < entry.get("updatedAt", 0):
root["profiles"][new_profile][book_id] = entry
root["profiles"].pop(old_profile, None)
atomic_write_json(PROGRESS_FILE, root)
return jsonify({"ok": True})
if __name__ == "__main__":
app.run(host="127.0.0.1", port=5000)
💡 Progress for every profile and book lives in one file,
/srv/audiobook-data/progress.json, written atomically
(write to a temp file, then rename) so a crash or power loss mid-write
can't corrupt it. The API only listens on 127.0.0.1 — it's
never exposed directly, only reachable through nginx's /api/
proxy from step 8.
sudo nano /etc/nginx/sites-available/audiobooks
Paste the following content:
server {
listen 8081;
location /library.json {
alias /srv/audiobook-data/library.json;
}
location / {
root /mnt/audiodrive/audiobooks;
autoindex off;
}
location /api/ {
proxy_pass http://127.0.0.1:5000;
}
}
⚠ nginx cannot read config.env.
The paths above are hardcoded directly in this file. If you ever move
your books to a different drive or folder, you must update
alias and root here manually —
changing config.env alone will not be enough.
sudo ln -s /etc/nginx/sites-available/audiobooks \
/etc/nginx/sites-enabled/audiobooks
sudo nginx -t && sudo systemctl reload nginx
nginx serves files as the www-data user. The library scanner
also needs to set www-data as the owner of
library.json after every regeneration — otherwise nginx
returns a 403 error and the app cannot load the book list.
Grant www-data the ability to run only the exact
chown and chmod commands it needs — nothing else:
sudo visudo -f /etc/sudoers.d/www-data-chown
Paste the following content:
www-data ALL=(root) NOPASSWD: /usr/bin/chown www-data\:www-data /srv/audiobook-data/library.json
www-data ALL=(root) NOPASSWD: /usr/bin/chown -R www-data\:www-data /mnt/audiodrive/audiobooks
www-data ALL=(root) NOPASSWD: /usr/bin/chmod 644 /srv/audiobook-data/library.json
www-data ALL=(root) NOPASSWD: /usr/bin/chmod 664 /srv/audiobook-data/library.json
www-data ALL=(root) NOPASSWD: /usr/bin/chmod -R 755 /mnt/audiodrive/audiobooks
www-data ALL=(root) NOPASSWD: /usr/bin/chown www-data\:www-data /srv/audiobook-data/duration_cache.json
www-data ALL=(root) NOPASSWD: /usr/bin/chmod 664 /srv/audiobook-data/duration_cache.json
www-data ALL=(root) NOPASSWD: /usr/bin/chmod 775 /srv/audiobook-data
The backslash before each colon is required — sudoers treats an
unescaped : in a command argument as a syntax error, and
the whole file (and anything that depends on it, including the very
first library scan during install) fails to load until it's fixed.
Verify the syntax before saving:
sudo visudo -c -f /etc/sudoers.d/www-data-chown
You should see parsed OK. If not, re-open the file and check for typos.
💡 visudo uses nano by default —
Ctrl+O to save, Ctrl+X to exit.
The NOPASSWD rule is intentionally narrow: it only covers
the exact commands listed. www-data cannot run anything
else with elevated privileges.
⚠ The paths in this file must match your actual setup.
If you mounted your drive at a different location (e.g. /mnt/wd
instead of /mnt/audiodrive), update the paths here accordingly.
The paths must match exactly — sudoers does not support wildcards
for directory arguments.
This script scans your audiobook folders and generates
library.json — the file the app reads to show
your book list. It uses a duration cache so re-runs after
adding one book are fast.
sudo nano /srv/audiobook-api/regen_library.sh
Paste the following content:
#!/usr/bin/env bash
# regen_library.sh
set -euo pipefail
CONFIG_FILE="/srv/audiobook-api/config.env"
[[ -f "$CONFIG_FILE" ]] || { echo "ERROR: config.env not found"; exit 1; }
source "$CONFIG_FILE"
[[ -n "${AUDIOBOOKS_DIR:-}" ]] || { echo "ERROR: AUDIOBOOKS_DIR not set"; exit 1; }
DATA_DIR="/srv/audiobook-data"
OUT="$DATA_DIR/library.json"
mkdir -p "$DATA_DIR"
umask 0002
echo "[regen] Scanning: $AUDIOBOOKS_DIR"
echo "[regen] Output: $OUT"
AUDIOBOOKS_DIR="$AUDIOBOOKS_DIR" OUT_FILE="$OUT" python3 - <<'PY'
import os, sys, json, subprocess, tempfile, shutil
AUDIOBOOKS_DIR = os.environ["AUDIOBOOKS_DIR"]
OUT_FILE = os.environ["OUT_FILE"]
AUDIO_EXT = (".mp3", ".m4b", ".m4a", ".wav", ".ogg", ".flac")
IMAGE_EXT = (".jpg", ".jpeg", ".png")
PREFERRED_COVERS = ("cover.jpg", "cover.jpeg", "cover.png", "folder.jpg", "folder.png")
if not shutil.which("ffprobe"):
print("ERROR: ffprobe not found. Install: sudo apt install ffmpeg")
sys.exit(1)
if not os.path.isdir(AUDIOBOOKS_DIR):
print(f"ERROR: Directory not found: {AUDIOBOOKS_DIR}")
sys.exit(1)
CACHE_FILE = os.path.join(os.path.dirname(OUT_FILE), "duration_cache.json")
def load_cache():
try:
with open(CACHE_FILE, "r", encoding="utf-8") as f: return json.load(f)
except Exception: return {}
def save_cache(cache):
try:
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(CACHE_FILE))
with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump(cache, f)
os.replace(tmp, CACHE_FILE)
os.chmod(CACHE_FILE, 0o664)
except Exception as e: print(f"[warn] Could not save cache: {e}")
cache = load_cache()
def slugify(s):
s = s.lower().strip()
out = []
for ch in s:
if ch.isalnum(): out.append(ch)
elif ch in (" ", "-", "_"): out.append("-")
slug = "".join(out)
while "--" in slug: slug = slug.replace("--", "-")
return slug.strip("-")
def get_duration_ms(path):
try:
mtime = str(os.path.getmtime(path))
key = f"{path}:{mtime}"
if key in cache: return cache[key]
except OSError: pass
try:
r = subprocess.run(
["ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", path],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=15)
txt = (r.stdout or "").strip()
if not txt or txt.lower() == "n/a": return 0
ms = int(float(txt) * 1000)
try:
mtime = str(os.path.getmtime(path))
cache[f"{path}:{mtime}"] = ms
except OSError: pass
return ms
except Exception: return 0
def get_chapters(path):
"""
Reads chapter markers from an audio file using ffprobe.
Works for m4b/m4a (iTunes chapters) and mp3 (ID3 CHAP frames).
Returns a list of {"title", "startMs", "endMs"} dicts, or [] if none found.
"""
try:
r = subprocess.run(
["ffprobe", "-v", "quiet",
"-print_format", "json",
"-show_chapters", path],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=30)
data = json.loads(r.stdout or "{}")
raw_chapters = data.get("chapters", [])
if len(raw_chapters) <= 1:
return []
chapters = []
for i, ch in enumerate(raw_chapters):
title = ch.get("tags", {}).get("title", f"Chapter {i + 1}")
start_ms = int(float(ch.get("start_time", 0)) * 1000)
end_ms = int(float(ch.get("end_time", 0)) * 1000)
chapters.append({"title": title, "startMs": start_ms, "endMs": end_ms})
return chapters
except Exception as e:
print(f" [warn] Could not read chapters from {os.path.basename(path)}: {e}")
return []
def extract_embedded_cover(audio_path, folder_name):
"""
Attempts to extract embedded artwork from audio file using ffmpeg.
Works for m4b (iTunes cover art) and mp3 (ID3 APIC frame).
Saves as cover.jpg in book folder with correct owner/permissions.
Returns relative path on success, None if no artwork or cover already exists.
"""
# Check all possible cover files -- if any exists, skip
full_folder = os.path.join(AUDIOBOOKS_DIR, folder_name)
for name in PREFERRED_COVERS:
if os.path.isfile(os.path.join(full_folder, name)):
return None
existing_imgs = [n for n in os.listdir(full_folder) if n.lower().endswith(IMAGE_EXT)]
if existing_imgs:
return None
ext = os.path.splitext(audio_path)[1].lower()
if ext not in (".m4b", ".m4a", ".mp3"):
return None
cover_path = os.path.join(AUDIOBOOKS_DIR, folder_name, "cover.jpg")
try:
r = subprocess.run(
["ffmpeg", "-v", "quiet", "-i", audio_path,
"-an", # bez audio streama
"-vcodec", "copy", # kopiraj video stream (cover art)
"-y", # overwrite ako postoji
cover_path],
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
timeout=30
)
if r.returncode == 0 and os.path.isfile(cover_path) and os.path.getsize(cover_path) > 0:
# Set correct permissions -- www-data must read, upload user must write
os.chmod(cover_path, 0o664)
print(f" [cover] Extracted embedded artwork")
return f"{folder_name}/cover.jpg"
else:
# Cleanup ako je ffmpeg napravio prazan fajl
try:
if os.path.isfile(cover_path) and os.path.getsize(cover_path) == 0:
os.unlink(cover_path)
except OSError: pass
return None
except Exception as e:
print(f" [warn] Could not extract cover from {os.path.basename(audio_path)}: {e}")
return None
def find_cover(folder_name):
full = os.path.join(AUDIOBOOKS_DIR, folder_name)
for name in PREFERRED_COVERS:
if os.path.isfile(os.path.join(full, name)):
return f"{folder_name}/{name}"
try:
imgs = sorted(n for n in os.listdir(full) if n.lower().endswith(IMAGE_EXT))
if imgs: return f"{folder_name}/{imgs[0]}"
except OSError: pass
return None
def list_audio_files(folder_name):
full = os.path.join(AUDIOBOOKS_DIR, folder_name)
try:
return sorted(n for n in os.listdir(full) if n.lower().endswith(AUDIO_EXT))
except OSError: return []
books = []
skipped_zero = []
warned_partial = []
missing_covers = [] # Books without cover that have a single audio file -- candidates for extraction
# -- PASS 1: duration, chapters, existing covers --
for folder_name in sorted(os.listdir(AUDIOBOOKS_DIR)):
full_folder = os.path.join(AUDIOBOOKS_DIR, folder_name)
if not os.path.isdir(full_folder) or folder_name.startswith("."): continue
audio_files = list_audio_files(folder_name)
if not audio_files:
print(f" [skip] No audio files in: {folder_name}"); continue
print(f" [book] {folder_name} ({len(audio_files)} track(s))")
tracks = []
zero_tracks = []
for filename in audio_files:
full_file = os.path.join(full_folder, filename)
ms = get_duration_ms(full_file)
if ms == 0:
zero_tracks.append(filename)
print(f" [warn] Duration 0ms: {filename}")
else:
print(f" [ok] {filename} ({ms // 1000}s)")
tracks.append({"file": filename, "durationMs": ms})
if zero_tracks:
if len(zero_tracks) == len(audio_files): skipped_zero.append(folder_name)
else: warned_partial.append(folder_name)
# Chapters for single-file books
chapters = []
if len(audio_files) == 1:
full_file = os.path.join(full_folder, audio_files[0])
chapters = get_chapters(full_file)
if chapters:
print(f" [chapters] Found {len(chapters)} chapters")
# Cover -- only check if already exists, do NOT attempt extraction yet
cover_rel = find_cover(folder_name)
book = {
"id": slugify(folder_name),
"title": folder_name,
"coverUrl": cover_rel,
"tracks": tracks,
}
if chapters:
book["chapters"] = chapters
books.append(book)
# Track candidates for cover extraction -- single file books without a cover
if cover_rel is None and len(audio_files) == 1:
missing_covers.append((folder_name, audio_files[0]))
# -- Atomic write library.json -- Pass 1 result --
def write_library(books):
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(OUT_FILE))
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump({"books": books}, f, ensure_ascii=False, indent=2)
os.replace(tmp, OUT_FILE)
os.chmod(OUT_FILE, 0o664)
except Exception as e:
try: os.unlink(tmp)
except OSError: pass
raise e
write_library(books)
save_cache(cache)
print("")
print("[regen] Pass 1 done. Written " + str(len(books)) + " book(s) to " + OUT_FILE)
# -- PASS 2: cover extraction for books without a cover --
# Each successful extraction immediately patches library.json
if missing_covers:
print("")
print("[cover] Attempting cover extraction for " + str(len(missing_covers)) + " books...")
extracted_covers = []
for folder_name, audio_filename in missing_covers:
full_file = os.path.join(AUDIOBOOKS_DIR, folder_name, audio_filename)
cover_rel = extract_embedded_cover(full_file, folder_name)
if cover_rel:
extracted_covers.append(folder_name)
# Patch only this entry in library.json
try:
data = json.loads(open(OUT_FILE, encoding="utf-8").read())
book_id = slugify(folder_name)
for b in data["books"]:
if b["id"] == book_id:
b["coverUrl"] = cover_rel
break
write_library(data["books"])
print(f" [ok] Cover added to library.json for: {folder_name}")
except Exception as e:
print(f" [warn] Could not patch library.json: {e}")
if extracted_covers:
print("")
print("[cover] Extracted embedded cover art for " + str(len(extracted_covers)) + " book(s):")
for b in extracted_covers: print(" - " + b)
else:
print(" No embedded artwork found in remaining books.")
if skipped_zero:
print()
print("WARNING: ALL tracks 0ms duration:")
for b in skipped_zero: print(f" - {b}")
if warned_partial:
print()
print("WARNING: SOME tracks 0ms duration:")
for b in warned_partial: print(f" - {b}")
PY
# Postavi ispravne permisije na sve output fajlove
sudo chown www-data:www-data "$OUT"
sudo chmod 664 "$OUT"
sudo chown www-data:www-data "$DATA_DIR/duration_cache.json" 2>/dev/null || true
sudo chmod 664 "$DATA_DIR/duration_cache.json" 2>/dev/null || true
sudo chmod 775 "$DATA_DIR"
echo "[regen] Done."
chmod +x /srv/audiobook-api/regen_library.sh
sudo -u www-data /srv/audiobook-api/regen_library.sh
⚠ The first run can take a while for large libraries — it reads every audio file to measure its duration. Subsequent runs are fast because results are cached. Let it finish before starting the services.
This script watches for changes in your books folder and triggers a library rescan — but only after disk activity has settled for 25 seconds. This prevents regen from running while a book upload is still in progress.
sudo nano /srv/audiobook-api/audiobook-watch.sh
Paste the following content:
#!/bin/bash
source /srv/audiobook-api/config.env
echo "[watch] Watching $AUDIOBOOKS_DIR for changes..."
while true; do
inotifywait -r -e modify,create,delete,moved_to,moved_from \
"$AUDIOBOOKS_DIR" 2>/dev/null
echo "[watch] Change detected. Waiting for activity to settle..."
while inotifywait -r -e modify,create,delete,moved_to,moved_from \
-t 25 "$AUDIOBOOKS_DIR" 2>/dev/null; do
echo "[watch] Still active, resetting timer..."
done
echo "[watch] Regenerating library..."
/srv/audiobook-api/regen_library.sh
echo "[watch] Done."
done
chmod +x /srv/audiobook-api/audiobook-watch.sh
💡 The 25-second timer resets every time new activity is detected. If you are copying a large book that takes several minutes, regen will only run once — 25 seconds after the last file write completes.
Two services — one for the Flask API, one that runs the watcher script.
Both read the books path from config.env.
/etc/systemd/system/audiobook-api.service
[Unit]
Description=Audiobook API
After=network.target
[Service]
User=www-data
Group=www-data
WorkingDirectory=/srv/audiobook-api
ExecStart=/srv/audiobook-api/venv/bin/gunicorn \
--workers 2 \
--bind 127.0.0.1:5000 \
--access-logfile - \
--error-logfile - \
audiobook_api:app
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
💡 The API runs as www-data so it can write
progress.json with the correct ownership. This is why
we set up the sudoers rule in step 9.
/etc/systemd/system/audiobook-watch.service
[Unit]
Description=Audiobook Library Watcher
After=network.target
[Service]
User=www-data
Group=www-data
ExecStart=/bin/bash /srv/audiobook-api/audiobook-watch.sh
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable audiobook-api audiobook-watch
sudo systemctl start audiobook-api audiobook-watch
💡 Make sure /srv/audiobook-api/audiobook-watch.sh uses
the variable AUDIOBOOKS_DIR (from config.env),
not BOOKS_DIR — the two config files use different variable names.
sudo systemctl status audiobook-api
sudo systemctl status audiobook-watch
Both services should show Active: active (running) in green.
If either shows failed, check the logs for details:
sudo journalctl -u audiobook-api -n 30
sudo journalctl -u audiobook-watch -n 30
Find your Raspberry Pi's local IP address:
hostname -I
Before opening the app, verify the server is responding correctly. Open a browser on any device connected to the same Wi-Fi network and visit:
http://192.168.x.x:8081/library.json
You should see a JSON response with your book list. If you see an error,
double-check that both services are running (systemctl status audiobook-api)
and that nginx is active (sudo systemctl status nginx).
Once the browser test works, open the app, go to Settings → Server and enter:
http://192.168.x.x:8081
Replace 192.168.x.x with your Pi's actual IP address.
💡 For remote access outside your home network, consider setting up a VPN (WireGuard is lightweight and works well on Raspberry Pi).
Keeping up to date
The Android app updates automatically through the Play Store.
The server side does not — but Install.sh is safe to
re-run on a server that's already set up: it detects the existing installation,
reuses your audiobooks path, and refreshes every script, config file and service
to the current version without touching anything you haven't changed.
💡 Not every app update requires a server update. Release notes on the Play Store will say if one is needed.
Connect to your Pi via SSH and run the exact same command you used the first time:
bash <(curl -s https://raw.githubusercontent.com/dejnastosic986/audiobook-player/main/Install.sh)
It will print "Existing installation detected" and show your current audiobooks path — confirm to continue. No questions asked twice.
The script rewrites audiobook_api.py, regen_library.sh,
the watcher, config.env, the sudoers rule, the nginx config and
both systemd units, then restarts audiobook-api and
audiobook-watch and runs a fresh library scan so any new data
(like chapter metadata or extracted cover art) shows up right away.
⚠ The library scan after an update can take longer than usual if the new version reads more data per file (e.g. chapters). Let it finish before checking the app.
Using AudioVault Manager (the desktop admin tool)? Its Diagnostics tab does the same comparison automatically every time you open it — anything outdated gets an Update button, no terminal needed.
v1.5 Chapter support
regen_library.sh now reads chapter markers from m4b and mp3 files
using ffprobe and includes them in library.json.
Without this update, the chapter navigation UI in the app will not appear
for server-mode users.
v1.6 Automatic cover extraction
For single-file books with no cover image, regen_library.sh now
pulls the embedded artwork out of the m4b/m4a/mp3 file itself using
ffmpeg and saves it as cover.jpg — no need to find
and upload cover art by hand for most books.
⚠ This version also needs 3 extra lines in
/etc/sudoers.d/www-data-chown (for duration_cache.json
and the data folder's permissions) — see step 9. Without them, a manual regen
run will report a failure at the very end even though the library itself was
written correctly.
Stuck, or found a bug?
GitHub Discussions is the best place to ask setup questions, share what you've built, or search whether someone's already hit the same error — it's public and searchable, so an answer today can save the next person a support message later.
Found something that looks like a bug, or a step in this guide that doesn't match what actually happened on your Pi? Open an issue — include what you ran and the exact error/output where you can.
Design Philosophy
AudioVault has no user accounts, no passwords, and no authentication. Rest assured, this is NOT an oversight — it is a deliberate design decision, stemming from a specific philosophy.
This Android audiobook app was built for one reason: the frustration of paying for an audiobook and having it taken away. Audible removes titles. Services shut down. Terms change — and items in your library disappear. Our standpoint is: if you bought it, you should own it — permanently, unconditionally, and without anxiety over losing your precious books.
A public pirate audiobook repository needs to be discoverable, scalable, and easy to use for strangers. This audiobook app is none of those things:
The server runs on a private IP address inside your home network. There is no domain, no DNS, no way to find it from the outside unless you deliberately expose it.
A Raspberry Pi serving files over a home connection cannot handle hundreds of concurrent users. The architecture is explicitly designed for single-family use.
Setting up the server requires SSH access, terminal commands, and manual file management. This is not a service anyone stumbles into — it requires deliberate technical effort.
There is no public-facing interface, no browse page, no way to discover what books are on the server without already having the app and knowing the address.
The conscious choice to omit authentication is what keeps this app honest about what it is — a personal tool for people who believe in owning their audiobooks, not a platform with pretensions of scaling. If you want to share audiobooks with the world, this app is the wrong choice. If you want to share them with your family — without asking anyone's permission or jumping through hoops.