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
@@ -80,6 +80,9 @@ type Querier interface {
|
||||
CreateMediaHighlight(ctx context.Context, arg CreateMediaHighlightParams) (MediaHighlights, error)
|
||||
CreateMediaHighlightFull(ctx context.Context, arg CreateMediaHighlightFullParams) (MediaHighlights, error)
|
||||
// Media Items queries
|
||||
// narrators/narrators_search are NOT NULL DEFAULT '{}' (A2): callers MUST
|
||||
// pass empty slices, never Go nil (an explicit NULL INSERT bypasses the
|
||||
// column default and 23502s). The API create handler normalizes nil.
|
||||
CreateMediaItem(ctx context.Context, arg CreateMediaItemParams) (MediaItems, error)
|
||||
// MEDIA ITEM FORMATS QUERIES
|
||||
// Create media item format
|
||||
@@ -88,6 +91,8 @@ type Querier interface {
|
||||
// (library_id, file_path) this inserts NOTHING and returns no row
|
||||
// (pgx.ErrNoRows) — the handler then answers 200 with the existing row
|
||||
// and a duplicate flag instead of pretending it created something.
|
||||
// narrators NOT NULL: callers pass empty slices, never nil (see
|
||||
// CreateMediaItem note).
|
||||
CreateMediaItemIfNew(ctx context.Context, arg CreateMediaItemIfNewParams) (MediaItems, error)
|
||||
// Media Notes queries
|
||||
CreateMediaNote(ctx context.Context, arg CreateMediaNoteParams) (MediaNotes, error)
|
||||
@@ -250,6 +255,24 @@ type Querier interface {
|
||||
GetLibraryTypeByName(ctx context.Context, name string) (LibraryTypes, error)
|
||||
// Library Types queries
|
||||
GetLibraryTypes(ctx context.Context) ([]LibraryTypes, error)
|
||||
GetLibraryUsageAuthors(ctx context.Context, arg GetLibraryUsageAuthorsParams) ([]GetLibraryUsageAuthorsRow, error)
|
||||
// ============================================================================
|
||||
// LIBRARY USAGE STATS (the /stats Library tab; the StatisticsService
|
||||
// LibraryUsage aggregate). media_items INVENTORY aggregates — what is in
|
||||
// the library, not what was read. Every query carries BOTH filters:
|
||||
// $1 = single-library scope (NULL = all visible libraries — "All")
|
||||
// $2 = the requester's visible-library id set (NULL = no filter; the
|
||||
// default-ALLOW family lens). A non-visible $1 is REJECTED at the
|
||||
// service/handler layer with 403 — the SQL filter is defense only.
|
||||
// + the healthy-item filter, matched to GetVisibleLibraryMediaCounts:
|
||||
// archived_at IS NULL AND missing_scan_count = 0 (dead rows lie).
|
||||
// ============================================================================
|
||||
GetLibraryUsageCards(ctx context.Context, arg GetLibraryUsageCardsParams) (GetLibraryUsageCardsRow, error)
|
||||
GetLibraryUsageGenres(ctx context.Context, arg GetLibraryUsageGenresParams) ([]GetLibraryUsageGenresRow, error)
|
||||
GetLibraryUsageLargestItems(ctx context.Context, arg GetLibraryUsageLargestItemsParams) ([]GetLibraryUsageLargestItemsRow, error)
|
||||
// The Longest Items panel, one query both modes: $3 = true ranks by
|
||||
// AUDIO duration (audio present in scope), false by page_count (text).
|
||||
GetLibraryUsageLongestItems(ctx context.Context, arg GetLibraryUsageLongestItemsParams) ([]GetLibraryUsageLongestItemsRow, error)
|
||||
GetLibraryVisibility(ctx context.Context, arg GetLibraryVisibilityParams) (LibraryVisibility, error)
|
||||
// ============================================================================
|
||||
// LIBRARY WITH TYPE INFO QUERIES
|
||||
|
||||
Reference in new issue
Block a user