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