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() }()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
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")
|
||||||
|
}
|
||||||
@@ -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 |
|
||||||
@@ -80,6 +80,9 @@ type Querier interface {
|
|||||||
CreateMediaHighlight(ctx context.Context, arg CreateMediaHighlightParams) (MediaHighlights, error)
|
CreateMediaHighlight(ctx context.Context, arg CreateMediaHighlightParams) (MediaHighlights, error)
|
||||||
CreateMediaHighlightFull(ctx context.Context, arg CreateMediaHighlightFullParams) (MediaHighlights, error)
|
CreateMediaHighlightFull(ctx context.Context, arg CreateMediaHighlightFullParams) (MediaHighlights, error)
|
||||||
// Media Items queries
|
// 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)
|
CreateMediaItem(ctx context.Context, arg CreateMediaItemParams) (MediaItems, error)
|
||||||
// MEDIA ITEM FORMATS QUERIES
|
// MEDIA ITEM FORMATS QUERIES
|
||||||
// Create media item format
|
// Create media item format
|
||||||
@@ -88,6 +91,8 @@ type Querier interface {
|
|||||||
// (library_id, file_path) this inserts NOTHING and returns no row
|
// (library_id, file_path) this inserts NOTHING and returns no row
|
||||||
// (pgx.ErrNoRows) — the handler then answers 200 with the existing row
|
// (pgx.ErrNoRows) — the handler then answers 200 with the existing row
|
||||||
// and a duplicate flag instead of pretending it created something.
|
// 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)
|
CreateMediaItemIfNew(ctx context.Context, arg CreateMediaItemIfNewParams) (MediaItems, error)
|
||||||
// Media Notes queries
|
// Media Notes queries
|
||||||
CreateMediaNote(ctx context.Context, arg CreateMediaNoteParams) (MediaNotes, error)
|
CreateMediaNote(ctx context.Context, arg CreateMediaNoteParams) (MediaNotes, error)
|
||||||
@@ -250,6 +255,24 @@ type Querier interface {
|
|||||||
GetLibraryTypeByName(ctx context.Context, name string) (LibraryTypes, error)
|
GetLibraryTypeByName(ctx context.Context, name string) (LibraryTypes, error)
|
||||||
// Library Types queries
|
// Library Types queries
|
||||||
GetLibraryTypes(ctx context.Context) ([]LibraryTypes, error)
|
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)
|
GetLibraryVisibility(ctx context.Context, arg GetLibraryVisibilityParams) (LibraryVisibility, error)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// LIBRARY WITH TYPE INFO QUERIES
|
// LIBRARY WITH TYPE INFO QUERIES
|
||||||
|
|||||||
@@ -1037,6 +1037,9 @@ type CreateMediaItemParams struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Media Items queries
|
// 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.
|
||||||
func (q *Queries) CreateMediaItem(ctx context.Context, arg CreateMediaItemParams) (MediaItems, error) {
|
func (q *Queries) CreateMediaItem(ctx context.Context, arg CreateMediaItemParams) (MediaItems, error) {
|
||||||
row := q.db.QueryRow(ctx, CreateMediaItem,
|
row := q.db.QueryRow(ctx, CreateMediaItem,
|
||||||
arg.LibraryID,
|
arg.LibraryID,
|
||||||
@@ -1273,6 +1276,8 @@ type CreateMediaItemIfNewParams struct {
|
|||||||
// (library_id, file_path) this inserts NOTHING and returns no row
|
// (library_id, file_path) this inserts NOTHING and returns no row
|
||||||
// (pgx.ErrNoRows) — the handler then answers 200 with the existing row
|
// (pgx.ErrNoRows) — the handler then answers 200 with the existing row
|
||||||
// and a duplicate flag instead of pretending it created something.
|
// and a duplicate flag instead of pretending it created something.
|
||||||
|
// narrators NOT NULL: callers pass empty slices, never nil (see
|
||||||
|
// CreateMediaItem note).
|
||||||
func (q *Queries) CreateMediaItemIfNew(ctx context.Context, arg CreateMediaItemIfNewParams) (MediaItems, error) {
|
func (q *Queries) CreateMediaItemIfNew(ctx context.Context, arg CreateMediaItemIfNewParams) (MediaItems, error) {
|
||||||
row := q.db.QueryRow(ctx, CreateMediaItemIfNew,
|
row := q.db.QueryRow(ctx, CreateMediaItemIfNew,
|
||||||
arg.LibraryID,
|
arg.LibraryID,
|
||||||
@@ -4996,6 +5001,291 @@ func (q *Queries) GetLibraryTypes(ctx context.Context) ([]LibraryTypes, error) {
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GetLibraryUsageAuthors = `-- name: GetLibraryUsageAuthors :many
|
||||||
|
SELECT COALESCE(mi.author, '') AS name, COUNT(*)::int AS row_count
|
||||||
|
FROM media_items mi
|
||||||
|
WHERE mi.author IS NOT NULL AND mi.author <> ''
|
||||||
|
AND mi.archived_at IS NULL
|
||||||
|
AND mi.missing_scan_count = 0
|
||||||
|
AND ($1::uuid IS NULL OR mi.library_id = $1::uuid)
|
||||||
|
AND ($2::uuid[] IS NULL OR mi.library_id = ANY($2::uuid[]))
|
||||||
|
GROUP BY mi.author
|
||||||
|
ORDER BY row_count DESC, name ASC
|
||||||
|
LIMIT 10
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetLibraryUsageAuthorsParams struct {
|
||||||
|
Column1 pgtype.UUID `db:"column_1" json:"column_1"`
|
||||||
|
Column2 []pgtype.UUID `db:"column_2" json:"column_2"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetLibraryUsageAuthorsRow struct {
|
||||||
|
Name string `db:"name" json:"name"`
|
||||||
|
RowCount int32 `db:"row_count" json:"row_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetLibraryUsageAuthors(ctx context.Context, arg GetLibraryUsageAuthorsParams) ([]GetLibraryUsageAuthorsRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, GetLibraryUsageAuthors, arg.Column1, arg.Column2)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []GetLibraryUsageAuthorsRow{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i GetLibraryUsageAuthorsRow
|
||||||
|
if err := rows.Scan(&i.Name, &i.RowCount); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const GetLibraryUsageCards = `-- name: GetLibraryUsageCards :one
|
||||||
|
|
||||||
|
WITH usage_scope AS (
|
||||||
|
SELECT mi.author, mi.file_size, mi.page_count, mi.narrators, mi.audio_metadata
|
||||||
|
FROM media_items mi
|
||||||
|
WHERE mi.archived_at IS NULL
|
||||||
|
AND mi.missing_scan_count = 0
|
||||||
|
AND ($1::uuid IS NULL OR mi.library_id = $1::uuid)
|
||||||
|
AND ($2::uuid[] IS NULL OR mi.library_id = ANY($2::uuid[]))
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM usage_scope)::int AS items,
|
||||||
|
(SELECT COUNT(DISTINCT author) FROM usage_scope
|
||||||
|
WHERE author IS NOT NULL AND author <> '')::int AS authors,
|
||||||
|
(SELECT COUNT(DISTINCT n) FROM usage_scope s, unnest(s.narrators) AS n
|
||||||
|
WHERE n <> '')::int AS narrators,
|
||||||
|
(SELECT COALESCE(SUM(file_size), 0) FROM usage_scope)::bigint AS total_size_bytes,
|
||||||
|
(SELECT COALESCE(SUM(page_count), 0) FROM usage_scope)::bigint AS total_pages,
|
||||||
|
(SELECT COALESCE(
|
||||||
|
SUM((s.audio_metadata->>'duration_seconds')::float8), 0
|
||||||
|
)::bigint FROM usage_scope s
|
||||||
|
WHERE s.audio_metadata IS NOT NULL
|
||||||
|
AND s.audio_metadata ? 'duration_seconds') AS total_seconds
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetLibraryUsageCardsParams struct {
|
||||||
|
Column1 pgtype.UUID `db:"column_1" json:"column_1"`
|
||||||
|
Column2 []pgtype.UUID `db:"column_2" json:"column_2"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetLibraryUsageCardsRow struct {
|
||||||
|
Items int32 `db:"items" json:"items"`
|
||||||
|
Authors int32 `db:"authors" json:"authors"`
|
||||||
|
Narrators int32 `db:"narrators" json:"narrators"`
|
||||||
|
TotalSizeBytes int64 `db:"total_size_bytes" json:"total_size_bytes"`
|
||||||
|
TotalPages int64 `db:"total_pages" json:"total_pages"`
|
||||||
|
TotalSeconds int64 `db:"total_seconds" json:"total_seconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 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).
|
||||||
|
//
|
||||||
|
// ============================================================================
|
||||||
|
func (q *Queries) GetLibraryUsageCards(ctx context.Context, arg GetLibraryUsageCardsParams) (GetLibraryUsageCardsRow, error) {
|
||||||
|
row := q.db.QueryRow(ctx, GetLibraryUsageCards, arg.Column1, arg.Column2)
|
||||||
|
var i GetLibraryUsageCardsRow
|
||||||
|
err := row.Scan(
|
||||||
|
&i.Items,
|
||||||
|
&i.Authors,
|
||||||
|
&i.Narrators,
|
||||||
|
&i.TotalSizeBytes,
|
||||||
|
&i.TotalPages,
|
||||||
|
&i.TotalSeconds,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const GetLibraryUsageGenres = `-- name: GetLibraryUsageGenres :many
|
||||||
|
SELECT COALESCE(mi.genre, '') AS name, COUNT(*)::int AS row_count
|
||||||
|
FROM media_items mi
|
||||||
|
WHERE mi.genre IS NOT NULL AND mi.genre <> ''
|
||||||
|
AND mi.archived_at IS NULL
|
||||||
|
AND mi.missing_scan_count = 0
|
||||||
|
AND ($1::uuid IS NULL OR mi.library_id = $1::uuid)
|
||||||
|
AND ($2::uuid[] IS NULL OR mi.library_id = ANY($2::uuid[]))
|
||||||
|
GROUP BY mi.genre
|
||||||
|
ORDER BY row_count DESC, name ASC
|
||||||
|
LIMIT 5
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetLibraryUsageGenresParams struct {
|
||||||
|
Column1 pgtype.UUID `db:"column_1" json:"column_1"`
|
||||||
|
Column2 []pgtype.UUID `db:"column_2" json:"column_2"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetLibraryUsageGenresRow struct {
|
||||||
|
Name string `db:"name" json:"name"`
|
||||||
|
RowCount int32 `db:"row_count" json:"row_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetLibraryUsageGenres(ctx context.Context, arg GetLibraryUsageGenresParams) ([]GetLibraryUsageGenresRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, GetLibraryUsageGenres, arg.Column1, arg.Column2)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []GetLibraryUsageGenresRow{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i GetLibraryUsageGenresRow
|
||||||
|
if err := rows.Scan(&i.Name, &i.RowCount); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const GetLibraryUsageLargestItems = `-- name: GetLibraryUsageLargestItems :many
|
||||||
|
SELECT
|
||||||
|
mi.id::uuid AS media_item_id,
|
||||||
|
mi.title,
|
||||||
|
COALESCE(mi.author, '') AS author,
|
||||||
|
mi.library_id,
|
||||||
|
mi.cover_image_path,
|
||||||
|
COALESCE(mi.file_size, 0)::bigint AS file_size_bytes
|
||||||
|
FROM media_items mi
|
||||||
|
WHERE mi.archived_at IS NULL
|
||||||
|
AND mi.missing_scan_count = 0
|
||||||
|
AND COALESCE(mi.file_size, 0) > 0
|
||||||
|
AND ($1::uuid IS NULL OR mi.library_id = $1::uuid)
|
||||||
|
AND ($2::uuid[] IS NULL OR mi.library_id = ANY($2::uuid[]))
|
||||||
|
ORDER BY mi.file_size DESC, mi.title ASC
|
||||||
|
LIMIT 5
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetLibraryUsageLargestItemsParams struct {
|
||||||
|
Column1 pgtype.UUID `db:"column_1" json:"column_1"`
|
||||||
|
Column2 []pgtype.UUID `db:"column_2" json:"column_2"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetLibraryUsageLargestItemsRow struct {
|
||||||
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||||
|
Title string `db:"title" json:"title"`
|
||||||
|
Author string `db:"author" json:"author"`
|
||||||
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||||
|
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
|
||||||
|
FileSizeBytes int64 `db:"file_size_bytes" json:"file_size_bytes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetLibraryUsageLargestItems(ctx context.Context, arg GetLibraryUsageLargestItemsParams) ([]GetLibraryUsageLargestItemsRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, GetLibraryUsageLargestItems, arg.Column1, arg.Column2)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []GetLibraryUsageLargestItemsRow{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i GetLibraryUsageLargestItemsRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.MediaItemID,
|
||||||
|
&i.Title,
|
||||||
|
&i.Author,
|
||||||
|
&i.LibraryID,
|
||||||
|
&i.CoverImagePath,
|
||||||
|
&i.FileSizeBytes,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const GetLibraryUsageLongestItems = `-- name: GetLibraryUsageLongestItems :many
|
||||||
|
SELECT
|
||||||
|
mi.id::uuid AS media_item_id,
|
||||||
|
mi.title,
|
||||||
|
COALESCE(mi.author, '') AS author,
|
||||||
|
mi.library_id,
|
||||||
|
mi.cover_image_path,
|
||||||
|
COALESCE(mi.file_size, 0)::bigint AS file_size_bytes,
|
||||||
|
COALESCE(mi.page_count, 0)::int AS page_count,
|
||||||
|
COALESCE((mi.audio_metadata->>'duration_seconds')::float8, 0)::bigint AS total_seconds
|
||||||
|
FROM media_items mi
|
||||||
|
WHERE mi.archived_at IS NULL
|
||||||
|
AND mi.missing_scan_count = 0
|
||||||
|
AND ($1::uuid IS NULL OR mi.library_id = $1::uuid)
|
||||||
|
AND ($2::uuid[] IS NULL OR mi.library_id = ANY($2::uuid[]))
|
||||||
|
AND (CASE WHEN $3::bool
|
||||||
|
THEN COALESCE((mi.audio_metadata->>'duration_seconds')::float8, 0) > 0
|
||||||
|
ELSE COALESCE(mi.page_count, 0) > 0
|
||||||
|
END)
|
||||||
|
ORDER BY (CASE WHEN $3::bool
|
||||||
|
THEN COALESCE((mi.audio_metadata->>'duration_seconds')::float8, 0)
|
||||||
|
ELSE COALESCE(mi.page_count, 0)::float8
|
||||||
|
END) DESC, mi.title ASC
|
||||||
|
LIMIT 5
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetLibraryUsageLongestItemsParams struct {
|
||||||
|
Column1 pgtype.UUID `db:"column_1" json:"column_1"`
|
||||||
|
Column2 []pgtype.UUID `db:"column_2" json:"column_2"`
|
||||||
|
Column3 bool `db:"column_3" json:"column_3"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetLibraryUsageLongestItemsRow struct {
|
||||||
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||||
|
Title string `db:"title" json:"title"`
|
||||||
|
Author string `db:"author" json:"author"`
|
||||||
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||||
|
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
|
||||||
|
FileSizeBytes int64 `db:"file_size_bytes" json:"file_size_bytes"`
|
||||||
|
PageCount int32 `db:"page_count" json:"page_count"`
|
||||||
|
TotalSeconds int64 `db:"total_seconds" json:"total_seconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Longest Items panel, one query both modes: $3 = true ranks by
|
||||||
|
// AUDIO duration (audio present in scope), false by page_count (text).
|
||||||
|
func (q *Queries) GetLibraryUsageLongestItems(ctx context.Context, arg GetLibraryUsageLongestItemsParams) ([]GetLibraryUsageLongestItemsRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, GetLibraryUsageLongestItems, arg.Column1, arg.Column2, arg.Column3)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []GetLibraryUsageLongestItemsRow{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i GetLibraryUsageLongestItemsRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.MediaItemID,
|
||||||
|
&i.Title,
|
||||||
|
&i.Author,
|
||||||
|
&i.LibraryID,
|
||||||
|
&i.CoverImagePath,
|
||||||
|
&i.FileSizeBytes,
|
||||||
|
&i.PageCount,
|
||||||
|
&i.TotalSeconds,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const GetLibraryVisibility = `-- name: GetLibraryVisibility :one
|
const GetLibraryVisibility = `-- name: GetLibraryVisibility :one
|
||||||
SELECT id, user_id, library_id, is_visible, created_at, updated_at FROM library_visibility WHERE user_id = $1 AND library_id = $2
|
SELECT id, user_id, library_id, is_visible, created_at, updated_at FROM library_visibility WHERE user_id = $1 AND library_id = $2
|
||||||
`
|
`
|
||||||
@@ -8987,7 +9277,8 @@ SELECT
|
|||||||
rh.id, rh.media_item_id, rh.device_id, rh.progress_percentage,
|
rh.id, rh.media_item_id, rh.device_id, rh.progress_percentage,
|
||||||
rh.reading_session_start, rh.reading_session_end,
|
rh.reading_session_start, rh.reading_session_end,
|
||||||
rh.pages_read, rh.time_spent_seconds, rh.device_metadata, rh.created_at,
|
rh.pages_read, rh.time_spent_seconds, rh.device_metadata, rh.created_at,
|
||||||
mi.title, mi.author, mi.library_id, mi.cover_image_path
|
mi.title, mi.author, mi.library_id, mi.cover_image_path,
|
||||||
|
mi.library_type_name
|
||||||
FROM reading_history rh
|
FROM reading_history rh
|
||||||
JOIN media_items mi ON mi.id = rh.media_item_id
|
JOIN media_items mi ON mi.id = rh.media_item_id
|
||||||
WHERE rh.user_id = $1
|
WHERE rh.user_id = $1
|
||||||
@@ -9021,6 +9312,7 @@ type GetUserReadingSessionsRangeRow struct {
|
|||||||
Author pgtype.Text `db:"author" json:"author"`
|
Author pgtype.Text `db:"author" json:"author"`
|
||||||
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||||
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
|
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
|
||||||
|
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// STATISTICS (reading sessions) — time spent, pages, streaks, breakdowns.
|
// STATISTICS (reading sessions) — time spent, pages, streaks, breakdowns.
|
||||||
@@ -9060,6 +9352,7 @@ func (q *Queries) GetUserReadingSessionsRange(ctx context.Context, arg GetUserRe
|
|||||||
&i.Author,
|
&i.Author,
|
||||||
&i.LibraryID,
|
&i.LibraryID,
|
||||||
&i.CoverImagePath,
|
&i.CoverImagePath,
|
||||||
|
&i.LibraryTypeName,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,6 +149,9 @@ GROUP BY l.id;
|
|||||||
|
|
||||||
-- Media Items queries
|
-- Media Items queries
|
||||||
-- name: CreateMediaItem :one
|
-- name: CreateMediaItem :one
|
||||||
|
-- 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.
|
||||||
INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publisher, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, library_type_name, audio_metadata, narrators, narrators_search, abridged)
|
INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publisher, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, library_type_name, audio_metadata, narrators, narrators_search, abridged)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48)
|
||||||
ON CONFLICT (library_id, file_path) DO UPDATE SET updated_at = NOW()
|
ON CONFLICT (library_id, file_path) DO UPDATE SET updated_at = NOW()
|
||||||
@@ -159,6 +162,8 @@ RETURNING *;
|
|||||||
-- (library_id, file_path) this inserts NOTHING and returns no row
|
-- (library_id, file_path) this inserts NOTHING and returns no row
|
||||||
-- (pgx.ErrNoRows) — the handler then answers 200 with the existing row
|
-- (pgx.ErrNoRows) — the handler then answers 200 with the existing row
|
||||||
-- and a duplicate flag instead of pretending it created something.
|
-- and a duplicate flag instead of pretending it created something.
|
||||||
|
-- narrators NOT NULL: callers pass empty slices, never nil (see
|
||||||
|
-- CreateMediaItem note).
|
||||||
INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publisher, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, library_type_name, audio_metadata, narrators, narrators_search, abridged)
|
INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publisher, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, library_type_name, audio_metadata, narrators, narrators_search, abridged)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48)
|
||||||
ON CONFLICT (library_id, file_path) DO NOTHING
|
ON CONFLICT (library_id, file_path) DO NOTHING
|
||||||
@@ -1543,7 +1548,8 @@ SELECT
|
|||||||
rh.id, rh.media_item_id, rh.device_id, rh.progress_percentage,
|
rh.id, rh.media_item_id, rh.device_id, rh.progress_percentage,
|
||||||
rh.reading_session_start, rh.reading_session_end,
|
rh.reading_session_start, rh.reading_session_end,
|
||||||
rh.pages_read, rh.time_spent_seconds, rh.device_metadata, rh.created_at,
|
rh.pages_read, rh.time_spent_seconds, rh.device_metadata, rh.created_at,
|
||||||
mi.title, mi.author, mi.library_id, mi.cover_image_path
|
mi.title, mi.author, mi.library_id, mi.cover_image_path,
|
||||||
|
mi.library_type_name
|
||||||
FROM reading_history rh
|
FROM reading_history rh
|
||||||
JOIN media_items mi ON mi.id = rh.media_item_id
|
JOIN media_items mi ON mi.id = rh.media_item_id
|
||||||
WHERE rh.user_id = sqlc.arg('user_id')
|
WHERE rh.user_id = sqlc.arg('user_id')
|
||||||
@@ -3129,3 +3135,106 @@ WHERE l.id = $1;
|
|||||||
SELECT * FROM media_items
|
SELECT * FROM media_items
|
||||||
WHERE library_id = $1 AND tags @> ARRAY[$2::text]
|
WHERE library_id = $1 AND tags @> ARRAY[$2::text]
|
||||||
ORDER BY title ASC;
|
ORDER BY title ASC;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 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).
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- name: GetLibraryUsageCards :one
|
||||||
|
WITH usage_scope AS (
|
||||||
|
SELECT mi.author, mi.file_size, mi.page_count, mi.narrators, mi.audio_metadata
|
||||||
|
FROM media_items mi
|
||||||
|
WHERE mi.archived_at IS NULL
|
||||||
|
AND mi.missing_scan_count = 0
|
||||||
|
AND ($1::uuid IS NULL OR mi.library_id = $1::uuid)
|
||||||
|
AND ($2::uuid[] IS NULL OR mi.library_id = ANY($2::uuid[]))
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM usage_scope)::int AS items,
|
||||||
|
(SELECT COUNT(DISTINCT author) FROM usage_scope
|
||||||
|
WHERE author IS NOT NULL AND author <> '')::int AS authors,
|
||||||
|
(SELECT COUNT(DISTINCT n) FROM usage_scope s, unnest(s.narrators) AS n
|
||||||
|
WHERE n <> '')::int AS narrators,
|
||||||
|
(SELECT COALESCE(SUM(file_size), 0) FROM usage_scope)::bigint AS total_size_bytes,
|
||||||
|
(SELECT COALESCE(SUM(page_count), 0) FROM usage_scope)::bigint AS total_pages,
|
||||||
|
(SELECT COALESCE(
|
||||||
|
SUM((s.audio_metadata->>'duration_seconds')::float8), 0
|
||||||
|
)::bigint FROM usage_scope s
|
||||||
|
WHERE s.audio_metadata IS NOT NULL
|
||||||
|
AND s.audio_metadata ? 'duration_seconds') AS total_seconds;
|
||||||
|
|
||||||
|
-- name: GetLibraryUsageGenres :many
|
||||||
|
SELECT COALESCE(mi.genre, '') AS name, COUNT(*)::int AS row_count
|
||||||
|
FROM media_items mi
|
||||||
|
WHERE mi.genre IS NOT NULL AND mi.genre <> ''
|
||||||
|
AND mi.archived_at IS NULL
|
||||||
|
AND mi.missing_scan_count = 0
|
||||||
|
AND ($1::uuid IS NULL OR mi.library_id = $1::uuid)
|
||||||
|
AND ($2::uuid[] IS NULL OR mi.library_id = ANY($2::uuid[]))
|
||||||
|
GROUP BY mi.genre
|
||||||
|
ORDER BY row_count DESC, name ASC
|
||||||
|
LIMIT 5;
|
||||||
|
|
||||||
|
-- name: GetLibraryUsageAuthors :many
|
||||||
|
SELECT COALESCE(mi.author, '') AS name, COUNT(*)::int AS row_count
|
||||||
|
FROM media_items mi
|
||||||
|
WHERE mi.author IS NOT NULL AND mi.author <> ''
|
||||||
|
AND mi.archived_at IS NULL
|
||||||
|
AND mi.missing_scan_count = 0
|
||||||
|
AND ($1::uuid IS NULL OR mi.library_id = $1::uuid)
|
||||||
|
AND ($2::uuid[] IS NULL OR mi.library_id = ANY($2::uuid[]))
|
||||||
|
GROUP BY mi.author
|
||||||
|
ORDER BY row_count DESC, name ASC
|
||||||
|
LIMIT 10;
|
||||||
|
|
||||||
|
-- name: GetLibraryUsageLongestItems :many
|
||||||
|
-- The Longest Items panel, one query both modes: $3 = true ranks by
|
||||||
|
-- AUDIO duration (audio present in scope), false by page_count (text).
|
||||||
|
SELECT
|
||||||
|
mi.id::uuid AS media_item_id,
|
||||||
|
mi.title,
|
||||||
|
COALESCE(mi.author, '') AS author,
|
||||||
|
mi.library_id,
|
||||||
|
mi.cover_image_path,
|
||||||
|
COALESCE(mi.file_size, 0)::bigint AS file_size_bytes,
|
||||||
|
COALESCE(mi.page_count, 0)::int AS page_count,
|
||||||
|
COALESCE((mi.audio_metadata->>'duration_seconds')::float8, 0)::bigint AS total_seconds
|
||||||
|
FROM media_items mi
|
||||||
|
WHERE mi.archived_at IS NULL
|
||||||
|
AND mi.missing_scan_count = 0
|
||||||
|
AND ($1::uuid IS NULL OR mi.library_id = $1::uuid)
|
||||||
|
AND ($2::uuid[] IS NULL OR mi.library_id = ANY($2::uuid[]))
|
||||||
|
AND (CASE WHEN $3::bool
|
||||||
|
THEN COALESCE((mi.audio_metadata->>'duration_seconds')::float8, 0) > 0
|
||||||
|
ELSE COALESCE(mi.page_count, 0) > 0
|
||||||
|
END)
|
||||||
|
ORDER BY (CASE WHEN $3::bool
|
||||||
|
THEN COALESCE((mi.audio_metadata->>'duration_seconds')::float8, 0)
|
||||||
|
ELSE COALESCE(mi.page_count, 0)::float8
|
||||||
|
END) DESC, mi.title ASC
|
||||||
|
LIMIT 5;
|
||||||
|
|
||||||
|
-- name: GetLibraryUsageLargestItems :many
|
||||||
|
SELECT
|
||||||
|
mi.id::uuid AS media_item_id,
|
||||||
|
mi.title,
|
||||||
|
COALESCE(mi.author, '') AS author,
|
||||||
|
mi.library_id,
|
||||||
|
mi.cover_image_path,
|
||||||
|
COALESCE(mi.file_size, 0)::bigint AS file_size_bytes
|
||||||
|
FROM media_items mi
|
||||||
|
WHERE mi.archived_at IS NULL
|
||||||
|
AND mi.missing_scan_count = 0
|
||||||
|
AND COALESCE(mi.file_size, 0) > 0
|
||||||
|
AND ($1::uuid IS NULL OR mi.library_id = $1::uuid)
|
||||||
|
AND ($2::uuid[] IS NULL OR mi.library_id = ANY($2::uuid[]))
|
||||||
|
ORDER BY mi.file_size DESC, mi.title ASC
|
||||||
|
LIMIT 5;
|
||||||
@@ -3,6 +3,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
"bookhoard/internal/services"
|
"bookhoard/internal/services"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -76,6 +77,42 @@ func (h *StatsHandler) ThisWeek(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusOK, week)
|
return c.JSON(http.StatusOK, week)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LibraryUsage handles GET /api/stats/library-usage?library_id=<uuid|omit>
|
||||||
|
// — the /stats Library tab: media_items INVENTORY aggregates (items,
|
||||||
|
// authors, narrators, size, audio hours, pages, top genres/authors,
|
||||||
|
// longest/largest items) for one library or the All-libraries scope.
|
||||||
|
// Family lens: a library_id outside the requester's visible set is a 403,
|
||||||
|
// never a silent pass (AGENTS.md standing rule; history: 431fec0).
|
||||||
|
// Omitted library_id aggregates across all visible libraries. No time
|
||||||
|
// dimension — that is the reading tab's business.
|
||||||
|
func (h *StatsHandler) LibraryUsage(c *echo.Context) error {
|
||||||
|
user, ok := mustUser(c)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
|
||||||
|
}
|
||||||
|
|
||||||
|
var libraryID pgtype.UUID
|
||||||
|
if raw := c.QueryParam("library_id"); raw != "" {
|
||||||
|
parsed, err := uuid.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "invalid library id")
|
||||||
|
}
|
||||||
|
libraryID = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
stats, err := h.statsService.LibraryUsage(c.Request().Context(),
|
||||||
|
pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
|
||||||
|
libraryID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, services.ErrLibraryNotVisible) {
|
||||||
|
return echo.NewHTTPError(http.StatusForbidden, "library not in the user's visible set")
|
||||||
|
}
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, stats)
|
||||||
|
}
|
||||||
|
|
||||||
// BookStats handles GET /api/stats/books/:id — the per-book card on the
|
// BookStats handles GET /api/stats/books/:id — the per-book card on the
|
||||||
// book detail page (lifetime totals + recent sessions).
|
// book detail page (lifetime totals + recent sessions).
|
||||||
func (h *StatsHandler) BookStats(c *echo.Context) error {
|
func (h *StatsHandler) BookStats(c *echo.Context) error {
|
||||||
|
|||||||
@@ -22,4 +22,7 @@ func registerAnalyticsRoutes(cfg *Config) {
|
|||||||
stats.GET("/summary", cfg.StatsHandler.Summary)
|
stats.GET("/summary", cfg.StatsHandler.Summary)
|
||||||
stats.GET("/this-week", cfg.StatsHandler.ThisWeek)
|
stats.GET("/this-week", cfg.StatsHandler.ThisWeek)
|
||||||
stats.GET("/books/:id", cfg.StatsHandler.BookStats)
|
stats.GET("/books/:id", cfg.StatsHandler.BookStats)
|
||||||
|
// Library tab: media_items inventory aggregates (what's in the
|
||||||
|
// library), scoped per request; reading_history is never touched here.
|
||||||
|
stats.GET("/library-usage", cfg.StatsHandler.LibraryUsage)
|
||||||
}
|
}
|
||||||
@@ -5,8 +5,10 @@ import (
|
|||||||
"bookhoard/internal/utils"
|
"bookhoard/internal/utils"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -80,10 +82,11 @@ func ResolveStatsRange(tz string, rangeKey string) (StatsRange, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DailyStats struct {
|
type DailyStats struct {
|
||||||
Date string `json:"date"` // YYYY-MM-DD, in the user's timezone
|
Date string `json:"date"` // YYYY-MM-DD, in the user's timezone
|
||||||
Minutes int `json:"minutes"`
|
Minutes int `json:"minutes"`
|
||||||
Pages int `json:"pages"`
|
AudioMinutes int `json:"audio_minutes"` // listening wall-clock inside Minutes (media-mode split for the /stats side-by-side charts)
|
||||||
Sessions int `json:"sessions"`
|
Pages int `json:"pages"`
|
||||||
|
Sessions int `json:"sessions"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type StreakStats struct {
|
type StreakStats struct {
|
||||||
@@ -132,6 +135,7 @@ type UserSummaryStats struct {
|
|||||||
StartDate string `json:"start_date"`
|
StartDate string `json:"start_date"`
|
||||||
EndDate string `json:"end_date"`
|
EndDate string `json:"end_date"`
|
||||||
TotalMinutes int `json:"total_minutes"`
|
TotalMinutes int `json:"total_minutes"`
|
||||||
|
TotalAudioMinutes int `json:"total_audio_minutes"` // listening minutes inside TotalMinutes (mode-aware wording surface)
|
||||||
TotalPages int `json:"total_pages"`
|
TotalPages int `json:"total_pages"`
|
||||||
SessionCount int `json:"session_count"`
|
SessionCount int `json:"session_count"`
|
||||||
BooksActive int `json:"books_with_activity"`
|
BooksActive int `json:"books_with_activity"`
|
||||||
@@ -198,6 +202,11 @@ func (s *StatisticsService) userVisibleLibraryIDs(ctx context.Context, userID pg
|
|||||||
return ids, nil
|
return ids, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ErrLibraryNotVisible is returned by LibraryUsage when the caller asked
|
||||||
|
// for a scoped library outside the requester's visible set — the handler
|
||||||
|
// maps it to 403 (never silently pass; the AGENTS.md family-lens rule).
|
||||||
|
var ErrLibraryNotVisible = errors.New("library not in the user's visible set")
|
||||||
|
|
||||||
// visibleOrNil maps an empty visible set to a NULL filter argument.
|
// visibleOrNil maps an empty visible set to a NULL filter argument.
|
||||||
func visibleOrNil(ids []pgtype.UUID) []pgtype.UUID {
|
func visibleOrNil(ids []pgtype.UUID) []pgtype.UUID {
|
||||||
if len(ids) == 0 {
|
if len(ids) == 0 {
|
||||||
@@ -270,11 +279,23 @@ func (s *StatisticsService) UserSummary(ctx context.Context, userID pgtype.UUID,
|
|||||||
continue // defensive: outside the inclusive window
|
continue // defensive: outside the inclusive window
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Media mode (the side-by-side wording split): minutes are the
|
||||||
|
// merged currency everywhere; PAGES only exist for text. The
|
||||||
|
// joined item's library type is the discriminator (denormalized
|
||||||
|
// column, trigger-maintained) — 'audiobooks' = audio wall-clock,
|
||||||
|
// everything else = text.
|
||||||
|
isAudio := strings.EqualFold(strings.TrimSpace(sess.LibraryTypeName.String), "audiobooks")
|
||||||
|
|
||||||
if sess.TimeSpentSeconds.Valid {
|
if sess.TimeSpentSeconds.Valid {
|
||||||
seconds := int64(sess.TimeSpentSeconds.Int32)
|
seconds := int64(sess.TimeSpentSeconds.Int32)
|
||||||
totalSessionSeconds += seconds
|
totalSessionSeconds += seconds
|
||||||
summary.TotalMinutes += int(seconds / 60)
|
minutes := int(seconds / 60)
|
||||||
entry.Minutes += int(seconds / 60)
|
summary.TotalMinutes += minutes
|
||||||
|
entry.Minutes += minutes
|
||||||
|
if isAudio {
|
||||||
|
summary.TotalAudioMinutes += minutes
|
||||||
|
entry.AudioMinutes += minutes
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if sess.PagesRead.Valid {
|
if sess.PagesRead.Valid {
|
||||||
summary.TotalPages += int(sess.PagesRead.Int32)
|
summary.TotalPages += int(sess.PagesRead.Int32)
|
||||||
@@ -526,6 +547,159 @@ func (s *StatisticsService) ThisWeek(ctx context.Context, userID pgtype.UUID, tz
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LibraryUsage types — the /stats Library tab (Audiobookshelf-style
|
||||||
|
// GENERAL book stats). INVENTORY facts from media_items: what is in the
|
||||||
|
// scoped pool, not what was read. Unlike the reading aggregates these are
|
||||||
|
// not calendar-bound; the reading tab keeps its own range selector.
|
||||||
|
type NameCountStats struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UsageItemStats struct {
|
||||||
|
MediaItemID string `json:"media_item_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
CoverImage string `json:"cover_image_path"`
|
||||||
|
Seconds float64 `json:"seconds"` // audio duration (0 unless hours_mode)
|
||||||
|
PageCount int `json:"pages"` // text pages (0 unless !hours_mode)
|
||||||
|
SizeBytes int64 `json:"size"` // always set, both modes
|
||||||
|
}
|
||||||
|
|
||||||
|
// LibraryUsageStats is the whole tab payload for one scope: "" LibraryID
|
||||||
|
// = the All-libraries aggregate across the requester's visible set.
|
||||||
|
// HoursMode says which unit the LONGEST panel (and the runtime card
|
||||||
|
// swap) render: true = audio hours (audio present in scope), false =
|
||||||
|
// text pages. TotalSeconds/TotalPages both always carry their sums so
|
||||||
|
// the client never has to infer.
|
||||||
|
type LibraryUsageStats 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 []NameCountStats `json:"genres"`
|
||||||
|
AuthorsTop []NameCountStats `json:"top_authors"`
|
||||||
|
LongestItems []UsageItemStats `json:"longest_items"`
|
||||||
|
LargestItems []UsageItemStats `json:"largest_items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LibraryUsage builds the /stats Library tab payload for one scope.
|
||||||
|
// libraryID scopes to one library (invalid = all visible). An explicit
|
||||||
|
// library OUTSIDE the requester's visible set returns ErrLibraryNotVisible
|
||||||
|
// (handler → 403; never silently pass — the family-lens standing rule).
|
||||||
|
func (s *StatisticsService) LibraryUsage(ctx context.Context, userID pgtype.UUID, libraryID pgtype.UUID) (LibraryUsageStats, error) {
|
||||||
|
visibleIDs, err := s.userVisibleLibraryIDs(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return LibraryUsageStats{}, err
|
||||||
|
}
|
||||||
|
visible := visibleOrNil(visibleIDs)
|
||||||
|
|
||||||
|
if libraryID.Valid {
|
||||||
|
found := false
|
||||||
|
for _, id := range visibleIDs {
|
||||||
|
if id == libraryID {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return LibraryUsageStats{}, ErrLibraryNotVisible
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
params := database.GetLibraryUsageCardsParams{
|
||||||
|
Column1: libraryID,
|
||||||
|
Column2: visible,
|
||||||
|
}
|
||||||
|
cards, err := s.db.GetLibraryUsageCards(ctx, params)
|
||||||
|
if err != nil {
|
||||||
|
return LibraryUsageStats{}, fmt.Errorf("failed to load library cards: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := LibraryUsageStats{
|
||||||
|
LibraryID: "",
|
||||||
|
Items: int(cards.Items),
|
||||||
|
Authors: int(cards.Authors),
|
||||||
|
Narrators: int(cards.Narrators),
|
||||||
|
TotalSizeBytes: cards.TotalSizeBytes,
|
||||||
|
TotalSeconds: cards.TotalSeconds,
|
||||||
|
TotalPages: int(cards.TotalPages),
|
||||||
|
HoursMode: cards.TotalSeconds > 0,
|
||||||
|
Genres: []NameCountStats{},
|
||||||
|
AuthorsTop: []NameCountStats{},
|
||||||
|
LongestItems: []UsageItemStats{},
|
||||||
|
LargestItems: []UsageItemStats{},
|
||||||
|
}
|
||||||
|
if libraryID.Valid {
|
||||||
|
out.LibraryID = uuid.UUID(libraryID.Bytes).String()
|
||||||
|
}
|
||||||
|
|
||||||
|
genres, err := s.db.GetLibraryUsageGenres(ctx, database.GetLibraryUsageGenresParams(params))
|
||||||
|
if err != nil {
|
||||||
|
return LibraryUsageStats{}, fmt.Errorf("failed to load library genres: %w", err)
|
||||||
|
}
|
||||||
|
for _, row := range genres {
|
||||||
|
out.Genres = append(out.Genres, NameCountStats{Name: row.Name, Count: int(row.RowCount)})
|
||||||
|
}
|
||||||
|
|
||||||
|
authors, err := s.db.GetLibraryUsageAuthors(ctx, database.GetLibraryUsageAuthorsParams(params))
|
||||||
|
if err != nil {
|
||||||
|
return LibraryUsageStats{}, fmt.Errorf("failed to load library authors: %w", err)
|
||||||
|
}
|
||||||
|
for _, row := range authors {
|
||||||
|
out.AuthorsTop = append(out.AuthorsTop, NameCountStats{Name: row.Name, Count: int(row.RowCount)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// The longest panel ranks by hours when audio data exists in scope,
|
||||||
|
// else by pages (text libraries; the "Longest Items" equivalent that
|
||||||
|
// actually means something there).
|
||||||
|
longestRows, err := s.db.GetLibraryUsageLongestItems(ctx, database.GetLibraryUsageLongestItemsParams{
|
||||||
|
Column1: libraryID,
|
||||||
|
Column2: visible,
|
||||||
|
Column3: out.HoursMode,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return LibraryUsageStats{}, fmt.Errorf("failed to load longest items: %w", err)
|
||||||
|
}
|
||||||
|
for _, row := range longestRows {
|
||||||
|
entry := UsageItemStats{
|
||||||
|
Title: row.Title,
|
||||||
|
Author: row.Author,
|
||||||
|
Seconds: float64(row.TotalSeconds),
|
||||||
|
PageCount: int(row.PageCount),
|
||||||
|
SizeBytes: row.FileSizeBytes,
|
||||||
|
}
|
||||||
|
if row.MediaItemID.Valid {
|
||||||
|
entry.MediaItemID = uuid.UUID(row.MediaItemID.Bytes).String()
|
||||||
|
entry.CoverImage = utils.ResolveMediaURL(row.LibraryID, row.CoverImagePath)
|
||||||
|
}
|
||||||
|
out.LongestItems = append(out.LongestItems, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
largestRows, err := s.db.GetLibraryUsageLargestItems(ctx, database.GetLibraryUsageLargestItemsParams(params))
|
||||||
|
if err != nil {
|
||||||
|
return LibraryUsageStats{}, fmt.Errorf("failed to load largest items: %w", err)
|
||||||
|
}
|
||||||
|
for _, row := range largestRows {
|
||||||
|
entry := UsageItemStats{
|
||||||
|
Title: row.Title,
|
||||||
|
Author: row.Author,
|
||||||
|
SizeBytes: row.FileSizeBytes,
|
||||||
|
}
|
||||||
|
if row.MediaItemID.Valid {
|
||||||
|
entry.MediaItemID = uuid.UUID(row.MediaItemID.Bytes).String()
|
||||||
|
entry.CoverImage = utils.ResolveMediaURL(row.LibraryID, row.CoverImagePath)
|
||||||
|
}
|
||||||
|
out.LargestItems = append(out.LargestItems, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// BookStats is the per-book card: lifetime totals + the most recent
|
// BookStats is the per-book card: lifetime totals + the most recent
|
||||||
// sessions (bounded so the detail page never scans unbounded history).
|
// sessions (bounded so the detail page never scans unbounded history).
|
||||||
func (s *StatisticsService) BookStats(ctx context.Context, userID pgtype.UUID, mediaItemID uuid.UUID, tz string) (BookStats, error) {
|
func (s *StatisticsService) BookStats(ctx context.Context, userID pgtype.UUID, mediaItemID uuid.UUID, tz string) (BookStats, error) {
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ templ Header(user User, currentPath string) {
|
|||||||
</header>
|
</header>
|
||||||
<!-- module bundle; ?v=<commit-ish stamp> busts stale intermediate
|
<!-- module bundle; ?v=<commit-ish stamp> busts stale intermediate
|
||||||
builds across app restarts (browser caches are keyed by URL) -->
|
builds across app restarts (browser caches are keyed by URL) -->
|
||||||
<script type="module" src="/static/main.js?v=3"></script>
|
<script type="module" src="/static/main.js?v=4"></script>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -631,7 +631,7 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</span> <input type=\"text\" id=\"header-search\" placeholder=\"Search your library…\" class=\"input pl-10\" autocomplete=\"off\"></div></div></header><!-- module bundle; ?v=<commit-ish stamp> busts stale intermediate\n\t\t builds across app restarts (browser caches are keyed by URL) --><script type=\"module\" src=\"/static/main.js?v=3\"></script></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</span> <input type=\"text\" id=\"header-search\" placeholder=\"Search your library…\" class=\"input pl-10\" autocomplete=\"off\"></div></div></header><!-- module bundle; ?v=<commit-ish stamp> busts stale intermediate\n\t\t builds across app restarts (browser caches are keyed by URL) --><script type=\"module\" src=\"/static/main.js?v=4\"></script></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
+134
-57
@@ -22,77 +22,154 @@ templ Stats(user User) {
|
|||||||
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Reading Stats</h1>
|
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Reading Stats</h1>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm" style="color: var(--text-secondary)">Days are bucketed in your timezone ({ user.Timezone }) · totals derive from your devices' sync pushes, not a stopwatch.</p>
|
<p class="text-sm" style="color: var(--text-secondary)">Days are bucketed in your timezone ({ user.Timezone }) · totals derive from your devices' sync pushes, not a stopwatch.</p>
|
||||||
</div>
|
<div class="flex gap-2 mt-4" id="stats-tab-row">
|
||||||
<div class="card p-4 mb-6 flex flex-wrap items-center justify-between gap-3">
|
<button data-tab="reading" @click="selectTab('reading')" class="stats-tab-btn btn btn-secondary text-sm">Reading</button>
|
||||||
<div class="flex gap-2" id="stats-range-selector">
|
<button data-tab="library" @click="selectTab('library')" class="stats-tab-btn btn btn-secondary text-sm">Library</button>
|
||||||
<button data-range="7" @click="selectRange('7')" class="stats-range-btn btn btn-secondary text-sm">Last 7 days</button>
|
|
||||||
<button data-range="30" @click="selectRange('30')" class="stats-range-btn btn btn-secondary text-sm">30 days</button>
|
|
||||||
<button data-range="90" @click="selectRange('90')" class="stats-range-btn btn btn-secondary text-sm">90 days</button>
|
|
||||||
<button data-range="year" @click="selectRange('year')" class="stats-range-btn btn btn-secondary text-sm">This year</button>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs" style="color: var(--text-secondary)" id="stats-window-label"></p>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-2 lg:grid-cols-5 gap-4 mb-8">
|
|
||||||
<div class="stat-card">
|
|
||||||
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Reading Time</h3>
|
|
||||||
<p id="stats-total-minutes" class="text-3xl font-bold" style="color: var(--accent)">–</p>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Pages</h3>
|
|
||||||
<p id="stats-total-pages" class="text-3xl font-bold" style="color: var(--accent)">–</p>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Streaks</h3>
|
|
||||||
<p id="stats-streaks" class="text-lg font-bold leading-tight mt-1" style="color: var(--text-primary)">–</p>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Books Finished</h3>
|
|
||||||
<p id="stats-books-finished" class="text-3xl font-bold" style="color: var(--accent)">–</p>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Most Active</h3>
|
|
||||||
<p id="stats-most-active" class="text-sm font-bold leading-snug mt-2" style="color: var(--text-primary)">–</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
<!-- READING tab — behavior stats from sync pushes (all libraries) -->
|
||||||
<div class="card p-6">
|
<div id="stats-reading-section">
|
||||||
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary)">Daily Reading Minutes</h3>
|
<div class="card p-4 mb-6 flex flex-wrap items-center justify-between gap-3">
|
||||||
<div class="h-64">
|
<div class="flex gap-2" id="stats-range-selector">
|
||||||
<canvas id="stats-daily-minutes-chart"></canvas>
|
<button data-range="7" @click="selectRange('7')" class="stats-range-btn btn btn-secondary text-sm">Last 7 days</button>
|
||||||
|
<button data-range="30" @click="selectRange('30')" class="stats-range-btn btn btn-secondary text-sm">30 days</button>
|
||||||
|
<button data-range="90" @click="selectRange('90')" class="stats-range-btn btn btn-secondary text-sm">90 days</button>
|
||||||
|
<button data-range="year" @click="selectRange('year')" class="stats-range-btn btn btn-secondary text-sm">This year</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs" style="color: var(--text-secondary)" id="stats-window-label"></p>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 lg:grid-cols-5 gap-4 mb-8">
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Reading Time</h3>
|
||||||
|
<p id="stats-total-minutes" class="text-3xl font-bold" style="color: var(--accent)">–</p>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)" id="stats-pages-card-label">Pages</h3>
|
||||||
|
<p id="stats-total-pages" class="text-3xl font-bold" style="color: var(--accent)">–</p>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Streaks</h3>
|
||||||
|
<p id="stats-streaks" class="text-lg font-bold leading-tight mt-1" style="color: var(--text-primary)">–</p>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Books Finished</h3>
|
||||||
|
<p id="stats-books-finished" class="text-3xl font-bold" style="color: var(--accent)">–</p>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Most Active</h3>
|
||||||
|
<p id="stats-most-active" class="text-sm font-bold leading-snug mt-2" style="color: var(--text-primary)">–</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card p-6">
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
|
||||||
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary)">Pages per Day</h3>
|
<div class="card p-6">
|
||||||
<div class="h-64">
|
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary)">Daily Reading Minutes</h3>
|
||||||
<canvas id="stats-daily-pages-chart"></canvas>
|
<div class="h-64">
|
||||||
|
<canvas id="stats-daily-minutes-chart"></canvas>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card p-6" id="stats-pages-chart-panel">
|
||||||
|
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary)" id="stats-pages-chart-label">Pages per Day</h3>
|
||||||
|
<div class="h-64">
|
||||||
|
<canvas id="stats-daily-pages-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card p-6 hidden" id="stats-listening-chart-panel">
|
||||||
|
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary)">Listening per Day (hrs)</h3>
|
||||||
|
<div class="h-64">
|
||||||
|
<canvas id="stats-daily-listening-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
|
||||||
|
<div class="card p-6">
|
||||||
|
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary)">By Library</h3>
|
||||||
|
<div id="stats-library-breakdown" class="space-y-3">
|
||||||
|
<div class="text-center py-6 text-sm" style="color: var(--text-secondary)">Loading…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card p-6">
|
||||||
|
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary)">By Device</h3>
|
||||||
|
<p class="text-xs mb-3" style="color: var(--text-secondary)">Where the sync pushes came from — registered device names, plus a Web bucket for pushes that carry no device credential (the web reader).</p>
|
||||||
|
<div id="stats-device-breakdown" class="space-y-3">
|
||||||
|
<div class="text-center py-6 text-sm" style="color: var(--text-secondary)">Loading…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card p-6">
|
||||||
|
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary)">Most Read</h3>
|
||||||
|
<div id="stats-top-books" class="space-y-3">
|
||||||
|
<div class="text-center py-6 text-sm" style="color: var(--text-secondary)">Loading…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="stats-empty" class="card p-8 text-center hidden">
|
||||||
|
<p class="text-sm mb-2" style="color: var(--text-primary)">No reading sessions recorded in this range yet.</p>
|
||||||
|
<p class="text-xs" style="color: var(--text-secondary)">Sessions are captured every time a device syncs reading progress (KOReader, the app, the web reader).</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
|
<!-- LIBRARY tab — media_items inventory stats (what's IN the
|
||||||
<div class="card p-6">
|
scoped pool), no time dimension; ABS-style general book
|
||||||
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary)">By Library</h3>
|
stats. Data arrives client-side; see stats.ts -->
|
||||||
<div id="stats-library-breakdown" class="space-y-3">
|
<div id="stats-library-section" class="hidden">
|
||||||
<div class="text-center py-6 text-sm" style="color: var(--text-secondary)">Loading…</div>
|
<div class="card p-4 mb-6 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="text-sm" for="stats-library-select" style="color: var(--text-secondary)">Library</label>
|
||||||
|
<select id="stats-library-select" @change="selectLibrary($event.target.value)" class="btn btn-secondary text-sm bg-transparent">
|
||||||
|
<option value="">All libraries</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs" style="color: var(--text-secondary)" id="stats-library-scope-label">Inventory statistics — what's in the library, not when it was read.</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 lg:grid-cols-5 gap-4 mb-8">
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Items</h3>
|
||||||
|
<p id="lib-stat-items" class="text-3xl font-bold" style="color: var(--accent)">–</p>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)" id="lib-stat-time-label">Hours</h3>
|
||||||
|
<p id="lib-stat-time" class="text-3xl font-bold" style="color: var(--accent)">–</p>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Authors</h3>
|
||||||
|
<p id="lib-stat-authors" class="text-3xl font-bold" style="color: var(--accent)">–</p>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Size (GB)</h3>
|
||||||
|
<p id="lib-stat-size" class="text-3xl font-bold" style="color: var(--accent)">–</p>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Narrators</h3>
|
||||||
|
<p id="lib-stat-narrators" class="text-3xl font-bold" style="color: var(--accent)">–</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card p-6">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||||
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary)">By Device</h3>
|
<div class="card p-6">
|
||||||
<p class="text-xs mb-3" style="color: var(--text-secondary)">Where the sync pushes came from — registered device names, plus a Web bucket for pushes that carry no device credential (the web reader).</p>
|
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary)">Top Genres</h3>
|
||||||
<div id="stats-device-breakdown" class="space-y-3">
|
<div id="stats-lib-genres" class="space-y-3">
|
||||||
<div class="text-center py-6 text-sm" style="color: var(--text-secondary)">Loading…</div>
|
<div class="text-center py-6 text-sm" style="color: var(--text-secondary)">Loading…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card p-6">
|
||||||
|
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary)">Top Authors</h3>
|
||||||
|
<div id="stats-lib-authors" class="space-y-3">
|
||||||
|
<div class="text-center py-6 text-sm" style="color: var(--text-secondary)">Loading…</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card p-6">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||||
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary)">Most Read</h3>
|
<div class="card p-6">
|
||||||
<div id="stats-top-books" class="space-y-3">
|
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary)" id="stats-lib-longest-label">Longest Items (hrs)</h3>
|
||||||
<div class="text-center py-6 text-sm" style="color: var(--text-secondary)">Loading…</div>
|
<div id="stats-lib-longest" class="space-y-3">
|
||||||
|
<div class="text-center py-6 text-sm" style="color: var(--text-secondary)">Loading…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card p-6">
|
||||||
|
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary)">Largest Items</h3>
|
||||||
|
<div id="stats-lib-largest" class="space-y-3">
|
||||||
|
<div class="text-center py-6 text-sm" style="color: var(--text-secondary)">Loading…</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="stats-empty" class="card p-8 text-center hidden">
|
|
||||||
<p class="text-sm mb-2" style="color: var(--text-primary)">No reading sessions recorded in this range yet.</p>
|
|
||||||
<p class="text-xs" style="color: var(--text-secondary)">Sessions are captured every time a device syncs reading progress (KOReader, the app, the web reader).</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
@ErrorToast("")
|
@ErrorToast("")
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ func Stats(user User) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, ") · totals derive from your devices' sync pushes, not a stopwatch.</p></div><div class=\"card p-4 mb-6 flex flex-wrap items-center justify-between gap-3\"><div class=\"flex gap-2\" id=\"stats-range-selector\"><button data-range=\"7\" @click=\"selectRange('7')\" class=\"stats-range-btn btn btn-secondary text-sm\">Last 7 days</button> <button data-range=\"30\" @click=\"selectRange('30')\" class=\"stats-range-btn btn btn-secondary text-sm\">30 days</button> <button data-range=\"90\" @click=\"selectRange('90')\" class=\"stats-range-btn btn btn-secondary text-sm\">90 days</button> <button data-range=\"year\" @click=\"selectRange('year')\" class=\"stats-range-btn btn btn-secondary text-sm\">This year</button></div><p class=\"text-xs\" style=\"color: var(--text-secondary)\" id=\"stats-window-label\"></p></div><div class=\"grid grid-cols-2 lg:grid-cols-5 gap-4 mb-8\"><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Reading Time</h3><p id=\"stats-total-minutes\" class=\"text-3xl font-bold\" style=\"color: var(--accent)\">–</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Pages</h3><p id=\"stats-total-pages\" class=\"text-3xl font-bold\" style=\"color: var(--accent)\">–</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Streaks</h3><p id=\"stats-streaks\" class=\"text-lg font-bold leading-tight mt-1\" style=\"color: var(--text-primary)\">–</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Books Finished</h3><p id=\"stats-books-finished\" class=\"text-3xl font-bold\" style=\"color: var(--accent)\">–</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Most Active</h3><p id=\"stats-most-active\" class=\"text-sm font-bold leading-snug mt-2\" style=\"color: var(--text-primary)\">–</p></div></div><div class=\"grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8\"><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\">Daily Reading Minutes</h3><div class=\"h-64\"><canvas id=\"stats-daily-minutes-chart\"></canvas></div></div><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\">Pages per Day</h3><div class=\"h-64\"><canvas id=\"stats-daily-pages-chart\"></canvas></div></div></div><div class=\"grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8\"><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\">By Library</h3><div id=\"stats-library-breakdown\" class=\"space-y-3\"><div class=\"text-center py-6 text-sm\" style=\"color: var(--text-secondary)\">Loading…</div></div></div><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\">By Device</h3><p class=\"text-xs mb-3\" style=\"color: var(--text-secondary)\">Where the sync pushes came from — registered device names, plus a Web bucket for pushes that carry no device credential (the web reader).</p><div id=\"stats-device-breakdown\" class=\"space-y-3\"><div class=\"text-center py-6 text-sm\" style=\"color: var(--text-secondary)\">Loading…</div></div></div><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\">Most Read</h3><div id=\"stats-top-books\" class=\"space-y-3\"><div class=\"text-center py-6 text-sm\" style=\"color: var(--text-secondary)\">Loading…</div></div></div></div><div id=\"stats-empty\" class=\"card p-8 text-center hidden\"><p class=\"text-sm mb-2\" style=\"color: var(--text-primary)\">No reading sessions recorded in this range yet.</p><p class=\"text-xs\" style=\"color: var(--text-secondary)\">Sessions are captured every time a device syncs reading progress (KOReader, the app, the web reader).</p></div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, ") · totals derive from your devices' sync pushes, not a stopwatch.</p><div class=\"flex gap-2 mt-4\" id=\"stats-tab-row\"><button data-tab=\"reading\" @click=\"selectTab('reading')\" class=\"stats-tab-btn btn btn-secondary text-sm\">Reading</button> <button data-tab=\"library\" @click=\"selectTab('library')\" class=\"stats-tab-btn btn btn-secondary text-sm\">Library</button></div></div><!-- READING tab — behavior stats from sync pushes (all libraries) --><div id=\"stats-reading-section\"><div class=\"card p-4 mb-6 flex flex-wrap items-center justify-between gap-3\"><div class=\"flex gap-2\" id=\"stats-range-selector\"><button data-range=\"7\" @click=\"selectRange('7')\" class=\"stats-range-btn btn btn-secondary text-sm\">Last 7 days</button> <button data-range=\"30\" @click=\"selectRange('30')\" class=\"stats-range-btn btn btn-secondary text-sm\">30 days</button> <button data-range=\"90\" @click=\"selectRange('90')\" class=\"stats-range-btn btn btn-secondary text-sm\">90 days</button> <button data-range=\"year\" @click=\"selectRange('year')\" class=\"stats-range-btn btn btn-secondary text-sm\">This year</button></div><p class=\"text-xs\" style=\"color: var(--text-secondary)\" id=\"stats-window-label\"></p></div><div class=\"grid grid-cols-2 lg:grid-cols-5 gap-4 mb-8\"><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Reading Time</h3><p id=\"stats-total-minutes\" class=\"text-3xl font-bold\" style=\"color: var(--accent)\">–</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\" id=\"stats-pages-card-label\">Pages</h3><p id=\"stats-total-pages\" class=\"text-3xl font-bold\" style=\"color: var(--accent)\">–</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Streaks</h3><p id=\"stats-streaks\" class=\"text-lg font-bold leading-tight mt-1\" style=\"color: var(--text-primary)\">–</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Books Finished</h3><p id=\"stats-books-finished\" class=\"text-3xl font-bold\" style=\"color: var(--accent)\">–</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Most Active</h3><p id=\"stats-most-active\" class=\"text-sm font-bold leading-snug mt-2\" style=\"color: var(--text-primary)\">–</p></div></div><div class=\"grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8\"><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\">Daily Reading Minutes</h3><div class=\"h-64\"><canvas id=\"stats-daily-minutes-chart\"></canvas></div></div><div class=\"card p-6\" id=\"stats-pages-chart-panel\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\" id=\"stats-pages-chart-label\">Pages per Day</h3><div class=\"h-64\"><canvas id=\"stats-daily-pages-chart\"></canvas></div></div><div class=\"card p-6 hidden\" id=\"stats-listening-chart-panel\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\">Listening per Day (hrs)</h3><div class=\"h-64\"><canvas id=\"stats-daily-listening-chart\"></canvas></div></div></div><div class=\"grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8\"><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\">By Library</h3><div id=\"stats-library-breakdown\" class=\"space-y-3\"><div class=\"text-center py-6 text-sm\" style=\"color: var(--text-secondary)\">Loading…</div></div></div><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\">By Device</h3><p class=\"text-xs mb-3\" style=\"color: var(--text-secondary)\">Where the sync pushes came from — registered device names, plus a Web bucket for pushes that carry no device credential (the web reader).</p><div id=\"stats-device-breakdown\" class=\"space-y-3\"><div class=\"text-center py-6 text-sm\" style=\"color: var(--text-secondary)\">Loading…</div></div></div><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\">Most Read</h3><div id=\"stats-top-books\" class=\"space-y-3\"><div class=\"text-center py-6 text-sm\" style=\"color: var(--text-secondary)\">Loading…</div></div></div></div><div id=\"stats-empty\" class=\"card p-8 text-center hidden\"><p class=\"text-sm mb-2\" style=\"color: var(--text-primary)\">No reading sessions recorded in this range yet.</p><p class=\"text-xs\" style=\"color: var(--text-secondary)\">Sessions are captured every time a deviLine truncated
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
+359
-10
@@ -5,10 +5,21 @@ import type { ChartConfiguration } from "chart.js";
|
|||||||
// Reading statistics page (/stats): one summary fetch per range, charts
|
// Reading statistics page (/stats): one summary fetch per range, charts
|
||||||
// hydrated client-side. Chart.js is dynamically imported so the chunk
|
// hydrated client-side. Chart.js is dynamically imported so the chunk
|
||||||
// only loads on this page.
|
// only loads on this page.
|
||||||
|
//
|
||||||
|
// Two tabs:
|
||||||
|
// - Reading — behavior stats from sync pushes, all libraries. Days
|
||||||
|
// bucket in the user's timezone. Minutes are the merged currency
|
||||||
|
// (audio wall-clock counts honestly); only the PAGES unit splits
|
||||||
|
// side-by-side (Pages · text vs Listening per Day), because a
|
||||||
|
// listen-only day must never render as a lying zero.
|
||||||
|
// - Library — media_items INVENTORY stats for one library (or the
|
||||||
|
// All-libraries aggregate): items, hours/pages, authors, size,
|
||||||
|
// narrators, top genres/authors, longest/largest items.
|
||||||
|
|
||||||
interface DailyEntry {
|
interface DailyEntry {
|
||||||
date: string;
|
date: string;
|
||||||
minutes: number;
|
minutes: number;
|
||||||
|
audio_minutes: number;
|
||||||
pages: number;
|
pages: number;
|
||||||
sessions: number;
|
sessions: number;
|
||||||
}
|
}
|
||||||
@@ -55,6 +66,7 @@ interface Summary {
|
|||||||
start_date: string;
|
start_date: string;
|
||||||
end_date: string;
|
end_date: string;
|
||||||
total_minutes: number;
|
total_minutes: number;
|
||||||
|
total_audio_minutes: number;
|
||||||
total_pages: number;
|
total_pages: number;
|
||||||
session_count: number;
|
session_count: number;
|
||||||
books_with_activity: number;
|
books_with_activity: number;
|
||||||
@@ -71,13 +83,57 @@ interface Summary {
|
|||||||
device_breakdown?: DeviceRow[];
|
device_breakdown?: DeviceRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Library tab payload (GET /api/stats/library-usage) ----------------------
|
||||||
|
|
||||||
|
interface NameCount {
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UsageItem {
|
||||||
|
media_item_id: string;
|
||||||
|
title: string;
|
||||||
|
author: string;
|
||||||
|
cover_image_path: string;
|
||||||
|
seconds: number; // audio duration (hours-mode)
|
||||||
|
pages: number; // text pages (pages-mode)
|
||||||
|
size: number; // always set
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LibraryUsage {
|
||||||
|
library_id: string; // "" = all visible libraries
|
||||||
|
items: number;
|
||||||
|
authors: number;
|
||||||
|
narrators: number;
|
||||||
|
total_size_bytes: number;
|
||||||
|
total_seconds: number;
|
||||||
|
total_pages: number;
|
||||||
|
hours_mode: boolean;
|
||||||
|
genres: NameCount[];
|
||||||
|
top_authors: NameCount[];
|
||||||
|
longest_items: UsageItem[];
|
||||||
|
largest_items: UsageItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VisibleLibrary {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type_name: string;
|
||||||
|
is_visible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface ChartLike {
|
interface ChartLike {
|
||||||
destroy(): void;
|
destroy(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let minutesChart: ChartLike | null = null;
|
let minutesChart: ChartLike | null = null;
|
||||||
let pagesChart: ChartLike | null = null;
|
let pagesChart: ChartLike | null = null;
|
||||||
|
let listeningChart: ChartLike | null = null;
|
||||||
let currentRange = "7";
|
let currentRange = "7";
|
||||||
|
let currentTab = "reading";
|
||||||
|
let currentLibraryID = ""; // "" = All libraries
|
||||||
|
let libraryOptionsLoaded = false;
|
||||||
|
let libraryDataLoaded = false;
|
||||||
|
|
||||||
// Page-scoped helpers -----------------------------------------------------
|
// Page-scoped helpers -----------------------------------------------------
|
||||||
|
|
||||||
@@ -110,6 +166,53 @@ function selectRange(range: string): void {
|
|||||||
void loadSummary();
|
void loadSummary();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tabs ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
function markTabButtons(active: string): void {
|
||||||
|
document.querySelectorAll<HTMLElement>(".stats-tab-btn").forEach((btn) => {
|
||||||
|
const isActive = btn.dataset.tab === active;
|
||||||
|
btn.classList.toggle("btn-active", isActive);
|
||||||
|
btn.classList.toggle("opacity-50", !isActive);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectTab(tab: string): void {
|
||||||
|
currentTab = tab === "library" ? "library" : "reading";
|
||||||
|
markTabButtons(currentTab);
|
||||||
|
|
||||||
|
const reading = document.getElementById("stats-reading-section");
|
||||||
|
const library = document.getElementById("stats-library-section");
|
||||||
|
reading?.classList.toggle("hidden", currentTab !== "reading");
|
||||||
|
library?.classList.toggle("hidden", currentTab !== "library");
|
||||||
|
|
||||||
|
// Reading tab state addressable via the URL either way; the library
|
||||||
|
// selection rides its own param (set in selectLibrary).
|
||||||
|
syncUrl();
|
||||||
|
if (currentTab === "library" && !libraryOptionsLoaded) {
|
||||||
|
void loadLibraries();
|
||||||
|
}
|
||||||
|
if (currentTab === "library" && !libraryDataLoaded) {
|
||||||
|
void loadLibraryUsage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectLibrary(libraryID: string): void {
|
||||||
|
currentLibraryID = libraryID || "";
|
||||||
|
libraryDataLoaded = false;
|
||||||
|
syncUrl();
|
||||||
|
void loadLibraryUsage();
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncUrl(): void {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (currentTab !== "reading") params.set("tab", currentTab);
|
||||||
|
if (currentLibraryID) params.set("library_id", currentLibraryID);
|
||||||
|
const qs = params.toString();
|
||||||
|
window.history.replaceState(null, "", window.location.pathname + (qs ? `?${qs}` : ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary fetch (Reading tab) -----------------------------------------------
|
||||||
|
|
||||||
async function loadSummary(): Promise<void> {
|
async function loadSummary(): Promise<void> {
|
||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem("token");
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
@@ -150,6 +253,47 @@ function themeVar(name: string, fallback: string): string {
|
|||||||
return value || fallback;
|
return value || fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fmtHoursLabel(seconds: number): string {
|
||||||
|
return `${(seconds / 3600).toFixed(1)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mode-aware wording for the Pages card (the only top-line widget that
|
||||||
|
* carries a unit audio cannot share): in a mixed pool it counts TEXT
|
||||||
|
* pages explicitly; in an audio-only pool the slot carries the TIME
|
||||||
|
* equivalent instead of a lying zero. Display-time only — the source
|
||||||
|
* data is untouched.
|
||||||
|
*/
|
||||||
|
function renderPagesCard(summary: Summary): void {
|
||||||
|
const hasText = summary.total_pages > 0;
|
||||||
|
const hasAudio = summary.total_audio_minutes > 0;
|
||||||
|
const cardLabel = document.getElementById("stats-pages-card-label");
|
||||||
|
const chartLabel = document.getElementById("stats-pages-chart-label");
|
||||||
|
const pagesPanel = document.getElementById("stats-pages-chart-panel");
|
||||||
|
const listeningPanel = document.getElementById("stats-listening-chart-panel");
|
||||||
|
|
||||||
|
let label: string;
|
||||||
|
let value: string;
|
||||||
|
if (hasText) {
|
||||||
|
label = hasAudio ? "Pages · text" : "Pages";
|
||||||
|
value = `${summary.total_pages}`;
|
||||||
|
} else if (hasAudio) {
|
||||||
|
label = "Listening Time";
|
||||||
|
value = fmtMinutes(summary.total_audio_minutes);
|
||||||
|
} else {
|
||||||
|
label = "Pages";
|
||||||
|
value = "0";
|
||||||
|
}
|
||||||
|
if (cardLabel) cardLabel.textContent = label;
|
||||||
|
text("stats-total-pages", value);
|
||||||
|
if (chartLabel) chartLabel.textContent = hasAudio ? "Text Pages per Day" : "Pages per Day";
|
||||||
|
|
||||||
|
// Charts row: Listening per Day appears only when audio exists in the
|
||||||
|
// range; the text-pages chart drops out of an audio-only range.
|
||||||
|
pagesPanel?.classList.toggle("hidden", !hasText && hasAudio);
|
||||||
|
listeningPanel?.classList.toggle("hidden", !hasAudio);
|
||||||
|
}
|
||||||
|
|
||||||
function renderSummary(summary: Summary): void {
|
function renderSummary(summary: Summary): void {
|
||||||
const hasData = summary.total_minutes > 0 || summary.session_count > 0;
|
const hasData = summary.total_minutes > 0 || summary.session_count > 0;
|
||||||
document.getElementById("stats-empty")?.classList.toggle("hidden", hasData);
|
document.getElementById("stats-empty")?.classList.toggle("hidden", hasData);
|
||||||
@@ -158,7 +302,7 @@ function renderSummary(summary: Summary): void {
|
|||||||
|
|
||||||
const mins = summary.total_minutes;
|
const mins = summary.total_minutes;
|
||||||
text("stats-total-minutes", mins >= 60 ? `${Math.floor(mins / 60)} h ${mins % 60} min` : `${mins} min`);
|
text("stats-total-minutes", mins >= 60 ? `${Math.floor(mins / 60)} h ${mins % 60} min` : `${mins} min`);
|
||||||
text("stats-total-pages", `${summary.total_pages}`);
|
renderPagesCard(summary);
|
||||||
text(
|
text(
|
||||||
"stats-streaks",
|
"stats-streaks",
|
||||||
summary.streak.current > 0 || summary.streak.longest > 0
|
summary.streak.current > 0 || summary.streak.longest > 0
|
||||||
@@ -203,8 +347,10 @@ async function renderDailyCharts(daily: DailyEntry[]): Promise<void> {
|
|||||||
|
|
||||||
minutesChart?.destroy();
|
minutesChart?.destroy();
|
||||||
pagesChart?.destroy();
|
pagesChart?.destroy();
|
||||||
|
listeningChart?.destroy();
|
||||||
minutesChart = null;
|
minutesChart = null;
|
||||||
pagesChart = null;
|
pagesChart = null;
|
||||||
|
listeningChart = null;
|
||||||
|
|
||||||
const minutesCanvas = document.getElementById("stats-daily-minutes-chart") as HTMLCanvasElement | null;
|
const minutesCanvas = document.getElementById("stats-daily-minutes-chart") as HTMLCanvasElement | null;
|
||||||
if (minutesCanvas) {
|
if (minutesCanvas) {
|
||||||
@@ -238,6 +384,27 @@ async function renderDailyCharts(daily: DailyEntry[]): Promise<void> {
|
|||||||
options: baseOptions,
|
options: baseOptions,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Listening per Day (hrs) — the audio half of the side-by-side pages
|
||||||
|
// split; its panel is hidden by renderPagesCard unless the range has
|
||||||
|
// audio minutes.
|
||||||
|
const listeningCanvas = document.getElementById("stats-daily-listening-chart") as HTMLCanvasElement | null;
|
||||||
|
if (listeningCanvas) {
|
||||||
|
listeningChart = new ChartCtor(listeningCanvas, {
|
||||||
|
type: "bar",
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: daily.map((d) => (d.audio_minutes || 0) / 60),
|
||||||
|
backgroundColor: accent,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: baseOptions,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLibraries(rows: LibraryRow[]): void {
|
function renderLibraries(rows: LibraryRow[]): void {
|
||||||
@@ -246,7 +413,7 @@ function renderLibraries(rows: LibraryRow[]): void {
|
|||||||
renderBars(container, rows.map((r) => ({
|
renderBars(container, rows.map((r) => ({
|
||||||
label: r.name,
|
label: r.name,
|
||||||
sub: `${fmtMinutes(r.minutes)} · ${r.sessions} sessions`,
|
sub: `${fmtMinutes(r.minutes)} · ${r.sessions} sessions`,
|
||||||
minutes: r.minutes,
|
value: r.minutes,
|
||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,7 +423,7 @@ function renderDevices(rows: DeviceRow[]): void {
|
|||||||
renderBars(container, rows.map((r) => ({
|
renderBars(container, rows.map((r) => ({
|
||||||
label: r.name || "Web",
|
label: r.name || "Web",
|
||||||
sub: `${r.books} ${r.books === 1 ? "book" : "books"} · ${r.sessions} sessions`,
|
sub: `${r.books} ${r.books === 1 ? "book" : "books"} · ${r.sessions} sessions`,
|
||||||
minutes: r.minutes,
|
value: r.minutes,
|
||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,17 +434,19 @@ function fmtMinutes(minutes: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderBars(
|
function renderBars(
|
||||||
container: HTMLElement,
|
container: HTMLElement | null,
|
||||||
rows: Array<{ label: string; sub: string; minutes: number }>,
|
rows: Array<{ label: string; sub: string; value: number }>,
|
||||||
|
emptyText = "Nothing recorded in this range yet.",
|
||||||
): void {
|
): void {
|
||||||
|
if (!container) return;
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
container.innerHTML = `<p class="text-sm text-center py-6" style="color: var(--text-secondary)">Nothing recorded in this range yet.</p>`;
|
container.innerHTML = `<p class="text-sm text-center py-6" style="color: var(--text-secondary)">${emptyText}</p>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const max = Math.max(...rows.map((r) => r.minutes), 1);
|
const max = Math.max(...rows.map((r) => r.value), 1);
|
||||||
container.innerHTML = rows
|
container.innerHTML = rows
|
||||||
.map((row) => {
|
.map((row) => {
|
||||||
const pct = Math.round((row.minutes / max) * 100);
|
const pct = Math.round((row.value / max) * 100);
|
||||||
return `
|
return `
|
||||||
<div>
|
<div>
|
||||||
<div class="flex justify-between text-sm mb-1">
|
<div class="flex justify-between text-sm mb-1">
|
||||||
@@ -316,6 +485,176 @@ function renderTopBooks(books: TopBook[]): void {
|
|||||||
.join("");
|
.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Library tab ---------------------------------------------------------------
|
||||||
|
|
||||||
|
async function loadLibraries(): Promise<VisibleLibrary[]> {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
if (!token) return [];
|
||||||
|
const res = await fetch("/api/libraries/visibility", {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
showToast("Failed to load libraries", "error");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const rows = (await res.json()) as VisibleLibrary[];
|
||||||
|
libraryOptionsLoaded = true;
|
||||||
|
fillLibrarySelector(rows);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillLibrarySelector(rows: VisibleLibrary[]): void {
|
||||||
|
const select = document.getElementById("stats-library-select") as HTMLSelectElement | null;
|
||||||
|
if (!select) return;
|
||||||
|
select.innerHTML = `<option value="">All libraries</option>` +
|
||||||
|
rows
|
||||||
|
.map((r) => `<option value="${escapeHtml(r.id)}">${escapeHtml(r.name)}</option>`)
|
||||||
|
.join("");
|
||||||
|
// Honor a URL-seeded selection initStatsPage may have set.
|
||||||
|
if (currentLibraryID) select.value = currentLibraryID;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLibraryUsage(): Promise<void> {
|
||||||
|
if (libraryDataLoaded) return;
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
if (!token) return;
|
||||||
|
libraryDataLoaded = true;
|
||||||
|
|
||||||
|
const qs = currentLibraryID ? `?library_id=${encodeURIComponent(currentLibraryID)}` : "";
|
||||||
|
const targets = [
|
||||||
|
"lib-stat-items",
|
||||||
|
"lib-stat-time",
|
||||||
|
"lib-stat-authors",
|
||||||
|
"lib-stat-size",
|
||||||
|
"lib-stat-narrators",
|
||||||
|
];
|
||||||
|
targets.forEach((id) => text(id, "–"));
|
||||||
|
for (const id of ["stats-lib-genres", "stats-lib-authors", "stats-lib-longest", "stats-lib-largest"]) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.innerHTML = `<div class="text-center py-6 text-sm" style="color: var(--text-secondary)">Loading…</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/stats/library-usage${qs}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (res.status === 403) {
|
||||||
|
showToast("Library not visible to you", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error(String(res.status));
|
||||||
|
renderLibraryUsage((await res.json()) as LibraryUsage);
|
||||||
|
} catch (error) {
|
||||||
|
libraryDataLoaded = false; // allow a retry on next activation
|
||||||
|
console.error("Failed to load library stats:", error);
|
||||||
|
showToast("Failed to load library stats", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtBytes(bytes: number): string {
|
||||||
|
if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(2)} GB`;
|
||||||
|
if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(1)} MB`;
|
||||||
|
if (bytes >= 1e3) return `${(bytes / 1e3).toFixed(0)} KB`;
|
||||||
|
return `${bytes} B`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLibraryUsage(payload: LibraryUsage): void {
|
||||||
|
const scopeLabel = document.getElementById("stats-library-scope-label");
|
||||||
|
if (scopeLabel) {
|
||||||
|
scopeLabel.textContent = payload.library_id
|
||||||
|
? "Inventory statistics for this library — what's in it, not when it was read."
|
||||||
|
: "Inventory statistics across every visible library.";
|
||||||
|
}
|
||||||
|
|
||||||
|
text("lib-stat-items", `${payload.items}`);
|
||||||
|
text("lib-stat-authors", `${payload.authors}`);
|
||||||
|
text("lib-stat-narrators", `${payload.narrators}`);
|
||||||
|
|
||||||
|
// The Hours/Pages card adapts to the scope: audiobooks carry real
|
||||||
|
// durations; text libraries rank in pages instead.
|
||||||
|
const timeLabel = document.getElementById("lib-stat-time-label");
|
||||||
|
if (payload.hours_mode) {
|
||||||
|
if (timeLabel) timeLabel.textContent = "Hours";
|
||||||
|
text("lib-stat-time", fmtHoursLabel(payload.total_seconds));
|
||||||
|
} else {
|
||||||
|
if (timeLabel) timeLabel.textContent = "Pages";
|
||||||
|
text("lib-stat-time", `${payload.total_pages}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const gb = payload.total_size_bytes / 1e9;
|
||||||
|
text("lib-stat-size", gb >= 0.1 ? gb.toFixed(1) : (payload.total_size_bytes / 1e6).toFixed(1) + " MB");
|
||||||
|
|
||||||
|
const itemTotal = Math.max(payload.items, 1);
|
||||||
|
renderBars(
|
||||||
|
document.getElementById("stats-lib-genres"),
|
||||||
|
payload.genres.map((g) => ({
|
||||||
|
label: g.name,
|
||||||
|
sub: `${g.count} ${g.count === 1 ? "item" : "items"} · ${Math.round((g.count / itemTotal) * 100)}%`,
|
||||||
|
value: g.count,
|
||||||
|
})),
|
||||||
|
"No genres recorded in this pool yet.",
|
||||||
|
);
|
||||||
|
renderBars(
|
||||||
|
document.getElementById("stats-lib-authors"),
|
||||||
|
payload.top_authors.map((a) => ({
|
||||||
|
label: a.name,
|
||||||
|
sub: `${a.count} ${a.count === 1 ? "book" : "books"} · ${Math.round((a.count / itemTotal) * 100)}%`,
|
||||||
|
value: a.count,
|
||||||
|
})),
|
||||||
|
"No authors recorded in this pool yet.",
|
||||||
|
);
|
||||||
|
renderUsageItems("stats-lib-longest", "stats-lib-longest-label", payload.longest_items, payload.hours_mode);
|
||||||
|
renderUsageItems("stats-lib-largest", null, payload.largest_items, payload.hours_mode, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUsageItems(
|
||||||
|
containerID: string,
|
||||||
|
labelID: string | null,
|
||||||
|
items: UsageItem[],
|
||||||
|
hoursMode: boolean,
|
||||||
|
largest = false,
|
||||||
|
): void {
|
||||||
|
const container = document.getElementById(containerID);
|
||||||
|
if (!container) return;
|
||||||
|
if (labelID && !largest) {
|
||||||
|
const label = document.getElementById(labelID);
|
||||||
|
if (label) label.textContent = hoursMode ? "Longest Items (hrs)" : "Longest Items (pages)";
|
||||||
|
}
|
||||||
|
if (items.length === 0) {
|
||||||
|
container.innerHTML = `<p class="text-sm text-center py-6" style="color: var(--text-secondary)">Nothing to rank in this pool yet.</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const values = items.map((i) => (largest ? i.size : hoursMode ? i.seconds : i.pages));
|
||||||
|
const max = Math.max(...values, 1);
|
||||||
|
container.innerHTML = items
|
||||||
|
.map((item, i) => {
|
||||||
|
const value = largest
|
||||||
|
? fmtBytes(item.size)
|
||||||
|
: hoursMode
|
||||||
|
? (item.seconds / 3600).toFixed(1)
|
||||||
|
: `${item.pages}`;
|
||||||
|
const barPct = Math.round((values[i] / max) * 100);
|
||||||
|
return `
|
||||||
|
<a href="/media/${item.media_item_id}" class="block p-2 rounded-lg hover:bg-surface-hover">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="w-10 h-14 rounded overflow-hidden shrink-0" style="background-color: var(--bg-primary)">
|
||||||
|
<img src="${item.cover_image_path || "/static/placeholder-book.svg"}" alt="" class="w-full h-full object-cover" loading="lazy" onerror="this.style.display='none'"/>
|
||||||
|
</span>
|
||||||
|
<span class="flex-1 min-w-0">
|
||||||
|
<span class="block text-sm font-medium truncate" style="color: var(--text-primary)">${escapeHtml(item.title)}</span>
|
||||||
|
<span class="block text-xs truncate" style="color: var(--text-secondary)">${escapeHtml(item.author || "")}</span>
|
||||||
|
</span>
|
||||||
|
<span class="text-xs shrink-0" style="color: var(--text-secondary)">${i + 1}.</span>
|
||||||
|
<span class="text-sm font-bold shrink-0" style="color: var(--text-primary)">${escapeHtml(value)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-2 rounded-full overflow-hidden mt-2" style="background-color: var(--bg-primary)">
|
||||||
|
<div class="h-full rounded-full" style="width: ${barPct}%; background-color: var(--accent)"></div>
|
||||||
|
</div>
|
||||||
|
</a>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(value: string): string {
|
function escapeHtml(value: string): string {
|
||||||
return value
|
return value
|
||||||
.replaceAll("&", "&")
|
.replaceAll("&", "&")
|
||||||
@@ -325,16 +664,26 @@ function escapeHtml(value: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function initStatsPage(): void {
|
function initStatsPage(): void {
|
||||||
const initial = new URLSearchParams(window.location.search).get("range") || "7";
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const initial = params.get("range") || "7";
|
||||||
currentRange = ["7", "30", "90", "year"].includes(initial) ? initial : "7";
|
currentRange = ["7", "30", "90", "year"].includes(initial) ? initial : "7";
|
||||||
markRangeButtons(currentRange);
|
markRangeButtons(currentRange);
|
||||||
|
|
||||||
|
const libParam = params.get("library_id") || "";
|
||||||
|
if (/^[0-9a-f-]{36}$/i.test(libParam)) currentLibraryID = libParam;
|
||||||
|
|
||||||
|
markTabButtons("reading");
|
||||||
void loadSummary();
|
void loadSummary();
|
||||||
|
|
||||||
|
selectTab(params.get("tab") || "reading");
|
||||||
}
|
}
|
||||||
|
|
||||||
export { initStatsPage, loadSummary, renderSummary, selectRange };
|
export { initStatsPage, loadSummary, renderSummary, selectRange, selectTab, selectLibrary };
|
||||||
|
|
||||||
Alpine.data("stats", () => ({
|
Alpine.data("stats", () => ({
|
||||||
initStatsPage,
|
initStatsPage,
|
||||||
loadSummary,
|
loadSummary,
|
||||||
selectRange,
|
selectRange,
|
||||||
|
selectTab,
|
||||||
|
selectLibrary,
|
||||||
}));
|
}));
|
||||||
Reference in new issue
Block a user