From 562ae635d783b97c0e23c93bf0a50789b1f3e5e2 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:48:57 -0400 Subject: [PATCH] feat: improve audiobook groups and notification refresh --- .../plans/2026-06-09-literary-works.md | 2 +- internal/api/handlers/audiobook_groups.go | 76 ++++-- internal/catalog/audiobook_groups.go | 252 ++++++++++++++---- internal/catalog/audiobook_groups_cache.go | 3 +- internal/catalog/audiobook_groups_test.go | 96 +++++++ ...260617163035_audiobook_item_file_stats.sql | 191 +++++++++++++ web/src/api/types.ts | 2 + web/src/components/RealtimeEventsProvider.tsx | 170 ++++++------ .../audiobooks/AudiobookGroupsView.tsx | 163 ++++++----- web/src/hooks/queries/audiobookGroups.ts | 67 +++-- web/src/hooks/queries/keys.ts | 4 +- web/src/hooks/queries/notifications.ts | 12 +- 12 files changed, 760 insertions(+), 278 deletions(-) create mode 100644 internal/catalog/audiobook_groups_test.go create mode 100644 migrations/sql/20260617163035_audiobook_item_file_stats.sql diff --git a/docs/superpowers/plans/2026-06-09-literary-works.md b/docs/superpowers/plans/2026-06-09-literary-works.md index e1196fad..ae7aaf33 100644 --- a/docs/superpowers/plans/2026-06-09-literary-works.md +++ b/docs/superpowers/plans/2026-06-09-literary-works.md @@ -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 diff --git a/internal/api/handlers/audiobook_groups.go b/internal/api/handlers/audiobook_groups.go index 9aac0cb1..f29ea562 100644 --- a/internal/api/handlers/audiobook_groups.go +++ b/internal/api/handlers/audiobook_groups.go @@ -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= (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") +} diff --git a/internal/catalog/audiobook_groups.go b/internal/catalog/audiobook_groups.go index 4bc5018b..33046fdb 100644 --- a/internal/catalog/audiobook_groups.go +++ b/internal/catalog/audiobook_groups.go @@ -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) + "%" } diff --git a/internal/catalog/audiobook_groups_cache.go b/internal/catalog/audiobook_groups_cache.go index 32e392e8..dc2b3f6f 100644 --- a/internal/catalog/audiobook_groups_cache.go +++ b/internal/catalog/audiobook_groups_cache.go @@ -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=") diff --git a/internal/catalog/audiobook_groups_test.go b/internal/catalog/audiobook_groups_test.go new file mode 100644 index 00000000..7b58a209 --- /dev/null +++ b/internal/catalog/audiobook_groups_test.go @@ -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 +} diff --git a/migrations/sql/20260617163035_audiobook_item_file_stats.sql b/migrations/sql/20260617163035_audiobook_item_file_stats.sql new file mode 100644 index 00000000..49220578 --- /dev/null +++ b/migrations/sql/20260617163035_audiobook_item_file_stats.sql @@ -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 diff --git a/web/src/api/types.ts b/web/src/api/types.ts index b018c545..c8fe294c 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -881,6 +881,8 @@ export interface AudiobookGroup { export interface AudiobookGroupsResponse { total: number; + total_exact: boolean; + has_more: boolean; groups: AudiobookGroup[]; } diff --git a/web/src/components/RealtimeEventsProvider.tsx b/web/src/components/RealtimeEventsProvider.tsx index 22a9ec20..ae81fa71 100644 --- a/web/src/components/RealtimeEventsProvider.tsx +++ b/web/src/components/RealtimeEventsProvider.tsx @@ -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(); + 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((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( () => ({ 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((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(); - 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 {children}; diff --git a/web/src/components/audiobooks/AudiobookGroupsView.tsx b/web/src/components/audiobooks/AudiobookGroupsView.tsx index adc696eb..13feebde 100644 --- a/web/src/components/audiobooks/AudiobookGroupsView.tsx +++ b/web/src/components/audiobooks/AudiobookGroupsView.tsx @@ -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(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(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 (
@@ -175,7 +165,7 @@ export default function AudiobookGroupsView({ setFilter(event.target.value)} - placeholder={`Filter ${noun}…`} + placeholder={`Search ${noun}…`} className="w-full max-w-xs" /> - {data && ( + {firstPage && ( - {groups.length === data.total ? data.total : `${groups.length} of ${data.total}`} {noun} + {countLabel} {noun} )}
- {isLoading ? ( + {isInitialLoading ? ( isSeries ? (
{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.`}

) : isSeries ? ( -
- {visibleGroups.map((group) => ( - - ))} -
- ) : ( -
- {visibleGroups.map((group) => ( -
- - - ))} -
+ + ))} + +
+ {isFetchingNextPage ? : null} +
+ + ) : ( + <> +
+ {groups.map((group) => ( + + ))} +
+
+ {isFetchingNextPage ? : null} +
+ )} - {!isLoading && hasMore &&
}
); } diff --git a/web/src/hooks/queries/audiobookGroups.ts b/web/src/hooks/queries/audiobookGroups.ts index 04816d2c..2e49ced7 100644 --- a/web/src/hooks/queries/audiobookGroups.ts +++ b/web/src/hooks/queries/audiobookGroups.ts @@ -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 { - 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( - `/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(`/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, }); } diff --git a/web/src/hooks/queries/keys.ts b/web/src/hooks/queries/keys.ts index e2c54eaf..5dbb3d75 100644 --- a/web/src/hooks/queries/keys.ts +++ b/web/src/hooks/queries/keys.ts @@ -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) => diff --git a/web/src/hooks/queries/notifications.ts b/web/src/hooks/queries/notifications.ts index 67bb696c..28c46fbd 100644 --- a/web/src/hooks/queries/notifications.ts +++ b/web/src/hooks/queries/notifications.ts @@ -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(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", }); }