Merge branch 'codex/notifications-request-burst'
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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) + "%"
|
||||
}
|
||||
|
||||
@@ -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=")
|
||||
|
||||
@@ -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
|
||||
@@ -881,6 +881,8 @@ export interface AudiobookGroup {
|
||||
|
||||
export interface AudiobookGroupsResponse {
|
||||
total: number;
|
||||
total_exact: boolean;
|
||||
has_more: boolean;
|
||||
groups: AudiobookGroup[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user