No DRM · No subscriptions · 100% Free (and always will be)

Your library,
your rules.

AudioVault is a free Android audiobook player that streams from your own server or directly from your phone. You own your books — forever.

No account required
No cloud lock-in
Family sharing via server
Works offline

Overview

Android audiobook player
with two modes

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.

📱

Local Storage

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.

🏠

Home Server

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

Getting started
on your phone

No server, no terminal. Just files.

1

Create a folder on your phone

Using your file manager, create a dedicated folder for your audiobooks — for example Audiobooks in your internal storage or SD card.

2

Organize your books

Each book must be in its own subfolder. The folder name becomes the book title in the app. Recommended format:

📁 Audiobooks/
  📁 Terry Pratchett - Discworld 01 - The Colour of Magic/
    🎵 chapter01.mp3
    🎵 chapter02.mp3
    🖼 cover.jpg
  📁 Frank Herbert - Dune/
    🎵 dune.m4b
    🖼 cover.png

⚠ Renaming a folder will reset your listening progress for that book.

3

Select the folder in the app

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.

Supported formats

mp3 m4b m4a flac ogg wav

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

Running your
own server

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.

Run the installer script

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.

terminal — download and inspect first (recommended)
curl -s https://raw.githubusercontent.com/dejnastosic986/audiobook-player/main/install.sh -o install.sh
nano install.sh
terminal — run when ready
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.

1

Install Raspberry Pi OS

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:

  • Enable SSH — Use password authentication
  • Set a username and password — remember these, you will need them
  • Configure your Wi-Fi network name and password
  • Set the hostname to something memorable, e.g. audioserver

Insert the SD card into your Pi and power it on. Wait about 60 seconds for it to boot.

2

Connect to your Pi via SSH

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:

terminal — replace "pi" with your username if you chose a different one
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:

terminal
sudo apt update && sudo apt upgrade -y

This may take a few minutes. Press Y and Enter if prompted.

3

Install required packages

terminal
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:

terminal
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
4

Mount your external drive automatically

Connect your USB hard drive or SSD to the Pi. First, find its identifier:

terminal
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):

terminal
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:

terminal
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:

/etc/fstab
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:

terminal
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

5

Create the config file

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:

terminal — open file for editing
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):

terminal
sudo nano /srv/audiobook-api/config.env

Paste the following content:

/srv/audiobook-api/config.env
# 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):

terminal
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.

6

Organize your audiobooks

Copy your books to the external drive. One subfolder per book, cover image inside:

📁 /mnt/audiodrive/audiobooks/
  📁 Terry Pratchett - Discworld 01 - The Colour of Magic/
    🎵 chapter01.mp3
    🎵 chapter02.mp3
    🖼 cover.jpg
  📁 Frank Herbert - Dune/
    🎵 dune.m4b
7

Create the Flask API

First, install the required Python packages:

terminal

The venv from step 3 already has Flask installed. Create the API file:

terminal
sudo nano /srv/audiobook-api/audiobook_api.py

Paste the following content:

audiobook_api.py
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.

8

Configure nginx

terminal
sudo nano /etc/nginx/sites-available/audiobooks

Paste the following content:

/etc/nginx/sites-available/audiobooks
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.

terminal
sudo ln -s /etc/nginx/sites-available/audiobooks \
          /etc/nginx/sites-enabled/audiobooks
sudo nginx -t && sudo systemctl reload nginx
9

Fix file permissions for 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:

terminal
sudo visudo -f /etc/sudoers.d/www-data-chown

Paste the following content:

/etc/sudoers.d/www-data-chown
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:

terminal
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.

10

Create the library scanner

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.

terminal
sudo nano /srv/audiobook-api/regen_library.sh

Paste the following content:

regen_library.sh
#!/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."
terminal
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.

11

Create the folder watcher script

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.

terminal
sudo nano /srv/audiobook-api/audiobook-watch.sh

Paste the following content:

audiobook-watch.sh
#!/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
terminal
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.

12

Set up systemd services

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
terminal — enable and start both services
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.

terminal — verify both services are running
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
13

Connect the app

Find your Raspberry Pi's local IP address:

terminal
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

Updating your
server scripts

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.

1

Re-run the installer

Connect to your Pi via SSH and run the exact same command you used the first time:

terminal
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.

2

That's it

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.

What changed in each version

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?

Get help

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

Why is there
no login.

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.

Why our free audiobook player is NOT piracy software

A public pirate audiobook repository needs to be discoverable, scalable, and easy to use for strangers. This audiobook app is none of those things:

🔒

Not discoverable

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.

📦

Not scalable

A Raspberry Pi serving files over a home connection cannot handle hundreds of concurrent users. The architecture is explicitly designed for single-family use.

🛠

Not plug-and-play

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.

🚫

No search, no catalog

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.