Skip to content

feat: add sorting and filtering for search results - #292

Open
YousefHadder wants to merge 1 commit into
stabldev:mainfrom
YousefHadder:feat/sort-filter-results
Open

feat: add sorting and filtering for search results#292
YousefHadder wants to merge 1 commit into
stabldev:mainfrom
YousefHadder:feat/sort-filter-results

Conversation

@YousefHadder

@YousefHadder YousefHadder commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Closes #202.

Search results can now be reordered and narrowed in place, without re-querying the indexer, since the full result set is already held in memory.

sort-filter-demo

Sorting

  • Clicking a column header sorts by it. Clicking the active column reverses direction; clicking # restores the indexer's ordering. Title, Size and S:L are all sortable.
  • s opens a popup listing the sort fields, each showing the direction it will apply, so seeders/size/leechers default to high-to-low and title to A-Z.
  • S reverses the current direction.
  • Sorting is stable, so ties keep their original relevance ranking.

Filtering

  • f hides dead torrents (0 seeders).
  • x clears sorting and filters together.

The bindings only fire while the results table has focus, so typing s or f in the search box is unaffected. The results border shows the active state, e.g. results (12/40) - seeders v. Defaults are configurable through general.default_sort, general.default_sort_order and general.min_seeders.

On the approach

#240 suggested using DataTable.sort(), and I want to explain why I didn't, since it's the main thing that would need re-litigating.

DataTable.sort() passes rendered cell text to the key function. Size cells are formatted strings, so "6.00 GB" sorts below "850 MB" lexically, and S:L sorts as "1882:8877" < "559:851". Getting correct ordering would mean parsing those strings back into the numbers they were formatted from. It also only reorders _row_locations without touching cell values, so the No column would shuffle out of sequence, and it can't hide rows at all, so filtering needs a separate mechanism regardless.

Instead, ordering lives in a new core/results.py (ResultView) that sorts the underlying Torrent objects and keeps the exact numeric values. It has no Textual imports, so it's unit-testable on its own. Happy to revisit if you'd still rather go the other way.

Also from #240: no type annotations were changed, and both Title and S:L are sortable by clicking the header.

Two pre-existing bugs fixed along the way

Routing all rendering through a single _render_rows() path fixed two things that were already broken:

  • Row numbers could skip, because the render loop enumerated the full result list but skipped duplicate magnet URIs with continue, advancing the counter for rows that were never drawn.
  • _search_results_map was never cleared between searches, so entries from old queries accumulated and could be resolved on selection.

Rebased

#290 and #291 have landed, and this is now rebased onto them as a single commit containing only the sorting and filtering work. The null-handling and column-width changes that used to show up in this diff are gone.

Docs

The demo above is also in the README, below the existing one. I committed it to docs/_static/ rather than linking an attachment, to match how demo.gif is handled and so it renders on the docs site too.

docs/usage.md gained a walkthrough of sorting and filtering, docs/configuration.md covers the three new keys, and docs/roadmap.md marks the item done.

While writing that up I also corrected four pre-existing errors in the keybinding docs: r doesn't exist, quit is ctrl+q rather than q, d and D were missing entirely, and Enter was described incorrectly. Those are unrelated to this feature, so say the word and I'll pull them into a separate docs: PR.

Testing

138 tests pass, including 4 snapshot tests and a new tests/test_core_results.py that covers ResultView in isolation. CI is green on Python 3.10 through 3.14.

Beyond the suite, I verified against a live Jackett instance rather than fixtures — all eight sort key and direction combinations checked for correct ordering across 5,753 real results.

@YousefHadder
YousefHadder force-pushed the feat/sort-filter-results branch 2 times, most recently from 58eff4c to 9483861 Compare August 15, 2026 23:09
@YousefHadder

YousefHadder commented Aug 15, 2026

Copy link
Copy Markdown
Contributor Author

@stabldev would you like me to add a GIF in README to show the changes in this PR ? I use VHS script to generate it, would be a nice addition to the README file.

@stabldev

Copy link
Copy Markdown
Owner

@stabldev would you like me to add a GIF in README to show the changes in this PR ? I use VHS script to generate it, would be a nice addition to the README file.

I'd love that! also could you rebase the PR?
Thanks for your contributions btw :D

@YousefHadder

YousefHadder commented Aug 16, 2026

Copy link
Copy Markdown
Contributor Author

@stabldev

The repo convention is a committed file, not an attachment URL — README line 13 uses ./docs/_static/demo.gif. So I'd commit docs/_static/sort-demo.gif (545KB, well under the existing 1.7MB demo) and switch docs/usage.md off its user-attachments URL to match.

An alternative in vhs is to upload the generated GIF into a cloud server, which will return a link that you can add to the README file to show the Demo, I do this personally to avoid uploading large media files into the repo.

Can do either, what do you prefer ?

Also the other 2 open PRs #293 and #294 will also create merge conflicts, I will go ahead and resolve the current ones here, and you can make the call on the order of merging those 3, which I can resolve future conflicts later.

@stabldev

Copy link
Copy Markdown
Owner

@stabldev

The repo convention is a committed file, not an attachment URL — README line 13 uses ./docs/_static/demo.gif. So I'd commit docs/_static/sort-demo.gif (545KB, well under the existing 1.7MB demo) and switch docs/usage.md off its user-attachments URL to match.

An alternative in vhs is to upload the generated GIF into a cloud server, which will return a link that you can add to the README file to show the Demo, I do this personally to avoid uploading large media files into the repo.

Can do either, what do you prefer ?

Let's do the first method,
also your GIF only shows sorting and filtering, no?

@YousefHadder

Copy link
Copy Markdown
Contributor Author

Let's do the first method,

Sounds good.

also your GIF only shows sorting and filtering, no?

For this PR yes, I will slightly change the generation script and add a new GIF.

Search results can now be reordered and narrowed in place, without
re-querying the indexer, since the full result set is already held in
memory.

Sorting:
- Clicking a column header sorts by it. Clicking the active column
  reverses the direction; clicking `#` restores the indexer's ordering.
  `Title`, `Size` and `S:L` are all sortable this way.
- `s` opens a popup listing the sort fields (relevance, seeders, size,
  title, leechers). Each entry shows the direction it will apply, so
  seeders/size/leechers default to high-to-low and title to A-Z.
- `S` reverses the current direction.
- Sorting is stable, so ties keep their original relevance ranking.

Filtering:
- `f` hides dead torrents (0 seeders).
- `x` clears sorting and filters, restoring the indexer's own ordering.

The key bindings only fire while the results table has focus, so typing
`s` or `f` in the search box is unaffected. The results border shows the
active state, e.g. `results (12/40) - seeders v`.

Defaults are configurable via `general.default_sort`,
`general.default_sort_order` and `general.min_seeders`.

Ordering logic lives in a new `core/results.py` (`ResultView`), kept free
of Textual imports so it can be unit tested directly. It sorts the
underlying `Torrent` objects rather than calling `DataTable.sort()`,
because that passes rendered cell text to the key function: size cells
are formatted strings like "6.00 GB", which sort lexically below
"850 MB" and would have to be parsed back into bytes to compare. Sorting
the model keeps the exact numeric values.

The sort keys coerce missing numeric fields to a sentinel rather than
trusting the `int` annotations. Real trackers return null for size,
seeders or leechers when they don't publish them, and comparing None
against an int raises TypeError part-way through the sort, which took
down the entire results view. This covers size, seeders and leechers
rather than special-casing whichever field happened to fail first.

Two pre-existing bugs are fixed as a consequence of routing all rendering
through a single `_render_rows()` path:

- Row numbers could skip. The render loop enumerated the full result list
  but skipped duplicate magnet URIs with `continue`, so the counter
  advanced for rows that were never drawn.
- `_search_results_map` was never cleared between searches, so entries
  from previous queries accumulated and could be resolved on selection.

The README and the usage guide embed a recorded demo of the sort menu, the
direction toggle and the seeded-only filter, following the existing
convention of committing the asset under `docs/_static/`.
@YousefHadder
YousefHadder force-pushed the feat/sort-filter-results branch from 9483861 to 5fcb023 Compare August 16, 2026 03:13
@stabldev

stabldev commented Aug 16, 2026

Copy link
Copy Markdown
Owner

Hey @YousefHadder! great work on this PR.
I noticed two small config/UX behaviors while testing locally:

When setting general.default_sort = "title" in config.toml, _build_view() falls back to DEFAULT_SORT_ORDER = "desc". This forces descending = True, causing title results to load as Z-A (title ↓) rather than the natural A-Z (title ↑).
Passing descending=None when default_sort_order isn't explicitly overridden, so view.set_sort() uses _DEFAULT_DESCENDING[sort_key], can prolly fix this.

Another one is- currently, pressing "x" resets the view back to Relevance (raw indexer order). If a user has a custom general.default_sort configured (e.g. seeders), should "x" restore raw relevance order, or should it restore the user's configured default_sort?

@YousefHadder

Copy link
Copy Markdown
Contributor Author

@stabldev Good catches, both.

Sort order — confirmed, and your diagnosis is right:

default_sort='title', order unset  ->  title ↓  (descending=True)
descending=None                    ->  title ↑  (descending=False)

One wrinkle on detecting "not explicitly overridden": _create_default_config() writes the key out, so a fresh config.toml physically contains default_sort_order = "desc". That splits users in two — anyone upgrading doesn't have the key at all and hits the "desc" fallback, while new installs have it written as "desc". Keying off absence fixes the first group and leaves the second broken, and it's easy to miss because editing your own existing config is exactly the first case.

So I'd make "auto" the neutral value: DEFAULT_SORT_ORDER = "auto", written into fresh configs, parsed to None so _DEFAULT_DESCENDING[key] decides. Explicit asc/desc still win, a missing key still lands on auto, and the generated config keeps advertising that the option exists. set_sort() already accepts bool | None, so it's a small change. I'd keep the relevance guard, since reversed relevance means worst-match-first.

x — I lean toward restoring the configured default_sort. Otherwise default_sort is effectively startup-only: set it to seeders and there's no way back to it short of reopening the popup. Raw relevance stays one click away on the # header, and with the default config (relevance) nothing changes for most people.

The trade-off is that it should then restore min_seeders too, so x becomes "back to my baseline" rather than "show me everything" — someone with min_seeders = 5 would keep that filter. I think that's the more coherent of the two, but it's your call.

I'll check tomorrow for your response here, then make the fixes and test them.

Let me know if you have any other concerns, Thank you.

@stabldev

Copy link
Copy Markdown
Owner

@YousefHadder spot on!

Using auto for DEFAULT_SORT_ORDER is a great solution- it handles both fresh installs and existing configs seamlessly.
And I fully agree on x resetting to the configured baseline (default_sort + min_seeders). that makes x much more useful throughout a session.

Your proposed approach sounds great. go ahead with the fixes whenever you're ready.
Thanks for the quick and thoughtful responses!

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.

[Feature] Sorting + Filtering feature

2 participants