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
@@ -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")
|
||||
}
|
||||
Reference in new issue
Block a user