56 Commits
Author SHA1 Message Date
edde746 5f397a99d9 fix(discover): let a refreshed row override a stale local watch patch
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
2026-08-08 09:09:48 +02:00
edde746 58d5d3c4ef fix(plex): read external ids from legacy agent and HAMA AniDB guids
Plex only builds the `Guid` array for the Plex Movie / Plex TV Series
agents. A library still on a legacy agent answers with the scalar `guid`
alone, so `fetchExternalIds` returned nothing for it and every consumer
went quiet: trackers logged "no external IDs" and skipped the write,
manual ratings showed "Not available", the detail screen dropped its
watchlist button, and Continue Watching stopped collapsing duplicate
copies. The reverse lookup already read that scalar; only the forward
path ignored it.

Read both shapes from the one request the method already makes, with the
array winning per field and the scalar filling the rest.

HAMA identifies anime by AniDB id and nothing else, which no id set could
carry. AniDB is the Fribb mapping's own primary key, so it now travels on
`ExternalIds` and indexes those rows directly — 7177 of them expose no
tvdb/tmdb/imdb at all and were unreachable by any other path. Only plain
`anidb-` maps: `anidb2`..`anidb9` group several AniDB entries under one
TVDB-numbered show, so the guid names the root entry only.

Two guards keep the new id where it means something. It is trusted for
season 1, because that mode puts the anime there and its specials in
season 0, while a higher season means the library is numbered by TVDB
instead. And it resolves nothing for Trakt and Simkl, which never map
anime and cannot address an AniDB id, so they keep reporting no ids
rather than failing silently further down. `hasCatalogIds` marks the
callers that can only speak IMDb/TMDB/TVDB.

close #1788
2026-08-04 16:56:54 +02:00
edde746 74d3af3ae1 perf(home): load the home screen once instead of twice per cold start
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
2026-08-04 04:35:06 +02:00
edde746 35061f9f68 fix(jellyfin): scope global search to visible libraries
Jellyfin search rows do not expose their owning collection, so hidden libraries cannot be filtered after the response. Scope searches to visible libraries, stamp the returned rows, reuse the latest loaded views, propagate cancellation, and fail closed when views cannot be loaded.

Keep full candidate budgets and split music libraries into parallel album, audio, and artist requests. Album requests disable UserData and use the existing album field set to avoid recursive per-album work from #1552; audio requests retain cheap leaf play state.

close #1770
2026-08-02 09:29:59 +02:00
edde746 1d9ffb7427 fix(search): exclude hidden libraries from global search results
searchAcrossServers was the only aggregation entry point without a
hiddenLibraryKeys parameter, so libraries hidden from home hubs, Continue
Watching and the library rail still surfaced their contents in the Search
tab. Thread the profile's hidden keys from SearchScreen through to the
aggregation, and drop matching items between the fan-out and the ranking
pass so hidden hits cannot spend the result limit and shrink what is
shown. Items the backend cannot attribute to a library, such as Plex
shared and external media, are kept.

The screen re-runs the visible query when a library is hidden or unhidden
while results are on screen. Its listener is attached only after the
provider has hydrated, so the initial load notification cannot race the
first query into running twice.

Plex search rows now go through the library-aware tagger, so a response
that names its section only via librarySectionKey or
targetLibrarySectionID is still attributable, and therefore filterable.

Jellyfin search results carry no library id at all: the mapper's
ParentLibraryId is not a Jellyfin field, and ParentId resolves to a
season or physical folder rather than a CollectionFolder. Filtering there
needs server-side ParentId scoping and is left for a follow-up.

close #1770
2026-08-02 09:29:59 +02:00
edde746 bc0d14a749 fix(explore): list every library copy of a title, not one per server
`MediaServerClient.findByExternalIds` returned `MediaItem?`, so the Explore
"In these libraries" chooser could never show more than one copy per server.
A movie held by both a 4K library and an HD library on one Plex server
therefore resolved to whichever copy came back first, with no way to reach
the other.

Return every id-verified match instead. `/library/all` is already
server-wide and each `Metadata` entry carries its own `librarySectionID`,
so both copies come back labelled with no extra request; Plex was simply
taking `Metadata[0]` and the title ladder was returning on its first hit.
An exact-guid hit no longer short-circuits the title search either — a
library still on a legacy agent has a different primary guid and is
invisible to the `guid=` filter.

Copies are deduped by global key and ordered best-first, and each row now
states its resolution, since library names need not mention it.

Resolution passes merge rather than replace: the cross-server fan-out logs
and skips per-server failures, so a later pass can come back short a server
that answered an earlier one, and a failed pass no longer claims the title
left the library. Duplicate keys fold field by field, because Jellyfin's
library stamp is a best-effort ancestors lookup that returns the item bare
when it fails and an unstamped row is indistinguishable from its sibling.
Focus nodes are keyed by copy and reclaimed after a merge re-sorts the
rows, so a dpad user is not thrown to a different copy.

close #1754
2026-08-02 06:40:04 +02:00
edde746 0eee9f688d fix(explore): match sequel catalog entries to their parent library show
MAL/AniList season entries never matched the library show they belong to.
Both backends use the catalog title as a server-side filter before verifying
external ids, and a title like "Mushoku Tensei: Jobless Reincarnation Season 2"
cannot reach a show stored as "Mushoku Tensei: Jobless Reincarnation". Measured
against a 267-show Plex library, 3 of 113 mapped sequel entries matched.

The reverse lookup now takes two ordered title candidates instead of one: the
entry's own title and its season-stripped form, with typographic punctuation
normalised because both servers miss on a curly apostrophe. That matches 77 of
113. Widening it further to romaji/native/synonym variants reached only 81, so
the cap stays at two rather than spending up to five more requests per lookup
that finds nothing.

A sequel's year is its own season's, not the parent show's, so the +/-1 year
window is dropped for one - it would exclude the very show being looked for.
That also keeps a miss at the two requests the single-title lookup already
spent. A Plex Discover item additionally skips the title search entirely by
filtering on the plex:// guid its own rating key already is, which costs no
extra request and needs no cloud lookup.

A season 2+ entry only matches when the server really has that season, which
costs one children fetch on a match. Only a season both TVDB and TMDB agree on
is gated: which provider a library numbers its seasons by is a server setting
no dataset supplies and none of it is inferable from the ids an item exposes,
so a disagreeing reference is left ungated rather than gated on a guess.

The match cache is keyed per source and per entry rather than by canonical id,
which every season of a series shares: all five Mushoku Tensei entries collapse
to imdb:tt13293588, so one season-gated result would have poisoned the rest.

Entries whose Fribb row carries no provider id at all remain unmatched. That is
an upstream mapping gap, not something to guess around with extra lookups.

close #1704
2026-07-29 01:35:35 +02:00
edde746 a56b9a3dfb Merge the deduplication and dead-code removal pass
Consolidates duplicated logic behind shared implementations — paginated
grid tabs, focus chrome, cached remote stores, sheet selection columns,
the server artifact store and a test fixture layer — and removes code
that had become unreachable. Net reduction of about 5,500 lines with no
behaviour change.

Where a fix had landed separately in code that moved into a shared
helper, the fix was re-applied inside the helper rather than left behind
in the copy that went away.
2026-07-26 19:41:23 +02:00
edde746 1fea9ef6e3 fix(search): recover omitted Plex media categories
close #1598
2026-07-26 14:58:38 +02:00
edde746 83f4e2a263 refactor: fold single-use helpers into their call sites
Collapses indirection layers and one-caller abstractions across the video
player, shortcut dispatch, shader loading and context-menu code, including
the VideoPIPManager pass-through over PipService.
2026-07-26 06:09:49 +02:00
edde746 dcdbbc8483 feat(explore): match catalog items to library content via reverse external-id lookup 2026-07-10 07:07:07 +02:00
edde746 764345f021 feat(music): audio downloads and music home rows
Album/artist downloads expand to tracks with pinned parent metadata,
aggregate progress, container deletes with reference-counted album
covers, and a Music tab on the downloads screen with fully offline
album playback. Home rows include music libraries (fixes plex hub
items being filtered to video types) and audio playlists join
download/sync rules.
2026-07-05 21:51:37 +02:00
edde746 ee3c75ba9e fix(playback): remember last played media version for continue watching
close #1492
2026-07-05 14:29:57 +02:00
edde746 0dfe013928 fix(home): include clip libraries in per-library home rows
close #1476
2026-07-05 09:43:34 +02:00
edde746 c4184c6148 fix(ci): restore sanity checks 2026-07-05 08:34:55 +02:00
edde746 87aea49ab6 fix(servers): stop rebinds from flashing an empty home screen after sign-in
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.
2026-07-04 23:44:38 +02:00
edde746 edd0618e64 fix: harden playback and discover state 2026-06-14 09:44:04 +02:00
edde746 979db254d1 perf(providers): delta-fetch newly-online servers instead of full refetch
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.
2026-06-12 17:12:18 +02:00
edde746 eef73e6447 fix: expand home hub previews
close #1268
2026-06-07 17:35:56 +02:00
edde746 e8df18e7b2 fix(jellyfin): order continue watching + up next by recency 2026-06-07 16:34:18 +02:00
edde746 74b8dc4561 refactor: type server identifiers 2026-06-01 11:06:03 +02:00
edde746 6111c381af fix(libraries): reload when a server connects after initial load 2026-05-30 14:01:01 +02:00
edde746 be86a33057 fix(discover): hide duplicate continue watching
close #1113
2026-05-26 14:48:16 +02:00
edde746 be0e9686b5 fix(search): rank media search results
close #1056
2026-05-16 21:40:22 +02:00
edde746 e37602421b fix(discover): expose full continue watching
close #1045
2026-05-15 18:49:23 +02:00
edde746 ae7f4a7871 fix(jellyfin): unblock large library loading
close #1018
2026-05-12 05:28:20 +02:00
edde746 fcd8198fcb fix(plex): use promoted home hubs
close #1004
2026-05-11 13:18:43 +02:00
edde746 3a93ec80ec fix(jellyfin): include movie home hubs 2026-05-10 00:53:20 +02:00
edde746 5fe5534bae fix(jellyfin): bound home artwork load 2026-05-08 06:06:09 +02:00
edde746 9e4230597f fix(jellyfin): label per-library hubs with library name
close #975
2026-05-04 10:24:01 +02:00
edde746 31d2d9dc98 feat: jellyfin 2026-05-01 01:20:36 +02:00
edde746 3da51f9d64 chore: pre-commit ci hook, dart format 2026-04-18 12:40:35 +02:00
edde746 ab5ad3c839 fix: use hubs API for continue watching
close #748
2026-03-26 18:58:18 +01:00
edde746 b05d69b10f fix: filter hub detail view by library section
close #638
2026-03-06 18:36:45 +01:00
edde746 c9a1bc97c4 fix: remove dead state paths and restore track hotkeys 2026-03-06 11:50:45 +01:00
edde746 8571ee74c4 fix: use addedAt for unwatched on-deck sort fallback
close #636
2026-03-05 19:57:36 +01:00
edde746 8cd48f3af5 fix: firstClient null check race 2026-03-04 10:01:40 +01:00
edde746 acf91107ad fix: split combined recently added hubs by library
closes #552
2026-02-26 00:30:23 +01:00
edde746 e23f472d78 refactor: centralize globalKey construction via buildGlobalKey() 2026-02-23 17:53:20 +01:00
edde746 04438c8e45 refactor: code quality & format 2026-02-15 04:07:09 +01:00
edde746 b7ec24c2c1 refactor: centralize library state in LibrariesProvider 2026-01-24 20:03:06 +01:00
edde746 54e8e0c621 refactor: abstract player & deduplicate 2026-01-24 04:06:58 +01:00
edde746 b5726fe4b7 chore: format 2026-01-20 13:46:44 +01:00
edde746 4edaf3ebea fix: respect hidden libraries in continue watching
close #246
2026-01-15 06:36:53 +01:00
edde746 5ed2532bf1 feat: global hubs with settings toggle
close #216 & #217
2026-01-06 23:23:03 +01:00
edde746 84c255b917 refactor: deduplicate logic 2025-12-21 13:19:21 +01:00
edde746 3c89d0eba8 refactor: update formatting 2025-12-15 02:29:03 +01:00
edde746 18c322f66a refactor: navigation/offline handling
and other improvments
2025-12-13 00:16:50 +01:00
edde746 6db21d93bb fix: hide hidden libraries on the home screen
close #145
2025-12-05 14:08:04 +01:00
edde746 8f817815e6 refactor: simplify file structure 2025-12-04 08:36:07 +01:00