feat(stats): Library tab — per-library inventory stats + audio-aware Reading tab

The /stats page gains a second tab (Reading | Library) implementing the
Audiobookshelf-style library dashboard (HANDOFF item 9): media_items
INVENTORY aggregates — items, hours/pages, distinct authors, size,
distinct narrators, Top Genres / Top Authors bars, Longest / Largest
items — for one library or the All-libraries aggregate.

- GET /api/stats/library-usage?library_id=<uuid|omit>; family lens
  enforced: a library_id outside the requester's visible set is a 403,
  never a silent pass (AGENTS.md standing rule; 431fec0 history)
- six sqlc queries with the visibility pattern + healthy-item filter
  (archived_at IS NULL AND missing_scan_count = 0); hours_mode lets the
  client swap the Hours/Pages card and the Longest ranking per scope
- Reading tab unit audit (HANDOFF item 3, settled 2026-10-16): minutes
  are the merged currency everywhere honest; only the PAGES unit splits
  — Pages card goes mode-aware (Pages · text / Listening Time in an
  audio-only pool) and the charts row gains Listening per Day (hrs)
  when audio minutes exist in the range; summary daily buckets gain
  audio_minutes split by the item's library type
- tab state in the URL (?tab=library&library_id=…); selector fed by the
  existing /api/libraries/visibility endpoint; main.js?v=4
- integration tests: family-lens 403 pin, hidden-library exclusion
  (differential), seeded text+audio aggregate correctness, auth gate
- docs/developer/api/stats/get_library_usage.md

Known-remaining suite failures are environmental: TestConflictList_Empty
+ TestWebSocketPingPong collide with the concurrently-running app session
(live emulator pushes + scans on the shared dev DB), and
TestAdminCreateUser's timezone expectation drifted from live system
settings — all three fail on the clean tree too.
This commit is contained in:
John O'Keefe committed 2026-10-16 10:20:17 -04:00
1 parent a222ef5c27
commit ce366b1604
13 files changed
+1493 -78

No files matched your search

+23
View File
@@ -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
+294 -1
View File
@@ -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
}
+110 -1
View File
@@ -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;