feat(stats): Library tab — per-library inventory stats + audio-aware Reading tab
The /stats page gains a second tab (Reading | Library) implementing the
Audiobookshelf-style library dashboard (HANDOFF item 9): media_items
INVENTORY aggregates — items, hours/pages, distinct authors, size,
distinct narrators, Top Genres / Top Authors bars, Longest / Largest
items — for one library or the All-libraries aggregate.
- GET /api/stats/library-usage?library_id=<uuid|omit>; family lens
enforced: a library_id outside the requester's visible set is a 403,
never a silent pass (AGENTS.md standing rule; 431fec0 history)
- six sqlc queries with the visibility pattern + healthy-item filter
(archived_at IS NULL AND missing_scan_count = 0); hours_mode lets the
client swap the Hours/Pages card and the Longest ranking per scope
- Reading tab unit audit (HANDOFF item 3, settled 2026-10-16): minutes
are the merged currency everywhere honest; only the PAGES unit splits
— Pages card goes mode-aware (Pages · text / Listening Time in an
audio-only pool) and the charts row gains Listening per Day (hrs)
when audio minutes exist in the range; summary daily buckets gain
audio_minutes split by the item's library type
- tab state in the URL (?tab=library&library_id=…); selector fed by the
existing /api/libraries/visibility endpoint; main.js?v=4
- integration tests: family-lens 403 pin, hidden-library exclusion
(differential), seeded text+audio aggregate correctness, auth gate
- docs/developer/api/stats/get_library_usage.md
Known-remaining suite failures are environmental: TestConflictList_Empty
+ TestWebSocketPingPong collide with the concurrently-running app session
(live emulator pushes + scans on the shared dev DB), and
TestAdminCreateUser's timezone expectation drifted from live system
settings — all three fail on the clean tree too.
This commit is contained in:
1 parent
a222ef5c27
commit
ce366b1604
13 files changed
+1493
-78
No files matched your search
@@ -0,0 +1,103 @@
|
||||
# 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
|
||||
|
||||
```http
|
||||
GET /api/stats/library-usage?library_id=8c8d0cee-1cca-4def-9a37-edad3a4d438b
|
||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
```
|
||||
|
||||
## Response (200 OK)
|
||||
|
||||
```json
|
||||
{
|
||||
"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:
|
||||
Bookhoard audiobooks are one file per book until multi-file support
|
||||
lands, so a file count would just equal `items`. See HANDOFF item 9.
|
||||
|
||||
## 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 |
|
||||
Reference in new issue
Block a user