Skip to content

fix: correct episode pagination for sorts other than newest-first - #972

Open
xoiram wants to merge 1 commit into
madeofpendletonwool:mainfrom
xoiram:fix/episode-pagination-sort-filter-bug
Open

fix: correct episode pagination for sorts other than newest-first#972
xoiram wants to merge 1 commit into
madeofpendletonwool:mainfrom
xoiram:fix/episode-pagination-sort-filter-bug

Conversation

@xoiram

@xoiram xoiram commented Aug 23, 2026

Copy link
Copy Markdown

I've recently migrated from Pocket Casts, and this project seems to tick all my boxes for what I want. However, when trying to navigate some of my bigger podcast backlogs I noticed "oldest first" doesn't really work in the web UI. This fixes that. It also adds a small test to verify the behavior against the postgres docker that was present.

I've also published a docker image with these changes and verified it works on my installation. Docker image is here.

Full disclosure: This was done with the help of Claude Sonnet 5.

Claude summary of the changes:

return_podcast_episodes_capitalized capped each UNION branch (Episodes, YouTubeVideos) with ORDER BY pubdate DESC LIMIT (limit+offset) regardless of the requested sort, then re-sorted the truncated set in the outer query. That's only valid when sorting newest-first: for any other sort (oldest first, title, duration) on a podcast with more episodes than the current page window, the per-branch cap discarded rows the outer sort actually needed. In practice this made "oldest first" pagination loop over the same newest-N episodes forever instead of ever reaching older ones, and made completed/incomplete/in-progress filters silently drop matches that weren't among the newest N raw rows (even though the separate, unrestricted count query correctly reported them).

Fixes, for both the Postgres and MySQL code paths:

  • Each per-branch subquery now orders by the actually-requested sort column/direction instead of a hardcoded recency order, restoring the top-K-merge correctness of the per-branch LIMIT for every sort.
  • The completed/incomplete/in-progress filter is now pushed into each branch's WHERE before its LIMIT, instead of only being applied after truncation.
  • Added an explicit tiebreaker (episodeid/videoid per branch, is_youtube + Episodeid at the outer level) so ties in the sort column can't produce a different row order across separate page requests as per_branch_limit grows, which could otherwise reintroduce the same duplicate/skip behavior via a different path.
  • Consolidated the sort-column and filter-clause mappings, which had drifted into 4 and 6 duplicated match blocks respectively across the two DB backends — the kind of divergence that caused the original bug.

Adds in-binary regression tests (rust-api has no lib target, so these can't live under tests/) covering: oldest-first pagination visiting every episode without duplicates, a completed-filter match outside the newest page, and pagination stability when the sort column is fully tied. Verified against a real Postgres, including confirming each test fails against the pre-fix query and passes after.

Wires a new rust-backend-tests CI job (its own ephemeral Postgres service) to run these, since the existing backend-tests job's Python suite is already broken (imports a FastAPI app module that no longer exists) and untested. Also gitignores rust-api/target/, which wasn't previously excluded.

return_podcast_episodes_capitalized capped each UNION branch (Episodes,
YouTubeVideos) with `ORDER BY pubdate DESC LIMIT (limit+offset)` regardless
of the requested sort, then re-sorted the truncated set in the outer query.
That's only valid when sorting newest-first: for any other sort (oldest
first, title, duration) on a podcast with more episodes than the current
page window, the per-branch cap discarded rows the outer sort actually
needed. In practice this made "oldest first" pagination loop over the same
newest-N episodes forever instead of ever reaching older ones, and made
completed/incomplete/in-progress filters silently drop matches that weren't
among the newest N raw rows (even though the separate, unrestricted count
query correctly reported them).

Fixes, for both the Postgres and MySQL code paths:
- Each per-branch subquery now orders by the actually-requested sort
  column/direction instead of a hardcoded recency order, restoring the
  top-K-merge correctness of the per-branch LIMIT for every sort.
- The completed/incomplete/in-progress filter is now pushed into each
  branch's WHERE before its LIMIT, instead of only being applied after
  truncation.
- Added an explicit tiebreaker (episodeid/videoid per branch, is_youtube +
  Episodeid at the outer level) so ties in the sort column can't produce a
  different row order across separate page requests as per_branch_limit
  grows, which could otherwise reintroduce the same duplicate/skip
  behavior via a different path.
- Consolidated the sort-column and filter-clause mappings, which had
  drifted into 4 and 6 duplicated match blocks respectively across the two
  DB backends — the kind of divergence that caused the original bug.

Adds in-binary regression tests (rust-api has no lib target, so these
can't live under tests/) covering: oldest-first pagination visiting every
episode without duplicates, a completed-filter match outside the newest
page, and pagination stability when the sort column is fully tied. Verified
against a real Postgres, including confirming each test fails against the
pre-fix query and passes after.

Wires a new rust-backend-tests CI job (its own ephemeral Postgres service)
to run these, since the existing backend-tests job's Python suite is
already broken (imports a FastAPI app module that no longer exists) and
untested. Also gitignores rust-api/target/, which wasn't previously excluded.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant