feat(stats): Library tab — per-library inventory stats + audio-aware Reading tab
The /stats page gains a second tab (Reading | Library) implementing the
Audiobookshelf-style library dashboard (HANDOFF item 9): media_items
INVENTORY aggregates — items, hours/pages, distinct authors, size,
distinct narrators, Top Genres / Top Authors bars, Longest / Largest
items — for one library or the All-libraries aggregate.
- GET /api/stats/library-usage?library_id=<uuid|omit>; family lens
enforced: a library_id outside the requester's visible set is a 403,
never a silent pass (AGENTS.md standing rule; 431fec0 history)
- six sqlc queries with the visibility pattern + healthy-item filter
(archived_at IS NULL AND missing_scan_count = 0); hours_mode lets the
client swap the Hours/Pages card and the Longest ranking per scope
- Reading tab unit audit (HANDOFF item 3, settled 2026-10-16): minutes
are the merged currency everywhere honest; only the PAGES unit splits
— Pages card goes mode-aware (Pages · text / Listening Time in an
audio-only pool) and the charts row gains Listening per Day (hrs)
when audio minutes exist in the range; summary daily buckets gain
audio_minutes split by the item's library type
- tab state in the URL (?tab=library&library_id=…); selector fed by the
existing /api/libraries/visibility endpoint; main.js?v=4
- integration tests: family-lens 403 pin, hidden-library exclusion
(differential), seeded text+audio aggregate correctness, auth gate
- docs/developer/api/stats/get_library_usage.md
Known-remaining suite failures are environmental: TestConflictList_Empty
+ TestWebSocketPingPong collide with the concurrently-running app session
(live emulator pushes + scans on the shared dev DB), and
TestAdminCreateUser's timezone expectation drifted from live system
settings — all three fail on the clean tree too.
This commit is contained in:
1 parent
a222ef5c27
commit
ce366b1604
13 files changed
+1493
-78
No files matched your search
@@ -80,6 +80,9 @@ type Querier interface {
|
||||
CreateMediaHighlight(ctx context.Context, arg CreateMediaHighlightParams) (MediaHighlights, error)
|
||||
CreateMediaHighlightFull(ctx context.Context, arg CreateMediaHighlightFullParams) (MediaHighlights, error)
|
||||
// Media Items queries
|
||||
// narrators/narrators_search are NOT NULL DEFAULT '{}' (A2): callers MUST
|
||||
// pass empty slices, never Go nil (an explicit NULL INSERT bypasses the
|
||||
// column default and 23502s). The API create handler normalizes nil.
|
||||
CreateMediaItem(ctx context.Context, arg CreateMediaItemParams) (MediaItems, error)
|
||||
// MEDIA ITEM FORMATS QUERIES
|
||||
// Create media item format
|
||||
@@ -88,6 +91,8 @@ type Querier interface {
|
||||
// (library_id, file_path) this inserts NOTHING and returns no row
|
||||
// (pgx.ErrNoRows) — the handler then answers 200 with the existing row
|
||||
// and a duplicate flag instead of pretending it created something.
|
||||
// narrators NOT NULL: callers pass empty slices, never nil (see
|
||||
// CreateMediaItem note).
|
||||
CreateMediaItemIfNew(ctx context.Context, arg CreateMediaItemIfNewParams) (MediaItems, error)
|
||||
// Media Notes queries
|
||||
CreateMediaNote(ctx context.Context, arg CreateMediaNoteParams) (MediaNotes, error)
|
||||
@@ -250,6 +255,24 @@ type Querier interface {
|
||||
GetLibraryTypeByName(ctx context.Context, name string) (LibraryTypes, error)
|
||||
// Library Types queries
|
||||
GetLibraryTypes(ctx context.Context) ([]LibraryTypes, error)
|
||||
GetLibraryUsageAuthors(ctx context.Context, arg GetLibraryUsageAuthorsParams) ([]GetLibraryUsageAuthorsRow, error)
|
||||
// ============================================================================
|
||||
// LIBRARY USAGE STATS (the /stats Library tab; the StatisticsService
|
||||
// LibraryUsage aggregate). media_items INVENTORY aggregates — what is in
|
||||
// the library, not what was read. Every query carries BOTH filters:
|
||||
// $1 = single-library scope (NULL = all visible libraries — "All")
|
||||
// $2 = the requester's visible-library id set (NULL = no filter; the
|
||||
// default-ALLOW family lens). A non-visible $1 is REJECTED at the
|
||||
// service/handler layer with 403 — the SQL filter is defense only.
|
||||
// + the healthy-item filter, matched to GetVisibleLibraryMediaCounts:
|
||||
// archived_at IS NULL AND missing_scan_count = 0 (dead rows lie).
|
||||
// ============================================================================
|
||||
GetLibraryUsageCards(ctx context.Context, arg GetLibraryUsageCardsParams) (GetLibraryUsageCardsRow, error)
|
||||
GetLibraryUsageGenres(ctx context.Context, arg GetLibraryUsageGenresParams) ([]GetLibraryUsageGenresRow, error)
|
||||
GetLibraryUsageLargestItems(ctx context.Context, arg GetLibraryUsageLargestItemsParams) ([]GetLibraryUsageLargestItemsRow, error)
|
||||
// The Longest Items panel, one query both modes: $3 = true ranks by
|
||||
// AUDIO duration (audio present in scope), false by page_count (text).
|
||||
GetLibraryUsageLongestItems(ctx context.Context, arg GetLibraryUsageLongestItemsParams) ([]GetLibraryUsageLongestItemsRow, error)
|
||||
GetLibraryVisibility(ctx context.Context, arg GetLibraryVisibilityParams) (LibraryVisibility, error)
|
||||
// ============================================================================
|
||||
// LIBRARY WITH TYPE INFO QUERIES
|
||||
|
||||
@@ -1037,6 +1037,9 @@ type CreateMediaItemParams struct {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
row := q.db.QueryRow(ctx, CreateMediaItem,
|
||||
arg.LibraryID,
|
||||
@@ -1273,6 +1276,8 @@ type CreateMediaItemIfNewParams struct {
|
||||
// (library_id, file_path) this inserts NOTHING and returns no row
|
||||
// (pgx.ErrNoRows) — the handler then answers 200 with the existing row
|
||||
// and a duplicate flag instead of pretending it created something.
|
||||
// narrators NOT NULL: callers pass empty slices, never nil (see
|
||||
// CreateMediaItem note).
|
||||
func (q *Queries) CreateMediaItemIfNew(ctx context.Context, arg CreateMediaItemIfNewParams) (MediaItems, error) {
|
||||
row := q.db.QueryRow(ctx, CreateMediaItemIfNew,
|
||||
arg.LibraryID,
|
||||
@@ -4996,6 +5001,291 @@ func (q *Queries) GetLibraryTypes(ctx context.Context) ([]LibraryTypes, error) {
|
||||
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
|
||||
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.reading_session_start, rh.reading_session_end,
|
||||
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
|
||||
JOIN media_items mi ON mi.id = rh.media_item_id
|
||||
WHERE rh.user_id = $1
|
||||
@@ -9021,6 +9312,7 @@ type GetUserReadingSessionsRangeRow struct {
|
||||
Author pgtype.Text `db:"author" json:"author"`
|
||||
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||
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.
|
||||
@@ -9060,6 +9352,7 @@ func (q *Queries) GetUserReadingSessionsRange(ctx context.Context, arg GetUserRe
|
||||
&i.Author,
|
||||
&i.LibraryID,
|
||||
&i.CoverImagePath,
|
||||
&i.LibraryTypeName,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -149,6 +149,9 @@ GROUP BY l.id;
|
||||
|
||||
-- Media Items queries
|
||||
-- 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)
|
||||
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()
|
||||
@@ -159,6 +162,8 @@ RETURNING *;
|
||||
-- (library_id, file_path) this inserts NOTHING and returns no row
|
||||
-- (pgx.ErrNoRows) — the handler then answers 200 with the existing row
|
||||
-- and a duplicate flag instead of pretending it created something.
|
||||
-- narrators NOT NULL: callers pass empty slices, never nil (see
|
||||
-- CreateMediaItem note).
|
||||
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)
|
||||
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.reading_session_start, rh.reading_session_end,
|
||||
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
|
||||
JOIN media_items mi ON mi.id = rh.media_item_id
|
||||
WHERE rh.user_id = sqlc.arg('user_id')
|
||||
@@ -3129,3 +3135,106 @@ WHERE l.id = $1;
|
||||
SELECT * FROM media_items
|
||||
WHERE library_id = $1 AND tags @> ARRAY[$2::text]
|
||||
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 (
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/services"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -76,6 +77,42 @@ func (h *StatsHandler) ThisWeek(c *echo.Context) error {
|
||||
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
|
||||
// book detail page (lifetime totals + recent sessions).
|
||||
func (h *StatsHandler) BookStats(c *echo.Context) error {
|
||||
|
||||
@@ -22,4 +22,7 @@ func registerAnalyticsRoutes(cfg *Config) {
|
||||
stats.GET("/summary", cfg.StatsHandler.Summary)
|
||||
stats.GET("/this-week", cfg.StatsHandler.ThisWeek)
|
||||
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"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -80,10 +82,11 @@ func ResolveStatsRange(tz string, rangeKey string) (StatsRange, error) {
|
||||
}
|
||||
|
||||
type DailyStats struct {
|
||||
Date string `json:"date"` // YYYY-MM-DD, in the user's timezone
|
||||
Minutes int `json:"minutes"`
|
||||
Pages int `json:"pages"`
|
||||
Sessions int `json:"sessions"`
|
||||
Date string `json:"date"` // YYYY-MM-DD, in the user's timezone
|
||||
Minutes int `json:"minutes"`
|
||||
AudioMinutes int `json:"audio_minutes"` // listening wall-clock inside Minutes (media-mode split for the /stats side-by-side charts)
|
||||
Pages int `json:"pages"`
|
||||
Sessions int `json:"sessions"`
|
||||
}
|
||||
|
||||
type StreakStats struct {
|
||||
@@ -132,6 +135,7 @@ type UserSummaryStats struct {
|
||||
StartDate string `json:"start_date"`
|
||||
EndDate string `json:"end_date"`
|
||||
TotalMinutes int `json:"total_minutes"`
|
||||
TotalAudioMinutes int `json:"total_audio_minutes"` // listening minutes inside TotalMinutes (mode-aware wording surface)
|
||||
TotalPages int `json:"total_pages"`
|
||||
SessionCount int `json:"session_count"`
|
||||
BooksActive int `json:"books_with_activity"`
|
||||
@@ -198,6 +202,11 @@ func (s *StatisticsService) userVisibleLibraryIDs(ctx context.Context, userID pg
|
||||
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.
|
||||
func visibleOrNil(ids []pgtype.UUID) []pgtype.UUID {
|
||||
if len(ids) == 0 {
|
||||
@@ -270,11 +279,23 @@ func (s *StatisticsService) UserSummary(ctx context.Context, userID pgtype.UUID,
|
||||
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 {
|
||||
seconds := int64(sess.TimeSpentSeconds.Int32)
|
||||
totalSessionSeconds += seconds
|
||||
summary.TotalMinutes += int(seconds / 60)
|
||||
entry.Minutes += int(seconds / 60)
|
||||
minutes := int(seconds / 60)
|
||||
summary.TotalMinutes += minutes
|
||||
entry.Minutes += minutes
|
||||
if isAudio {
|
||||
summary.TotalAudioMinutes += minutes
|
||||
entry.AudioMinutes += minutes
|
||||
}
|
||||
}
|
||||
if sess.PagesRead.Valid {
|
||||
summary.TotalPages += int(sess.PagesRead.Int32)
|
||||
@@ -526,6 +547,159 @@ func (s *StatisticsService) ThisWeek(ctx context.Context, userID pgtype.UUID, tz
|
||||
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
|
||||
// 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) {
|
||||
|
||||
Reference in new issue
Block a user