diff --git a/cmd/server/tests/series_sort_filter_test.go b/cmd/server/tests/series_sort_filter_test.go new file mode 100644 index 00000000..3170c1a1 --- /dev/null +++ b/cmd/server/tests/series_sort_filter_test.go @@ -0,0 +1,218 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "regexp" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestSeriesSortAndFilter pins the series page's sort/filter contract +// (2026-10-27 feature): the /series SSR page and the /api/series JSON +// endpoint share one sort registry (services.ResolveSeriesSort) and one +// SQL CASE cascade (GetDistinctSeries). The fixture gives three series +// DISTINCT answers per sort key so ordering bugs can't hide: +// +// created author books viewer progress +// Probe Zebra 01-03 Zeta Zed 2 1 of 2 read (01-09) → reading +// Probe Mango 01-02 Alpha One 3 3 of 3 read (01-10) → finished +// Probe Apple 01-01 Midway Two 1 none → unstarted +// +// All viewer progress belongs to the FRESH regular user +// (getRegularUserID) and cascades away with the test library — the +// shared admin's reading data is never touched. +func TestSeriesSortAndFilter(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + setup := setupTestServer(t) + ctx := context.Background() + + libID := createTestLibraryWithFolder(t, setup.Server, setup.Token, "Test Series Filter Library", false) + viewerID := getRegularUserID(t, setup.DB) + + type seriesFixture struct { + name string + author string + createdAt string + books []struct { + title string + percentage string + lastReadAt string + } + } + fixtures := []seriesFixture{ + { + name: "Probe Zebra Series", author: "Zeta Zed", createdAt: "2026-01-03 12:00:00+00", + books: []struct { + title string + percentage string + lastReadAt string + }{ + {title: "Zebra Book 1", percentage: "1.0", lastReadAt: "2026-01-09 08:00:00+00"}, + {title: "Zebra Book 2", percentage: "0.5", lastReadAt: "2026-01-09 08:00:00+00"}, + }, + }, + { + name: "Probe Mango Series", author: "Alpha One", createdAt: "2026-01-02 12:00:00+00", + books: []struct { + title string + percentage string + lastReadAt string + }{ + {title: "Mango Book 1", percentage: "1.0", lastReadAt: "2026-01-10 08:00:00+00"}, + {title: "Mango Book 2", percentage: "1.0", lastReadAt: "2026-01-10 08:00:00+00"}, + {title: "Mango Book 3", percentage: "1.0", lastReadAt: "2026-01-10 08:00:00+00"}, + }, + }, + { + name: "Probe Apple Series", author: "Midway Two", createdAt: "2026-01-01 12:00:00+00", + books: []struct { + title string + percentage string + lastReadAt string + }{ + {title: "Apple Book 1", percentage: "0", lastReadAt: ""}, + }, + }, + } + + stripComments := regexp.MustCompile(``) + // Card headings only: the empty-state panels use different h3 + // classes ("text-xl font-semibold mb-2"), and "No Series Match" + // must not read as a rendered series. + nameRegex := regexp.MustCompile(`

]*>([^<]+)

`) + + renderedNames := func(query string) []string { + code, body := ssrGet(t, setup.Server.URL+"/series?library_id="+libID+query, setup.RegularToken) + if code != 200 { + t.Logf("series request %q -> %d; body head: %.500s", query, code, body) + } + require.Equal(t, 200, code) + var names []string + for _, m := range nameRegex.FindAllStringSubmatch(stripComments.ReplaceAllString(body, ""), -1) { + names = append(names, strings.TrimSpace(m[1])) + } + return names + } + + t.Cleanup(func() { + _, _ = setup.DBPool.Exec(ctx, "DELETE FROM libraries WHERE id = $1", libID) + }) + + for _, fx := range fixtures { + for _, b := range fx.books { + var itemID string + err := setup.DBPool.QueryRow(ctx, + `INSERT INTO media_items (library_id, title, file_path, series, authors, created_at, imported_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $6, $6) RETURNING id`, + libID, b.title, fmt.Sprintf("/tmp/test-series-filter/%s.epub", b.title), fx.name, []string{fx.author}, fx.createdAt, + ).Scan(&itemID) + require.NoError(t, err) + if b.percentage != "0" && b.percentage != "" { + _, err = setup.DBPool.Exec(ctx, + `INSERT INTO reading_progress (media_item_id, user_id, percentage, last_read_at) + VALUES ($1, $2, $3::float, $4::timestamptz)`, + itemID, viewerID, b.percentage, b.lastReadAt, + ) + require.NoError(t, err) + } + } + } + + t.Run("default sort is recently added", func(t *testing.T) { + assert.Equal(t, []string{"Probe Zebra Series", "Probe Mango Series", "Probe Apple Series"}, renderedNames("")) + }) + + t.Run("every sort key orders the grid", func(t *testing.T) { + cases := []struct { + sort string + want []string + }{ + {"sort=recent", []string{"Zebra", "Mango", "Apple"}}, + {"sort=recent_asc", []string{"Apple", "Mango", "Zebra"}}, + {"sort=name_asc", []string{"Apple", "Mango", "Zebra"}}, + {"sort=name_desc", []string{"Zebra", "Mango", "Apple"}}, + {"sort=author_asc", []string{"Mango", "Apple", "Zebra"}}, + {"sort=author_desc", []string{"Zebra", "Apple", "Mango"}}, + {"sort=books_desc", []string{"Mango", "Zebra", "Apple"}}, + {"sort=books_asc", []string{"Apple", "Zebra", "Mango"}}, + {"sort=progress_desc", []string{"Mango", "Zebra", "Apple"}}, + {"sort=progress_asc", []string{"Apple", "Zebra", "Mango"}}, + {"sort=last_read_desc", []string{"Mango", "Zebra", "Apple"}}, + {"sort=last_read_asc", []string{"Zebra", "Mango", "Apple"}}, + } + for _, tc := range cases { + got := renderedNames("&" + tc.sort) + require.Len(t, got, 3, tc.sort) + for i, want := range tc.want { + assert.Contains(t, got[i], want, "%s: position %d", tc.sort, i) + } + } + }) + + t.Run("unknown sort falls back to the default", func(t *testing.T) { + // Unknown keys resolve to "recent" in the registry; the SQL never + // interpolates the value (bound-param CASE cascade), so a hostile + // sort value is inert by construction. + assert.Equal(t, []string{"Probe Zebra Series", "Probe Mango Series", "Probe Apple Series"}, renderedNames("&sort=bogus-key")) + }) + + t.Run("name query filters", func(t *testing.T) { + assert.Equal(t, []string{"Probe Mango Series"}, renderedNames("&q=Mango")) + // ILIKE is case-insensitive and substring-shaped. + assert.Equal(t, []string{"Probe Mango Series"}, renderedNames("&q=mango")) + }) + + t.Run("author query filters on any book's author", func(t *testing.T) { + assert.Equal(t, []string{"Probe Mango Series"}, renderedNames("&author=alpha")) + assert.Empty(t, renderedNames("&author=nobody")) + }) + + t.Run("status filter matches the viewer's progress", func(t *testing.T) { + assert.Equal(t, []string{"Probe Mango Series"}, renderedNames("&status=finished"), "all books finished") + assert.Equal(t, []string{"Probe Zebra Series"}, renderedNames("&status=reading"), "some book mid-read") + assert.Equal(t, []string{"Probe Apple Series"}, renderedNames("&status=unstarted"), "no progress anywhere") + }) + + t.Run("filters compose", func(t *testing.T) { + assert.Equal(t, []string{"Probe Zebra Series"}, renderedNames("&q=probe&status=reading")) + assert.Equal(t, []string{"Probe Mango Series"}, renderedNames("&q=probe&status=finished&sort=progress_desc")) + }) + + t.Run("cards carry the viewer's read count", func(t *testing.T) { + _, body := ssrGet(t, setup.Server.URL+"/series?library_id="+libID, setup.RegularToken) + assert.Contains(t, body, "3 of 3 read") + assert.Contains(t, body, "1 of 2 read") + assert.NotContains(t, body, "of 1 read", "unstarted series must not render a progress line") + }) + + t.Run("JSON API shares the sort/filter contract", func(t *testing.T) { + req, err := http.NewRequest("GET", setup.Server.URL+"/api/series?library_id="+libID+"&sort=author_asc&status=finished", nil) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+setup.RegularToken) + resp, err := (&http.Client{}).Do(req) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, 200, resp.StatusCode) + + var payload struct { + Series []struct { + Name string `json:"name"` + ReadCount int64 `json:"read_count"` + } `json:"series"` + Total int `json:"total"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&payload)) + require.Len(t, payload.Series, 1) + assert.Equal(t, "Probe Mango Series", payload.Series[0].Name) + assert.Equal(t, int64(3), payload.Series[0].ReadCount) + assert.Equal(t, 1, payload.Total) + }) +} diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 351a75e5..7ac051d9 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -5266,21 +5266,59 @@ func (q *Queries) GetDictionaryEntry(ctx context.Context, word string) (Dictiona const GetDistinctSeries = `-- name: GetDistinctSeries :many SELECT library_id, series, COUNT(*) as book_count, MAX(series_count) as total_in_series, - MAX(created_at) as last_entry_at + MAX(created_at) as last_entry_at, + COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999) AS finished_count, + MAX(rp.last_read_at) AS last_read_at FROM media_items -WHERE ($1::uuid IS NULL OR library_id = $1::uuid) - AND ($2::uuid[] IS NULL OR library_id = ANY($2::uuid[])) +LEFT JOIN reading_progress rp + ON rp.media_item_id = media_items.id + AND rp.user_id = $1::uuid +WHERE ($2::uuid IS NULL OR library_id = $2::uuid) + AND ($3::uuid[] IS NULL OR library_id = ANY($3::uuid[])) AND archived_at IS NULL AND missing_scan_count = 0 AND series IS NOT NULL AND series != '' + AND ($4::text IS NULL OR series ILIKE '%' || $4::text || '%') GROUP BY library_id, series -ORDER BY MAX(created_at) DESC, library_id, series -LIMIT $4 OFFSET $3 +HAVING ($5::text IS NULL OR BOOL_OR(author ILIKE '%' || $5::text || '%')) + AND ( + $6::text IS NULL + OR CASE $6::text + WHEN 'finished' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999) = COUNT(*) + WHEN 'reading' THEN COUNT(*) FILTER (WHERE rp.percentage > 0 AND rp.percentage < 0.999) >= 1 + WHEN 'unstarted' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) > 0) = 0 + ELSE true + END + ) +ORDER BY + CASE WHEN COALESCE($7::text, 'recent') = 'recent' THEN MAX(created_at) END DESC NULLS LAST, + CASE WHEN $7::text = 'recent_asc' THEN MAX(created_at) END ASC NULLS LAST, + CASE WHEN $7::text = 'name_asc' THEN series END ASC NULLS LAST, + CASE WHEN $7::text = 'name_desc' THEN series END DESC NULLS LAST, + CASE WHEN $7::text = 'author_asc' THEN MIN(COALESCE(author, '')) END ASC NULLS LAST, + CASE WHEN $7::text = 'author_desc' THEN MIN(COALESCE(author, '')) END DESC NULLS LAST, + CASE WHEN $7::text = 'books_desc' THEN COUNT(*) END DESC NULLS LAST, + CASE WHEN $7::text = 'books_asc' THEN COUNT(*) END ASC NULLS LAST, + CASE WHEN $7::text = 'progress_desc' + THEN (COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999))::float / NULLIF(COUNT(*), 0) + END DESC NULLS LAST, + CASE WHEN $7::text = 'progress_asc' + THEN (COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999))::float / NULLIF(COUNT(*), 0) + END ASC NULLS LAST, + CASE WHEN $7::text = 'last_read_desc' THEN MAX(rp.last_read_at) END DESC NULLS LAST, + CASE WHEN $7::text = 'last_read_asc' THEN MAX(rp.last_read_at) END ASC NULLS LAST, + library_id, series +LIMIT $9 OFFSET $8 ` type GetDistinctSeriesParams struct { + ViewerUserID pgtype.UUID `db:"viewer_user_id" json:"viewer_user_id"` LibraryID pgtype.UUID `db:"library_id" json:"library_id"` VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"` + SeriesQuery pgtype.Text `db:"series_query" json:"series_query"` + AuthorQuery pgtype.Text `db:"author_query" json:"author_query"` + Status pgtype.Text `db:"status" json:"status"` + Sort pgtype.Text `db:"sort" json:"sort"` Offset pgtype.Int4 `db:"offset" json:"offset"` Limit pgtype.Int4 `db:"limit" json:"limit"` } @@ -5291,20 +5329,35 @@ type GetDistinctSeriesRow struct { BookCount int64 `db:"book_count" json:"book_count"` TotalInSeries interface{} `db:"total_in_series" json:"total_in_series"` LastEntryAt interface{} `db:"last_entry_at" json:"last_entry_at"` + FinishedCount int64 `db:"finished_count" json:"finished_count"` + LastReadAt interface{} `db:"last_read_at" json:"last_read_at"` } // Series are grouped PER LIBRARY: the same series name in two libraries // (novels vs manga adaptations, LN splits) stays two entries. Visible // filter enforces per-user library visibility at the source. -// Tiebreaker REQUIRED for LIMIT/OFFSET pagination: bulk imports in one -// transaction share a single NOW() timestamp, leaving many groups tied on -// MAX(created_at). Postgres does not guarantee tie order between -// executions, so page boundaries could repeat or drop series. The GROUP BY -// keys make the sort total and deterministic. +// +// Series-page sort/filter contract (extensible registry in +// services.ResolveSeriesSort): SeriesQuery is a series-NAME substring so +// it filters in WHERE (the group key — counts stay true); AuthorQuery +// and Status filter via HAVING so COUNT(*) keeps reporting the whole +// series. ViewerUserId scopes the per-user progress aggregates (the rp +// join can't multiply rows: reading_progress is UNIQUE per +// media_item+user). Sort is a BOUND PARAM compared inside the CASE +// cascade — never interpolated — and the GROUP BY tiebreaker stays +// REQUIRED for LIMIT/OFFSET determinism: bulk imports in one +// transaction share a single NOW() timestamp, leaving many groups tied +// on MAX(created_at), and Postgres does not guarantee tie order between +// executions, so page boundaries could repeat or drop series. func (q *Queries) GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesParams) ([]GetDistinctSeriesRow, error) { rows, err := q.db.Query(ctx, GetDistinctSeries, + arg.ViewerUserID, arg.LibraryID, arg.VisibleLibraryIds, + arg.SeriesQuery, + arg.AuthorQuery, + arg.Status, + arg.Sort, arg.Offset, arg.Limit, ) @@ -5321,6 +5374,8 @@ func (q *Queries) GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesPa &i.BookCount, &i.TotalInSeries, &i.LastEntryAt, + &i.FinishedCount, + &i.LastReadAt, ); err != nil { return nil, err } @@ -5333,22 +5388,54 @@ func (q *Queries) GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesPa } const GetDistinctSeriesCount = `-- name: GetDistinctSeriesCount :one -SELECT COUNT(DISTINCT (library_id, series))::int -FROM media_items -WHERE ($1::uuid IS NULL OR library_id = $1::uuid) - AND ($2::uuid[] IS NULL OR library_id = ANY($2::uuid[])) - AND archived_at IS NULL - AND missing_scan_count = 0 - AND series IS NOT NULL AND series != '' +SELECT COUNT(*)::int FROM ( + SELECT library_id, series + FROM media_items + LEFT JOIN reading_progress rp + ON rp.media_item_id = media_items.id + AND rp.user_id = $1::uuid + WHERE ($2::uuid IS NULL OR library_id = $2::uuid) + AND ($3::uuid[] IS NULL OR library_id = ANY($3::uuid[])) + AND archived_at IS NULL + AND missing_scan_count = 0 + AND series IS NOT NULL AND series != '' + AND ($4::text IS NULL OR series ILIKE '%' || $4::text || '%') + GROUP BY library_id, series + HAVING ($5::text IS NULL OR BOOL_OR(author ILIKE '%' || $5::text || '%')) + AND ( + $6::text IS NULL + OR CASE $6::text + WHEN 'finished' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999) = COUNT(*) + WHEN 'reading' THEN COUNT(*) FILTER (WHERE rp.percentage > 0 AND rp.percentage < 0.999) >= 1 + WHEN 'unstarted' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) > 0) = 0 + ELSE true + END + ) +) groups ` type GetDistinctSeriesCountParams struct { + ViewerUserID pgtype.UUID `db:"viewer_user_id" json:"viewer_user_id"` LibraryID pgtype.UUID `db:"library_id" json:"library_id"` VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"` + SeriesQuery pgtype.Text `db:"series_query" json:"series_query"` + AuthorQuery pgtype.Text `db:"author_query" json:"author_query"` + Status pgtype.Text `db:"status" json:"status"` } +// Count counterpart of GetDistinctSeries: identical WHERE + JOIN + +// HAVING so the pagination total matches what the page query returns +// under the same filters (Sort is a page-ORDER concern, not a set +// membership one, so it is absent here by design). func (q *Queries) GetDistinctSeriesCount(ctx context.Context, arg GetDistinctSeriesCountParams) (int32, error) { - row := q.db.QueryRow(ctx, GetDistinctSeriesCount, arg.LibraryID, arg.VisibleLibraryIds) + row := q.db.QueryRow(ctx, GetDistinctSeriesCount, + arg.ViewerUserID, + arg.LibraryID, + arg.VisibleLibraryIds, + arg.SeriesQuery, + arg.AuthorQuery, + arg.Status, + ) var column_1 int32 err := row.Scan(&column_1) return column_1, err diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index 3aa48e31..4ce21cd4 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -4071,32 +4071,94 @@ ORDER BY mi.created_at DESC; -- Series are grouped PER LIBRARY: the same series name in two libraries -- (novels vs manga adaptations, LN splits) stays two entries. Visible -- filter enforces per-user library visibility at the source. +-- +-- Series-page sort/filter contract (extensible registry in +-- services.ResolveSeriesSort): SeriesQuery is a series-NAME substring so +-- it filters in WHERE (the group key — counts stay true); AuthorQuery +-- and Status filter via HAVING so COUNT(*) keeps reporting the whole +-- series. ViewerUserId scopes the per-user progress aggregates (the rp +-- join can't multiply rows: reading_progress is UNIQUE per +-- media_item+user). Sort is a BOUND PARAM compared inside the CASE +-- cascade — never interpolated — and the GROUP BY tiebreaker stays +-- REQUIRED for LIMIT/OFFSET determinism: bulk imports in one +-- transaction share a single NOW() timestamp, leaving many groups tied +-- on MAX(created_at), and Postgres does not guarantee tie order between +-- executions, so page boundaries could repeat or drop series. SELECT library_id, series, COUNT(*) as book_count, MAX(series_count) as total_in_series, - MAX(created_at) as last_entry_at + MAX(created_at) as last_entry_at, + COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999) AS finished_count, + MAX(rp.last_read_at) AS last_read_at FROM media_items +LEFT JOIN reading_progress rp + ON rp.media_item_id = media_items.id + AND rp.user_id = sqlc.narg('viewer_user_id')::uuid WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid) AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR library_id = ANY(sqlc.narg('visible_library_ids')::uuid[])) AND archived_at IS NULL AND missing_scan_count = 0 AND series IS NOT NULL AND series != '' + AND (sqlc.narg('series_query')::text IS NULL OR series ILIKE '%' || sqlc.narg('series_query')::text || '%') GROUP BY library_id, series --- Tiebreaker REQUIRED for LIMIT/OFFSET pagination: bulk imports in one --- transaction share a single NOW() timestamp, leaving many groups tied on --- MAX(created_at). Postgres does not guarantee tie order between --- executions, so page boundaries could repeat or drop series. The GROUP BY --- keys make the sort total and deterministic. -ORDER BY MAX(created_at) DESC, library_id, series +HAVING (sqlc.narg('author_query')::text IS NULL OR BOOL_OR(author ILIKE '%' || sqlc.narg('author_query')::text || '%')) + AND ( + sqlc.narg('status')::text IS NULL + OR CASE sqlc.narg('status')::text + WHEN 'finished' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999) = COUNT(*) + WHEN 'reading' THEN COUNT(*) FILTER (WHERE rp.percentage > 0 AND rp.percentage < 0.999) >= 1 + WHEN 'unstarted' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) > 0) = 0 + ELSE true + END + ) +ORDER BY + CASE WHEN COALESCE(sqlc.narg('sort')::text, 'recent') = 'recent' THEN MAX(created_at) END DESC NULLS LAST, + CASE WHEN sqlc.narg('sort')::text = 'recent_asc' THEN MAX(created_at) END ASC NULLS LAST, + CASE WHEN sqlc.narg('sort')::text = 'name_asc' THEN series END ASC NULLS LAST, + CASE WHEN sqlc.narg('sort')::text = 'name_desc' THEN series END DESC NULLS LAST, + CASE WHEN sqlc.narg('sort')::text = 'author_asc' THEN MIN(COALESCE(author, '')) END ASC NULLS LAST, + CASE WHEN sqlc.narg('sort')::text = 'author_desc' THEN MIN(COALESCE(author, '')) END DESC NULLS LAST, + CASE WHEN sqlc.narg('sort')::text = 'books_desc' THEN COUNT(*) END DESC NULLS LAST, + CASE WHEN sqlc.narg('sort')::text = 'books_asc' THEN COUNT(*) END ASC NULLS LAST, + CASE WHEN sqlc.narg('sort')::text = 'progress_desc' + THEN (COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999))::float / NULLIF(COUNT(*), 0) + END DESC NULLS LAST, + CASE WHEN sqlc.narg('sort')::text = 'progress_asc' + THEN (COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999))::float / NULLIF(COUNT(*), 0) + END ASC NULLS LAST, + CASE WHEN sqlc.narg('sort')::text = 'last_read_desc' THEN MAX(rp.last_read_at) END DESC NULLS LAST, + CASE WHEN sqlc.narg('sort')::text = 'last_read_asc' THEN MAX(rp.last_read_at) END ASC NULLS LAST, + library_id, series LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset'); -- name: GetDistinctSeriesCount :one -SELECT COUNT(DISTINCT (library_id, series))::int -FROM media_items -WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid) - AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR library_id = ANY(sqlc.narg('visible_library_ids')::uuid[])) - AND archived_at IS NULL - AND missing_scan_count = 0 - AND series IS NOT NULL AND series != ''; +-- Count counterpart of GetDistinctSeries: identical WHERE + JOIN + +-- HAVING so the pagination total matches what the page query returns +-- under the same filters (Sort is a page-ORDER concern, not a set +-- membership one, so it is absent here by design). +SELECT COUNT(*)::int FROM ( + SELECT library_id, series + FROM media_items + LEFT JOIN reading_progress rp + ON rp.media_item_id = media_items.id + AND rp.user_id = sqlc.narg('viewer_user_id')::uuid + WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid) + AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR library_id = ANY(sqlc.narg('visible_library_ids')::uuid[])) + AND archived_at IS NULL + AND missing_scan_count = 0 + AND series IS NOT NULL AND series != '' + AND (sqlc.narg('series_query')::text IS NULL OR series ILIKE '%' || sqlc.narg('series_query')::text || '%') + GROUP BY library_id, series + HAVING (sqlc.narg('author_query')::text IS NULL OR BOOL_OR(author ILIKE '%' || sqlc.narg('author_query')::text || '%')) + AND ( + sqlc.narg('status')::text IS NULL + OR CASE sqlc.narg('status')::text + WHEN 'finished' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999) = COUNT(*) + WHEN 'reading' THEN COUNT(*) FILTER (WHERE rp.percentage > 0 AND rp.percentage < 0.999) >= 1 + WHEN 'unstarted' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) > 0) = 0 + ELSE true + END + ) +) groups; -- name: GetSeriesCovers :many SELECT cover_image_path, library_id diff --git a/internal/handlers/series.go b/internal/handlers/series.go index c0ed1ecb..c0996e20 100644 --- a/internal/handlers/series.go +++ b/internal/handlers/series.go @@ -64,7 +64,19 @@ func (h *SeriesHandler) GetSeries(c *echo.Context) error { return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"}) } - seriesList, total, err := h.seriesService.GetSeriesPage(c.Request().Context(), libUUID, visibleIDs, limit, offset) + // Sort/filter params (same contract as the SSR /series page): + // unknown values resolve to defaults inside the service. + opts := services.SeriesPageOptions{ + Sort: c.QueryParam("sort"), + Query: c.QueryParam("q"), + Author: c.QueryParam("author"), + Status: c.QueryParam("status"), + } + if u, ok := c.Get("user").(database.Users); ok { + opts.ViewerUserID = u.ID + } + + seriesList, total, err := h.seriesService.GetSeriesPage(c.Request().Context(), libUUID, visibleIDs, limit, offset, opts) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load series"}) } @@ -76,6 +88,8 @@ func (h *SeriesHandler) GetSeries(c *echo.Context) error { TotalInSeries int `json:"total_in_series"` CoverPaths []string `json:"cover_paths"` LastEntryAt string `json:"last_entry_at"` + ReadCount int64 `json:"read_count"` + LastReadAt string `json:"last_read_at"` } response := make([]SeriesResponse, 0, len(seriesList)) @@ -87,6 +101,8 @@ func (h *SeriesHandler) GetSeries(c *echo.Context) error { TotalInSeries: s.TotalInSeries, CoverPaths: s.CoverPaths, LastEntryAt: s.LastEntryAt, + ReadCount: s.ReadCount, + LastReadAt: s.LastReadAt, }) } @@ -150,7 +166,7 @@ func (h *SeriesHandler) GetSeriesBooks(c *echo.Context) error { }) } -func GetSeriesCardsData(ctx context.Context, db *database.Queries, libraryID pgtype.UUID, visibleLibraryIDs []pgtype.UUID, limit, offset int) ([]services.SeriesInfo, int, error) { +func GetSeriesCardsData(ctx context.Context, db *database.Queries, libraryID pgtype.UUID, visibleLibraryIDs []pgtype.UUID, limit, offset int, opts services.SeriesPageOptions) ([]services.SeriesInfo, int, error) { svc := services.NewSeriesService(db) - return svc.GetSeriesPage(ctx, libraryID, visibleLibraryIDs, limit, offset) + return svc.GetSeriesPage(ctx, libraryID, visibleLibraryIDs, limit, offset, opts) } diff --git a/internal/router/frontend.go b/internal/router/frontend.go index 8546d78a..708c6810 100644 --- a/internal/router/frontend.go +++ b/internal/router/frontend.go @@ -149,6 +149,25 @@ func registerFrontendRoutes(cfg *Config) { } offset := (page - 1) * perSeriesPage + // Series-page state (URL is the source of truth — plain GET form, + // no htmx on this page). Unknown values resolve to defaults in the + // service's registries, so hand-edited URLs stay safe. + state := templates.SeriesPageState{ + Sort: c.QueryParam("sort"), + Query: c.QueryParam("q"), + Author: c.QueryParam("author"), + Status: c.QueryParam("status"), + } + opts := services.SeriesPageOptions{ + Sort: state.Sort, + Query: state.Query, + Author: state.Author, + Status: state.Status, + } + if viewerID, vidErr := uuid.Parse(user.ID); vidErr == nil { + opts.ViewerUserID = uuidToPGType(viewerID) + } + var seriesCards []templates.SeriesCardData totalPages := 1 @@ -162,7 +181,7 @@ func registerFrontendRoutes(cfg *Config) { } else if libRes.LibUUID.Valid && !visibleSet[libRes.LibraryID] { errorMsg = "Library not accessible" } else { - seriesList, total, err := handlers.GetSeriesCardsData(c.Request().Context(), cfg.Queries, libRes.LibUUID, visibleIDs, perSeriesPage, offset) + seriesList, total, err := handlers.GetSeriesCardsData(c.Request().Context(), cfg.Queries, libRes.LibUUID, visibleIDs, perSeriesPage, offset, opts) if err != nil { log.Printf("GetSeriesCardsData failed: %v", err) errorMsg = "Error loading series" @@ -183,6 +202,7 @@ func registerFrontendRoutes(cfg *Config) { BookCount: s.BookCount, TotalInSeries: s.TotalInSeries, CoverPaths: covers, + ReadCount: s.ReadCount, }) } } @@ -194,7 +214,7 @@ func registerFrontendRoutes(cfg *Config) { } var buf bytes.Buffer - err = templates.Series(user, seriesCards, libData, libraryID, totalPages, page, errorMsg).Render(c.Request().Context(), &buf) + err = templates.Series(user, seriesCards, libData, libraryID, totalPages, page, state, errorMsg).Render(c.Request().Context(), &buf) if err != nil { return err } diff --git a/internal/services/series_service.go b/internal/services/series_service.go index 7ef61989..f5319d89 100644 --- a/internal/services/series_service.go +++ b/internal/services/series_service.go @@ -16,6 +16,69 @@ type SeriesInfo struct { TotalInSeries int CoverPaths []string LastEntryAt string + // Viewer-scoped progress aggregates (requesting user): books + // finished (percentage >= 0.999) and the series' most recent read. + ReadCount int64 + LastReadAt string +} + +// SeriesPageOptions carries the series page's sort/filter state. Zero +// values mean defaults (recent, no filters). Extensible by design: +// adding a sort = one entry in seriesSortKeys + a matching CASE arm in +// queries.sql (GetDistinctSeries) + one