Skip to content

Library: keep the A-Z rail under Unwatched and genre filters (#869) - #1030

Open
Bardioc1977 wants to merge 11 commits into
GLinnik21:mainfrom
Bardioc1977:fix-869-letter-rail
Open

Bardioc1977 wants to merge 11 commits into
GLinnik21:mainfrom
Bardioc1977:fix-869-letter-rail

Conversation

@Bardioc1977

@Bardioc1977 Bardioc1977 commented Oct 25, 2026 •

Copy link
Copy Markdown

What

Title-sorted Library listings keep their A-Z rail when Unwatched or a genre is on. Before, the rail vanished on the first filter.

Why

ListingView::rail_available hid the rail under a filter because the letter counts came from an unfiltered firstCharacter
request, fetched once per section and listing type, while the grid was filtered. The rail jumps by prefix sums of those counts,
so with a filter on every jump would have landed on the wrong title.

How

  • Client::section_directory takes the listing's wire terms; the letters job sends the SAME terms the grid page sends
    (SecState::query_filters, the single source: type=, unwatched/unwatchedLeaves, genre).
  • The landed counts are keyed by what they counted (LettersKey: applied Unwatched, applied genre, a membership generation,
    in the new browse/letters.rs) and are shown only while that key is the one the section's query would ask with. It is
    checked where the letters are read, so the saved-genre restore (applied by the first page's landing, after the letters
    were asked) is correct without touching its code, and an answer asked under older terms is never shown.
  • A watched-state read-back or a library scan that changed what a section lists bumps the generation, so the counts are
    asked again (this also fixes a latent offset shift after a scan on unfiltered lists).
  • The Collections loan no longer carries the letters, and every library-type change forgets them: a watch done while
    Collections is listed cannot leave the pre-watch counts over the refetched grid. Cost: one small request on every return
    from Collections.
  • Two lists are held per section, not one: the landed list and the one it replaced (SecState::letters_kept, every reader
    picks through SecState::shown_letters). Turning a genre or Unwatched on and then off brings the unfiltered rail back at
    once, with no read. Stated cost and bounds: a third key evicts the oldest (genre A, genre B, then back to unfiltered reads
    once more), and a list counted under Unwatched is never the one kept, because a watch made while the switch is off flags
    nothing (membership_depends_on_viewing), so a kept one could come back wrong. A membership change (scan, read-back) or a
    library type change forgets the kept list as it forgets the shown one.
  • A failed or panicked letters read leaves the list as it was (hidden, it no longer describes the grid) and holds the same
    key back for 120 steps instead of dropping the rail for good; an edit asks at once.
  • rail_available loses its Unwatched/genre terms. The sort rule is unchanged: title ascending and more than one letter.
    Descending title and every other sort still hide the rail (a descending rail is feasible, measured below, but changes
    rail.rs semantics and is a follow-up).
  • Mock PMS: firstCharacter and /all honour type, unwatched, unwatchedLeaves, genre. On shows and seasons the mock
    keeps unwatched and unwatchedLeaves apart, as a real PMS does (30 of 33 shows against 24, docs/pms-api.md): unwatchedLeaves
    is "not every leaf watched", unwatched reads the row's own viewCount, which a show never carries in the mock. Only the
    divergence is modelled, not PMS's undocumented rule. LetterRailFilters.test_unwatched_is_not_unwatched_leaves_on_shows
    pins that the mock tells the keys apart; the key the app sends for a show is pinned by the Rust request-terms test
    the_letters_request_carries_the_terms_the_grid_sends (data/src/browse/listing_jobs_tests.rs).
  • Docs: docs/pms-api.md 2b, docs/sans-io.md. README untouched (bug fix).

Verified

Red, then green (new tests failed against the unfixed code for the intended reasons, observed before the fix):

  • listing_jobs: 9 of 17 failed, e.g. the_letters_request_carries_the_terms_the_grid_sends ("Movie Primary: the edit owes the
    letters again", left 0, right 1), an_answer_asked_under_older_terms_is_never_shown (counts of the unfiltered listing over a
    filtered grid: [("A", 3), ("B", 4)]), a_restored_genre_owes_the_letters_again, a_membership_change_owes_the_letters_again,
    a_scan_owes_the_letters_of_a_section_that_is_not_showing, returning_from_collections_asks_for_the_letters_again,
    the_rail_is_hidden_by_the_sort_and_the_list_and_not_by_the_filters, and the Collections-loan regression
    a_watch_while_collections_is_listed_does_not_leave_old_counts_over_the_new_grid ("the counts from before the watch over the
    refetched grid: [("A", 3), ("B", 4)]"); view::query_and_menus_are_one_retained_publication ("Unwatched and a genre no
    longer hide the rail"). tests/test_mock_pms_library.py::LetterRailFilters failed against the old mock (60 != 40 : unwatched=1).
  • Review round, red then green: turning_a_filter_off_brings_the_unfiltered_rail_back_without_a_read and
    only_the_last_two_lists_are_held failed on the one-slot code (left: [], right: [("A", 3), ("B", 4)]);
    test_unwatched_is_not_unwatched_leaves_on_shows failed against the one-predicate mock (5 == 5). Guards mutation-checked
    (each mutant killed): keeping an Unwatched list (an_unwatched_list_is_not_kept_across_the_switch), forget_letters not
    clearing the kept list and the kept list ignoring the generation (a_membership_change_and_a_type_change_forget_the_kept_list).
  • Green, on this branch merged with origin/main (merge 89aaa61f, 29 commits of main, no textual conflict; head
    ae264baa), committed tree, git status --short empty before and after:
    • make check-cargo: exit 0, 14 test binaries, among them test result: ok. 1263 passed; 0 failed (data), test result: ok. 1721 passed; 0 failed (screens), test result: ok. 1864 passed; 0 failed; 67 ignored (media)
    • make test-hostsim: test result: ok. 1175 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; make test-hostsim C=plx_media: test result: ok. 2068 passed; 0 failed; 67 ignored
    • make check-nodef, make lint, ci/check-module-layers.py (green — 14 layers, 1446 modules), ci/check-list-caps.py (289 sites, every one classed), ci/check-localization.py: exit 0
    • ci/check-deps.sh: every stage ok, ledgers unchanged (blocking 2, workers 9, globals 66, forks 58, fxlanes 11 sites)
    • make check-python-harness and tests/test_harness.py: Ran 544 tests in 231.918s / OK (skipped=1)
    • tests/test_mock_pms_library.py: Ran 89 tests ... OK (skipped=8); tests/test_mock_pms_discover.py: Ran 22 tests ... OK;
      tests/mock_pms.py --selftest: exit 0
    • make check-replay: 3 fixtures, 6 replays, 0 failures; 4 parallel, every replay verdict=SAME
    • make check: 1 of 113 steps FAILED (cargo branch ok, 283 s); the one failure is ci/test_disk_preflight.py::test_a_checkout_outside_the_named_roots_is_not_planned_under_an_override
      (1 != 2 : disk-preflight: only 2.0 GiB free for ~/main/rust-modules/target ...), an assertion about the free space of the
      machine it ran on, not this change.
  • Real PMS 1.43.5, read-only GETs: per-letter counts sum to the filtered totalSize and prefix-summed offsets land on the
    right title for movies (unwatched, genre, both, unwatched descending) and shows (unwatchedLeaves, genre). Measured while
    planning; docs/pms-api.md 2b records it.
  • Simulator (macOS, 1280x720, against tests/mock_pms.py --movies 60 --rail-fixture; host FFmpeg not built, UI/browse only):
    rail A C F M Z with Unwatched on ("40 of 60 films"), the request log shows firstCharacter?unwatched=1; jumping to F lands on
    the card whose year and length equal the mock's item at the prefix-summed offset; with Unwatched + genre (11 films) the rail
    is present and a jump to Z lands on offset 9; Date Added hides the rail and re-selecting Title brings it back with no new
    letters request; a Collections round trip with the filters on costs exactly one new firstCharacter?unwatched=1&genre=1 and the
    rail returns after the reload.
  • NOT verified on the TV: LG text rasterization of the rail, timing, the video plane. Not measured: PMS versions other than 1.43.5.

Notes

  • Known, older than this change and unchanged: a watch that covers no loaded row of the current section (a title in a page
    that is not loaded, or watched from Home) flags nothing while Unwatched is on, so later pages and the counts can differ by
    one until the next requery. The page lane has the same blind spot.
  • Stated, unchanged by the kept list: if an unfiltered read fails (back-off) while a filtered list is still the landed one, a watch
    made with Unwatched off and Unwatched switched on again could show that filtered list once more; the page lane's
    membership_stale has no term to flag it with. It needs a failed read, a watch and a toggle inside the 120-step back-off.
  • The first Library boot with a saved genre now asks one extra unfiltered letters request (the genre is applied by the first
    page's landing); it is never shown over the filtered grid.
  • Branch parity-specs, docs/library-filters-spec.md lines 67-69 ("today that holds for unwatched/genre") become true with
    this PR; its open question 5 is partly answered (unwatched, unwatchedLeaves, genre, type: yes; other facets unmeasured).

Closes #869

Signed-off-by: Heiko Kopp 47413920+Bardioc1977@users.noreply.github.com

🤖 Generated with Claude Code

Bardioc1977 and others added 7 commits October 25, 2026 06:19
…hedLeaves and genre

The mock's firstCharacter ignored every filter and the type, and /all ignored
unwatchedLeaves (the key the app sends for shows and seasons). Both now scope
like the real PMS 1.43.5: the counts describe the filtered listing, so their
sum is the filtered totalSize and prefix sums are the offsets of each letter.

New class LetterRailFilters pins that. Against the unmodified mock it failed
with e.g. "60 != 40 : unwatched=1", "60 != 16 : genre=1" and
"Lists differ: [{'key': 'a', ...}] != []" (a genre matching nothing).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: Heiko Kopp <47413920+Bardioc1977@users.noreply.github.com>
Tests for GLinnik21#869, written before the fix and failing against it:

- the letters request carries the terms the grid sends (wire level, movies and
  the TV types), and is asked again when they change
- an answer asked under older terms, a genre restored by the first page, a
  read-back or scan that changed the membership: the old counts are never
  shown over the new grid
- a watch while the Collections page is listed: the loan handed the
  pre-watch Unwatched counts back over the refetched grid
- the rail follows the sort and the list, not the filters (the existing
  view test that pinned the opposite is turned around)

Guards that stay green: a sort edit owes nothing, an empty answer is a landed
list. The loopback rig learns a port and a section kind.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: Heiko Kopp <47413920+Bardioc1977@users.noreply.github.com>
`Client::section_directory` took only a metadata type, so a `firstCharacter`
read could never describe a filtered listing. It now takes the wire terms of
the listing it describes and appends them exactly as `section_items_query`
does (one pair at a time, in order); an unfiltered movie request is the same
bytes as before. `LibraryType::type_terms` is the one place the `type=` pair
is made, for the listing request, the genre list and the saved-genre check.

No caller sends more than the type yet.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: Heiko Kopp <47413920+Bardioc1977@users.noreply.github.com>
…the grid's own listing

The rail hid itself under Unwatched or a genre because its letter counts came
from an unfiltered firstCharacter read, asked once per section and type, while
the grid was filtered: the jump offsets are prefix sums of those counts and
would land on the wrong titles.

- The letters job sends the same terms as the grid's page read
  (SecState::query_filters), and its answer comes back under a LettersKey: the
  applied Unwatched, the applied genre and a membership generation.
- A list is shown only while its key is the one the section's query would ask
  with (SecState::letters_current), checked where the letters are read. That
  covers a genre restored by the first page's landing, an answer asked under
  older terms, and a sort edit changes nothing.
- A read-back or library scan that changed what the section lists bumps the
  generation, so the counts are asked again (also for unfiltered lists).
- Every library type change forgets the letters, and the Collections loan no
  longer carries them: a watch done while Collections is listed cannot leave
  the pre-watch counts over the refetched grid.
- A read that fails or panics leaves the list as it was, hidden, and holds the
  same key back for 120 steps instead of dropping the rail for good; the
  directory answer carries ok for it.
- rail_available loses its Unwatched and genre terms; the sort rule is
  unchanged (title ascending, more than one letter).

The new code is browse/letters.rs. Tests: the red commit's plus the request
shape, the back-off, and the loan test.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: Heiko Kopp <47413920+Bardioc1977@users.noreply.github.com>
…b carries them

pms-api.md 2b records that firstCharacter honours type, unwatched,
unwatchedLeaves and genre like /all (counts sum to the filtered totalSize, an
empty filter answers an empty Directory, prefix-summed offsets land on the
right title; PMS 1.43.5, movies and shows). sans-io.md follows the listing
lanes: letters are owed by letters_current rather than a done flag, a failed
letters read backs off instead of landing empty, and the job carries the
grid's terms and a LettersKey.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: Heiko Kopp <47413920+Bardioc1977@users.noreply.github.com>
SecState::loaned said the Collections loan holds the letters; it stopped doing
so when the letters became keyed counts that every type change forgets.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: Heiko Kopp <47413920+Bardioc1977@users.noreply.github.com>
Signed-off-by: Heiko Kopp <47413920+Bardioc1977@users.noreply.github.com>
Bardioc1977 and others added 3 commits October 27, 2026 18:01
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: Heiko Kopp <47413920+Bardioc1977@users.noreply.github.com>
…g a filter off keeps the rail

One list slot per section made a genre or Unwatched switched on and off hide the rail and re-ask the unfiltered counts on every return. The section now holds the landed list and the one it replaced (two keys, never more); every reader picks through SecState::shown_letters. A list counted under Unwatched is never the kept one, since a watch made while the switch is off flags nothing, and a membership change or a type change forgets the kept list as it forgets the shown one.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: Heiko Kopp <47413920+Bardioc1977@users.noreply.github.com>
…nd seasons

A real PMS answers them differently for shows (30 of 33 against 24, docs/pms-api.md), and the mock treated them as one, so it could not catch an app that sent the wrong key. unwatched=1 on a show or season now reads the row's own viewCount; a test pins that the two keys differ and points at the Rust request-terms test for the key the app sends.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: Heiko Kopp <47413920+Bardioc1977@users.noreply.github.com>
docs/sans-io.md: kept main's text and numbers (discovery lane, pins' session
input, Related tail window) and kept this branch's "letters carry the grid's
terms" bullet, which sits in the listing lanes section ahead of main's new
bullets.

rust-modules/data/src/browse/mod.rs: declared both `mod letters` and main's
`pub mod discovery_job`; the pump step keeps this branch's letters-backoff
tick and takes main's `land_discovery_owned_with_gate(.., session)` and
`maybe_discover_owned(adapter, keys, jobs)`.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: Heiko Kopp <47413920+Bardioc1977@users.noreply.github.com>

This branch has not been deployed

No deployments
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.

Sort title + filter unwatched = no right-side letter bar

1 participant