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:
John O'Keefe committed 2026-10-16 10:20:17 -04:00
1 parent a222ef5c27
commit ce366b1604
13 files changed
+1493 -78

No files matched your search

+247
View File
@@ -517,3 +517,250 @@ func TestReadingStatsWeekLibraryScope(t *testing.T) {
defer func() { _ = resp.Body.Close() }()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
}
// TestLibraryUsageStats: the /stats Library tab (GET
// /api/stats/library-usage) — media_items INVENTORY aggregates scoped per
// request. Read-only over seeded rows (no reading_history), so the live
// dev-reader warnings do not apply. Seeds both a text and an audiobook
// library to pin the hours-vs-pages mode selection and every panel.
func TestLibraryUsageStats(t *testing.T) {
setup := setupTestServer(t)
userID := getRegularUserID(t, setup.DB)
ctx := context.Background()
ebooksID := uuid.MustParse(createLibraryDirect(t, setup.DB, userID,
fmt.Sprintf("Test Usage Ebooks %d", time.Now().UnixNano()), "ebooks"))
audioID := uuid.MustParse(createLibraryDirect(t, setup.DB, userID,
fmt.Sprintf("Test Usage Audio %d", time.Now().UnixNano()), "audiobooks"))
seed := func(lib uuid.UUID, title, author, genre string, pages int32, size int64, narrators []string, durationSeconds int) database.MediaItems {
if narrators == nil {
narrators = []string{} // NOT NULL DEFAULT '{}' — explicit NULL would 23502
}
audioMeta := []byte(nil)
if durationSeconds > 0 {
audioMeta = []byte(fmt.Sprintf(`{"duration_seconds":%d,"chapters":[]}`, durationSeconds))
}
media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: [16]byte(lib), Valid: true},
Title: title,
Author: pgtype.Text{String: author, Valid: true},
Genre: pgtype.Text{String: genre, Valid: true},
FilePath: fmt.Sprintf("/tmp/usage-%s-%d.bin", title, time.Now().UnixNano()),
FileSize: pgtype.Int8{Int64: size, Valid: true},
MimeType: pgtype.Text{String: "application/octet-stream", Valid: true},
PageCount: pgInt4(pages),
Narrators: narrators,
NarratorsSearch: narrators,
AudioMetadata: audioMeta,
})
require.NoError(t, err, "seed usage item")
return media
}
// Text library: 3 items, 2 distinct authors, genres Fantasy×2 +
// Computers×1, 1000 total pages, longest = the 500-page book.
seed(ebooksID, "Usage Dune", "Ann Author", "Fantasy", 300, 1_000_000, nil, 0)
seed(ebooksID, "Usage Botany", "Ann Author", "Fantasy", 200, 2_000_000, nil, 0)
seed(ebooksID, "Usage Computery", "Bob Author", "Computers", 500, 500_000, nil, 0)
// Audio library: 2 items, 1 author, 2 distinct narrators, real
// durations (36h + 5h), the 36h one also the largest file.
seed(audioID, "Usage LongListen", "Cal Author", "", 0, 1_400_000_000,
[]string{"Ann Narrator", "Bob Narrator"}, 129_600)
seed(audioID, "Usage ShortListen", "Cal Author", "", 0, 900_000_000,
[]string{"Ann Narrator"}, 18_000)
fetch := func(query string) (int, []byte) {
url := setup.Server.URL + "/api/stats/library-usage" + query
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
return resp.StatusCode, body
}
type UsageItemT struct {
MediaItemID string `json:"media_item_id"`
Title string `json:"title"`
Author string `json:"author"`
Seconds float64 `json:"seconds"`
Pages int `json:"pages"`
Size int64 `json:"size"`
}
type NameCountT struct {
Name string `json:"name"`
Count int `json:"count"`
}
var usage struct {
LibraryID string `json:"library_id"`
Items int `json:"items"`
Authors int `json:"authors"`
Narrators int `json:"narrators"`
TotalSizeBytes int64 `json:"total_size_bytes"`
TotalSeconds int64 `json:"total_seconds"`
TotalPages int `json:"total_pages"`
HoursMode bool `json:"hours_mode"`
Genres []NameCountT `json:"genres"`
AuthorsTop []NameCountT `json:"top_authors"`
LongestItems []UsageItemT `json:"longest_items"`
LargestItems []UsageItemT `json:"largest_items"`
}
decode := func(body []byte) {
require.NoError(t, json.Unmarshal(body, &usage))
}
// Text library scope: pages mode.
code, body := fetch("?library_id=" + ebooksID.String())
require.Equal(t, http.StatusOK, code)
decode(body)
assert.Equal(t, ebooksID.String(), usage.LibraryID)
assert.Equal(t, 3, usage.Items)
assert.Equal(t, 2, usage.Authors)
assert.Equal(t, 0, usage.Narrators)
assert.Equal(t, int64(3_500_000), usage.TotalSizeBytes)
assert.Equal(t, int64(0), usage.TotalSeconds)
assert.Equal(t, 1000, usage.TotalPages)
assert.False(t, usage.HoursMode, "no audio in scope → pages mode")
require.Len(t, usage.Genres, 2)
assert.Equal(t, "Fantasy", usage.Genres[0].Name)
assert.Equal(t, 2, usage.Genres[0].Count)
assert.Equal(t, "Computers", usage.Genres[1].Name)
require.Len(t, usage.AuthorsTop, 2)
assert.Equal(t, "Ann Author", usage.AuthorsTop[0].Name)
assert.Equal(t, 2, usage.AuthorsTop[0].Count)
require.Len(t, usage.LongestItems, 3)
assert.Equal(t, "Usage Computery", usage.LongestItems[0].Title)
assert.Equal(t, 500, usage.LongestItems[0].Pages)
require.Len(t, usage.LargestItems, 3)
assert.Equal(t, int64(2_000_000), usage.LargestItems[0].Size, "largest = file_size DESC")
// Audio library scope: hours mode.
code, body = fetch("?library_id=" + audioID.String())
require.Equal(t, http.StatusOK, code)
decode(body)
assert.Equal(t, 2, usage.Items)
assert.Equal(t, 1, usage.Authors)
assert.Equal(t, 2, usage.Narrators, "Ann + Bob across the two audiobooks")
assert.Equal(t, int64(147_600), usage.TotalSeconds, "36h + 5h")
assert.True(t, usage.HoursMode, "audio in scope → hours mode")
require.Len(t, usage.LongestItems, 2)
assert.Equal(t, "Usage LongListen", usage.LongestItems[0].Title)
assert.InEpsilon(t, 129_600, usage.LongestItems[0].Seconds, 0.001)
require.Len(t, usage.LargestItems, 2)
assert.Equal(t, int64(1_400_000_000), usage.LargestItems[0].Size)
// All libraries (no param): the aggregate across the visible set.
// The shared dev DB carries REAL libraries every user sees by default
// (Manga/Comics/Ebooks + whatever the live reader has), so totals are
// relative floors — but inclusion + ranking are exact.
code, body = fetch("")
require.Equal(t, http.StatusOK, code)
decode(body)
assert.Equal(t, "", usage.LibraryID)
assert.GreaterOrEqual(t, usage.Items, 5, "both test pools are in the aggregate")
assert.GreaterOrEqual(t, usage.Authors, 3)
assert.GreaterOrEqual(t, usage.Narrators, 2)
assert.GreaterOrEqual(t, usage.TotalSeconds, int64(147_600), "audio sums include the seeded audiobooks")
assert.GreaterOrEqual(t, usage.TotalPages, 1000)
assert.True(t, usage.HoursMode, "audio exists in the aggregate → longest ranks by hours")
require.NotEmpty(t, usage.LongestItems)
assert.GreaterOrEqual(t, usage.LongestItems[0].Seconds, float64(129_600),
"the seeded 36h item ranks at-or-above the top of the longest list")
// Malformed scope is a clean 400.
code, _ = fetch("?library_id=nope")
assert.Equal(t, http.StatusBadRequest, code)
// Auth gate.
anonym, err := http.Get(setup.Server.URL + "/api/stats/library-usage")
require.NoError(t, err)
assert.Equal(t, http.StatusUnauthorized, anonym.StatusCode)
_ = anonym.Body.Close()
}
// TestLibraryUsageStats_Visibility pins the family-lens contract on the
// Library tab: a library hidden from the requester contributes NOTHING to
// the All-libraries aggregate, and naming it explicitly is a 403 — never
// a silent pass (AGENTS.md standing rule; history: 431fec0).
func TestLibraryUsageStats_Visibility(t *testing.T) {
setup := setupTestServer(t)
userID := getRegularUserID(t, setup.DB)
ctx := context.Background()
visibleID := uuid.MustParse(createLibraryDirect(t, setup.DB, userID,
fmt.Sprintf("Test Usage Visible %d", time.Now().UnixNano()), "ebooks"))
hiddenID := uuid.MustParse(createLibraryDirect(t, setup.DB, userID,
fmt.Sprintf("Test Usage Hidden %d", time.Now().UnixNano()), "ebooks"))
seed := func(lib uuid.UUID, title string, size int64) {
_, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: [16]byte(lib), Valid: true},
Title: title,
FilePath: fmt.Sprintf("/tmp/usage-vis-%s-%d.bin", title, time.Now().UnixNano()),
FileSize: pgtype.Int8{Int64: size, Valid: true},
MimeType: pgtype.Text{String: "application/octet-stream", Valid: true},
Narrators: []string{},
NarratorsSearch: []string{},
})
require.NoError(t, err)
}
seed(visibleID, "Usage Visible One", 1_000_000)
seed(visibleID, "Usage Visible Two", 2_000_000)
seed(hiddenID, "Usage Hidden One", 4_000_000)
setVisibility := func(visible bool) int {
payload, _ := json.Marshal(map[string]interface{}{
"library_id": hiddenID.String(),
"is_visible": visible,
})
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/libraries/visibility", bytes.NewBuffer(payload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
return resp.StatusCode
}
fetch := func(query string) (int, []byte) {
url := setup.Server.URL + "/api/stats/library-usage" + query
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
return resp.StatusCode, body
}
// Baseline with the library visible: the aggregate includes its item.
code, body := fetch("")
require.Equal(t, http.StatusOK, code)
var before struct {
Items int `json:"items"`
}
require.NoError(t, json.Unmarshal(body, &before))
// Hide, then re-measure: a differential of exactly one item proves
// the hidden pool stays out of the aggregate (exact, and immune to
// whatever else lives in the shared dev libraries).
require.Equal(t, http.StatusOK, setVisibility(false))
defer func() { require.Equal(t, http.StatusOK, setVisibility(true)) }()
// Explicit hidden library → 403, the REQUIRED pin.
code, _ = fetch("?library_id=" + hiddenID.String())
assert.Equal(t, http.StatusForbidden, code, "hidden library scope must 403")
// The All-libraries aggregate excludes the hidden pool.
var after struct {
Items int `json:"items"`
}
code, body = fetch("")
require.Equal(t, http.StatusOK, code)
require.NoError(t, json.Unmarshal(body, &after))
assert.Equal(t, before.Items-1, after.Items, "hiding the library removes exactly its item from the aggregate")
}