Files
bookhoard/docs/developer/api/stats/get_library_usage.md
T
John O'Keefe 6216bd9bcf feat(audiobooks): multi-file books (folder-of-tracks + zip) + exhaustive metadata ingestion
Multi-file book ingest: a directory of ≥2 audio files (recursively —
disc-part folders like CD1/Disc 2 merge upward, siblings never merge)
ingests as ONE book whose file_path is the book-root directory; a zip
of audio tracks is its own book (file_path = the archive). Both write
the documented chapters abstraction {index,title,start,end} into
audio_metadata and add additive keys: layout ("folder"|"zip") and a
tracks[] manifest (per-track path, global-seconds start/end, duration,
size). Pre-grouping one-track strays under a book root are absorbed on
rescan (annotations cascade). Rescans take a stat-only fast path
(track count + total size + known durations unchanged → no re-probe —
a 1189-track book must not re-ffprobe every auto-scan); a zero-duration
book counts as changed so a ffprobe-equipped rescan fills durations in.
Folder-book roots count as present in the archive lifecycle pass, and
per-item rescan (RescanMediaItem) routes multi-file books through a
book-level refresh — the generic updater would read the root/zip as if
it were the book file and clobber format_group with "unknown".

Serving: GET /api/media-items/:id/chapters gains additive layout +
tracks keys (urls → the new endpoint). New GET
/api/media-items/:id/audio/:trackIndex streams one track — folder
tracks served from the real file (full Range via ServeContent), zip
tracks via a reopen-and-skip ReadSeeker over the entry (deflate has no
random access; acceptable at audio bitrates; stored entries stay
section readers). Both endpoints gate on library visibility (403 on
hidden libraries — family-lens rule). ServeFile and OPDS DownloadBook
refuse directory paths (folder books have no single-file download).

Web player (next commit) consumes the manifest; progress/bookmarks/
deep-links keep their global-seconds coordinates, so the server-side
progress model is untouched. sync learns FormatGroupAudiobook
(percentage passthrough in ConvertProgress); detectFormatType stops
reporting "unknown" for audio.

Metadata ingestion (Batch B) — read everything available, degrade
gracefully, documented precedence sidecars → folder structure →
embedded tags → filename: ffprobe tags are now a case-insensitive
lookup map (freeform keys pass through verbatim); composer fills
narrators (Audible's own convention, kept as ONE string — no reliable
multi-narrator delimiter); grouping + Audible freeform atoms
(SERIES/SERIES-PART/SUBTITLE/ASIN/ABRIDGED) fill series/index/
subtitle-join/ASIN/abridged (explicit sidecar abridged-false is
guarded); date/genre fall back to tags; album/album_artist/artist
fill title/author. Genre persists at create for all formats.
Embedded cover art (mp3 APIC, m4b covr, flac picture) extracts via one
optional ffmpeg call as a "<book>.cover.jpg" sibling — sidecar covers
win. CUE sheets (FILE-matched, INDEX 01 at 75fps) are the lowest-
priority chapter source under sidecars and embedded m4b chapters. A
lone m3u/m3u8 in a book root imposes playlist ordering + #EXTINF
names; (disc, track) tags reorder tracks when coverage is complete.
extractMetadata's default branch no longer pre-fills the filename as
title (tags must outrank it) — final filename fallbacks now exist on
BOTH the create and rescan paths (TestRescanKeepsFilenameTitle pins
that rescans cannot blank a title); tagless zip books take the zip's
own name, not their parent folder's.

Accepted containers widen to .m4b .m4a .mp3 .flac .zip .opus .ogg .oga
.aac .wav with real media types registered (audio/ogg per RFC 7845 for
Opus-in-Ogg) — all browser-playable; .wma deliberately excluded.

Tests: unit tests for CUE parsing, playlist hints, tag fallbacks
(synthetic ffprobe reports — ffmpeg cannot write Audible freeform
atoms), ordinal parsing, tag ordering, natural sort, grouping rules;
integration suite (11 tests) over the live dev DB: folder/zip ingest +
idempotency, stray absorption, chapters manifest, per-track streaming
with Range, hidden-library 403s, embedded covers + sidecar-wins, CUE
chapters, embedded m4b chapters, playlist ordering, tag fallbacks, all
five new containers with content-type assertions, rescan title pin.
Fixture media is real committed-format binary content living untracked
under internal/services/testdata/ (gitignored by policy; recipes in
docs/contributing/development.md → Media fixtures). Test image now
ships ffmpeg to match the runtime's audio toolchain.
2026-10-19 07:54:28 -04:00

4.9 KiB

Get Library Usage Statistics

The /stats page's Library tab payload: media_items inventory aggregates (what is IN the library), not reading behavior. Mirrors the Audiobookshelf library dashboard's information architecture.

Endpoint: GET /api/stats/library-usage Auth: Required (any authenticated user)

Query Parameters

Parameter Type Required Description
library_id string (UUID) No Scope to one library. Omit to aggregate across ALL of the requesting user's visible libraries.

Family-lens rule

Every response is scoped to the requester's visible-library set (library_visibility, default-ALLOW). An explicit library_id OUTSIDE that set is a 403 — never a silent pass. Omitted library_id aggregates only visible libraries.

Example Request

GET /api/stats/library-usage?library_id=8c8d0cee-1cca-4def-9a37-edad3a4d438b
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

Response (200 OK)

{
  "library_id": "8c8d0cee-1cca-4def-9a37-edad3a4d438b",
  "items": 743,
  "authors": 446,
  "narrators": 41,
  "total_size_bytes": 309237452800,
  "total_seconds": 291240,
  "total_pages": 0,
  "hours_mode": true,
  "genres": [
    { "name": "Fantasy", "count": 142 }
  ],
  "top_authors": [
    { "name": "Terry Pratchett", "count": 42 }
  ],
  "longest_items": [
    {
      "media_item_id": "uuid",
      "title": "Gardens of the Moon",
      "author": "Steven Erikson",
      "cover_image_path": "/uploads/8c8d0cee/cover.jpg",
      "seconds": 93600,
      "pages": 0,
      "size": 1400000000
    }
  ],
  "largest_items": [
    {
      "media_item_id": "uuid",
      "title": "Gardens of the Moon",
      "author": "Steven Erikson",
      "cover_image_path": "/uploads/8c8d0cee/cover.jpg",
      "seconds": 93600,
      "pages": 0,
      "size": 1400000000
    }
  ]
}

Field semantics

Field Meaning
library_id "" when the scope is the All-libraries aggregate.
items Healthy media items (archived / missing-scan rows never count).
authors DISTINCT non-empty author values.
narrators DISTINCT values unnested from the narrators TEXT[] column across the scope.
total_size_bytes SUM of file_size.
total_seconds SUM of audio_metadata.duration_seconds — real audio hours; 0 for text libraries.
total_pages SUM of page_count — meaningful for text libraries; 0 for audiobooks.
hours_mode true when audio-duration data exists in scope: the client renders the time card in HOURS and ranks Longest Items by seconds. false: the time card renders in PAGES and Longest Items ranks by pages. The client decides — the payload always carries both sums.
genres Top 5 non-empty genre by item count (ties break alphabetically).
top_authors Top 10 non-empty author by item count (ties break alphabetically).
longest_items Top 5 by seconds (hours mode) or pages (pages mode).
largest_items Top 5 by file_size — works for every library type.

Absent data

Audio Tracks (Audiobookshelf's per-file count) is deliberately absent: multi-file audiobooks (folder-of-tracks / zip books) now ingest as ONE item whose track list lives in audio_metadata.tracks, so a file count would still not equal a per-book "tracks" metric without a new query. Add it by aggregating jsonb_array_length(audio_metadata->'tracks') if the panel is ever wanted.

Error Responses

Code Description
400 library_id is not a valid UUID
401 Invalid or expired token
403 Library not in the user's visible set
500 Aggregation failure