Merge branch 'codex/notifications-request-burst'

This commit is contained in:
Quick
2026-06-17 13:49:18 -04:00
12 changed files with 760 additions and 278 deletions
@@ -35,7 +35,7 @@
Run:
```bash
cd /Users/jimcole/projects/silo/silo-server
# Commands assume the repository root is the cwd.
git fetch origin
git worktree add ../silo-server-literary-works origin/work/ebook-reader-ruler-profiles
cd ../silo-server-literary-works
+56 -20
View File
@@ -18,8 +18,10 @@ type audiobookGroupResponse struct {
}
type audiobookGroupsResponse struct {
Total int `json:"total"`
Groups []audiobookGroupResponse `json:"groups"`
Total int `json:"total"`
TotalExact bool `json:"total_exact"`
HasMore bool `json:"has_more"`
Groups []audiobookGroupResponse `json:"groups"`
}
// HandleGetAudiobookGroups — GET /api/v1/catalog/audiobook-groups
@@ -28,7 +30,7 @@ type audiobookGroupsResponse struct {
// aggregate stats (book count, total duration, per-profile progress counts)
// and up to four poster URLs for cover stacks. Query parameters:
// library_id=<id> (required), group_by=author|narrator|series (required),
// sort=name|count|duration (default name), limit, offset.
// sort=name|count|duration (default name), limit, offset, include_total, q.
//
// Group names round-trip into the corresponding catalog filter fields
// (author / narrator / series), which match case-insensitively.
@@ -58,10 +60,6 @@ func (h *CatalogHandler) HandleGetAudiobookGroups(w http.ResponseWriter, r *http
return
}
// maxAudiobookGroupsLimit bounds a single page. Paging is now an in-memory
// slice of the cached full list, so this is the response-size cap (the old
// 500/page cap in ListAudiobookGroups no longer sits on this path).
const maxAudiobookGroupsLimit = 5000
limit := 200
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
n, parseErr := strconv.Atoi(raw)
@@ -69,21 +67,30 @@ func (h *CatalogHandler) HandleGetAudiobookGroups(w http.ResponseWriter, r *http
writeError(w, http.StatusBadRequest, "bad_request", "limit must be a positive integer")
return
}
if n > maxAudiobookGroupsLimit {
n = maxAudiobookGroupsLimit
}
limit = n
}
offset := max(catalog.ParseIntParam(r.URL.Query().Get("offset")), 0)
includeTotal := true
if raw := strings.TrimSpace(r.URL.Query().Get("include_total")); raw != "" {
parsed, parseErr := strconv.ParseBool(raw)
if parseErr != nil {
writeError(w, http.StatusBadRequest, "bad_request", "include_total must be true or false")
return
}
includeTotal = parsed
}
groups, total, err := h.audiobookGroups().Page(
result, err := catalog.ListAudiobookGroups(
r.Context(),
h.itemsH.browseRepo.Pool(),
catalog.AudiobookGroupsQuery{
LibraryID: libraryID,
GroupBy: groupBy,
Sort: sort,
Limit: limit,
Offset: offset,
LibraryID: libraryID,
GroupBy: groupBy,
SearchPrefix: strings.TrimSpace(r.URL.Query().Get("q")),
IncludeTotal: includeTotal,
Sort: sort,
Limit: limit,
Offset: offset,
},
h.itemsH.accessFilter(r),
)
@@ -92,12 +99,18 @@ func (h *CatalogHandler) HandleGetAudiobookGroups(w http.ResponseWriter, r *http
return
}
resp := audiobookGroupsResponse{Total: total, Groups: make([]audiobookGroupResponse, 0, len(groups))}
for _, g := range groups {
resolvedPosters := h.resolveAudiobookGroupPosterURLs(r, result.Groups)
resp := audiobookGroupsResponse{
Total: result.Total,
TotalExact: result.TotalExact,
HasMore: result.HasMore,
Groups: make([]audiobookGroupResponse, 0, len(result.Groups)),
}
for _, g := range result.Groups {
posterURLs := make([]string, 0, len(g.PosterPaths))
for _, path := range g.PosterPaths {
if url := h.itemsH.presignURL(r, cardThumbnailPath(path), "card"); url != "" {
posterURLs = append(posterURLs, url)
if resolved := resolvedPosters[cardThumbnailPath(path)]; resolved.URL != "" {
posterURLs = append(posterURLs, resolved.URL)
}
}
resp.Groups = append(resp.Groups, audiobookGroupResponse{
@@ -112,3 +125,26 @@ func (h *CatalogHandler) HandleGetAudiobookGroups(w http.ResponseWriter, r *http
writeJSON(w, http.StatusOK, resp)
}
func (h *CatalogHandler) resolveAudiobookGroupPosterURLs(r *http.Request, groups []catalog.AudiobookGroup) map[string]catalog.ResolvedImageURL {
if h == nil || h.itemsH == nil || h.itemsH.detailSvc == nil || len(groups) == 0 {
return map[string]catalog.ResolvedImageURL{}
}
paths := make([]string, 0, len(groups)*4)
seen := make(map[string]struct{}, len(groups)*4)
for _, group := range groups {
for _, path := range group.PosterPaths {
normalized := cardThumbnailPath(path)
if normalized == "" {
continue
}
if _, ok := seen[normalized]; ok {
continue
}
seen[normalized] = struct{}{}
paths = append(paths, normalized)
}
}
return h.itemsH.detailSvc.PresignURLsWithExpiry(r.Context(), paths, "card")
}
+194 -58
View File
@@ -35,14 +35,25 @@ func ParseAudiobookGroupBy(raw string) (AudiobookGroupBy, bool) {
// AudiobookGroupsQuery controls the grouped audiobook browse lookup.
type AudiobookGroupsQuery struct {
LibraryID int
GroupBy AudiobookGroupBy
LibraryID int
GroupBy AudiobookGroupBy
SearchPrefix string
IncludeTotal bool
// Sort is one of "name" (default), "count", "duration".
Sort string
Limit int
Offset int
}
// AudiobookGroupsResult is the paged grouped browse response before API image
// URL resolution.
type AudiobookGroupsResult struct {
Groups []AudiobookGroup
Total int
HasMore bool
TotalExact bool
}
// AudiobookGroup is one grouped browse row: an author, narrator, or series
// with aggregate stats over the audiobooks visible to the viewer.
type AudiobookGroup struct {
@@ -56,12 +67,20 @@ type AudiobookGroup struct {
PosterPaths []string
}
// audiobookGroupsPageCap bounds a single externally-requested page.
type audiobookGroupsSQLPlan struct {
SQL string
Args []any
Limit int
FetchLimit int
Offset int
IncludeTotal bool
}
// audiobookGroupsPageCap bounds externally-requested pages.
const audiobookGroupsPageCap = 500
// audiobookGroupsFullCap bounds the full-list fetch used to warm the groups
// cache. It is far above any real author/narrator/series count so the cached
// list is effectively complete, while still capping a pathological library.
// audiobookGroupsFullCap preserves the full-list path used by the legacy
// short-lived cache; the endpoint now uses true paged queries instead.
const audiobookGroupsFullCap = 50000
// ListAudiobookGroups returns grouped browse rows for an audiobook library.
@@ -70,36 +89,109 @@ const audiobookGroupsFullCap = 50000
// filters, so a group name can be fed straight back into an author/narrator/
// series filter rule. Progress counts are scoped to the requesting profile via
// filter.UserID / filter.ProfileID.
func ListAudiobookGroups(ctx context.Context, pool *pgxpool.Pool, q AudiobookGroupsQuery, filter AccessFilter) ([]AudiobookGroup, int, error) {
return listAudiobookGroups(ctx, pool, q, filter, audiobookGroupsPageCap)
func ListAudiobookGroups(ctx context.Context, pool *pgxpool.Pool, q AudiobookGroupsQuery, filter AccessFilter) (AudiobookGroupsResult, error) {
return listAudiobookGroupsWithLimit(ctx, pool, q, filter, audiobookGroupsPageCap)
}
// listAllAudiobookGroups fetches the complete grouped list (offset 0, full cap)
// in a single query so the result can be cached and sliced per page without
// re-aggregating on every offset.
// listAllAudiobookGroups fetches a complete grouped list for the older cache
// helper. New callers should prefer ListAudiobookGroups so each page can avoid
// recomputing exact totals and poster stacks for groups outside the page.
func listAllAudiobookGroups(ctx context.Context, pool *pgxpool.Pool, q AudiobookGroupsQuery, filter AccessFilter) ([]AudiobookGroup, int, error) {
full := q
full.Limit = audiobookGroupsFullCap
full.Offset = 0
return listAudiobookGroups(ctx, pool, full, filter, audiobookGroupsFullCap)
full.IncludeTotal = true
result, err := listAudiobookGroupsWithLimit(ctx, pool, full, filter, audiobookGroupsFullCap)
if err != nil {
return nil, 0, err
}
return result.Groups, result.Total, nil
}
func listAudiobookGroups(ctx context.Context, pool *pgxpool.Pool, q AudiobookGroupsQuery, filter AccessFilter, maxLimit int) ([]AudiobookGroup, int, error) {
func listAudiobookGroupsWithLimit(ctx context.Context, pool *pgxpool.Pool, q AudiobookGroupsQuery, filter AccessFilter, maxLimit int) (AudiobookGroupsResult, error) {
if pool == nil {
return nil, 0, fmt.Errorf("audiobook groups: no database pool")
return AudiobookGroupsResult{}, fmt.Errorf("audiobook groups: no database pool")
}
plan, err := buildAudiobookGroupsSQLWithLimit(q, filter, maxLimit)
if err != nil {
return AudiobookGroupsResult{}, err
}
if plan.SQL == "" {
return AudiobookGroupsResult{Groups: []AudiobookGroup{}, TotalExact: plan.IncludeTotal}, nil
}
rows, err := pool.Query(ctx, plan.SQL, plan.Args...)
if err != nil {
return AudiobookGroupsResult{}, fmt.Errorf("querying audiobook groups: %w", err)
}
defer rows.Close()
total := 0
groups := make([]AudiobookGroup, 0, plan.Limit)
for rows.Next() {
var g AudiobookGroup
var posterPaths []string
if plan.IncludeTotal {
if err := rows.Scan(&g.Name, &g.ItemCount, &g.TotalDurationSeconds, &g.InProgressCount, &g.FinishedCount, &posterPaths, &total); err != nil {
return AudiobookGroupsResult{}, fmt.Errorf("scanning audiobook group: %w", err)
}
} else if err := rows.Scan(&g.Name, &g.ItemCount, &g.TotalDurationSeconds, &g.InProgressCount, &g.FinishedCount, &posterPaths); err != nil {
return AudiobookGroupsResult{}, fmt.Errorf("scanning audiobook group: %w", err)
}
if posterPaths == nil {
posterPaths = []string{}
}
g.PosterPaths = posterPaths
groups = append(groups, g)
}
if err := rows.Err(); err != nil {
return AudiobookGroupsResult{}, fmt.Errorf("iterating audiobook groups: %w", err)
}
hasMore := false
if plan.IncludeTotal {
hasMore = plan.Offset+len(groups) < total
} else if len(groups) > plan.Limit {
hasMore = true
groups = groups[:plan.Limit]
total = plan.Offset + len(groups) + 1
} else {
total = plan.Offset + len(groups)
}
return AudiobookGroupsResult{
Groups: groups,
Total: total,
HasMore: hasMore,
TotalExact: plan.IncludeTotal,
}, nil
}
func buildAudiobookGroupsSQL(q AudiobookGroupsQuery, filter AccessFilter) (audiobookGroupsSQLPlan, error) {
return buildAudiobookGroupsSQLWithLimit(q, filter, audiobookGroupsPageCap)
}
func buildAudiobookGroupsSQLWithLimit(q AudiobookGroupsQuery, filter AccessFilter, maxLimit int) (audiobookGroupsSQLPlan, error) {
if q.LibraryID <= 0 {
return nil, 0, fmt.Errorf("audiobook groups: library id is required")
return audiobookGroupsSQLPlan{}, fmt.Errorf("audiobook groups: library id is required")
}
limit := q.Limit
if limit <= 0 {
limit = 200
}
if maxLimit <= 0 {
maxLimit = audiobookGroupsPageCap
}
if limit > maxLimit {
limit = maxLimit
}
offset := max(q.Offset, 0)
fetchLimit := limit
if !q.IncludeTotal {
fetchLimit++
}
args := []any{q.LibraryID}
argIdx := 2
@@ -109,7 +201,12 @@ func listAudiobookGroups(ctx context.Context, pool *pgxpool.Pool, q AudiobookGro
"mil.media_folder_id = $1",
}
if !appendAudiobookItemAccessConditions("mi", filter, &conditions, &args, &argIdx) {
return []AudiobookGroup{}, 0, nil
return audiobookGroupsSQLPlan{
Limit: limit,
FetchLimit: fetchLimit,
Offset: offset,
IncludeTotal: q.IncludeTotal,
}, nil
}
var joinClause, nameExpr, groupExpr, posterOrder string
@@ -132,7 +229,18 @@ func listAudiobookGroups(ctx context.Context, pool *pgxpool.Pool, q AudiobookGro
groupExpr = "LOWER(BTRIM(s.series_name))"
posterOrder = "s.series_index NULLS LAST, b.sort_title"
default:
return nil, 0, fmt.Errorf("audiobook groups: unsupported group_by %q", q.GroupBy)
return audiobookGroupsSQLPlan{}, fmt.Errorf("audiobook groups: unsupported group_by %q", q.GroupBy)
}
groupFilters := make([]string, 0, 1)
if search := audiobookGroupSearchPattern(q.SearchPrefix); search != "" {
groupFilters = append(groupFilters, fmt.Sprintf(`%s LIKE $%d ESCAPE '\'`, groupExpr, argIdx))
args = append(args, search)
argIdx++
}
groupWhereClause := ""
if len(groupFilters) > 0 {
groupWhereClause = "WHERE " + strings.Join(groupFilters, " AND ")
}
userArg := fmt.Sprintf("$%d", argIdx)
@@ -151,7 +259,14 @@ func listAudiobookGroups(ctx context.Context, pool *pgxpool.Pool, q AudiobookGro
case "duration":
orderClause = "total_duration_seconds DESC, LOWER(name)"
default:
return nil, 0, fmt.Errorf("audiobook groups: unsupported sort %q", q.Sort)
return audiobookGroupsSQLPlan{}, fmt.Errorf("audiobook groups: unsupported sort %q", q.Sort)
}
totalSelect := ""
totalColumn := ""
if q.IncludeTotal {
totalSelect = ", COUNT(*) OVER ()::int AS total_groups"
totalColumn = ", pg.total_groups"
}
query := fmt.Sprintf(`
@@ -159,65 +274,86 @@ func listAudiobookGroups(ctx context.Context, pool *pgxpool.Pool, q AudiobookGro
SELECT
mi.content_id,
mi.poster_path,
mi.sort_title,
COALESCE((
SELECT SUM(mf.duration)
FROM media_files mf
WHERE mf.content_id = mi.content_id AND mf.missing_since IS NULL
), 0) AS duration_seconds
COALESCE(NULLIF(mi.sort_title, ''), mi.title) AS sort_title,
COALESCE(afs.duration_seconds, 0)::bigint AS duration_seconds
FROM media_items mi
JOIN media_item_libraries mil ON mil.content_id = mi.content_id
LEFT JOIN audiobook_item_file_stats afs
ON afs.media_folder_id = mil.media_folder_id
AND afs.content_id = mi.content_id
WHERE %s
),
grouped AS (
SELECT
%s AS group_key,
%s AS name,
COUNT(*)::int AS item_count,
COALESCE(SUM(b.duration_seconds), 0)::bigint AS total_duration_seconds,
COUNT(*) FILTER (WHERE uwp.media_item_id IS NOT NULL AND NOT uwp.completed)::int AS in_progress_count,
COUNT(*) FILTER (WHERE uwp.completed)::int AS finished_count,
(ARRAY_AGG(b.poster_path ORDER BY %s) FILTER (WHERE NULLIF(b.poster_path, '') IS NOT NULL))[1:4] AS poster_paths
COUNT(*) FILTER (WHERE uwp.completed)::int AS finished_count
FROM books b
%s
LEFT JOIN user_watch_progress uwp
ON uwp.media_item_id = b.content_id
AND uwp.user_id = %s
AND uwp.profile_id = %s
%s
GROUP BY %s
),
paged_groups AS (
SELECT
group_key,
name,
item_count,
total_duration_seconds,
in_progress_count,
finished_count%s
FROM grouped
ORDER BY %s
LIMIT $%d OFFSET $%d
)
SELECT name, item_count, total_duration_seconds, in_progress_count, finished_count, poster_paths,
COUNT(*) OVER ()::int AS total_groups
FROM grouped
ORDER BY %s
LIMIT $%d OFFSET $%d`,
SELECT
pg.name,
pg.item_count,
pg.total_duration_seconds,
pg.in_progress_count,
pg.finished_count,
COALESCE(posters.poster_paths, '{}'::text[]) AS poster_paths%s
FROM paged_groups pg
LEFT JOIN LATERAL (
SELECT ARRAY(
SELECT b.poster_path
FROM books b
%s
WHERE %s = pg.group_key
AND NULLIF(b.poster_path, '') IS NOT NULL
ORDER BY %s
LIMIT 4
) AS poster_paths
) posters ON TRUE
ORDER BY %s`,
strings.Join(conditions, " AND "),
nameExpr, posterOrder, joinClause, userArg, profileArg, groupExpr, orderClause,
argIdx, argIdx+1,
groupExpr, nameExpr, joinClause, userArg, profileArg, groupWhereClause, groupExpr,
totalSelect, orderClause, argIdx, argIdx+1,
totalColumn, joinClause, groupExpr, posterOrder, orderClause,
)
args = append(args, limit, offset)
args = append(args, fetchLimit, offset)
rows, err := pool.Query(ctx, query, args...)
if err != nil {
return nil, 0, fmt.Errorf("querying audiobook groups: %w", err)
}
defer rows.Close()
total := 0
groups := make([]AudiobookGroup, 0, limit)
for rows.Next() {
var g AudiobookGroup
var posterPaths []string
if err := rows.Scan(&g.Name, &g.ItemCount, &g.TotalDurationSeconds, &g.InProgressCount, &g.FinishedCount, &posterPaths, &total); err != nil {
return nil, 0, fmt.Errorf("scanning audiobook group: %w", err)
}
if posterPaths == nil {
posterPaths = []string{}
}
g.PosterPaths = posterPaths
groups = append(groups, g)
}
if err := rows.Err(); err != nil {
return nil, 0, fmt.Errorf("iterating audiobook groups: %w", err)
}
return groups, total, nil
return audiobookGroupsSQLPlan{
SQL: query,
Args: args,
Limit: limit,
FetchLimit: fetchLimit,
Offset: offset,
IncludeTotal: q.IncludeTotal,
}, nil
}
func audiobookGroupSearchPattern(raw string) string {
raw = strings.ToLower(strings.TrimSpace(raw))
if raw == "" {
return ""
}
replacer := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
return replacer.Replace(raw) + "%"
}
+2 -1
View File
@@ -105,8 +105,9 @@ func audiobookGroupsCacheKey(q AudiobookGroupsQuery, filter AccessFilter) string
if sortKey == "" {
sortKey = "name"
}
searchKey := strings.ToLower(strings.TrimSpace(q.SearchPrefix))
var b strings.Builder
fmt.Fprintf(&b, "%d|%s|%s|u=%d|p=%s|cr=%s", q.LibraryID, q.GroupBy, sortKey, filter.UserID, filter.ProfileID, filter.MaxContentRating)
fmt.Fprintf(&b, "%d|%s|%s|q=%s|u=%d|p=%s|cr=%s", q.LibraryID, q.GroupBy, sortKey, searchKey, filter.UserID, filter.ProfileID, filter.MaxContentRating)
b.WriteString("|allow=")
b.WriteString(joinSortedInts(filter.AllowedLibraryIDs))
b.WriteString("|deny=")
+96
View File
@@ -0,0 +1,96 @@
package catalog
import (
"strings"
"testing"
)
func TestBuildAudiobookGroupsSQL_UsesItemFileStatsInsteadOfMediaFilesSubquery(t *testing.T) {
plan, err := buildAudiobookGroupsSQL(AudiobookGroupsQuery{
LibraryID: 10,
GroupBy: AudiobookGroupByAuthor,
Sort: "count",
Limit: 60,
Offset: 0,
IncludeTotal: true,
}, AccessFilter{})
if err != nil {
t.Fatalf("buildAudiobookGroupsSQL error: %v", err)
}
if !strings.Contains(plan.SQL, "audiobook_item_file_stats") {
t.Fatalf("audiobook groups query must use maintained item file stats; got:\n%s", plan.SQL)
}
if strings.Contains(plan.SQL, "FROM media_files mf") || strings.Contains(plan.SQL, "SELECT SUM(mf.duration)") {
t.Fatalf("audiobook groups query must not use the old correlated media_files duration lookup; got:\n%s", plan.SQL)
}
}
func TestBuildAudiobookGroupsSQL_SkipTotalOmitsWindowCountAndFetchesLimitPlusOne(t *testing.T) {
plan, err := buildAudiobookGroupsSQL(AudiobookGroupsQuery{
LibraryID: 10,
GroupBy: AudiobookGroupByNarrator,
Sort: "name",
Limit: 60,
Offset: 120,
IncludeTotal: false,
}, AccessFilter{})
if err != nil {
t.Fatalf("buildAudiobookGroupsSQL error: %v", err)
}
if strings.Contains(plan.SQL, "COUNT(*) OVER ()") {
t.Fatalf("include_total=false must omit window counts; got:\n%s", plan.SQL)
}
if strings.Contains(plan.SQL, "total_groups") {
t.Fatalf("include_total=false must omit exact total selection; got:\n%s", plan.SQL)
}
if plan.FetchLimit != 61 {
t.Fatalf("FetchLimit = %d, want limit+1 for has_more detection", plan.FetchLimit)
}
if plan.Limit != 60 {
t.Fatalf("Limit = %d, want requested page size", plan.Limit)
}
}
func TestBuildAudiobookGroupsSQL_SearchAppliesNormalizedPrefixFilter(t *testing.T) {
for _, tc := range []struct {
name string
groupBy AudiobookGroupBy
want string
}{
{name: "author", groupBy: AudiobookGroupByAuthor, want: "LOWER(BTRIM(p.name)) LIKE $"},
{name: "narrator", groupBy: AudiobookGroupByNarrator, want: "LOWER(BTRIM(p.name)) LIKE $"},
{name: "series", groupBy: AudiobookGroupBySeries, want: "LOWER(BTRIM(s.series_name)) LIKE $"},
} {
t.Run(tc.name, func(t *testing.T) {
plan, err := buildAudiobookGroupsSQL(AudiobookGroupsQuery{
LibraryID: 10,
GroupBy: tc.groupBy,
Sort: "name",
Limit: 60,
SearchPrefix: "Neil",
IncludeTotal: true,
}, AccessFilter{})
if err != nil {
t.Fatalf("buildAudiobookGroupsSQL error: %v", err)
}
if !strings.Contains(plan.SQL, tc.want) {
t.Fatalf("expected normalized prefix predicate %q; got:\n%s", tc.want, plan.SQL)
}
if !containsArg(plan.Args, "neil%") {
t.Fatalf("search args = %#v, want neil%%", plan.Args)
}
})
}
}
func containsArg(args []any, want any) bool {
for _, arg := range args {
if arg == want {
return true
}
}
return false
}
@@ -0,0 +1,191 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE IF NOT EXISTS public.audiobook_item_file_stats (
media_folder_id integer NOT NULL,
content_id text NOT NULL,
duration_seconds bigint NOT NULL DEFAULT 0,
active_file_count integer NOT NULL DEFAULT 0,
updated_at timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT audiobook_item_file_stats_pkey PRIMARY KEY (media_folder_id, content_id),
CONSTRAINT audiobook_item_file_stats_media_folder_id_fkey FOREIGN KEY (media_folder_id) REFERENCES public.media_folders(id) ON DELETE CASCADE,
CONSTRAINT audiobook_item_file_stats_content_id_fkey FOREIGN KEY (content_id) REFERENCES public.media_items(content_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_audiobook_item_file_stats_content
ON public.audiobook_item_file_stats USING btree (content_id, media_folder_id);
CREATE OR REPLACE FUNCTION public.refresh_audiobook_item_file_stats(p_media_folder_id integer, p_content_id text)
RETURNS void
LANGUAGE plpgsql
AS $$
DECLARE
v_item_type text;
v_duration_seconds bigint;
v_active_file_count integer;
BEGIN
IF p_media_folder_id IS NULL OR NULLIF(BTRIM(p_content_id), '') IS NULL THEN
RETURN;
END IF;
SELECT mi.type
INTO v_item_type
FROM public.media_items mi
WHERE mi.content_id = p_content_id;
IF v_item_type IS DISTINCT FROM 'audiobook' THEN
DELETE FROM public.audiobook_item_file_stats
WHERE media_folder_id = p_media_folder_id
AND content_id = p_content_id;
RETURN;
END IF;
SELECT
COALESCE(SUM(COALESCE(mf.duration, 0)), 0)::bigint,
COUNT(*)::integer
INTO v_duration_seconds, v_active_file_count
FROM public.media_files mf
WHERE mf.media_folder_id = p_media_folder_id
AND mf.content_id = p_content_id
AND mf.missing_since IS NULL;
IF COALESCE(v_active_file_count, 0) > 0 THEN
INSERT INTO public.audiobook_item_file_stats (
media_folder_id,
content_id,
duration_seconds,
active_file_count,
updated_at
) VALUES (
p_media_folder_id,
p_content_id,
COALESCE(v_duration_seconds, 0),
v_active_file_count,
NOW()
)
ON CONFLICT (media_folder_id, content_id) DO UPDATE SET
duration_seconds = EXCLUDED.duration_seconds,
active_file_count = EXCLUDED.active_file_count,
updated_at = NOW();
ELSE
DELETE FROM public.audiobook_item_file_stats
WHERE media_folder_id = p_media_folder_id
AND content_id = p_content_id;
END IF;
END;
$$;
CREATE OR REPLACE FUNCTION public.audiobook_item_file_stats_media_files_trigger()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
PERFORM public.refresh_audiobook_item_file_stats(OLD.media_folder_id, OLD.content_id);
RETURN OLD;
END IF;
IF TG_OP = 'UPDATE'
AND (OLD.media_folder_id IS DISTINCT FROM NEW.media_folder_id OR OLD.content_id IS DISTINCT FROM NEW.content_id) THEN
PERFORM public.refresh_audiobook_item_file_stats(OLD.media_folder_id, OLD.content_id);
END IF;
PERFORM public.refresh_audiobook_item_file_stats(NEW.media_folder_id, NEW.content_id);
RETURN NEW;
END;
$$;
CREATE OR REPLACE FUNCTION public.refresh_audiobook_item_file_stats_for_item(p_content_id text)
RETURNS void
LANGUAGE sql
AS $$
SELECT public.refresh_audiobook_item_file_stats(active_files.media_folder_id, active_files.content_id)
FROM (
SELECT DISTINCT mf.media_folder_id, mf.content_id
FROM public.media_files mf
WHERE mf.content_id = p_content_id
) active_files;
$$;
CREATE OR REPLACE FUNCTION public.audiobook_item_file_stats_media_items_trigger()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
DELETE FROM public.audiobook_item_file_stats
WHERE content_id = OLD.content_id;
RETURN OLD;
END IF;
IF TG_OP = 'UPDATE'
AND (OLD.content_id IS DISTINCT FROM NEW.content_id OR OLD.type IS DISTINCT FROM NEW.type) THEN
PERFORM public.refresh_audiobook_item_file_stats_for_item(OLD.content_id);
END IF;
PERFORM public.refresh_audiobook_item_file_stats_for_item(NEW.content_id);
RETURN NEW;
END;
$$;
INSERT INTO public.audiobook_item_file_stats (
media_folder_id,
content_id,
duration_seconds,
active_file_count,
updated_at
)
SELECT
mf.media_folder_id,
mf.content_id,
COALESCE(SUM(COALESCE(mf.duration, 0)), 0)::bigint AS duration_seconds,
COUNT(*)::integer AS active_file_count,
NOW() AS updated_at
FROM public.media_files mf
JOIN public.media_items mi ON mi.content_id = mf.content_id
WHERE mi.type = 'audiobook'
AND mf.content_id IS NOT NULL
AND mf.missing_since IS NULL
GROUP BY mf.media_folder_id, mf.content_id
ON CONFLICT (media_folder_id, content_id) DO UPDATE SET
duration_seconds = EXCLUDED.duration_seconds,
active_file_count = EXCLUDED.active_file_count,
updated_at = NOW();
DROP TRIGGER IF EXISTS trg_audiobook_item_file_stats_media_files_insert_delete ON public.media_files;
DROP TRIGGER IF EXISTS trg_audiobook_item_file_stats_media_files_update ON public.media_files;
DROP TRIGGER IF EXISTS trg_audiobook_item_file_stats_media_items ON public.media_items;
CREATE TRIGGER trg_audiobook_item_file_stats_media_files_insert_delete
AFTER INSERT OR DELETE ON public.media_files
FOR EACH ROW EXECUTE FUNCTION public.audiobook_item_file_stats_media_files_trigger();
CREATE TRIGGER trg_audiobook_item_file_stats_media_files_update
AFTER UPDATE ON public.media_files
FOR EACH ROW
WHEN (
OLD.content_id IS DISTINCT FROM NEW.content_id OR
OLD.media_folder_id IS DISTINCT FROM NEW.media_folder_id OR
OLD.duration IS DISTINCT FROM NEW.duration OR
OLD.missing_since IS DISTINCT FROM NEW.missing_since
)
EXECUTE FUNCTION public.audiobook_item_file_stats_media_files_trigger();
CREATE TRIGGER trg_audiobook_item_file_stats_media_items
AFTER INSERT OR UPDATE OF content_id, type OR DELETE ON public.media_items
FOR EACH ROW EXECUTE FUNCTION public.audiobook_item_file_stats_media_items_trigger();
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TRIGGER IF EXISTS trg_audiobook_item_file_stats_media_items ON public.media_items;
DROP TRIGGER IF EXISTS trg_audiobook_item_file_stats_media_files_update ON public.media_files;
DROP TRIGGER IF EXISTS trg_audiobook_item_file_stats_media_files_insert_delete ON public.media_files;
DROP FUNCTION IF EXISTS public.audiobook_item_file_stats_media_items_trigger();
DROP FUNCTION IF EXISTS public.refresh_audiobook_item_file_stats_for_item(text);
DROP FUNCTION IF EXISTS public.audiobook_item_file_stats_media_files_trigger();
DROP FUNCTION IF EXISTS public.refresh_audiobook_item_file_stats(integer, text);
DROP INDEX IF EXISTS public.idx_audiobook_item_file_stats_content;
DROP TABLE IF EXISTS public.audiobook_item_file_stats;
-- +goose StatementEnd
+2
View File
@@ -881,6 +881,8 @@ export interface AudiobookGroup {
export interface AudiobookGroupsResponse {
total: number;
total_exact: boolean;
has_more: boolean;
groups: AudiobookGroup[];
}
+93 -77
View File
@@ -40,7 +40,7 @@ import {
} from "@/hooks/queries/mediaSurfaceRefresh";
import { bumpHomeRefreshSignal } from "@/pages/homeSurfaceRefresh";
import type { ReactNode } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useLocation } from "react-router";
interface JobWaiter {
@@ -479,13 +479,13 @@ export function RealtimeEventsProvider({ children }: { children: ReactNode }) {
waiter.reject(new Error(job.error_message || job.message || "Job failed"));
};
function currentChannels() {
const currentChannels = useCallback(() => {
return Array.from(channelRefsRef.current.entries())
.filter(([, count]) => count > 0)
.map(([channel]) => channel);
}
}, []);
function sendSubscribe() {
const sendSubscribe = useCallback(() => {
const socket = socketRef.current;
if (!socket || socket.readyState !== WebSocket.OPEN || !helloReceivedRef.current) {
return;
@@ -498,7 +498,44 @@ export function RealtimeEventsProvider({ children }: { children: ReactNode }) {
channels: currentChannels(),
}),
);
}
}, [currentChannels]);
const subscribeChannel = useCallback(
(channel: EventChannel, handlers?: EventChannelHandlers) => {
channelRefsRef.current.set(channel, (channelRefsRef.current.get(channel) ?? 0) + 1);
let handlerID: number | null = null;
if (handlers) {
handlerID = nextHandlerIDRef.current++;
const channelHandlers =
channelHandlersRef.current.get(channel) ?? new Map<number, EventChannelHandlers>();
channelHandlers.set(handlerID, handlers);
channelHandlersRef.current.set(channel, channelHandlers);
}
sendSubscribe();
return () => {
const current = channelRefsRef.current.get(channel) ?? 0;
if (current <= 1) {
channelRefsRef.current.delete(channel);
} else {
channelRefsRef.current.set(channel, current - 1);
}
if (handlerID != null) {
const channelHandlers = channelHandlersRef.current.get(channel);
channelHandlers?.delete(handlerID);
if (channelHandlers && channelHandlers.size === 0) {
channelHandlersRef.current.delete(channel);
}
}
sendSubscribe();
};
},
[sendSubscribe],
);
function dispatchChannelMessage(
channel: EventChannel,
@@ -874,83 +911,62 @@ export function RealtimeEventsProvider({ children }: { children: ReactNode }) {
// profile?.id is a dependency on purpose: the websocket binds to the
// active profile via the handshake ticket, so a profile switch must
// reconnect (and resubscribe) under the new identity.
}, [authenticatedUserID, profile?.id, pageActivity.canApplyRealtimeUpdates, queryClient]);
}, [
authenticatedUserID,
profile?.id,
pageActivity.canApplyRealtimeUpdates,
queryClient,
sendSubscribe,
]);
const awaitAdminJob = useCallback(
(jobId: string) => {
const cachedJob = findCachedAdminJob(queryClient, jobId);
if (cachedJob) {
if (cachedJob.status === "completed") {
return Promise.resolve(cachedJob);
}
if (cachedJob.status === "failed") {
return Promise.reject(
new Error(cachedJob.error_message || cachedJob.message || "Job failed"),
);
}
if (cachedJob.status === "cancelled") {
return Promise.reject(new Error(cachedJob.message || "Job cancelled"));
}
}
// Must match the gate on AdminRealtimeEventChannels: when the jobs
// channel isn't subscribed (not acting as admin), waiting on a live
// event would hang until the fallback timeout — poll instead.
if (!actingAdmin || connectionState !== "live") {
return pollAdminJobUntilTerminal(jobId);
}
return new Promise<AdminJob>((resolve, reject) => {
const existing = waitersRef.current.get(jobId);
if (existing) {
window.clearTimeout(existing.timeoutId);
}
const timeoutId = window.setTimeout(() => {
waitersRef.current.delete(jobId);
void pollAdminJobUntilTerminal(jobId).then(resolve).catch(reject);
}, 60_000);
waitersRef.current.set(jobId, { timeoutId, resolve, reject });
});
},
[actingAdmin, connectionState, queryClient],
);
const value = useMemo<RealtimeEventsContextValue>(
() => ({
connectionState,
awaitAdminJob: (jobId: string) => {
const cachedJob = findCachedAdminJob(queryClient, jobId);
if (cachedJob) {
if (cachedJob.status === "completed") {
return Promise.resolve(cachedJob);
}
if (cachedJob.status === "failed") {
return Promise.reject(
new Error(cachedJob.error_message || cachedJob.message || "Job failed"),
);
}
if (cachedJob.status === "cancelled") {
return Promise.reject(new Error(cachedJob.message || "Job cancelled"));
}
}
// Must match the gate on AdminRealtimeEventChannels: when the jobs
// channel isn't subscribed (not acting as admin), waiting on a live
// event would hang until the fallback timeout — poll instead.
if (!actingAdmin || connectionState !== "live") {
return pollAdminJobUntilTerminal(jobId);
}
return new Promise<AdminJob>((resolve, reject) => {
const existing = waitersRef.current.get(jobId);
if (existing) {
window.clearTimeout(existing.timeoutId);
}
const timeoutId = window.setTimeout(() => {
waitersRef.current.delete(jobId);
void pollAdminJobUntilTerminal(jobId).then(resolve).catch(reject);
}, 60_000);
waitersRef.current.set(jobId, { timeoutId, resolve, reject });
});
},
subscribeChannel: (channel: EventChannel, handlers?: EventChannelHandlers) => {
channelRefsRef.current.set(channel, (channelRefsRef.current.get(channel) ?? 0) + 1);
let handlerID: number | null = null;
if (handlers) {
handlerID = nextHandlerIDRef.current++;
const channelHandlers =
channelHandlersRef.current.get(channel) ?? new Map<number, EventChannelHandlers>();
channelHandlers.set(handlerID, handlers);
channelHandlersRef.current.set(channel, channelHandlers);
}
sendSubscribe();
return () => {
const current = channelRefsRef.current.get(channel) ?? 0;
if (current <= 1) {
channelRefsRef.current.delete(channel);
} else {
channelRefsRef.current.set(channel, current - 1);
}
if (handlerID != null) {
const channelHandlers = channelHandlersRef.current.get(channel);
channelHandlers?.delete(handlerID);
if (channelHandlers && channelHandlers.size === 0) {
channelHandlersRef.current.delete(channel);
}
}
sendSubscribe();
};
},
awaitAdminJob,
subscribeChannel,
}),
[connectionState, queryClient, actingAdmin],
[awaitAdminJob, connectionState, subscribeChannel],
);
return <RealtimeEventsContext.Provider value={value}>{children}</RealtimeEventsContext.Provider>;
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { ChevronRight } from "lucide-react";
import type { AudiobookGroup } from "@/api/types";
import { Input } from "@/components/ui/input";
@@ -15,11 +15,10 @@ import {
type AudiobookGroupBy,
type AudiobookGroupSort,
} from "@/hooks/queries/audiobookGroups";
import { useDebounce } from "@/hooks/useDebounce";
import { useIntersectionObserver } from "@/hooks/useIntersectionObserver";
import { formatHoursMinutes } from "@/lib/audiobooks/duration";
// Number of groups rendered per reveal step (initial paint + each scroll grow).
const GROUPS_REVEAL_BATCH = 120;
interface AudiobookGroupsViewProps {
libraryId: number;
groupBy: AudiobookGroupBy;
@@ -129,45 +128,36 @@ export default function AudiobookGroupsView({
// series read more naturally alphabetized.
const [sort, setSort] = useState<AudiobookGroupSort>(groupBy === "series" ? "name" : "count");
const [filter, setFilter] = useState("");
const { data, isLoading } = useAudiobookGroups(libraryId, groupBy, sort);
const debouncedFilter = useDebounce(filter.trim(), 250);
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useAudiobookGroups(
libraryId,
groupBy,
sort,
debouncedFilter,
);
const groups = useMemo(() => {
const all = data?.groups ?? [];
const needle = filter.trim().toLowerCase();
if (!needle) return all;
return all.filter((group) => group.name.toLowerCase().includes(needle));
}, [data?.groups, filter]);
// Incremental reveal: a large audiobook library can have tens of thousands of
// authors/narrators. Rendering them all at once mounts tens of thousands of
// DOM nodes (+ cover images) and freezes the main thread on load. Render a
// capped window and grow it as the user scrolls toward the end, so initial
// paint is bounded regardless of library size.
const [visibleCount, setVisibleCount] = useState(GROUPS_REVEAL_BATCH);
useEffect(() => {
setVisibleCount(GROUPS_REVEAL_BATCH);
}, [filter, sort, groupBy, libraryId]);
const visibleGroups = useMemo(() => groups.slice(0, visibleCount), [groups, visibleCount]);
const hasMore = visibleCount < groups.length;
const sentinelRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!hasMore) return;
const el = sentinelRef.current;
if (!el) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
setVisibleCount((count) => count + GROUPS_REVEAL_BATCH);
}
},
{ rootMargin: "800px" },
);
observer.observe(el);
return () => observer.disconnect();
}, [hasMore, groups.length]);
const groups = useMemo(() => data?.pages.flatMap((page) => page.groups) ?? [], [data?.pages]);
const firstPage = data?.pages[0];
const total = firstPage?.total ?? 0;
const totalExact = firstPage?.total_exact ?? false;
const isInitialLoading = isLoading && groups.length === 0;
const loadNextPage = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) {
void fetchNextPage();
}
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
const sentinelRef = useIntersectionObserver({
onIntersect: loadNextPage,
enabled: Boolean(hasNextPage) && !isFetchingNextPage,
});
const noun = GROUP_NOUN[groupBy];
const isSeries = groupBy === "series";
const countLabel = totalExact
? groups.length === total
? String(total)
: `${groups.length} of ${total}`
: `${groups.length}${hasNextPage ? "+" : ""}`;
return (
<div className="space-y-5">
@@ -175,7 +165,7 @@ export default function AudiobookGroupsView({
<Input
value={filter}
onChange={(event) => setFilter(event.target.value)}
placeholder={`Filter ${noun}…`}
placeholder={`Search ${noun}…`}
className="w-full max-w-xs"
/>
<Select value={sort} onValueChange={(value) => setSort(value as AudiobookGroupSort)}>
@@ -188,14 +178,14 @@ export default function AudiobookGroupsView({
<SelectItem value="duration">Longest</SelectItem>
</SelectContent>
</Select>
{data && (
{firstPage && (
<span className="text-muted-foreground ml-auto text-sm">
{groups.length === data.total ? data.total : `${groups.length} of ${data.total}`} {noun}
{countLabel} {noun}
</span>
)}
</div>
{isLoading ? (
{isInitialLoading ? (
isSeries ? (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{Array.from({ length: 12 }).map((_, i) => (
@@ -217,48 +207,57 @@ export default function AudiobookGroupsView({
{filter ? `No ${noun} match “${filter}”.` : `No ${noun} found in this library.`}
</p>
) : isSeries ? (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{visibleGroups.map((group) => (
<button
key={group.name}
type="button"
onClick={() => onSelectGroup(group.name)}
className="group/series text-left"
>
<div className="transition-transform duration-[--duration-fast] group-hover/series:scale-[1.02]">
<CoverStack group={group} large />
</div>
<div className="mt-2.5 truncate px-0.5 text-[14px] font-semibold tracking-tight">
{group.name}
</div>
<div className="text-muted-foreground truncate px-0.5 text-xs">
{groupStats(group)}
</div>
</button>
))}
</div>
) : (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{visibleGroups.map((group) => (
<button
key={group.name}
type="button"
onClick={() => onSelectGroup(group.name)}
className="surface-panel hover:bg-muted/40 flex items-center gap-4 rounded-xl border-0 px-4 py-3 text-left transition-colors duration-[--duration-fast]"
>
<CoverStack group={group} />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold">{group.name}</div>
<div className="text-muted-foreground mt-0.5 truncate text-xs">
<>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{groups.map((group) => (
<button
key={group.name}
type="button"
onClick={() => onSelectGroup(group.name)}
className="group/series text-left"
>
<div className="transition-transform duration-[--duration-fast] group-hover/series:scale-[1.02]">
<CoverStack group={group} large />
</div>
<div className="mt-2.5 truncate px-0.5 text-[14px] font-semibold tracking-tight">
{group.name}
</div>
<div className="text-muted-foreground truncate px-0.5 text-xs">
{groupStats(group)}
</div>
</div>
<ChevronRight className="text-muted-foreground/50 h-4 w-4 shrink-0" />
</button>
))}
</div>
</button>
))}
</div>
<div ref={sentinelRef} className="h-8">
{isFetchingNextPage ? <Skeleton className="mx-auto h-3 w-28 rounded" /> : null}
</div>
</>
) : (
<>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{groups.map((group) => (
<button
key={group.name}
type="button"
onClick={() => onSelectGroup(group.name)}
className="surface-panel hover:bg-muted/40 flex items-center gap-4 rounded-xl border-0 px-4 py-3 text-left transition-colors duration-[--duration-fast]"
>
<CoverStack group={group} />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold">{group.name}</div>
<div className="text-muted-foreground mt-0.5 truncate text-xs">
{groupStats(group)}
</div>
</div>
<ChevronRight className="text-muted-foreground/50 h-4 w-4 shrink-0" />
</button>
))}
</div>
<div ref={sentinelRef} className="h-8">
{isFetchingNextPage ? <Skeleton className="mx-auto h-3 w-28 rounded" /> : null}
</div>
</>
)}
{!isLoading && hasMore && <div ref={sentinelRef} aria-hidden className="h-1" />}
</div>
);
}
+33 -34
View File
@@ -1,4 +1,4 @@
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useInfiniteQuery } from "@tanstack/react-query";
import { api } from "@/api/client";
import type { AudiobookGroupsResponse } from "@/api/types";
@@ -7,55 +7,54 @@ import { catalogKeys } from "./keys";
export type AudiobookGroupBy = "author" | "narrator" | "series";
export type AudiobookGroupSort = "name" | "count" | "duration";
// Page until the reported total is reached so client-side filtering sees the
// complete list. The server caches the full grouped list per request, so a
// larger page is a cheap in-memory slice — a bigger page size means fewer
// sequential round-trips for large libraries (the dominant cold-load cost).
// GROUPS_MAX_PAGES bounds the worst case (pathological libraries).
const GROUPS_PAGE_SIZE = 2000;
const GROUPS_MAX_PAGES = 20;
const GROUPS_PAGE_SIZE = 60;
export async function fetchAudiobookGroups(
export async function fetchAudiobookGroupsPage(
libraryId: number,
groupBy: AudiobookGroupBy,
sort: AudiobookGroupSort,
offset: number,
includeTotal: boolean,
searchPrefix: string,
options?: RequestInit,
): Promise<AudiobookGroupsResponse> {
const groups: AudiobookGroupsResponse["groups"] = [];
let total = 0;
for (let page = 0; page < GROUPS_MAX_PAGES; page++) {
const params = new URLSearchParams({
library_id: String(libraryId),
group_by: groupBy,
sort,
limit: String(GROUPS_PAGE_SIZE),
offset: String(page * GROUPS_PAGE_SIZE),
});
const response = await api<AudiobookGroupsResponse>(
`/catalog/audiobook-groups?${params.toString()}`,
options,
);
groups.push(...response.groups);
total = response.total;
if (response.groups.length === 0 || groups.length >= total) {
break;
}
const params = new URLSearchParams({
library_id: String(libraryId),
group_by: groupBy,
sort,
limit: String(GROUPS_PAGE_SIZE),
offset: String(offset),
include_total: String(includeTotal),
});
const trimmedSearch = searchPrefix.trim();
if (trimmedSearch) {
params.set("q", trimmedSearch);
}
return { total, groups };
return api<AudiobookGroupsResponse>(`/catalog/audiobook-groups?${params.toString()}`, options);
}
export function useAudiobookGroups(
libraryId: number,
groupBy: AudiobookGroupBy,
sort: AudiobookGroupSort,
searchPrefix = "",
) {
return useQuery({
queryKey: catalogKeys.audiobookGroups(libraryId, groupBy, sort),
queryFn: () => fetchAudiobookGroups(libraryId, groupBy, sort),
const search = searchPrefix.trim();
return useInfiniteQuery({
queryKey: catalogKeys.audiobookGroups(libraryId, groupBy, sort, search),
queryFn: ({ pageParam, signal }: { pageParam: number; signal: AbortSignal }) =>
fetchAudiobookGroupsPage(libraryId, groupBy, sort, pageParam, pageParam === 0, search, {
signal,
}),
initialPageParam: 0,
getNextPageParam: (lastPage, allPages) => {
if (!lastPage.has_more) {
return undefined;
}
return allPages.reduce((offset, page) => offset + page.groups.length, 0);
},
enabled: libraryId > 0,
staleTime: 60_000,
placeholderData: keepPreviousData,
});
}
+2 -2
View File
@@ -63,8 +63,8 @@ export const catalogKeys = {
itemDetail: (id: string, libraryId?: number) =>
["catalog", "items", id, "detail", libraryId ?? "default"] as const,
itemVersions: (id: string) => ["catalog", "items", id, "versions"] as const,
audiobookGroups: (libraryId: number, groupBy: string, sort: string) =>
["catalog", "audiobookGroups", libraryId, groupBy, sort] as const,
audiobookGroups: (libraryId: number, groupBy: string, sort: string, search: string) =>
["catalog", "audiobookGroups", libraryId, groupBy, sort, search] as const,
itemEpisodes: (id: string, libraryId?: number) =>
["catalog", "items", id, "episodes", libraryId ?? "default"] as const,
seriesSeasons: (seriesId: string, libraryId?: number) =>
+9 -3
View File
@@ -299,13 +299,19 @@ export function applyNotificationRead(
/** Hydrates the unread badge from the websocket snapshot (recent unread rows). */
export function applyNotificationsSnapshot(queryClient: QueryClient, rows: AppNotification[]) {
// The snapshot is capped (25 rows); use it as a lower bound and refresh the
// real count lazily.
// exact count only when the cap means the lower bound may be incomplete.
queryClient.setQueryData<number>(notificationKeys.unreadCount(), (count) =>
Math.max(count ?? 0, rows.length),
);
void queryClient.invalidateQueries({ queryKey: notificationKeys.unreadCount() });
if (rows.length >= NOTIFICATIONS_PAGE_SIZE) {
void queryClient.invalidateQueries({ queryKey: notificationKeys.unreadCount() });
}
void queryClient.invalidateQueries({
queryKey: notificationKeys.all,
queryKey: notificationKeys.list("all"),
refetchType: "active",
});
void queryClient.invalidateQueries({
queryKey: notificationKeys.list("unread"),
refetchType: "active",
});
}