Pausing an episode on one device, finishing it on another and pressing
Refresh left the first device showing the old "minutes left". Restarting the
app showed the right value. Two independent defects produce that, and either
alone reproduces the report.
The first is the watch-state overlay. Every local watch event lands in
WatchStateStore as a patch, and WatchStateSnapshot.apply overwrites
viewOffsetMs unconditionally; isNewerThan only ever orders one patch against
another, never against the server row underneath. Nothing expires a patch and
nothing clears the map except a profile switch, so the Mac's own paused
position kept winning over every subsequent fetch until the process died.
A patch exists to bridge the gap between a local action and the next server
read of that item, so it should stop applying once that read happens. The
store now records the watermark at which a successful authoritative response
returned each key, and suppresses an acknowledged session patch at or below
it. Only a watermark is stored, never the observed state: WatchStateSnapshot
cannot hold a container's leaf counts, and keeping max() per key makes the
order two concurrent responses complete irrelevant. Suppression is a
read-time predicate, so nothing mutates during build.
The barrier covers the parentChain too. patchForItem picks the newest of the
item's own entry and its ancestors', so retiring only the item's entry would
let an older season mark win and render watched/0 -- worse than either the
stale value or the fresh one. An authoritative read of a child already
reflects any container mark that preceded it, so the child's observation
judges its ancestors as well; a newer container action still wins.
Provenance decides what may be suppressed at all. WatchStateEvent now carries
serverAcknowledged, defaulting to false so an unclassified emit site degrades
to today's behaviour rather than silently becoming retireable. An offline
write is owed to the server and a read must never retire it, so it stays
until a WatchPatchPromotionNotifier promotion says the queue replayed it. That
channel is deliberately not a WatchStateEvent: OfflineWatchSyncService reacts
to watched/unwatched by purging queued progress, so replaying one there would
delete a newer rewatch. Promotion matches an exact WatchPatchId -- session
minted for live crossings, derived from the persisted (profile, row, revision)
for queued ones so it still joins after a restart.
Report acceptance is not delivery: PlaybackReportSession resolves true for a
same-state startup heartbeat it drops, so acknowledgement now keys on
onDelivered. A MediaBrowser Started saves play count and last-played date but
not the position, so it cannot acknowledge an offset. No report-derived
watched crossing is acknowledged on any backend -- Jellyfin hard-codes its
threshold and Plex never loads the server pref that would tell it the real
one -- so only an awaited explicit markWatched settles one.
The second defect is that a failed Refresh reported success. Plex _fetchHubs
and the Jellyfin hub legs both degrade a failure to an empty list, and the
library prefetch discarded its failures, so a server whose every hub request
failed was recorded as succeeded; DiscoverProvider then kept the previous rows,
set loaded and surfaced nothing. Worse, the background Continue Watching
refresh wiped the row outright on zero success.
Hub legs now report what they degraded through a HubFetchDiagnostics sink,
which keeps partial rows alongside the failure and leaves every existing
caller untouched. Failures ride through the aggregation results, a leg that
could not run because discovery failed contributes that failure rather than a
successful no-op, and loaded-server ids became succeeded - failed - cancelled
so one bad leg no longer caches a server as covered and blocks its retry. The
toolbar awaits a DiscoverRefreshOutcome and shows the existing unableToLoad
snackbar on failure while the retained rows stay on screen. Rollback after a
mid-pass exception is version-guarded, refilters against the current hidden
libraries and no longer publishes a system shelf the pass never committed.
Observations are staged with the pass and flushed only once the same disposal,
generation and exception checks that authorise committing those rows have
passed, so a discarded or rolled-back response can never suppress a patch.
Also fixes a live data-loss race the promotion work would have built on:
upsertProgressAction stamped a millisecond timestamp and updated the row in
place, so a rewatch queued during an in-flight replay was deleted by id.
Revisions are now strictly monotonic per row, replay deletes and retry updates
compare against them, and the upsert resets the retry fields because a new
revision is a new logical action.
close#1829
Marking a movie or episode watched left it sitting in Continue Watching with a
checkmark, and the only way to shift it was to play it and skip to the end.
Continue Watching membership on a MediaBrowser server is derived from
UserData.PlaybackPositionTicks alone; Played is never consulted. Marking played
normally zeroes that position as a side effect, so the row usually disappears
and nothing ever checked that it had. When something writes a position back
afterwards the item is left played *and* resumable, which the resume route
happily keeps returning forever. markWatched now reads the UserItemDataDto the
mark already returns and clears the bookmark itself when the server left one
behind, so the postcondition holds however the item got into that state. The
follow-up write costs a request only when the invariant is actually broken.
The writer putting items there is our own offline queue. insertWatchAction
already drops queued progress for an item when the mark is itself queued, but
the online mark writes straight to the server and queues nothing, so a progress
row recorded earlier survived and replayed afterwards — pending actions go out
oldest first — restoring the very position the mark had cleared. The sync
service now listens for watch-state events and discards queued progress for
that item as the mark lands. Progress recorded after a mark is a rewatch and is
queued later, so it is untouched. Plex never showed this because it forwards the
recorded-at timestamp and lets the server discard a stale replay; the
MediaBrowser stop report has nowhere to put one.
Continue Watching also drops the row locally now instead of waiting a round trip
for the refetch to confirm it, matching what removal events already did, and
marking a season or show takes its on-deck episode with it.
Watched items are deliberately still not filtered out of the shelf: Jellyfin
keeps Played set when new progress arrives, so a rewatch in progress is
indistinguishable from a stuck row, and filtering would hide it.
close#1812
The Discover tab fanned out its whole request set twice on every cold
start and replayed slow rows on a shrinking timeout ladder, so a healthy
remote server produced anywhere from 4s to 15s of loading.
Measured against a remote Jellyfin server with four libraries, 24
interleaved cold-start samples per side:
requests 19 -> 9 payload 219 KB -> 94 KB
settled 5231ms -> 2502ms median, 13222ms -> 5927ms p95
Four independent causes:
- Retry policy. `Client.send` resolves on response headers, so the
connect budget covers the server's think time and a slow-but-alive
query raises `connectionTimeout`. Replaying it made the server re-run
the query with a shorter budget than the one it just missed; the
`[10s, 8s, 5s]` ladder turned an 11s answer into an empty row after
23s. Hub surfaces now get one whole-request deadline, retry only
immediate connection errors, and the deadline bounds the whole call
including the request still in flight.
- Request shape. `/Items/Latest` groups a TV library by series, so its
rows are Series folder dtos and `RecursiveItemCount`/`ChildCount` cost
a DB count each, per row. Hub rows now ask for `Overview` only; watch
state survives because Jellyfin derives `UserData.Played` from
`UnplayedItemCount` when the count fields are absent. `/Shows/NextUp`
sends `NextUpDateCutoff` to bound the server's series-key scan, and
`Thumb` leaves `EnableImageTypes` since nothing reads it. `UserData`
and `PremiereDate` leave the browse set: neither is an `ItemFields`
member, so the server dropped them anyway.
- Fan-out. Per-library hubs ran in batches of three separated by a
barrier, so one slow library stalled every library behind it. A
sliding window keeps the same peak concurrency without head-of-line
blocking. Concurrent `fetchLibraries` calls now share one `/Views`
instead of racing two identical round trips, Plex's global and music
hub legs start together, and Jellyfin gets Plex's pool tuning.
- Duplicate pass. `DiscoverScreen.initState` starts a load and the
online-entry hook asked for a full refresh on top of it, which
`CoalescedLoadCoordinator` correctly queued as a trailing pass. The
hook now calls `primeRefresh`, which rides along with a load already
in flight; profile switches still go through `fullRefresh`.
Refs #1784
Introduces shared seams for paginated views, D-pad reorder, media control
routing, async singletons and the device method channel, then points the
open-coded copies at them.
Also removes unused models and duplicated provider/server plumbing, folds
the twice-implemented artifact store in the server, and factors the
repeated Flutter toolchain prologue in CI into a composite action.
The library Recommended tab never subscribed to deletion events, so
"Delete from server" left the episode sitting in Continue Watching
until a full reload. Make the tab DeletionAware (remove in place across
all hubs, then resync) and give DiscoverProvider the same subscription
so the home row and hubs drop deleted items too.
close#1486
Signing in triggered two back-to-back profile rebinds; the second re-added
the same Jellyfin connection, which tore down the live client and aborted
the home screen's in-flight fetches. The aborted pass was committed as
loaded-empty, flashing 'no content available' until the follow-up load
landed. Fix at the root instead of patching the sign-in window:
- addJellyfinConnection now reuses the live client when the connection is
unchanged (token, deviceId, URL set), matching the existing Plex
refreshTokensForProfile behavior; material changes still recreate it.
- Cancelled requests are classified end-to-end: the client's
treat-as-empty helpers rethrow cancellations, and the aggregation
fan-outs report cancelledServerIds alongside succeededServerIds.
- A fetch pass in which zero servers succeeded is never authoritative:
it keeps existing content instead of wiping it (also fixes the
pre-existing blanking of home/sidebar on a totally failed refresh),
stays in loading while disrupted (cancellation or binding in flight),
and only commits loaded-empty on a settled failure.
Discover and libraries now fetch only the servers that came online since
the last pass and merge the results; already-loaded servers are not
refetched. Both providers also own their online-servers listener
(registered in the constructor, removed in dispose), so profile switches
no longer leak listeners that kept disposed providers refetching.