hzz docs hide-watched.md
6.8 kB · 124 lines
Markdown
at main

Hide watched: per-row policy over the audience #

Status #

Cached Home rows drop titles the current playback audience has already watched. The rule is pure merge logic in merge/.../HideWatched.kt (HideWatched.WatchedIndex, HideWatched.apply, RowPolicy.defaultFor) applied by CachedHomePresenter.rows() in app/.../home/CachedHome.kt. It covers UC5's last bullet and UC7's "the audience also drives the hide-watched policy". Per-row settings are not editable anywhere yet: every row uses the default for its feed kind. Host-tested only; no device acceptance has been performed and no provider, network or playback behavior is involved.

Policy semantics #

HideWatchedPolicy (core/model Items.kt) has two values:

  • HIDE_IF_ANY_WATCHED — a title is hidden when at least one person in the audience has watched it.
  • HIDE_IF_ALL_WATCHED — a title is hidden only when every person in the audience has watched it.

HideWatched.apply(items, audience, policy, watched, household) filters one row's merged items in place, keeping order and removing only the hidden ones. The watchers are the audience minus the Household user: Household never watches anything, so it is removed before counting rather than treated as a member who has never seen the title. The Household id is a trailing parameter because :merge does not know which UserId is the Household (User.isHousehold lives in the library snapshot); CachedHomePresenter.rows() resolves it.

An empty audience, or an audience containing only Household, hides nothing under either policy. "Everyone of nobody has watched it" is vacuously true and would empty the screen for a TV where no one has chosen who is watching.

Defaults per row #

RowPolicy.defaultFor(kind) returns the policy for a feed kind, or null for an exempt row:

Feed kind Default
History exempt (null)
Watchlist, Ratings, Recommendations, Catalog HIDE_IF_ALL_WATCHED

The default is all-watched because an item should stay on screen until everyone in front of the TV has seen it. History rows list watched titles by definition, so hiding watched titles would empty them. The spec's Row record carries a per-row hide-watched policy, pinned user and merge policy; none of that is stored or editable yet, and RowPolicy is the stand-in until row settings arrive from the web UI.

What counts as watched #

HideWatched.WatchedIndex.from(local, provider) runs ContinueWatchingReconciler.states() over the snapshot's local and imported progress and records, per user, the canonical IDs of every completed Movie or Series state. Completion is the rule already decided in progress reconciliation: an explicit watched mark, or a known positive duration with the position at or past 90%. Conflicts resolve exactly as in Continue watching, so a newer partial position beats an older explicit watched mark and the title reappears.

Episode states are skipped. A completed episode never marks its series watched; a series is watched only when its own series-level state is completed. There is no "all episodes seen" inference, no next-up reasoning and no season logic in this index.

CachedHomePresenter.watchedIndex() keeps an imported ProviderProgress record only when its account belongs to the same user the record names, matching the ownership rule Continue watching applies. Progress imported through another user's account does not make a title watched for that user. One index is built per snapshot and shared across all rows.

Audience, not selected person #

Two different sets of people act on a row, and they are kept apart on purpose:

  • The selected person (or Everyone) is the viewing scope. It decides which sources build a row through FeedMerge.ViewScope, as described in cached Home.
  • The durable playback audience (LibrarySnapshot.audience, the "who's watching" set from audience UI) decides which of those titles are hidden.

Selecting Bob's view with Alice and Bob as the audience shows Bob's sources and hides what both Alice and Bob have seen. Changing the audience re-filters the retained snapshot with no resubscription or provider call, and a title hidden for one audience reappears for another. Hidden titles are removed from the row's merged list, so they are not inspectable from Home either; the snapshot still holds them.

Search is exempt #

CachedSearch.titles() calls CachedHomePresenter.rows(state, hideWatched = false). A search still finds a title everyone in the audience has watched: a lookup that cannot find something a person has seen would look like missing data. Search is the only caller that asks for unfiltered rows.

Limits #

  • Per-row policy, pinned user and merge policy are not stored, not editable and not exposed in any UI or web page. Changing a row's policy means changing RowPolicy.defaultFor.
  • Hiding applies to cached Home rows only. The Continue watching row has its own completion handling and is not a FeedKind row.
  • Watched state comes from the cached local and imported progress in the snapshot. Nothing here fetches provider history, scrobbles or writes progress; a title a provider knows is watched but has not been imported is not hidden.
  • An older ID-based filter with its own Policy enum, HideWatchedFilter in merge/.../ForYouSeeds.kt, predates this rule and is unused by the app. HideWatched is the one wired in.

Verification #

merge/src/test/kotlin/tv/streamer/merge/HideWatchedTest.kt covers any/all with one, two and three watchers, the empty audience, Household never counting as a watcher, episodes not marking their series, explicit watched and the 90% threshold both counting, a newer partial position beating an older explicit mark, order preservation, and the per-kind defaults with history exempt.

CachedHomePresenterTest audienceDrivesHideWatchedPerRowAndHistoryIsExempt covers the app wiring over a LibrarySnapshot: hidden only once everyone in the audience has watched it, the history row untouched, reappearing when the audience changes, empty or Household-only audience hiding nothing, a newer partial position un-hiding, imported progress through another user's account ignored, and search keeping the title.

env ANDROID_HOME=/home/dev/android-sdk ./gradlew :merge:test --max-workers=2 --console=plain
env ANDROID_HOME=/home/dev/android-sdk ./gradlew :app:testDebugUnitTest --tests '*CachedHomePresenterTest' --max-workers=2 --console=plain
env ANDROID_HOME=/home/dev/android-sdk ./gradlew test :app:assembleDebug --max-workers=2 --console=plain
git diff --check

Fixtures are synthetic local writes. Host tests are not physical-TV acceptance of populated rows.